DPoP Resource Server Validation
The DPoP page explains how the BFF generates DPoP proofs and how the OpenIddict server binds tokens to cryptographic keys. This page covers the other side: how a resource server validates those proofs, regardless of which identity provider issued the token.
The problem
Section titled “The problem”OpenIddict’s built-in ValidateProofOfPossession handler validates DPoP proofs automatically —
but only when the resource server uses AddGranitOpenIddictAuthentication(). In microservice
architectures where the identity provider is Keycloak, Entra ID, Auth0, or Cognito, the
resource server uses standard JWT Bearer authentication and has no way to validate DPoP proofs.
Without server-side validation, DPoP is only half-implemented: the token is bound to a key,
but nothing prevents an attacker from stripping the DPoP header and presenting the token
as a plain Bearer token.
How it works
Section titled “How it works”Granit.Authentication.DPoP provides IdP-agnostic DPoP proof validation:
sequenceDiagram
participant BFF as BFF Server
participant API as Resource Server<br/>(Keycloak JWT Bearer)
participant Cache as IDistributedCache
BFF->>API: GET /api/products<br/>Authorization: DPoP eyJhbG...<br/>DPoP: eyJ0eXAiOiJkcG9w...
Note over API: DPoPValidationMiddleware
API->>API: 1. Accept "DPoP" auth scheme
API->>API: 2. Parse proof JWT header + payload
API->>API: 3. Verify EC/RSA signature
API->>API: 4. Check htm=GET, htu matches request URI
API->>API: 5. Check exp > now, iat reasonable
API->>Cache: 6. Check jti uniqueness (anti-replay)
API->>API: 7. Compute JWK thumbprint (RFC 7638)
API->>API: 8. Compare with cnf.jkt in access token
API-->>BFF: 200 OK
-
Scheme acceptance —
JwtBearerEvents.OnMessageReceivedextracts the token fromAuthorization: DPoP <token>(standard JWT Bearer only acceptsBearer). -
Proof validation —
IDPoPProofValidatorverifies the DPoP proof JWT: signature (ES256, PS256, EdDSA), HTTP method (htm), request URI (htu), expiration, issued-at. -
Anti-replay — The
jticlaim is checked againstIDistributedCacheto prevent proof replay within the validity window. Essential for FAPI 2.0 financial-grade security. -
Token binding —
JwkThumbprintCalculatorcomputes the SHA-256 thumbprint of the proof’s public key (RFC 7638) and compares it with thecnf.jktclaim in the access token. If they don’t match, the request is rejected with401 Unauthorized.
Package structure
Section titled “Package structure”DirectoryGranit.Authentication.DPoP/
DirectoryValidation/
- IDPoPProofValidator.cs Interface
DirectoryInternal/
- DPoPProofValidator.cs Signature, claims, binding
- JwkThumbprintCalculator.cs RFC 7638 thumbprint
DirectoryMiddleware/
- DPoPValidationMiddleware.cs After-auth enforcement
DirectoryExtensions/
- DPoPJwtBearerExtensions.cs One-line setup
DirectoryOptions/
- DPoPValidationOptions.cs Configuration
DirectoryDiagnostics/
- DPoPValidationMetrics.cs OpenTelemetry counters
- DPoPValidationActivitySource.cs Tracing spans
One-line integration
Section titled “One-line integration”[DependsOn( typeof(GranitAuthenticationJwtBearerKeycloakModule), typeof(GranitAuthenticationDPoPModule))] // ← add thispublic class CatalogServiceModule : GranitModule { }In OnApplicationInitialization:
app.UseAuthentication();app.UseGranitDPoPValidation(); // ← after UseAuthenticationapp.UseAuthorization();Configuration
Section titled “Configuration”{ "Authentication": { "Authority": "https://keycloak.example.com/realms/my-realm", "Audience": "catalog-service", "DPoP": { "RequireDPoP": true, "AllowedAlgorithms": ["ES256", "PS256"], "ProofMaxAge": "00:00:30", "NonceLifetime": "00:05:00", "EnableAntiReplay": true } }}| Property | Type | Default | Description |
|---|---|---|---|
RequireDPoP | bool | false | Reject requests without valid DPoP proof. When false, DPoP is validated if present but not required. |
AllowedAlgorithms | string[] | ["ES256", "PS256"] | Allowed signing algorithms for DPoP proofs. |
ProofMaxAge | TimeSpan | 00:00:30 | Maximum age of a DPoP proof JWT. |
NonceLifetime | TimeSpan | 00:05:00 | TTL for anti-replay jti entries in distributed cache. |
EnableAntiReplay | bool | true | Check jti uniqueness in IDistributedCache. |
JWK Thumbprint calculation (RFC 7638)
Section titled “JWK Thumbprint calculation (RFC 7638)”The core of DPoP binding is the JWK thumbprint — a deterministic hash of the client’s
public key. JwkThumbprintCalculator produces the exact same thumbprint that the
authorization server embedded in the token’s cnf.jkt claim.
Algorithm:
- Extract the public key from the DPoP proof’s
jwkheader - Build canonical JSON (alphabetical keys, required members only):
- EC:
{"crv":"P-256","kty":"EC","x":"...","y":"..."} - RSA:
{"e":"...","kty":"RSA","n":"..."}
- EC:
- SHA-256 hash → base64url encode
The result matches RFC 7638 §3.1 test vectors.
IdP compatibility
Section titled “IdP compatibility”| Identity provider | DPoP support | cnf.jkt in tokens | Works with this package |
|---|---|---|---|
| Keycloak 25+ | Native | Yes | Yes |
| Duende IdentityServer 7+ | Native | Yes | Yes |
| OpenIddict 7+ | Native | Yes | Yes (also works via built-in validation) |
| Entra ID | Preview | Partial | Yes (when cnf claim is present) |
| Auth0 | Not yet | No | Not applicable — no cnf claim issued |
| AWS Cognito | No | No | Not applicable |
Comparison: OpenIddict vs JWT Bearer DPoP
Section titled “Comparison: OpenIddict vs JWT Bearer DPoP”// Built-in: ValidateProofOfPossession handler// No extra package neededbuilder.AddGranitOpenIddictAuthentication(options =>{ options.RequireDPoP = true;});Uses OpenIddict’s native DPoP validation pipeline. Only works when the resource server validates tokens against an OpenIddict server.
// Granit.Authentication.DPoP package[DependsOn( typeof(GranitAuthenticationJwtBearerKeycloakModule), typeof(GranitAuthenticationDPoPModule))]
// In middleware:app.UseAuthentication();app.UseGranitDPoPValidation();app.UseAuthorization();IdP-agnostic: validates DPoP proofs using standard cryptography.
Works with any provider that issues cnf.jkt-bound tokens.
Observability
Section titled “Observability”Granit.Authentication.DPoP emits OpenTelemetry metrics and traces:
Metrics (meter: Granit.Authentication.DPoP):
| Metric | Type | Tags | Description |
|---|---|---|---|
granit.authentication.dpop.validation.total | Counter | tenant_id, result | Total proof validations (success/failure) |
granit.authentication.dpop.replay.rejected | Counter | tenant_id | Replay attempts blocked by jti check |
Activity source (Granit.Authentication.DPoP):
| Span | Description |
|---|---|
DPoP.ValidateProof | Proof JWT validation (signature + claims + binding) |
Security considerations
Section titled “Security considerations”| Threat | Mitigation |
|---|---|
| Stolen token + no DPoP proof | RequireDPoP: true rejects Bearer scheme |
| Stolen token + replayed proof | jti anti-replay via IDistributedCache |
| Proof for wrong endpoint | htm + htu validation (method + URI bound) |
| Expired proof | exp + ProofMaxAge enforcement |
| Weak algorithm | AllowedAlgorithms whitelist (default: ES256, PS256) |
| Key confusion (wrong key) | cnf.jkt thumbprint comparison |
See also
Section titled “See also”- DPoP — Proof-of-Possession — BFF-side key generation and proof creation
- Private Key JWT Authentication — eliminate shared secrets
- PAR (Pushed Authorization Requests) — move parameters to back-channel
- FAPI 2.0 Security Profile — full conformance checklist
- OIDC Client Primitives —
IDPoPProofService, typed requests - Token Management — automatic token lifecycle for service-to-service
- Authentication — JWT Bearer, Keycloak, Entra ID setup
- BFF Token Security — server-side token storage