Skip to main content
Version: v0.25.0 (Latest)

DPoP and PKCE

PKCE and DPoP are complementary security extensions for OAuth 2.0. PKCE (RFC 7636) protects the authorization code from interception during the redirect flow. DPoP (RFC 9449) binds tokens to a specific client key pair, so that a stolen access token cannot be used by an attacker who does not hold the corresponding private key.

Used together, they protect both the authorization code exchange and subsequent API calls.

PKCE (Proof Key for Code Exchange)

How PKCE Works

  1. The client generates a cryptographically random code_verifier.
  2. The client derives a code_challenge from the verifier (typically BASE64URL(SHA256(code_verifier))).
  3. The authorization request includes the code_challenge and code_challenge_method.
  4. The authorization server stores the challenge alongside the authorization code.
  5. During the token exchange, the client sends the original code_verifier. The server verifies that BASE64URL(SHA256(code_verifier)) matches the stored challenge.

Challenge Methods

MethodValueDescription
S256PkceMethod.S256SHA-256 hash of the verifier (recommended)
plainPkceMethod.PLAINVerifier sent as-is (not recommended; use only if the server does not support S256)
tip

Always use S256. The plain method offers no protection against interception and should only be used as a last resort when the authorization server does not support S256.

Generating a PKCE Challenge

The PkceService generates the code verifier and challenge. You can let the IDK generate a random verifier, or supply your own.

val pkceService = session.graph.pkceService

// Generate PKCE data (random code_verifier, S256 challenge)
val pkceData = pkceService.createPkce(
CreatePkceArgs(
codeVerifier = null, // auto-generate a random verifier
allowedMethods = listOf(PkceMethod.S256)
)
).getOrThrow()

// pkceData.codeVerifier - the random verifier (store securely)
// pkceData.codeChallenge - the derived challenge
// pkceData.codeChallengeMethod - PkceMethod.S256

Using PKCE with the OAuth 2.0 Client

The OAuth2Client integrates PKCE directly. When you call initiateAuthorization, the client generates PKCE automatically and returns the PkceData on the authorization result. You then pass that same PkceData to exchangeAuthorizationCode during the token exchange.

val oauth2Client = session.graph.oauth2Client

// 1. Discover the authorization server
val metadata = oauth2Client.fetchAuthorizationServerMetadata(
issuer = "https://auth.example.com"
).getOrThrow()

// 2. Initiate authorization - PKCE is generated automatically
val authorization = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid profile",
additionalParameters = emptyMap()
).getOrThrow()

// The authorization URL already includes code_challenge and code_challenge_method
val authUrl = authorization.authorizationUrl
val pkceData = authorization.pkceData // store this for the exchange step

openBrowser(authUrl)

// 3. After the user authorizes, exchange the code with the PKCE verifier
val authResponse = oauth2Client.parseAuthorizationResponse(
redirectUrl = callbackUrl
).getOrThrow()

val tokenResponse = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = null, // public client
authorizationCode = authResponse.code,
redirectUri = "myapp://callback",
pkceData = pkceData // the server verifies hash(verifier) == challenge
).getOrThrow()

val accessToken = tokenResponse.accessToken

Server-Side PKCE Verification

If you are building an authorization server and need to verify a PKCE code verifier against a stored challenge, use the PkceService:

val pkceService = session.graph.pkceService

val verifyResult = pkceService.verifyPkce(
VerifyPkceArgs(
codeVerifier = receivedCodeVerifier,
codeChallenge = storedCodeChallenge,
method = PkceMethod.S256
)
)

verifyResult.onFailure { error ->
// PKCE verification failed - reject the token request
}
note

In most client-side applications you will not need VerifyPkceCommand directly. The authorization server performs this verification automatically. This command is useful when you are implementing a server or need to verify PKCE outside of a standard flow.

DPoP (Demonstration of Proof of Possession)

