ZeroID implements two standards that together let agents and tools onboard themselves and prove ongoing possession of their credentials:
- DPoP (RFC 9449) — sender-constrained access tokens. Defeats bearer-token theft.
- Dynamic Client Registration (RFC 7591) + Client Configuration Endpoint (RFC 7592) — self-service OAuth client onboarding. Defeats hand-rolled admin shims.
This document covers both because they share design choices (intrinsic per-request auth, ZeroID-specific tenant + scope constraints) and they ship together. For the conceptual one-page overview see the Real-World Patterns section of the README (Pattern 7 = DPoP, Pattern 8 = DCR).
A standard OAuth2 access token is a bearer credential: anyone who has the bytes can use them until expiry or revocation. For a finance-bot or a high-trust orchestrator, the window between "token stolen" and "token revoked" is wide enough to do real damage. Network mitigations (mTLS, IP allowlists) don't scale to portable workloads.
DPoP closes the gap by binding the access token to a key the client holds in process memory. Every request that presents the token must also present a fresh JWT signed by that key. A stolen token without the key is useless.
The client generates an asymmetric key (ES256 or RS256), then signs a proof JWT whose payload covers the HTTP method (htm), the target URI (htu), an issued-at timestamp (iat), and a fresh JWT ID (jti). The proof's protected header carries typ: "dpop+jwt" and the public JWK.
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...
grant_type=client_credentials&client_id=...&client_secret=...&account_id=...&project_id=...&scope=payments:writeResponse:
{
"access_token": "eyJ0eXAi...",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "payments:write"
}The access token's claims include a cnf (confirmation) member with jkt = the base64url-encoded SHA-256 JWK thumbprint of the proof key (RFC 7638).
Per RFC 9449 §7, the access token is presented with the DPoP (not Bearer) auth scheme, and a new proof JWT is signed for this call. The new proof carries an ath claim — base64url(SHA-256(access_token)) — that binds the proof to this specific access token.
POST /api/v1/transfer HTTP/1.1
Host: payments.example.com
Authorization: DPoP eyJ0eXAi...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7... (different jti, ath claim set)The resource server calls ZeroID's POST /oauth2/token/introspect, sees cnf.jkt in the response, and validates the per-request proof against that thumbprint plus its own htm/htu.
Implemented in internal/service/dpop.go. Twelve steps, ordered for security:
- Parse the JWS, fail on malformed input.
typheader must bedpop+jwt.algmust be one of the allow-listed asymmetric algorithms (ES256,RS256). Symmetric algs are spec-forbidden.jwkheader must be present and must not carry private-key material (we type-assert againstjwk.ECDSAPrivateKey/jwk.RSAPrivateKey/jwk.OKPPrivateKey).- Verify the JWS signature using the embedded public JWK.
- Parse the payload (only after signature is verified).
htmmatches the request method exactly (case-sensitive per RFC 9110 §9.1).htumatches the request URL after stripping query and fragment. The URL we compare against is the request's effective URL — captured byinternal/middleware/RequestURLMiddleware— not the configuredcfg.Token.Issuer. This makes reverse-proxied deployments work transparently whenServerConfig.TrustForwardedHeaders = true.iatmust fall inside the freshness window (60 s in the past + 5 s of clock-skew tolerance).jtiis consumed atomically by INSERTing intodpop_jtiwithjtias the primary key. A23505duplicate-key error → replay. Wall-clock expiry (now + freshness + skew), not iat-relative — a malicious client cannot backdateiatto shrink the row's replay-coverage window.- If an access token is being validated at a resource server (
ValidateProofForToken),athis required and must equalbase64url(SHA-256(access_token)). - Compute the JWK thumbprint (SHA-256 per RFC 7638) — this is what becomes
cnf.jkton the issued token.
/oauth2/token reads the DPoP header on every grant type. When present and valid:
- The issued JWT carries
cnf: {"jkt": "<thumbprint>"}. - The persisted
IssuedCredentialrow records the thumbprint indpop_key_thumbprint. - The HTTP response's
token_typefield is"DPoP"instead of"Bearer". - Token introspection (
POST /oauth2/token/introspect) surfaces thecnfclaim alongside other claims.
When absent: standard Bearer behaviour. Existing callers see no change.
| Outcome | HTTP | OAuth error field |
|---|---|---|
DPoP header missing |
(n/a — DPoP is optional) | — |
| Malformed JWS / wrong typ / bad alg / private-key JWK / htm/htu/iat/jti/ath failure | 400 | invalid_dpop_proof |
| JTI replay detected | 400 | invalid_dpop_proof |
dpop_jti table unreachable |
500 | server_error |
The 500 case is deliberate: a database-unreachable signal must never look like an "invalid proof" 4xx, because that would mask outages as client errors. The service returns ErrDPoPStorageFailure (in internal/service/dpop.go) and the handler maps it explicitly.
If ZeroID sits behind nginx / an AWS ALB / a GCP LB, set:
server:
trust_forwarded_headers: trueRequestURLMiddleware will then read X-Forwarded-Proto and X-Forwarded-Host when reconstructing the URL the client signed. Leave it false if the service terminates TLS itself — otherwise a spoofed X-Forwarded-Host could move the htu goalpost.
The dpop_jti table is INSERT-only at the service layer; the cleanup worker (internal/worker/cleanup.go) sweeps rows where expires_at < now() on its periodic tick. Storage parameters are tuned for high churn:
CREATE TABLE dpop_jti (
jti VARCHAR(512) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL
) WITH (fillfactor = 90, autovacuum_vacuum_scale_factor = 0.05);autovacuum_vacuum_scale_factor = 0.05 keeps dead-tuple ratio under control (the default 0.2 is too lazy for INSERT-then-DELETE workloads).
Operational follow-up (not in this PR): at >100 token/sec sustained DPoP traffic, split the
dpop_jticleanup into a tighter 5-minute ticker independent of the credential/auth-code sweep. The hourly cadence is fine for early adoption; the analyst flagged the cutoff for visibility.
ZeroID does not gate its own endpoints on a downstream DPoP proof — /oauth2/token/introspect and /oauth2/token/revoke accept the access token under either auth scheme. The proof check is the resource server's job, and resource servers reach for ValidateProofForToken (passes accessToken so the ath check fires) rather than ValidateProof.
When a refresh token is issued in conjunction with a DPoP-bound access token (via the authorization_code grant whose /oauth2/token call carried a proof), the refresh token itself is bound to the same public key. Implementation:
refresh_tokens.dpop_key_thumbprint(added in migration 026) records the thumbprint.RotateRefreshTokenaccepts the presented proof's thumbprint as a parameter; the comparison runs inside the rotation transaction, so a bound refresh token that's presented with a wrong key / no proof:- returns
invalid_dpop_proof, notinvalid_grant, - does not consume the refresh token (the transaction rolls back),
- leaves the legitimate caller's next request with the correct key still working.
- returns
- The successor row carries the same thumbprint, so binding survives the rotation chain indefinitely.
An unbound refresh token (issued without DPoP) is not retroactively bound — even if a later rotation request presents a proof. That decision could change later; today it preserves the explicit user opt-in to DPoP.
- CIBA push mode: the CIBA push delivery path mints a token server-side with no client proof available; those tokens come out as Bearer regardless. CIBA poll mode is fully DPoP-capable today (the poll's
/oauth2/tokencall carries the proof normally). - Resource-server SDKs: the in-tree SDK helpers do not yet implement client-side proof generation. Tracking issue: future work.
- PS256 / EdDSA: only ES256 and RS256 are advertised today via
dpop_signing_alg_values_supported. Adding more is a one-line allow-list change. - Unbound → bound upgrade on rotation: today an unbound refresh token stays unbound across rotation even if the new request carries a proof. Upgrading on first proof is a small extension once we agree it's the desired UX.
OAuth clients are normally provisioned by an admin via a console. That works when the deployer of a service is the same team that runs the AS — but agent-tooling vendors who ship MCP servers, SDKs, or installer scripts to other tenants have no way to express "register an OAuth client when you install me." The workarounds (expose the admin API publicly with a sign-up form, ask each tenant's ops team to file a ticket) are operationally and security-wise bad.
RFC 7591 defines a standard registration endpoint; RFC 7592 defines the per-client management endpoints that follow it.
ZeroID has two registration paths; the right one depends on what you're registering.
| Use case | Endpoint | Auth shape |
|---|---|---|
Agent identity participating in delegation chains (token_exchange, multi-hop jwt-bearer) |
POST /api/v1/agents/register with public_key_pem |
Keypair owned by the agent; public PEM uploaded to the broker at registration. Self-signed JWT assertions verify against the stored key. |
| Confidential OAuth client — vendor MCP server, installer bootstrap, single-hop tool — that does not participate in delegation chains | POST /oauth2/register (this doc) with client_secret_basic / client_secret_post, optionally inline jwks for jwt-bearer assertions |
Client secret minted at registration; assertion-signing keys (if used) held at the broker. |
The split is deliberate. DCR-registered clients are explicitly blocked from token_exchange (enforced at the grant-type allow-list — see "What ZeroID enforces" below) because they have no IdentityID binding and cannot legitimately act as a delegation actor. Agents that need to participate in chains must register through the agent identity path. See the agent registration walkthrough in the README for that side of the API.
Both paths hold public keys at the broker, not behind the agent. public_key_pem (agent path) and inline jwks (DCR path) are the recommended shapes. jwks_uri (broker fetches keys from a URL the client publishes) is accepted for RFC 7591 spec compliance, but it requires every registered client to operate an internet-reachable HTTPS endpoint solely for key publication — and is not the recommended pattern for self-hosted ZeroID deployments. Broker-held keys remove an entire class of operational concerns (tunneling, NAT, certificate provisioning for client hosts) and limit internet-exposed surface area to ZeroID itself.
The platform decides who's allowed to self-register and mints an initial access token (IAT) — an ordinary ZeroID-issued JWT whose scopes claim contains the reserved client:register scope. Tokens are minted via any standard ZeroID grant (typically client_credentials against a confidential bootstrap client whose allowed_scopes list includes client:register).
IAT=$(curl -s -X POST https://auth.example/oauth2/token \
-d 'grant_type=client_credentials' \
-d 'client_id=...' -d 'client_secret=...' \
-d 'account_id=acme' -d 'project_id=prod' \
-d 'scope=client:register' | jq -r .access_token)curl -s -X POST https://auth.example/oauth2/register \
-H "Authorization: Bearer $IAT" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Notebook MCP",
"grant_types": ["client_credentials"],
"scope": "notebook:read notebook:write",
"token_endpoint_auth_method": "client_secret_post",
"software_id": "com.acme.notebook",
"software_version": "2.4.0"
}'The response contains the new client_id + client_secret (the plaintext secret is shown once and never persisted in plain form) and a registration_access_token that authenticates subsequent management calls.
{
"client_id": "9f43b1c2...",
"client_secret": "shown-once",
"client_id_issued_at": 1716000000,
"client_secret_expires_at": 0,
"client_name": "Acme Notebook MCP",
"grant_types": ["client_credentials"],
"scope": "notebook:read notebook:write",
"token_endpoint_auth_method": "client_secret_post",
"registration_access_token": "shown-once",
"registration_client_uri": "https://auth.example/oauth2/register/9f43b1c2..."
}# Read current registration
curl -X GET https://auth.example/oauth2/register/9f43b1c2 \
-H "Authorization: Bearer <registration_access_token>"
# Replace registration (full replacement — RFC 7592 §3)
curl -X PUT https://auth.example/oauth2/register/9f43b1c2 \
-H "Authorization: Bearer <registration_access_token>" \
-H "Content-Type: application/json" \
-d '{"client_name":"Acme Notebook MCP","grant_types":["client_credentials"],"scope":"notebook:read"}'
# Delete
curl -X DELETE https://auth.example/oauth2/register/9f43b1c2 \
-H "Authorization: Bearer <registration_access_token>"GET and PUT responses re-include the public client metadata but never re-reveal client_secret or registration_access_token.
For clients that will use the urn:ietf:params:oauth:grant-type:jwt-bearer grant — typically a service-account-style integration whose downstream calls are authorized by a signed JWT assertion — register an inline JWKS at registration time so the broker can verify those assertions:
# 1. Generate a P-256 keypair locally (one-time per client).
openssl ecparam -name prime256v1 -genkey -noout -out client-private.pem
openssl ec -in client-private.pem -pubout -out client-public.pem
# 2. Convert client-public.pem into JWK form (kty/crv/x/y). Any small helper
# works — Python's `cryptography` + `python-jose`, Go's `lestrrat-go/jwx`,
# or the `mkjwk` CLI. The shape below assumes you've already produced it.
# 3. Register, embedding the JWK inline.
curl -s -X POST https://auth.example/oauth2/register \
-H "Authorization: Bearer $IAT" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Notebook MCP",
"grant_types": [
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_credentials"
],
"scope": "notebook:read notebook:write",
"token_endpoint_auth_method": "client_secret_basic",
"jwks": {
"keys": [{
"kty": "EC",
"crv": "P-256",
"use": "sig",
"alg": "ES256",
"kid": "client-key-1",
"x": "<base64url of public x coordinate>",
"y": "<base64url of public y coordinate>"
}]
},
"software_id": "com.acme.notebook",
"software_version": "2.4.0"
}'When this client later presents a jwt-bearer grant:
curl -s -X POST https://auth.example/oauth2/token \
-u "<client_id>:<client_secret>" \
-d 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
-d 'assertion=<JWT signed by client-key-1>' \
-d 'scope=notebook:read'…the client authenticates to the token endpoint with HTTP Basic (its client_secret), and the assertion JWT is verified against the JWKS uploaded at registration. The two checks are independent: token_endpoint_auth_method governs how the client identifies itself to the endpoint; the jwks governs how its assertion signature is verified.
Why inline jwks rather than jwks_uri: RFC 7591 §2 accepts both. Inline jwks uploads the keys to the broker once at registration time, the broker stores them, and verification is local. jwks_uri requires the broker to fetch keys from a URL the client publishes, which means the client must run an internet-reachable HTTPS endpoint solely for key publication — workable when client and broker live in different security domains, but unnecessary friction for self-hosted ZeroID deployments. For key rotation, use an RFC 7592 PUT to swap the inline JWKS (single round-trip, no DNS or TLS dependency).
private_key_jwtis not currently accepted astoken_endpoint_auth_methodfor DCR-registered clients (see "What ZeroID enforces" below). DCR clients authenticate to the token endpoint with their client secret; the jwt-bearer grant is the path where signed assertions and JWKS come into play.
Implemented in internal/handler/dynamic_registration.go (handler) and internal/service/oauth_client.go (service).
validateInitialAccessToken rejects unless all of the following hold:
- JWS signature verifies against the local JWKS (any zeroid signing key).
issequalscfg.Token.Issuer.audcontainscfg.Token.Issuer— defence against tokens minted for a different protected resource being replayed here. (Per RFC 9068 §3, ZeroID-issued access tokens default toaud = [issuer], so this works out of the box.)iat/expare in-window perjwt.WithValidate(true).- The
scopesclaim containsclient:register. The accessor tries[]stringfirst then falls back to[]any— matchinginternal/middleware/AgentAuthMiddleware's pattern.
Tenant claims (account_id, project_id, sub) are extracted and surfaced into the audit log; OAuth clients themselves are global per ZeroID's design (see domain/token.go's OAuthClient comment) so they are not stored with a tenant column.
Validated by validateDCRClientMetadata:
client_nameis required.grant_typesdefaults to["client_credentials"]. The allow-list for DCR-registered clients isclient_credentialsandurn:ietf:params:oauth:grant-type:jwt-beareronly. Notably absent:authorization_code— no interactive consent flow exists for self-registered clients.urn:ietf:params:oauth:grant-type:token-exchange— DCR clients have noIdentityIDbinding and so cannot legitimately act as a delegation actor. Re-enable once that binding exists.
token_endpoint_auth_methodisclient_secret_post,client_secret_basic, or empty (defaults toclient_secret_basicper RFC 7591 §2)."none"is explicitly rejected — this server requires client authentication.redirect_urisis accepted for spec compliance but ignored.
VerifyRegistrationToken performs a constant-time check: regardless of whether the client_id exists, exactly one bcrypt comparison runs (against the stored hash on a hit, against dummyRegistrationTokenHash on a miss). Both hashes use dcrBcryptCost = 12 so timing is balanced.
RegistrationSource != "dynamic" short-circuits to "not found" before the bcrypt comparison so an admin-registered (internal) client can never be authenticated via a registration token — even if one is somehow guessed.
DeleteByClientID (in internal/store/postgres/oauth_client.go) adds WHERE registration_source = 'dynamic' as defence-in-depth. Even if a service-layer check is skipped or bypassed, the repository refuses to remove an internal client.
For the DPoP/DCR cross-cut: cnf is now in reservedClaims in internal/service/oauth.go. The external-principal-exchange flow (which lets a trusted service inject claims via additional_claims) cannot smuggle a cnf.jkt value through; the only path that writes cnf is credential.IssueCredential when req.DPoPKeyThumbprint came from a validated proof.
DCR adds two columns on the existing oauth_clients table:
ALTER TABLE oauth_clients
ADD COLUMN registration_source VARCHAR(50) NOT NULL DEFAULT 'internal',
ADD COLUMN registration_access_token VARCHAR(255);Existing rows back-fill to 'internal'; no manual migration step. The registration_access_token column is nullzero-tagged in the Go model so internal clients persist NULL (not "").
A standards-conformant client walks two documents to find the registration endpoint:
/.well-known/oauth-protected-resource(RFC 9728) — the resource server's metadata. Advertisesresource,authorization_servers(pointers to the AS),bearer_methods_supported: ["header"],dpop_bound_access_tokens_required: false. This is the document a 401 withWWW-Authenticate: Bearer resource_metadata="…"points the client at./.well-known/oauth-authorization-server(RFC 8414) — the authorization server's metadata, fetched after PRM points the client here. Advertises the actual endpoints:registration_endpoint— set to{baseURL}/oauth2/registerwhen DCR is wired (it always is in this build; the endpoint exists but every request 401s if the deployer doesn't mintclient:register-scoped tokens).dpop_signing_alg_values_supported: ["ES256", "RS256"].
The two-hop PRM → AS chain is what an RFC 8414/9728-conformant client walks. Publishing both documents lets stock OAuth clients work without ZeroID-specific shimming.
software_statement(RFC 7591 §2.3) — signed metadata assertions — not implemented.- Per-client
client_secret_expires_at— DCR clients today have a non-expiring secret (the response field is0per RFC 7591 §3.2.1 conventions). Rotation is supported via theRotateSecretadmin path on the underlying client, but no automatic expiry/rotation policy is wired. - Initial-access-token issuance UX — ZeroID does not yet ship a one-call "mint me an IAT" admin endpoint. Today it's an ordinary
client_credentialscall against a confidential client whoseallowed_scopeslist includesclient:register.
server:
trust_forwarded_headers: false # set true when behind a trusted edge proxy (nginx/ALB/etc.) for DPoP htu correctnessNo DCR-specific config knobs — the feature is governed by which clients hold client:register scope.
| Signal | What it means | Fix |
|---|---|---|
level=info, msg="DCR: dynamic client registered", client_id=..., registered_by_*=... |
DCR registration succeeded | informational; preserve for audit |
level=info, msg="DCR: initial access token rejected" |
A POST /oauth2/register call presented an IAT that failed validation | check IAT issuer / audience / freshness / scope |
level=info, msg="DCR: initial access token rejected — insufficient scope" |
IAT validated cryptographically but lacked client:register |
client error; respond 403 (handler already does) |
level=error, msg="DPoP JTI store unavailable" |
DB write to dpop_jti failed for a non-23505 reason |
check PG availability; ZeroID returned 500 |
| Concern | File |
|---|---|
| DPoP validator | internal/service/dpop.go |
| DPoP handler integration | internal/handler/oauth.go (search DPoPProof) |
| Request-URL middleware (for DPoP htu) | internal/middleware/request_url.go |
| DCR handler (POST/GET/PUT/DELETE) | internal/handler/dynamic_registration.go |
| DCR service methods | internal/service/oauth_client.go (search DynamicRegisterClient, VerifyRegistrationToken, UpdateDynamicClient, DeleteDynamicClient) |
| Repo guard | internal/store/postgres/oauth_client.go (DeleteByClientID) |
Cleanup worker (sweeps dpop_jti) |
internal/worker/cleanup.go |
| Discovery (well-known) | internal/handler/wellknown.go |
| Migrations | migrations/024_dynamic_client_registration.up.sql, migrations/025_dpop.up.sql, migrations/026_refresh_token_dpop_binding.up.sql |
| Refresh-token rotation w/ binding | internal/service/refresh_token.go (RotateRefreshToken, ErrDPoPBindingMismatch) |