Skip to content

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.

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.

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 not in future
    API->>Cache: 6. Validate nonce (if RequireNonce)
    API->>Cache: 7. Check jti uniqueness (anti-replay)
    API->>API: 8. Verify RSA key ≥ 2048-bit
    API->>API: 9. Reject private key params in JWK
    API->>API: 10. Compute JWK thumbprint (RFC 7638)
    API->>API: 11. Compare with cnf.jkt in access token

    API-->>BFF: 200 OK (DPoP-Nonce: ...)
  1. Scheme acceptanceJwtBearerEvents.OnMessageReceived extracts the token from Authorization: DPoP <token> (standard JWT Bearer only accepts Bearer).

  2. Proof validationIDPoPProofValidator verifies the DPoP proof JWT: structure, algorithm whitelist, HTTP method (htm), request URI (htu), expiration, issued-at (past and future bounds), and rejects JWKs containing private key parameters.

  3. Nonce validation — When RequireNonce is enabled, the nonce claim must match a server-issued value from a previous DPoP-Nonce response header. One-time use.

  4. Anti-replay — The jti claim is checked against IFusionCache to prevent proof replay within the validity window. Essential for FAPI 2.0 financial-grade security.

  5. Key strength — RSA keys are validated against MinimumRsaKeySize (default 2048-bit, per NIST SP 800-57).

  6. Token bindingJwkThumbprintCalculator computes the SHA-256 thumbprint of the proof’s public key (RFC 7638) and compares it with the cnf.jkt claim in the access token. When RequireTokenBinding is true, tokens without cnf.jkt are rejected.

  • 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
[DependsOn(
typeof(GranitAuthenticationJwtBearerKeycloakModule),
typeof(GranitAuthenticationDPoPModule))] // ← add this
public class CatalogServiceModule : GranitModule { }

In OnApplicationInitialization:

app.UseAuthentication();
app.UseGranitDPoPValidation(); // ← after UseAuthentication
app.UseAuthorization();
{
"Authentication": {
"Authority": "https://keycloak.example.com/realms/my-realm",
"Audience": "catalog-service",
"DPoP": {
"RequireDPoP": true,
"RequireNonce": false,
"RequireTokenBinding": false,
"AllowedAlgorithms": ["ES256", "PS256"],
"MaxProofLifetime": "00:05:00",
"ClockSkew": "00:00:30",
"EnableReplayProtection": true,
"MinimumRsaKeySize": 2048
}
}
}
PropertyTypeDefaultDescription
RequireDPoPboolfalseReject requests without valid DPoP proof. When false, DPoP is validated if present but not required.
RequireNonceboolfalseRequire server-issued nonces in proofs (RFC 9449 §8). When enabled, the server generates a nonce per response via the DPoP-Nonce header. Clients must include it in the nonce claim of subsequent proofs.
RequireTokenBindingboolfalseRequire the access token’s cnf.jkt claim to match the proof’s JWK thumbprint (RFC 9449 §4.3). When true, tokens without cnf are rejected even if the proof itself is valid.
AllowedAlgorithmsstring[]["ES256", "PS256"]Allowed signing algorithms for DPoP proofs.
ClockSkewTimeSpan00:00:30Maximum allowed clock skew for proof expiration validation.
MaxProofLifetimeTimeSpan00:05:00Maximum age of a DPoP proof JWT (now - iat). Proofs older than this are rejected. Also used as TTL for anti-replay jti and nonce cache entries.
EnableReplayProtectionbooltrueCheck jti uniqueness in IFusionCache (anti-replay).
MinimumRsaKeySizeint2048Minimum RSA key size (bits) accepted in DPoP proof JWKs. Keys smaller than this are rejected (NIST SP 800-57).

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:

  1. Extract the public key from the DPoP proof’s jwk header
  2. Build canonical JSON (alphabetical keys, required members only):
    • EC: {"crv":"P-256","kty":"EC","x":"...","y":"..."}
    • RSA: {"e":"...","kty":"RSA","n":"..."}
  3. SHA-256 hash → base64url encode

The result matches RFC 7638 §3.1 test vectors.

Identity providerDPoP supportcnf.jkt in tokensWorks with this package
Keycloak 25+NativeYesYes
Duende IdentityServer 7+NativeYesYes
OpenIddict 7+NativeYesYes (also works via built-in validation)
Entra IDPreviewPartialYes (when cnf claim is present)
Auth0Not yetNoNot applicable — no cnf claim issued
AWS CognitoNoNoNot applicable
// Built-in: ValidateProofOfPossession handler
// No extra package needed
builder.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 emits OpenTelemetry metrics and traces:

Metrics (meter: Granit.Authentication.DPoP):

MetricTypeTagsDescription
granit.authentication.dpop.validation.totalCountertenant_id, resultTotal proof validations (success/failure)
granit.authentication.dpop.replay.rejectedCountertenant_idReplay attempts blocked by jti check

Activity source (Granit.Authentication.DPoP):

SpanDescription
DPoP.ValidateProofProof JWT validation (signature + claims + binding)
ThreatMitigation
Stolen token + no DPoP proofRequireDPoP: true rejects Bearer scheme
Stolen token + replayed proofjti anti-replay via IFusionCache
Stolen token + forged proofRequireTokenBinding: true enforces cnf.jkt match
Proof replay across requestsRequireNonce: true — server-issued nonces (RFC 9449 §8)
Proof for wrong endpointhtm + htu validation (method + URI bound)
Expired / future-dated proofMaxProofLifetime + ClockSkew enforcement (both past and future)
Weak algorithmAllowedAlgorithms whitelist (default: ES256, PS256)
Weak RSA keyMinimumRsaKeySize: 2048 rejects keys below NIST SP 800-57 threshold
Private key leak in JWKDPoP proofs containing private key parameters (d, p, q) are rejected
Key confusion (wrong key)cnf.jkt thumbprint comparison (RFC 7638)