How DPoP Works

  1. The client generates a key pair and keeps the private key secure.
  2. For each HTTP request to the token endpoint (or resource server), the client creates a DPoP proof JWT signed with the private key.
  3. The DPoP proof binds the request to a specific HTTP method and URL, and optionally to an access token.
  4. The authorization server issues a DPoP-type access token bound to the client's public key.
  5. On each API call, the resource server verifies that the DPoP proof was signed by the key the token is bound to.

Creating DPoP Proofs

The DpopService creates and verifies DPoP proof JWTs. Before you can create a proof, you need a key pair. The private key stays on the device and is used to sign each proof; the public key gets embedded in the proof's JWT header so the server can verify the signature.

val dpopService = session.graph.dpopService
val keyManager = session.graph.keyManagerService

// Generate a key pair for DPoP
val keyPair = keyManager.generateKeyAsync(
providerId = null,
alias = "dpop-key",
alg = SignatureAlgorithm.ECDSA_SHA256
)

val publicJwk = keyPair.jose.publicJwk

// Create a DPoP proof for a token request
val proofResult = dpopService.createDpopProof(
options = CreateDpopProofOptions(
issuer = ManagedIdentifierOpts(
method = IdentifierMethodDefaults.KEY_ALIAS,
identifier = "dpop-key"
),
httpMethod = "POST",
httpUrl = "https://auth.example.com/token",
nonce = null, // set if the server provided a DPoP nonce
accessToken = null // set for resource requests (ath claim)
),
publicJwk = publicJwk
).getOrThrow()

// proofResult.dpopProof - the signed DPoP JWT string
// proofResult.jwkThumbprint - JWK thumbprint for token binding

DPoP Proof JWT Structure

It helps to see what the IDK is actually generating under the hood. A DPoP proof is a JWT with a specific header and payload:

Header:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}

Payload:

The payload binds the proof to a specific HTTP request. The htm and htu claims lock the proof to one method and URL, so a proof created for POST /token cannot be replayed against GET /userinfo.

{
"jti": "unique-identifier",
"htm": "POST",
"htu": "https://auth.example.com/token",
"iat": 1700000000,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
"nonce": "server-provided-nonce"
}
ClaimDescription
jtiUnique identifier for the proof (prevents replay)
htmHTTP method the proof is bound to
htuHTTP URL the proof is bound to
iatIssued-at timestamp
athAccess token hash (BASE64URL(SHA256(access_token))); present when binding a proof to a specific token
nonceServer-provided nonce; present when the server requires it

Using DPoP with the OAuth 2.0 Client

The OAuth2Client accepts a DpopContext on initiateAuthorization, exchangeAuthorizationCode, and refreshAccessToken. The DpopContext ties together the key pair, the JWK thumbprint, and an optional server-provided nonce.

The flow below has a few steps before you get to the actual authorization call. You need to generate a key pair, create an initial proof to obtain the JWK thumbprint (a fingerprint of the public key that the server uses for token binding), and then wrap everything into a DpopContext. Once that context is built, you pass it through the rest of the OAuth flow unchanged.

val oauth2Client = session.graph.oauth2Client
val dpopService = session.graph.dpopService
val keyManager = session.graph.keyManagerService

// 1. Generate a DPoP key pair
val keyPair = keyManager.generateKeyAsync(
providerId = null,
alias = "dpop-key",
alg = SignatureAlgorithm.ECDSA_SHA256
)

val publicJwk = keyPair.jose.publicJwk
val issuerOpts = ManagedIdentifierOpts(
method = IdentifierMethodDefaults.KEY_ALIAS,
identifier = "dpop-key"
)

// 2. Get the JWK thumbprint
val proofResult = dpopService.createDpopProof(
options = CreateDpopProofOptions(
issuer = issuerOpts,
httpMethod = "POST",
httpUrl = metadata.tokenEndpoint
),
publicJwk = publicJwk
).getOrThrow()

// 3. Build the DPoP context
val dpopCtx = DpopContext(
publicJwk = publicJwk,
issuer = issuerOpts,
jwkThumbprint = proofResult.jwkThumbprint,
dpopNonce = null
)

// 4. Initiate authorization with DPoP
val authorization = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid api:read",
dpopContext = dpopCtx,
additionalParameters = emptyMap()
).getOrThrow()

