A small, standards-exact OAuth 2.1 authorization server whose every access token is sender-constrained with DPoP (RFC 9449). A token lifted from a log, a proxy or a browser extension is inert without the private key it is bound to.
It is the server-side counterpart of
dpop_client, the Dart
package that signs DPoP proofs, and CI runs that client against this server
on every push.
Django 5.2 or 6.1, Django Ninja, PostgreSQL, Redis and joserfc. There are no
hand-rolled cryptographic primitives.
- Authorization Code with mandatory PKCE. S256 only;
plainis refused. Redirect URIs match exactly. Login and consent are server-rendered, CSRF- and frame-protected, and responses carryiss(RFC 9207). - Client Credentials for service-to-service calls.
- Refresh tokens: opaque, hashed at rest, rotated on every use. Reuse revokes the whole family.
- DPoP everywhere: the full RFC 9449 §4.3 checklist,
jtireplay cache, server nonces with theuse_dpop_nonceflow,cnf.jktbinding by RFC 7638 thumbprint,dpop_jktauthorization code binding (§10), andtoken_typeDPoP. - JWT access tokens in the RFC 9068 profile, signed with ES256 keys that rotate through pending → active → retired, several at once in the JWKS.
- Metadata (RFC 8414), Introspection (RFC 7662), Revocation (RFC 7009) and Pushed Authorization Requests (RFC 9126).
ninja_dpop: a reusable Django Ninja auth class for resource servers. It validates the token, the proof andath, with RFC-shapedWWW-Authenticate: DPoPerrors. See its README.- Rate limiting on
/token, constant-time comparisons for every secret, and error descriptions that never say which check failed.
sequenceDiagram
autonumber
participant C as Client (dpop_client)
participant AS as Authorization server
participant R as Redis
participant RS as Resource server (ninja_dpop)
Note over C: generates an ES256 key pair once;<br/>the private key never leaves the device
C->>AS: POST /oauth/token<br/>DPoP: proof{htm, htu, iat, jti}
AS->>R: nonce current?
AS-->>C: 400 use_dpop_nonce<br/>DPoP-Nonce: n1
C->>AS: POST /oauth/token<br/>DPoP: proof{…, nonce: n1}
AS->>AS: verify typ, alg, jwk is public, signature,<br/>htm, htu, iat window, nonce
AS->>R: SET jkt:jti NX EX 70 (replay cache)
AS-->>C: 200 {access_token: JWT{cnf.jkt = thumbprint(jwk)},<br/>token_type: "DPoP", refresh_token}<br/>DPoP-Nonce: n1
C->>RS: GET /api/orders<br/>Authorization: DPoP <token><br/>DPoP: proof{…, ath = SHA-256(token)}
RS->>RS: verify token (JWKS, iss, aud, exp, cnf.jkt)
RS->>RS: verify proof: key = cnf.jkt, ath matches, htm/htu
RS->>R: SET jkt:jti NX EX 70
RS-->>C: 200 orders
Note over C,RS: a stolen token without the key fails at the proof step,<br/>and a replayed proof fails at the jti step
With Docker:
docker compose up --build -d
docker compose exec web python manage.py createsuperuser
docker compose exec web python manage.py create_client "My service" \
--grant client_credentials --scope orders:read --scope orders:write
curl -s localhost:8000/.well-known/oauth-authorization-server | jqLocally, with uv and a Redis on localhost:6379:
make install
make run # migrations, then runserver on :8000 (SQLite)Register an app that signs users in:
DJANGO_DEBUG=true uv run python manage.py create_client "Mobile app" --type public \
--grant authorization_code --grant refresh_token \
--redirect-uri com.example.app:/oauth2redirect --scope profile --scope orders:readA confidential client's secret is printed once and stored only as a digest.
Signing keys are generated on first use and encrypted in the database. Rotate
them on a schedule with make keys (manage.py rotate_signing_keys).
final client = DPoPClient(DPoPKeyPair.generate());
final basic = 'Basic ${base64.encode(utf8.encode('$clientId:$clientSecret'))}';
final tokenUrl = Uri.parse('https://auth.example.com/oauth/token');
var proof = client.createProof(url: tokenUrl, method: 'POST');
var response = await http.post(tokenUrl, headers: {...proof.headers(), 'Authorization': basic},
body: {'grant_type': 'client_credentials'});
// 400 use_dpop_nonce: retry with the nonce the server just sent.
proof = client.createProof(url: tokenUrl, method: 'POST',
nonce: response.headers['dpop-nonce']);
response = await http.post(tokenUrl, headers: {...proof.headers(), 'Authorization': basic},
body: {'grant_type': 'client_credentials'});The full program, with the API calls and the negative checks, is
interop/dart/bin/interop.dart.
| Endpoint | Method | Spec | Notes |
|---|---|---|---|
/.well-known/oauth-authorization-server |
GET | RFC 8414 | Includes dpop_signing_alg_values_supported. |
/oauth/jwks |
GET | RFC 7517 | Pending, active and retired keys. |
/oauth/authorize |
GET | OAuth 2.1 §4.1.1, RFC 9126 §4 | response_type=code, S256 PKCE, optional dpop_jkt, or a PAR request_uri. |
/oauth/login, /oauth/consent |
GET, POST | Server-rendered; CSRF-protected; X-Frame-Options: DENY. |
|
/oauth/par |
POST | RFC 9126 | A DPoP proof here binds the code to its key (RFC 9449 §10.1). |
/oauth/token |
POST | OAuth 2.1 §3.2, RFC 9449 §5 | authorization_code, refresh_token, client_credentials. Proof required. Rate-limited. |
/oauth/introspect |
POST | RFC 7662 | Confidential clients; returns cnf.jkt and token_type: DPoP. |
/oauth/revoke |
POST | RFC 7009 | A refresh token revokes its whole grant. |
/oauth/docs |
GET | OpenAPI docs for the endpoints above. | |
/api/me, /api/orders |
GET, POST | RFC 9449 §7 | The example resource server (demo_api), behind ninja_dpop. |
Client authentication: client_secret_basic and client_secret_post for
confidential clients, and none for public clients, which rely on PKCE and
DPoP instead.
Every MUST in RFC 9449 §4.3 has its own test, with the step number in the
docstring. File paths are relative to tests/.
| RFC | Section | Requirement | Tests |
|---|---|---|---|
| 9449 | §4.3 step 1 | At most one DPoP header |
dpop/test_proof.py::test_more_than_one_dpop_header_is_refused, ::test_folded_dpop_headers_are_refused, test_token_endpoint.py::test_two_dpop_headers_are_refused |
| 9449 | §4.3 step 2 | A well-formed JWT | dpop/test_proof.py::test_a_proof_that_is_not_a_jwt_is_refused, ::test_a_payload_that_is_not_an_object_is_refused |
| 9449 | §4.3 step 3, §4.2 | typ, alg, jwk, jti, htm, htu, iat present |
dpop/test_proof.py::test_every_required_member_must_be_present, ::test_iat_must_be_an_integer |
| 9449 | §4.3 step 4 | typ is dpop+jwt |
dpop/test_proof.py::test_typ_must_be_dpop_jwt, ::test_typ_compares_case_insensitively |
| 9449 | §4.3 step 5 | Asymmetric, supported, not none |
dpop/test_proof.py::test_alg_none_is_refused, ::test_a_symmetric_alg_is_refused, ::test_an_asymmetric_alg_outside_the_allowlist_is_refused, ::test_a_key_that_does_not_fit_the_alg_is_refused |
| 9449 | §4.3 step 6 | The signature verifies with jwk |
dpop/test_proof.py::test_a_forged_signature_is_refused, ::test_a_tampered_payload_is_refused, ::test_a_proof_signed_by_a_key_other_than_its_jwk_is_refused |
| 9449 | §4.3 step 7 | jwk is not a private key |
dpop/test_proof.py::test_a_private_key_in_jwk_is_refused |
| 9449 | §4.3 step 8 | htm matches |
dpop/test_proof.py::test_htm_must_match_the_request_method, ::test_htm_is_case_sensitive, test_resource_server.py::test_a_proof_for_the_wrong_method_is_refused |
| 9449 | §4.3 step 9 | htu matches, ignoring query and fragment |
dpop/test_htu.py (RFC 3986 §6.2 normalization), dpop/test_proof.py::test_htu_must_match_the_request_uri, test_token_endpoint.py::test_htu_is_checked_against_the_issuer_not_the_host_header |
| 9449 | §4.3 step 10, §8 | The server nonce, when provided | dpop/test_replay_and_nonce.py::test_a_proof_without_a_required_nonce_asks_for_one, ::test_an_invented_nonce_asks_for_a_real_one, test_token_endpoint.py::test_a_request_without_a_nonce_is_told_to_use_one |
| 9449 | §4.3 step 11 | iat within the window |
dpop/test_proof.py::test_an_expired_proof_is_refused, ::test_a_proof_from_the_future_is_refused, ::test_a_slightly_fast_client_clock_is_tolerated |
| 9449 | §4.3 step 12 | ath matches; key matches cnf.jkt |
dpop/test_proof.py::test_ath_must_hash_the_presented_token, ::test_the_proof_key_must_be_the_bound_key, test_resource_server.py::test_a_proof_from_a_key_other_than_cnf_jkt_is_refused |
| 9449 | §5 | token_type DPoP; public clients' refresh tokens are key-bound |
test_token_endpoint.py::test_a_code_is_exchanged_for_a_dpop_bound_token, ::test_a_public_clients_refresh_token_is_bound_to_its_dpop_key, test_refresh.py::test_a_public_clients_refresh_needs_a_proof_from_the_bound_key |
| 9449 | §5.1 | dpop_signing_alg_values_supported |
test_metadata.py::test_metadata_advertises_the_dpop_algorithms_the_server_accepts |
| 9449 | §6.1 | cnf.jkt is the RFC 7638 thumbprint |
test_token_endpoint.py::test_the_access_token_is_bound_to_the_proof_key_by_thumbprint, dpop/test_proof.py::test_thumbprints_match_the_rfc_7638_example |
| 9449 | §6.2 | Introspection shows the binding | test_introspection_revocation.py::test_an_active_access_token_is_described_with_its_dpop_binding |
| 9449 | §7.1 | Authorization: DPoP, WWW-Authenticate: DPoP algs=… |
test_resource_server.py::test_no_credentials_gets_a_bare_challenge, ::test_a_token_without_a_proof_is_refused |
| 9449 | §7.2 | A bound token is never accepted as Bearer | test_resource_server.py::test_a_bound_token_presented_as_bearer_is_refused |
| 9449 | §9 | Resource server nonces | test_resource_server.py::test_a_resource_server_requiring_nonces_says_so_and_accepts_the_retry |
| 9449 | §10, §10.1 | dpop_jkt and PAR binding |
test_token_endpoint.py::test_dpop_jkt_from_the_authorization_request_must_match_the_proof, test_par.py::test_a_dpop_proof_on_the_push_binds_the_code_to_its_key |
| 9449 | §11.1 | jti replay detection |
dpop/test_replay_and_nonce.py::test_a_replayed_proof_is_refused_even_inside_its_time_window, ::test_a_jti_is_remembered_for_exactly_the_replay_window, test_resource_server.py::test_a_replayed_proof_is_refused |
| 7636 | §4.2, App. B | S256 transformation | test_pkce.py::test_s256_matches_the_rfc_7636_appendix_b_example |
| OAuth 2.1 | §4.1.1 | PKCE required; plain refused |
test_authorize.py::test_a_missing_code_challenge_is_refused, ::test_the_plain_pkce_method_is_refused, ::test_an_absent_pkce_method_is_refused_because_it_means_plain |
| OAuth 2.1 | §2.3.1, §4.1.2.1 | Exact redirect URI; never redirect to an unverified one | test_authorize.py::test_a_redirect_uri_that_is_not_exactly_registered_is_never_redirected_to |
| OAuth 2.1 | §4.1.3 | One-time codes; reuse revokes | test_token_endpoint.py::test_a_code_can_be_redeemed_once_and_reuse_revokes_what_it_issued |
| OAuth 2.1 | §4.3.1; RFC 9700 §4.14.2 | Refresh rotation, reuse detection | test_refresh.py::test_a_refresh_rotates_the_token, ::test_reusing_a_rotated_refresh_token_revokes_the_whole_family |
| 6749 | §3.1 | No repeated parameters | test_authorize.py::test_a_repeated_parameter_is_refused, test_token_endpoint.py::test_a_repeated_parameter_is_refused |
| 9068 | §2, §4 | JWT access token profile | test_token_endpoint.py::test_the_access_token_follows_the_rfc_9068_profile, test_resource_server.py::test_tokens_outside_the_rfc_9068_profile_are_refused |
| 9207 | §2 | iss in authorization responses |
test_authorize.py::test_an_approved_request_returns_a_code_state_and_issuer |
| 8414 | §2, §3 | Metadata | test_metadata.py |
| 7662 | §2.1, §2.2, §4 | Introspection | test_introspection_revocation.py::test_introspection_requires_client_authentication, ::test_a_client_learns_nothing_about_another_clients_tokens |
| 7009 | §2.1, §2.2 | Revocation | test_introspection_revocation.py::test_revoking_a_refresh_token_revokes_the_whole_grant, ::test_revoking_an_unknown_token_succeeds |
| 9126 | §2, §4 | PAR | test_par.py |
| — | — | Interop with dpop_client |
interop/test_dart_client.py (skipped without the Dart SDK) |
Protected against:
- Stolen access tokens (logs, proxies, browser storage, a compromised
resource server). Every token is bound to a key through
cnf.jkt, and every use needs a fresh proof signed by that key. - Stolen refresh tokens. A public client's refresh token is bound to its key. A confidential client's needs client authentication. Any token presented after rotation revokes the whole grant.
- Proof replay. Inside the
iatwindow, thejticache stops it (atomic, per key). Outside it,iatdoes. Pre-generated proofs are stopped by the server nonce (ADR 0002). - Proofs aimed at another endpoint or server.
htmandhtuare checked against the URL derived from configuration, never from theHostheader.athties each proof to one token. - Authorization code interception. S256 PKCE is mandatory, codes are
single-use and live 60 s, and reuse revokes what the code produced.
dpop_jktand PAR can bind the code to the client's key. - Open redirects and code leakage through redirects. Redirect URIs match exactly, and nothing is ever redirected to before the client and redirect URI are verified.
- Mix-up attacks.
issgoes in every authorization response (RFC 9207). - Clickjacking and CSRF on consent:
X-Frame-Options: DENYand Django's CSRF protection. - Credential theft from the database. Client secrets, codes, refresh tokens and PAR handles are stored as SHA-256 digests, and signing keys are encrypted.
- Oracles. Comparisons are constant-time, unknown clients take the same path as wrong secrets, error descriptions are uniform, and introspection and revocation answer another client's tokens as if they were unknown.
- Online guessing at
/token, through a per-address rate limit. - Algorithm confusion.
noneand MACs cannot be enabled at all, each algorithm is pinned to its key type and curve, and short RSA keys are refused.
Not protected against:
- A stolen DPoP private key. DPoP binds tokens to a key; it cannot tell
the key's owner from someone who copied it. Keep it in the platform
keystore, as
dpop_clientadvises. Nonces limit how far ahead a thief can work, and rotation limits how long a stolen refresh token lives, but neither helps while the thief has live access. - A compromised client device or app that signs proofs on the attacker's behalf.
- Revocation at resource servers that validate locally. A revoked access token is accepted until it expires, at most five minutes (ADR 0001). Resource servers that need instant revocation must introspect.
- Loss of Redis. Without the replay cache, replays inside the window succeed. There is deliberately no fallback.
- Phishing of the user's password on a look-alike login page. Nothing here offers phishing-resistant authentication.
- A compromised host, or a leaked
OAUTH_KEY_ENCRYPTION_SECRETtogether with the database. - Volumetric denial of service. The rate limit only slows guessing.
Everything is set through the environment (see .env.example)
or Django settings. The OAUTH_* settings are listed in
authserver/conf.py and the NINJA_DPOP settings in
ninja_dpop/conf.py.
| Setting | Default | |
|---|---|---|
OAUTH_ISSUER |
http://localhost:8000 |
The issuer identifier. Every htu at this server is checked against it. |
OAUTH_ACCESS_TOKEN_TTL |
300 s | |
OAUTH_REFRESH_TOKEN_TTL / OAUTH_REFRESH_FAMILY_TTL |
14 / 30 days | Per token, and the absolute lifetime of a grant. |
OAUTH_DPOP_ALGORITHMS |
ES256, ES384, Ed25519, PS256 | |
OAUTH_DPOP_PROOF_MAX_AGE / OAUTH_DPOP_CLOCK_SKEW |
60 s / 5 s | |
OAUTH_DPOP_REQUIRE_NONCE |
true | |
OAUTH_TOKEN_RATE_LIMIT / OAUTH_TOKEN_RATE_WINDOW |
60 per 60 s | Per client address. |
OAUTH_KEY_ENCRYPTION_SECRET |
DJANGO_SECRET_KEY |
Set it separately in production. |
make check # ruff, ruff format --check, mypy --strict, pytest
make test-postgres # the suite against Postgres at $DATABASE_URL
make interop # the Dart client against a real server and RedisThe suite runs on SQLite with fakeredis by default, and on Postgres whenever
DATABASE_URL is set, as CI does. The interop test runs when the Dart SDK is
on PATH and dpop_client
is checked out next to this repository.
Layout:
authserver/ the authorization server (Django app)
dpop/ RFC 9449 proof verification, replay cache, nonces (no Django)
ninja_dpop/ the resource-server auth class for Django Ninja
demo_api/ an example protected API
interop/dart/ the dpop_client interop program
docs/adr/ architecture decision records
- No OpenID Connect. There are no ID tokens, no
userinfoand no discovery beyond RFC 8414. - No dynamic client registration (RFC 7591). Clients are registered with
manage.py create_clientor the admin. - One audience. Resource indicators (RFC 8707) are not implemented, so
every token's
audisOAUTH_ACCESS_TOKEN_AUDIENCE. - DPoP only. There is no bearer-token fallback and no mTLS (RFC 8705); see ADR 0005.
- Consent is not remembered. Every authorization asks again.
- Login is not rate-limited or MFA-protected. It is a stand-in for a real identity provider.
- Signing keys live in the database, encrypted, rather than in an HSM or KMS.
- The rate limit keys on
REMOTE_ADDR. Behind a proxy, that must be set to the real client address. - Nonces are required for every client at the token endpoint when enabled; there is no per-client policy.
dpop_client: DPoP proofs for Dart and Flutter, the client this server is tested against.ci-workflows: the shared CI this repository calls.
MIT © 2026 Mohammad Zarif