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 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
  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: signature (ES256, PS256, EdDSA), HTTP method (htm), request URI (htu), expiration, issued-at.

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

  4. 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. If they don’t match, the request is rejected with 401 Unauthorized.

  • 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,
"AllowedAlgorithms": ["ES256", "PS256"],
"ProofMaxAge": "00:00:30",
"NonceLifetime": "00:05:00",
"EnableAntiReplay": true
}
}
}
PropertyTypeDefaultDescription
RequireDPoPboolfalseReject requests without valid DPoP proof. When false, DPoP is validated if present but not required.
AllowedAlgorithmsstring[]["ES256", "PS256"]Allowed signing algorithms for DPoP proofs.
ProofMaxAgeTimeSpan00:00:30Maximum age of a DPoP proof JWT.
NonceLifetimeTimeSpan00:05:00TTL for anti-replay jti entries in distributed cache.
EnableAntiReplaybooltrueCheck jti uniqueness in IDistributedCache.

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 IDistributedCache
Proof for wrong endpointhtm + htu validation (method + URI bound)
Expired proofexp + ProofMaxAge enforcement
Weak algorithmAllowedAlgorithms whitelist (default: ES256, PS256)
Key confusion (wrong key)cnf.jkt thumbprint comparison