openBrowser(authorization.authorizationUrl)

// 5. Exchange authorization code with DPoP
val authResponse = oauth2Client.parseAuthorizationResponse(
redirectUrl = callbackUrl
).getOrThrow()

val tokenResponse = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = null,
authorizationCode = authResponse.code,
redirectUri = "myapp://callback",
pkceData = authorization.pkceData,
dpopContext = dpopCtx
).getOrThrow()

// tokenResponse.tokenType == "DPoP"
// Capture the DPoP nonce for subsequent requests
val dpopNonce = tokenResponse.dpopNonce

Nonce Handling

Authorization servers may require a nonce in DPoP proofs (see RFC 9449 Section 8). The nonce is a server-side mechanism to limit the time window in which a DPoP proof is valid, adding replay protection on top of the jti claim. The server signals this by returning a dpopNonce value in the TokenResponse. You should capture it and include it in the DpopContext for all subsequent token operations.

Note that the nonce can change with every response, so you need to treat it as mutable state and always read the latest value.

// After the initial token exchange, capture the nonce
val dpopNonce = tokenResponse.dpopNonce

// Update the DPoP context with the server-provided nonce
val updatedDpopCtx = DpopContext(
publicJwk = dpopCtx.publicJwk,
issuer = dpopCtx.issuer,
jwkThumbprint = dpopCtx.jwkThumbprint,
dpopNonce = dpopNonce // include the server nonce
)

// Use the updated context for token refresh
val refreshResponse = oauth2Client.refreshAccessToken(
metadata = metadata,
clientAuthentication = null,
refreshToken = tokenResponse.refreshToken!!,
dpopContext = updatedDpopCtx
).getOrThrow()

// The server may return a new nonce - always capture it
val nextNonce = refreshResponse.dpopNonce

Making DPoP-Protected API Requests

Once you have a DPoP-bound access token, every API call to the resource server looks different from a standard Bearer token request. Instead of one Authorization header, you send two headers:

  • Authorization: DPoP <access_token> (note: DPoP, not Bearer)
  • DPoP: <proof_jwt>, a fresh DPoP proof bound to the HTTP method, URL, and access token

The proof must be freshly generated for each request. Setting the accessToken parameter when creating the proof causes the IDK to include the ath (access token hash) claim, which ties the proof to that specific token.

val dpopService = session.graph.dpopService

val resourceUrl = "https://api.example.com/userinfo"

// Create a DPoP proof bound to this specific request and access token
val proofResult = dpopService.createDpopProof(
options = CreateDpopProofOptions(
issuer = issuerOpts,
httpMethod = "GET",
httpUrl = resourceUrl,
nonce = currentDpopNonce, // server nonce if required
accessToken = tokenResponse.accessToken // generates ath claim
),
publicJwk = publicJwk
).getOrThrow()

// Make the API request with both headers
val request = HttpRequest.builder()
.url(resourceUrl)
.method("GET")
.header("Authorization", "DPoP ${tokenResponse.accessToken}")
.header("DPoP", proofResult.dpopProof)
.build()

val response = httpClient.execute(request)

Verifying DPoP Proofs (Server-Side)

If you are building a resource server or authorization server, use VerifyDpopProofCommand to validate incoming DPoP proofs. The verification checks that the proof JWT signature is valid, the htm and htu claims match the current request, the ath claim matches the presented access token, and the jti has not been seen before. You can also restrict which signing algorithms you accept.

val dpopService = session.graph.dpopService

val verifyResult = dpopService.verifyDpopProof(
options = VerifyDpopProofOptions(
dpopProof = incomingDpopHeader,
httpMethod = "GET",
httpUrl = "https://api.example.com/userinfo",
expectedNonce = currentServerNonce,
accessToken = incomingAccessToken,
expectedJwkThumbprint = boundThumbprint,
allowedSigningAlgs = listOf("ES256", "ES384")
)
)

