The Problem with Bearer Tokens

The name says it all: bearer tokens are held by whoever holds them. The OAuth RFC is blunt about it β€” RFC 6750 literally says "Any party in possession of a bearer token can use it to get access to the associated resources." There's no verification that the party presenting the token is the same party that received it.

Bearer tokens end up in browser history, referrer headers, server logs, memory dumps, and network captures. They get stolen through XSS, MITM attacks on misconfigured TLS, credential-stealing malware, and plain old misconfigured logging that writes tokens to files that end up in a monitoring dashboard accessible to half the company.

We've seen this in the wild: A financial services API we audited was logging full Authorization headers to a centralised log aggregation system. The logs were retained for 90 days and accessible to all engineers. Every access token issued in that period was effectively accessible to every person with log access. The tokens were long-lived (24-hour expiry). The blast radius was enormous.

The traditional mitigations β€” short token lifetimes, token rotation, refresh token rotation β€” help at the margins but don't solve the fundamental problem. A token stolen 30 seconds before expiry can still do a lot of damage.

How Token Replay Attacks Work

Token replay is exactly what it sounds like. An attacker intercepts a valid token in transit (or steals it from storage) and sends it to the resource server independently β€” replaying the original request, or crafting new requests with the stolen token.

The resource server validates the token signature and claims, finds everything looks fine, and grants access. It has no way to know whether the presenting client is the original legitimate client or an attacker who stole the token.

Common theft vectors: XSS extracting tokens from localStorage, SSRF logging outbound requests with Authorization headers, misconfigured CDN or reverse proxy caching responses that include tokens, MITM on internal networks with self-signed cert trust issues, compromised third-party scripts reading DOM storage.

What Sender-Constrained Tokens Are

Sender-constrained tokens β€” also called proof-of-possession (PoP) tokens β€” cryptographically bind the token to a specific key pair held by the legitimate client. When the client presents the token to a resource server, it must prove possession of the corresponding private key. Without that proof, the token is useless.

An attacker who steals a sender-constrained token has a token that names a specific public key β€” but doesn't have the private key that corresponds to it. Every request requires a fresh cryptographic proof. The token can't be replayed.

There are two main mechanisms for this in practice: DPoP (for browser and mobile clients) and mTLS (for service-to-service communication).

DPoP: Demonstrating Proof of Possession

DPoP (RFC 9449) is an OAuth extension that lets a client prove possession of a private key by signing a short-lived JWT and including it in an HTTP header alongside the access token. The access token itself is bound to the client's public key β€” it's embedded in the token when the authorization server issues it.

Here's the flow:

  1. Client generates an asymmetric key pair (typically ES256 or RS256)
  2. Client requests a token from the AS, including a DPoP proof JWT in the request header
  3. AS issues an access token that contains the client's public key (as a JWK thumbprint in the cnf claim)
  4. For every API request, the client signs a new DPoP proof JWT binding the request to a specific HTTP method, URI, and timestamp
  5. Resource server validates both the access token AND the DPoP proof β€” checking that the proof was signed with the key that matches the token's cnf claim
dpop_proof.py Python
import jwt, time, uuid
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization

# Generate client key pair (do this once per session)
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()

def create_dpop_proof(http_method: str, http_uri: str) -> str:
    """Create a DPoP proof JWT for a single request."""
    public_jwk = {
        "kty": "EC",
        "crv": "P-256",
        # ... public key coordinates
    }
    header = {
        "typ": "dpop+jwt",
        "alg": "ES256",
        "jwk": public_jwk,
    }
    payload = {
        "jti": str(uuid.uuid4()),      # unique per request
        "htm": http_method,            # HTTP method bound
        "htu": http_uri,               # URI bound
        "iat": int(time.time()),        # issued at (short-lived)
    }
    return jwt.encode(payload, private_key, algorithm="ES256", headers=header)

# Use it in requests:
import requests
proof = create_dpop_proof("GET", "https://api.example.com/resource")
resp = requests.get(
    "https://api.example.com/resource",
    headers={
        "Authorization": f"DPoP {access_token}",
        "DPoP": proof,
    }
)

Notice the key properties: the proof JWT binds the request to a specific method (htm) and URI (htu), has a unique ID (jti) to prevent replay of the proof itself, and is short-lived (iat with a small tolerance window). An attacker who intercepts this proof can't reuse it for a different endpoint or even the same endpoint a minute later.

mTLS for Service-to-Service Auth

Mutual TLS (mTLS) is the right tool for service-to-service communication in a microservices environment. Both the client and server authenticate with X.509 certificates during the TLS handshake. The client certificate's public key is embedded in the access token during issuance β€” the same sender-binding concept as DPoP, but using TLS certificates instead of JWTs.

RFC 8705 defines how OAuth token binding to mTLS works. The authorization server embeds a thumbprint of the client certificate in the token's cnf claim. The resource server compares the certificate presented during the mTLS connection against the thumbprint in the token.

mtls_client.py Python
import requests

# mTLS: present client cert with every request
session = requests.Session()
session.cert = ("/certs/client.crt", "/certs/client.key")
session.verify = "/certs/ca-bundle.crt"  # verify server cert against CA

