A Solution to Rampant Token Theft: Proof of Possession
Static API keys in environment variables and files are too easy to steal. A better model is proof-of-possession, where every API call must be signed by a non-exportable private key that is available only through a constrained signing interface.
Ben Houston • June 14, 2026 • 8 min read
TL;DR: Bind every API token to a non-exportable signing key, require a fresh short-lived signature on every request, and a stolen credential string becomes useless. This is basically OAuth's DPoP, made stricter for developer tokens.
Static API keys are obsolete. The operating environment changed. Developer laptops, CI runners, build scripts, package installs, and editor extensions are now active security boundaries. Most API credentials were designed for a world where a secret string in an environment variable was good enough.
The recent run of credential-stealing supply-chain compromises makes this clear. The Axios npm compromise delivered cross-platform malware during package installation. The TanStack npm compromise abused CI behavior to publish malicious packages that exfiltrated credentials from install hosts. The Red Hat Consulting GitLab incident involved copied repository and consulting data that may have included tokens. The pattern is endemic: attackers want the tokens, keys, and credentials sitting on developer machines and build systems.
The usual response is to rotate everything after a breach. That is necessary today, but it is also an admission that the model is wrong: if one copied string gives an attacker full access from anywhere, the string has too much authority.
The Better Model: DPoP, But Stricter#
The fix is proof-of-possession. In OAuth terms this is a sender-constrained token, and the closest standard is OAuth 2.0 Demonstrating Proof of Possession, RFC 9449, usually called DPoP. DPoP binds an access token to a public/private key pair: the client signs a per-request proof (a JWT covering the HTTP method, URL, timestamp, a unique ID, and a hash of the access token), and the server verifies the token is bound to the key that signed the proof.
The mechanics:
- A user, machine, service, or organization creates a public/private key pair.
- The private key is stored behind a non-exportable signing interface (Secure Enclave, TPM, YubiKey, HSM, cloud KMS, a workload identity system, or similar).
- The API provider stores the corresponding public key and issues a token bound to it.
- Every API request includes a fresh signed proof created by the private key.
- The provider verifies both the token and the proof before allowing the request.
The application can still keep a token in an environment variable, but that token is no longer a complete credential. It is a scoped capability that only works when paired with a fresh signature.
That changes the failure mode. If malware steals the API token, it is useless without the signer. If malware steals a signed request, the request expires almost immediately. If the proof is single-use, even replaying it within the validity window fails after the first use.
A developer-credential profile should go further than DPoP in three ways:
- Non-exportable keys. DPoP proves possession of a private key but does not require it to live in hardware. A private key sitting in a normal file on disk is better than a bearer token, but malware can still copy it. For developer credentials, the key should be behind a Secure Enclave, TPM, hardware key, HSM, or managed signing service.
- Beyond OAuth. Plain API keys, personal access tokens, npm tokens, registry tokens, cloud credentials, and webhook secrets should all become proof-of-possession credentials, whether the mechanism is DPoP-compatible or adapted for non-OAuth APIs.
- Single-use by default for high-risk APIs. DPoP's
jtilets servers detect replay; for sensitive operations, replay prevention should be mandatory, not optional.
Why Not mTLS / Client Certificates?#
The obvious objection is: we already have this, it's called mutual TLS. Client certificates are proof-of-possession too, with real strengths. They are protocol-agnostic rather than HTTP-only, and X.509 chains support native delegation, so a client can mint a more-constrained certificate for itself or others.
But "just use mTLS" conflates two separate things. The hard part of this problem is keeping the private key non-exportable, and mTLS does not give you that for free. A client certificate whose private key sits in a .pem file is exactly as stealable as a bearer token — malware just copies the key. mTLS only fixes theft when the key lives in a TPM, Secure Enclave, or HSM, which is the actual point here. On that axis, mTLS and request signing are complementary, not competing.
Where they differ is granularity. mTLS authenticates a connection, so every request over that channel rides for free. A signed proof authenticates a request, binding the method, URL, and body, which is what enables per-request scoping, body integrity, and single-use replay prevention. mTLS also tends to break at proxies and CDNs, where TLS is terminated before the application ever sees the certificate, forcing fragile identity-forwarding headers; application-layer proofs survive intermediaries end to end. And after thirty years, developer platforms still haven't adopted mTLS for API keys, because PKI provisioning and rotation are heavy. A DPoP-style profile rides on existing bearer-token plumbing and can be adopted incrementally.
mTLS with hardware-backed keys is a valid instance of this idea. The argument is not against client certificates; it is against any credential — bearer token or .pem file — that can be copied.
What Should Be Signed?#
A signed proof should be narrow. It should not say "this machine can use this API token." It should say "this machine can make this specific request right now."
At minimum, the signed payload should include:
- the API token identifier or access token hash
- the HTTP method
- the canonical URL or resource identifier
- the issued-at and expiration timestamps
- a nonce or unique request ID
- optionally, a hash of the request body
So a proof for POST /v1/packages/publish is not reusable for DELETE /v1/packages/latest, a proof for one host does not work on another, and a proof does not validate if the body has been changed.
The shorter the validity window, the better. For many calls, 30 seconds is generous; for sensitive operations, a few seconds; for the highest-risk operations, the proof should be single-use. Single-use needs only a short-lived replay cache keyed on the nonce — operationally realistic for providers who already run rate-limit and idempotency-key stores. Reserve it for package publishing, production deploys, cloud administration, payment operations, and credential management.
The Signer, Not The Device, Is The Boundary#
The key does not have to be bound to a physical device. The core idea is narrower: the private key must not be accessible to the application or user. It should only be available through a signing operation that produces narrowly scoped, short-lived proofs. That signer could be a developer's Secure Enclave or TPM, a YubiKey, a cloud KMS key with a sign-only policy, an HSM-backed organizational key, or a workload identity system for CI. What matters is not where the signer lives but that the signing key cannot be copied into an environment variable, checked into a repository, dumped from a config file, or loaded into every process that wants to call the API.
If the key sits in ~/.config, we have improved the protocol but not enough. On macOS, the Secure Enclave is a good home — tools like Secretive already use it for non-exportable SSH keys, gated by Touch ID, where the key never leaves the hardware boundary. Across platforms, YubiKeys and FIDO2 keys offer the same property (OpenSSH's ed25519-sk), Windows has TPM-backed storage, and servers can use cloud KMS, HSMs, or managed workload identities.
SSH uses the same pattern: the server stores a public key, the private key stays protected, and the caller proves possession by signing a challenge. The missing piece is API providers adopting the same pattern for HTTP APIs and developer tokens, while being stricter about keeping private keys behind sign-only interfaces.
This does not make incident response disappear. A compromised machine can still request signatures while malware is running, malicious editor extensions can invoke local tools, and CI runners still need isolation. But the blast radius shrinks dramatically: a breach no longer means every copied string is automatically a valid credential from anywhere in the world.
Developer Experience Matters#
This cannot work if every provider invents a different signing scheme. The developer experience should be boring:
api-provider keys register api-provider token create --bound-to signing-key api-provider request ...
Under the hood, the CLI or SDK talks to a signing service — Secure Enclave on macOS, TPM on Windows, a hardware key or agent on Linux, a workload identity system or KMS in CI. For application code, the library should hide most of the mechanics:
const client = new ApiClient({ token: process.env.API_TOKEN, signer: platformSigner('com.example.my-api-key'), }); await client.publishPackage(packagePath);
The private key is not in process.env, not in a .env file, not copied into a container image, not printed in a build log. The app can ask for a signature. It cannot steal the signing key.
The New Baseline#
There was a time when putting API keys in environment variables felt responsible — better than hardcoding them in source, friendly to twelve-factor apps. But the threat model moved. Dependency installation can execute attacker code, editor extensions can read workspaces, CI systems can be abused through subtle trust-boundary mistakes, and repository leaks regularly expose long-lived credentials.
The replacement is straightforward:
- keep long-term private keys out of process memory
- bind API tokens to public keys
- require a fresh signature on every request
- make signatures short-lived
- make sensitive signatures single-use
- store private keys behind non-exportable sign-only interfaces
SSH keys, passkeys, hardware security keys, mTLS, and DPoP already use this pattern. The pieces exist. What's missing is broad adoption by API providers and developer tooling.
A secret string that can be copied will eventually be copied. Binding tokens to non-exportable keys changes what "stolen credentials" actually means.