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 • 7 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. Not because providers are careless or developers are bad at handling secrets, but because the operating environment changed. Developer laptops, CI runners, build scripts, package installs, and editor extensions are now active security boundaries, and most API credentials were designed for a simpler 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.
A stolen string should not be enough to call an API.
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.
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 directly 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 is just another file 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.
This maps directly to how SSH already works: 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 important part is what is not present. 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. A secret string that can be copied is a secret string that will eventually be copied.
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
This is not exotic cryptography. It is the same pattern behind SSH keys, passkeys, hardware security keys, mTLS, and DPoP. The pieces already exist; what is missing is broad adoption by API providers and developer tooling.
The future is not "protect this string better." The future is "this string is not enough."