verifyResult.onSuccess { result ->
// result.header - DpopJwtHeader (typ, alg, jwk)
// result.payload - DpopJwtPayload (jti, htm, htu, iat, ath, nonce)
// result.jwkThumbprint - verified JWK thumbprint
println("DPoP proof verified for ${result.payload.htm} ${result.payload.htu}")
}

verifyResult.onFailure { error ->
// Proof is invalid - return 401
println("DPoP verification failed: ${error.message}")
}

Combining PKCE and DPoP

PKCE and DPoP protect different parts of the OAuth flow. PKCE prevents an attacker from intercepting and using an authorization code during the redirect. DPoP prevents an attacker from using a stolen access token, since they would not have the private key needed to create valid proofs. Using both together means that both the code exchange and the subsequent API calls are protected.

In practice, combining them requires very little extra work. The OAuth2Client handles PKCE automatically, so you just need to add a DpopContext to the same calls you were already making.

val oauth2Client = session.graph.oauth2Client
val dpopService = session.graph.dpopService
val keyManager = session.graph.keyManagerService

// 1. Generate a DPoP key pair
val keyPair = keyManager.generateKeyAsync(
providerId = null,
alias = "dpop-key",
alg = SignatureAlgorithm.ECDSA_SHA256
)
val publicJwk = keyPair.jose.publicJwk

// 2. Create an initial proof to get the JWK thumbprint
val metadata = oauth2Client.fetchAuthorizationServerMetadata(
issuer = "https://auth.example.com"
).getOrThrow()

val issuerOpts = ManagedIdentifierOpts(
method = IdentifierMethodDefaults.KEY_ALIAS,
identifier = "dpop-key"
)

val initialProof = dpopService.createDpopProof(
options = CreateDpopProofOptions(
issuer = issuerOpts,
httpMethod = "POST",
httpUrl = metadata.tokenEndpoint
),
publicJwk = publicJwk
).getOrThrow()

// 3. Build the DPoP context
val dpopCtx = DpopContext(
publicJwk = publicJwk,
issuer = issuerOpts,
jwkThumbprint = initialProof.jwkThumbprint,
dpopNonce = null
)

// 4. Initiate authorization with both PKCE (automatic) and DPoP
val authorization = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid api:read",
dpopContext = dpopCtx,
additionalParameters = emptyMap()
).getOrThrow()

openBrowser(authorization.authorizationUrl)

// 5. Exchange the code - PKCE verifier and DPoP context both included
val authResponse = oauth2Client.parseAuthorizationResponse(
redirectUrl = callbackUrl
).getOrThrow()

val tokenResponse = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = null,
authorizationCode = authResponse.code,
redirectUri = "myapp://callback",
pkceData = authorization.pkceData, // PKCE protection
dpopContext = dpopCtx // DPoP token binding
).getOrThrow()

// tokenResponse.tokenType == "DPoP"
// tokenResponse.dpopNonce - capture for subsequent requests

Best Practices

The code examples above cover the mechanics of PKCE and DPoP. The following guidelines address the operational details that are easy to overlook in production.

Always use PKCE for public clients. Mobile apps and single-page applications cannot keep a client secret confidential. PKCE protects the authorization code exchange without requiring one.

Use S256 as the challenge method. The plain method provides no cryptographic protection and should only be used if the authorization server does not support S256.

Consider DPoP for high-security scenarios. DPoP prevents stolen access tokens from being replayed. It is particularly valuable for mobile apps and APIs that handle sensitive data.

Protect DPoP private keys with hardware-backed storage. On iOS, use the Secure Enclave. On Android, use the Android Keystore. Hardware-backed keys cannot be exported, so a compromised device does not leak the private key.

Generate a fresh DPoP proof for every request. Each proof includes a unique jti and is bound to a specific HTTP method and URL. Reusing proofs is a security violation and will be rejected by conformant servers.

Always capture and forward the DPoP nonce. After every token response, check tokenResponse.dpopNonce. If present, include it in the DpopContext for the next request. Failing to do so will result in a rejected proof.

Combine PKCE and DPoP for defense in depth. PKCE protects the authorization code grant, while DPoP protects the tokens themselves. Together, they cover both phases of the OAuth 2.0 flow.