Skip to main content
DPoP (Demonstrating Proof-of-Possession, RFC 9449) cryptographically binds an access token to a public/private keypair your client holds. A stolen DPoP-bound token is useless to an attacker without the matching private key — every API call requires a freshly-signed proof JWT. This page is for integrations where bearer tokens aren’t enough.

Should you use DPoP?

SituationUse DPoP?
Server-side app handling its own tokensOptional. Bearer is fine if your server is secure.
Public client (SPA, mobile) handling high-value scopesYes, strongly recommended.
Server-side app where token leakage is a known concern (shared logging, third-party error trackers)Yes.
Any client with decks:write, sideboards:write, guides:write and a security mandateYes.
Internal scripts using a PATNo, doesn’t apply
You’re not sureStart with Bearer. Add DPoP when you ship to production scale.
DPoP is opt-in at Flexslot. Set dpop_bound_access_tokens: true on your OAuth client in the partner admin to require DPoP for all tokens issued to that client.

How DPoP works

The key insight: every single API request gets its own short-lived proof JWT, signed by the client’s private key, that names the URL, method, and the hash of the access token. An attacker who steals the access token can’t make the proof.

Set up your keypair

DPoP supports ES256 (ECDSA P-256), ES384, EdDSA, and RS256. Use ES256 unless you have a reason not to.
import { generateKeyPair, exportJWK } from 'jose'

const { publicKey, privateKey } = await generateKeyPair('ES256')

const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'ES256'
publicJwk.use = 'sig'
from jwcrypto.jwk import JWK

key = JWK.generate(kty='EC', crv='P-256', alg='ES256', use='sig')
public_jwk = key.export_public(as_dict=True)
Persist this keypair securely. The same key must be used across /token and all subsequent API calls.

Building a DPoP proof JWT

A DPoP proof is a JWT with: Header:
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
Payload:
{
  "jti": "01HX2K6F8N9P-unique-per-proof",
  "htm": "POST",
  "htu": "https://api.flexslot.gg/api/public/v1/oauth/token",
  "iat": 1717177200
}
When the proof accompanies an access token (i.e., when calling a resource server endpoint), also include:
{
  "ath": "BASE64URL(SHA256(access_token))"
}

Helper

import { SignJWT, exportJWK } from 'jose'
import crypto from 'node:crypto'

async function dpopProof({ privateKey, publicKey, method, url, accessToken }) {
  const jwk = await exportJWK(publicKey)

  const payload = {
    jti: crypto.randomUUID(),
    htm: method.toUpperCase(),
    htu: url,
    iat: Math.floor(Date.now() / 1000),
  }

  if (accessToken) {
    const ath = crypto.createHash('sha256').update(accessToken).digest('base64url')
    payload.ath = ath
  }

  return new SignJWT(payload)
    .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk })
    .sign(privateKey)
}
import hashlib
import json
import time
import uuid
from base64 import urlsafe_b64encode

from jwcrypto.jws import JWS

def dpop_proof(key, method: str, url: str, access_token: str | None = None) -> str:
    payload = {
        "jti": str(uuid.uuid4()),
        "htm": method.upper(),
        "htu": url,
        "iat": int(time.time()),
    }
    if access_token:
        ath = urlsafe_b64encode(
            hashlib.sha256(access_token.encode()).digest()
        ).rstrip(b"=").decode()
        payload["ath"] = ath

    header = {
        "typ": "dpop+jwt",
        "alg": "ES256",
        "jwk": key.export_public(as_dict=True),
    }
    jws = JWS(json.dumps(payload))
    jws.add_signature(key, alg="ES256", protected=json.dumps(header))
    return jws.serialize(compact=True)

Token exchange with DPoP

Add a DPoP header to the /token request:
DPOP_PROOF=$(node make-proof.js POST https://api.flexslot.gg/api/public/v1/oauth/token)

curl -X POST https://api.flexslot.gg/api/public/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "DPoP: $DPOP_PROOF" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=$REDIRECT_URI" \
  -d "code_verifier=$VERIFIER"
The response sets "token_type": "DPoP" to signal the token is sender-constrained:
{
  "access_token": "flx_at_01HX2K…",
  "token_type": "DPoP",
  "expires_in": 3600,
  "refresh_token": "flx_rt_01HX2K…",
  "scope": "decks:read"
}
The server binds the token to the JWK Thumbprint of your public key internally — it is not echoed in the token response (you already hold the key). On every API call, the resource server recomputes the thumbprint of the key in your DPoP proof and checks it against the bound value. You can confirm the binding out-of-band via the introspection endpoint, whose active response includes a cnf.jkt claim carrying the bound thumbprint (e.g. "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }).

Calling the resource server with DPoP

Two changes to every API call:
  1. The Authorization scheme is DPoP, not Bearer.
  2. Add a DPoP header with a fresh proof that includes ath.
DPOP_PROOF=$(node make-proof.js GET https://api.flexslot.gg/api/public/v1/games/magic-the-gathering/decks "$ACCESS_TOKEN")

curl https://api.flexslot.gg/api/public/v1/games/magic-the-gathering/decks \
  -H "Authorization: DPoP $ACCESS_TOKEN" \
  -H "DPoP: $DPOP_PROOF"
Every request needs a new DPoP proof. The jti must be unique (the server rejects replays) and the htm/htu must match the request. You can’t reuse proofs.

Refreshing DPoP-bound tokens

Refresh tokens for a DPoP client are also bound to the key. The /token request to refresh must include a DPoP proof signed by the same key as the original token exchange.
DPOP_PROOF=$(node make-proof.js POST https://api.flexslot.gg/api/public/v1/oauth/token)

curl -X POST https://api.flexslot.gg/api/public/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "DPoP: $DPOP_PROOF" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN"
If your key is lost, the refresh token is useless and the user must re-consent. Keep your key safe and back it up.

DPoP errors

Every DPoP failure uses the single error string invalid_dpop_proof — there is no invalid_token, and no nonce challenge.
ErrorHTTPMeaning
invalid_dpop_proof (token endpoint, {error, error_description} body)400On /token: proof JWT is malformed, signature doesn’t verify, iat is stale, jti was replayed, or a required claim is missing
invalid_dpop_proof (resource server, WWW-Authenticate: DPoP error="invalid_dpop_proof" header + FLEXSLOT_AUTHENTICATION_FAILED envelope)401On an API call: bad/missing/stale proof, ath mismatch, replayed jti, or the proof’s JWK thumbprint doesn’t match the key the token is bound to
Flexslot does not implement DPoP nonces (RFC 9449 §8 nonce mode is optional and not enabled), so your proofs never need a nonce claim and you will never see a DPoP-Nonce response header or a use_dpop_nonce error.

Trade-offs

ProCon
Stolen token alone is worthlessEvery request needs cryptographic signing — measurable CPU cost
Refresh token replay defense is built-inKey management becomes part of your ops
Provable client identity at the RSMore complex to debug — DPoP proofs are JWTs you’ll squint at
Per-request audit (jti)Requires backend support — many SDK paths don’t have DPoP yet

When NOT to use DPoP

  • For very low-value reads where the convenience of Bearer outweighs the marginal risk.
  • For machine-to-machine flows where mTLS (RFC 8705) is a better fit (continuous mutual auth, not per-request).
  • For environments where your TLS terminator strips the DPoP header — fix the infra first.

Reference

Build DPoP support after you have a working Bearer flow. Don’t try to debug PKCE and DPoP at the same time — flip on dpop_bound_access_tokens in partner admin only once everything else is green.