> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flexslot.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# DPoP — Sender-Constrained Tokens

> Bind access tokens to your client key so stolen tokens are useless

DPoP (Demonstrating Proof-of-Possession, [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) 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?

| Situation                                                                                           | Use DPoP?                                                      |
| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Server-side app handling its own tokens                                                             | Optional. Bearer is fine if your server is secure.             |
| Public client (SPA, mobile) handling high-value scopes                                              | **Yes, 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 mandate            | **Yes.**                                                       |
| Internal scripts using a PAT                                                                        | No, doesn't apply                                              |
| You're not sure                                                                                     | Start with Bearer. Add DPoP when you ship to production scale. |

<Note>
  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.
</Note>

## How DPoP works

```mermaid theme={null}
sequenceDiagram
    participant C as Client (holds keypair)
    participant AS as Authorization Server
    participant RS as Resource Server

    C->>C: Generate ES256 / EdDSA keypair (per-client, persistent)
    C->>C: Build DPoP proof JWT for /token (htm=POST, htu=/token, jti, iat)
    C->>AS: POST /token + DPoP: <proof-jwt>
    AS->>AS: Validate proof, bind token to thumbprint of public key (stored server-side)
    AS->>C: 200 {token_type: "DPoP", access_token, refresh_token}
    C->>C: Build DPoP proof JWT for the API call (htm=GET, htu=https://api.../games/magic-the-gathering/decks, ath=hash(access_token), jti, iat)
    C->>RS: GET /games/magic-the-gathering/decks + Authorization: DPoP <token> + DPoP: <proof-jwt>
    RS->>RS: Verify proof signature, check ath, jti, iat, jkt matches cnf.jkt
    RS->>C: 200 {results: [...]}
```

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.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { generateKeyPair, exportJWK } from 'jose'

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

  const publicJwk = await exportJWK(publicKey)
  publicJwk.alg = 'ES256'
  publicJwk.use = 'sig'
  ```

  ```python Python theme={null}
  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)
  ```
</CodeGroup>

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:**

```json theme={null}
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
```

**Payload:**

```json theme={null}
{
  "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:

```json theme={null}
{
  "ath": "BASE64URL(SHA256(access_token))"
}
```

### Helper

<CodeGroup>
  ```javascript Node.js theme={null}
  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)
  }
  ```

  ```python Python theme={null}
  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)
  ```
</CodeGroup>

## Token exchange with DPoP

Add a `DPoP` header to the `/token` request:

```bash theme={null}
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:

```json theme={null}
{
  "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](/oauth/overview#endpoints), 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`.

```bash theme={null}
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"
```

<Warning>
  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.
</Warning>

## 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.

```bash theme={null}
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.

| Error                                                                                                                                          | HTTP | Meaning                                                                                                                                            |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_dpop_proof` (token endpoint, `{error, error_description}` body)                                                                       | 400  | On `/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) | 401  | On 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 |

<Note>
  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.
</Note>

## Trade-offs

| Pro                                      | Con                                                             |
| ---------------------------------------- | --------------------------------------------------------------- |
| Stolen token alone is worthless          | Every request needs cryptographic signing — measurable CPU cost |
| Refresh token replay defense is built-in | Key management becomes part of your ops                         |
| Provable client identity at the RS       | More 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](https://www.rfc-editor.org/rfc/rfc8705)) 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

* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) — The DPoP spec.
* [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) — JWK Thumbprint (how `cnf.jkt` is computed).
* [jose for Node](https://github.com/panva/jose) — Recommended JOSE library.
* [jwcrypto for Python](https://jwcrypto.readthedocs.io/) — JWS/JWK support including DPoP.

<Tip>
  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.
</Tip>