response = session.get(
    "https://internal-api.example.com/data",
    headers={"Authorization": f"Bearer {access_token}"}
)
# The resource server validates that the cert thumbprint in the
# access token matches the cert used in the TLS handshake.
nginx-mtls.conf nginx
server {
    listen 443 ssl;
    # Require client certificate
    ssl_verify_client       on;
    ssl_client_certificate  /etc/nginx/certs/ca.crt;
    ssl_certificate         /etc/nginx/certs/server.crt;
    ssl_certificate_key     /etc/nginx/certs/server.key;

    location /api/ {
        proxy_pass http://backend;
        # Forward the client cert fingerprint to the backend
        proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint;
        proxy_set_header X-SSL-Client-DN          $ssl_client_s_dn;
    }
}

FAPI 2.0 and Financial API Requirements

The Financial-grade API (FAPI) 2.0 Security Profile, published by the OpenID Foundation, mandates sender-constrained tokens for all conforming implementations. If you're building open banking APIs, payment initiation services, or any financial API that needs to comply with PSD2 or similar regulations, sender-constrained tokens aren't optional.

FAPI 2.0 specifically requires either DPoP or mTLS for token binding. It also mandates PKCE, the use of PAR (Pushed Authorization Requests), and prohibits the implicit flow entirely. The intent is to make the authorization flow resistant to token theft at every step.

Broader adoption: Financial services led the adoption of sender-constrained tokens, but the pattern is spreading. Healthcare APIs under SMART on FHIR, government APIs under eIDAS 2.0, and enterprise identity platforms like Okta and Auth0 now support DPoP. This is becoming a standard security baseline, not a specialist financial requirement.

Implementing DPoP in Python and Node.js

Both ecosystems have libraries that handle most of the DPoP complexity. The key is making sure you're not re-implementing the crypto yourself.

dpop_server_validate.py Python β€” Server-side validation
from joserfc import jwt
from joserfc.jwk import OctKey
import time, hashlib, base64

def validate_dpop_proof(dpop_header: str, access_token: str,
                         method: str, uri: str) -> bool:
    # Decode the DPoP proof JWT header to get the embedded public key
    token = jwt.decode(dpop_header, algorithms=["ES256", "RS256"])
    claims = token.claims

    # Verify method and URI binding
    if claims["htm"] != method or claims["htu"] != uri:
        return False

    # Verify freshness β€” reject proofs older than 60 seconds
    if abs(time.time() - claims["iat"]) > 60:
        return False

    # Verify ath claim: hash of the access token
    expected_ath = base64.urlsafe_b64encode(
        hashlib.sha256(access_token.encode()).digest()
    ).rstrip(b"=").decode()
    if claims.get("ath") != expected_ath:
        return False

    # Check jti is not in replay cache
    if replay_cache.exists(claims["jti"]):
        return False
    replay_cache.set(claims["jti"], ttl=300)

    return True

When to Use DPoP vs mTLS

Both mechanisms solve the same problem but suit different contexts.

  • Use DPoP for browser and mobile clients, public OAuth clients, APIs accessed from user devices, and situations where deploying client certificates is impractical
  • Use mTLS for service-to-service communication in a microservices mesh, internal APIs with controlled client environments, and where a service mesh (Istio, Linkerd) can handle certificate rotation transparently
  • Use both if you're building a financial API that must comply with FAPI 2.0 β€” the spec allows either, but some implementations require mTLS specifically for certain grant types

mTLS gets easier with a service mesh: One of the historical objections to mTLS was certificate management complexity. A service mesh like Istio handles cert issuance, rotation, and distribution automatically via SPIFFE/SPIRE. The operational burden is much lower than it used to be.

Real-World Adoption Challenges

Sender-constrained tokens are technically solid. The adoption blockers are mostly operational.

  • Key management: Clients need to generate, store, and protect private keys. On browsers, the Web Crypto API can generate non-extractable keys in the browser's key store. On mobile, the device secure enclave. The challenge is rotation β€” what happens when a device is lost?
  • Replay cache: Server-side DPoP validation requires a replay cache to track used JTIs within the token lifetime window. At scale, this needs to be a distributed cache (Redis, Memcached) with appropriate TTLs.
  • Reverse proxy and load balancer support: Many reverse proxies strip custom headers or re-terminate TLS in ways that break mTLS. Verify your infrastructure supports the mechanism end-to-end before committing.
  • Library maturity: DPoP library support varies by language and framework. The spec is relatively recent β€” RFC 9449 was published in 2023. Check that your OAuth library version actually implements it rather than just claiming to.

Don't roll your own: The cryptographic requirements for correct DPoP and mTLS implementation are non-trivial. Use established OAuth libraries that have been reviewed against the RFC. The failure modes for DIY implementations β€” missing the replay check, not validating the htm/htu claims, accepting future-dated proofs β€” are all exploitable.

Test Your API Token Security

AquilaX API scanner tests for token authentication weaknesses including bearer token exposure, missing token binding, and OAuth implementation flaws.

Start Free Scan