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
- The client generates a cryptographically random
code_verifier. - The client derives a
code_challengefrom the verifier (typicallyBASE64URL(SHA256(code_verifier))). - The authorization request includes the
code_challengeandcode_challenge_method. - The authorization server stores the challenge alongside the authorization code.
- During the token exchange, the client sends the original
code_verifier. The server verifies thatBASE64URL(SHA256(code_verifier))matches the stored challenge.
Challenge Methods
| Method | Value | Description |
|---|---|---|
S256 | PkceMethod.S256 | SHA-256 hash of the verifier (recommended) |
plain | PkceMethod.PLAIN | Verifier sent as-is (not recommended; use only if the server does not support S256) |
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.
- Android/Kotlin
- iOS/Swift
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
let pkceService = session.graph.pkceService
// Generate PKCE data (random code_verifier, S256 challenge)
let pkceData = try await pkceService.createPkce(
args: CreatePkceArgs(
codeVerifier: nil, // auto-generate a random verifier
allowedMethods: [PkceMethod.s256]
)
).get()
// 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.
- Android/Kotlin
- iOS/Swift
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
let oauth2Client = session.graph.oauth2Client
// 1. Discover the authorization server
let metadata = try await oauth2Client.fetchAuthorizationServerMetadata(
issuer: "https://auth.example.com"
).get()
// 2. Initiate authorization - PKCE is generated automatically
let authorization = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid profile",
additionalParameters: [:]
).get()
// The authorization URL already includes code_challenge and code_challenge_method
let authUrl = authorization.authorizationUrl
let pkceData = authorization.pkceData // store this for the exchange step
openBrowser(url: authUrl)
// 3. After the user authorizes, exchange the code with the PKCE verifier
let authResponse = try await oauth2Client.parseAuthorizationResponse(
redirectUrl: callbackUrl
).get()
let tokenResponse = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: nil, // public client
authorizationCode: authResponse.code,
redirectUri: "myapp://callback",
pkceData: pkceData // the server verifies hash(verifier) == challenge
).get()
let 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:
- Android/Kotlin
- iOS/Swift
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
}
let pkceService = session.graph.pkceService
let verifyResult = try await pkceService.verifyPkce(
args: VerifyPkceArgs(
codeVerifier: receivedCodeVerifier,
codeChallenge: storedCodeChallenge,
method: PkceMethod.s256
)
)
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
- The client generates a key pair and keeps the private key secure.
- For each HTTP request to the token endpoint (or resource server), the client creates a DPoP proof JWT signed with the private key.
- The DPoP proof binds the request to a specific HTTP method and URL, and optionally to an access token.
- The authorization server issues a
DPoP-type access token bound to the client's public key. - 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.
- Android/Kotlin
- iOS/Swift
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
let dpopService = session.graph.dpopService
let keyManager = session.graph.keyManagerService
// Generate a key pair for DPoP
let keyPair = try await keyManager.generateKeyAsync(
providerId: nil,
alias: "dpop-key",
alg: .ecdsaSha256
)
let publicJwk = keyPair.jose.publicJwk
// Create a DPoP proof for a token request
let proofResult = try await dpopService.createDpopProof(
options: CreateDpopProofOptions(
issuer: ManagedIdentifierOpts(
method: .keyAlias,
identifier: "dpop-key"
),
httpMethod: "POST",
httpUrl: "https://auth.example.com/token",
nonce: nil,
accessToken: nil
),
publicJwk: publicJwk
).get()
// 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"
}
| Claim | Description |
|---|---|
jti | Unique identifier for the proof (prevents replay) |
htm | HTTP method the proof is bound to |
htu | HTTP URL the proof is bound to |
iat | Issued-at timestamp |
ath | Access token hash (BASE64URL(SHA256(access_token))); present when binding a proof to a specific token |
nonce | Server-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.
- Android/Kotlin
- iOS/Swift
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
let oauth2Client = session.graph.oauth2Client
let dpopService = session.graph.dpopService
let keyManager = session.graph.keyManagerService
// 1. Generate a DPoP key pair
let keyPair = try await keyManager.generateKeyAsync(
providerId: nil,
alias: "dpop-key",
alg: .ecdsaSha256
)
let publicJwk = keyPair.jose.publicJwk
let issuerOpts = ManagedIdentifierOpts(
method: .keyAlias,
identifier: "dpop-key"
)
// 2. Get the JWK thumbprint
let proofResult = try await dpopService.createDpopProof(
options: CreateDpopProofOptions(
issuer: issuerOpts,
httpMethod: "POST",
httpUrl: metadata.tokenEndpoint
),
publicJwk: publicJwk
).get()
// 3. Build the DPoP context
let dpopCtx = DpopContext(
publicJwk: publicJwk,
issuer: issuerOpts,
jwkThumbprint: proofResult.jwkThumbprint,
dpopNonce: nil
)
// 4. Initiate authorization with DPoP
let authorization = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid api:read",
dpopContext: dpopCtx,
additionalParameters: [:]
).get()
openBrowser(url: authorization.authorizationUrl)
// 5. Exchange authorization code with DPoP
let authResponse = try await oauth2Client.parseAuthorizationResponse(
redirectUrl: callbackUrl
).get()
let tokenResponse = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: nil,
authorizationCode: authResponse.code,
redirectUri: "myapp://callback",
pkceData: authorization.pkceData,
dpopContext: dpopCtx
).get()
// tokenResponse.tokenType == "DPoP"
// Capture the DPoP nonce for subsequent requests
let 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.
- Android/Kotlin
- iOS/Swift
// 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
// After the initial token exchange, capture the nonce
let dpopNonce = tokenResponse.dpopNonce
// Update the DPoP context with the server-provided nonce
let updatedDpopCtx = DpopContext(
publicJwk: dpopCtx.publicJwk,
issuer: dpopCtx.issuer,
jwkThumbprint: dpopCtx.jwkThumbprint,
dpopNonce: dpopNonce // include the server nonce
)
// Use the updated context for token refresh
let refreshResponse = try await oauth2Client.refreshAccessToken(
metadata: metadata,
clientAuthentication: nil,
refreshToken: tokenResponse.refreshToken!,
dpopContext: updatedDpopCtx
).get()
// The server may return a new nonce - always capture it
let 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, notBearer)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.
- Android/Kotlin
- iOS/Swift
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)
let dpopService = session.graph.dpopService
let resourceUrl = "https://api.example.com/userinfo"
// Create a DPoP proof bound to this specific request and access token
let proofResult = try await dpopService.createDpopProof(
options: CreateDpopProofOptions(
issuer: issuerOpts,
httpMethod: "GET",
httpUrl: resourceUrl,
nonce: currentDpopNonce,
accessToken: tokenResponse.accessToken
),
publicJwk: publicJwk
).get()
// Make the API request with both headers
var request = URLRequest(url: URL(string: resourceUrl)!)
request.httpMethod = "GET"
request.setValue("DPoP \(tokenResponse.accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(proofResult.dpopProof, forHTTPHeaderField: "DPoP")
let (data, response) = try await URLSession.shared.data(for: 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.
- Android/Kotlin
- iOS/Swift
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}")
}
let dpopService = session.graph.dpopService
let verifyResult = try await dpopService.verifyDpopProof(
options: VerifyDpopProofOptions(
dpopProof: incomingDpopHeader,
httpMethod: "GET",
httpUrl: "https://api.example.com/userinfo",
expectedNonce: currentServerNonce,
accessToken: incomingAccessToken,
expectedJwkThumbprint: boundThumbprint,
allowedSigningAlgs: ["ES256", "ES384"]
)
)
if case .success(let result) = verifyResult {
// result.header - DpopJwtHeader (typ, alg, jwk)
// result.payload - DpopJwtPayload (jti, htm, htu, iat, ath, nonce)
// result.jwkThumbprint - verified JWK thumbprint
print("DPoP proof verified for \(result.payload.htm) \(result.payload.htu)")
}
if case .failure(let error) = verifyResult {
// Proof is invalid - return 401
print("DPoP verification failed: \(error.localizedDescription)")
}
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.
- Android/Kotlin
- iOS/Swift
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
let oauth2Client = session.graph.oauth2Client
let dpopService = session.graph.dpopService
let keyManager = session.graph.keyManagerService
// 1. Generate a DPoP key pair
let keyPair = try await keyManager.generateKeyAsync(
providerId: nil,
alias: "dpop-key",
alg: .ecdsaSha256
)
let publicJwk = keyPair.jose.publicJwk
// 2. Create an initial proof to get the JWK thumbprint
let metadata = try await oauth2Client.fetchAuthorizationServerMetadata(
issuer: "https://auth.example.com"
).get()
let issuerOpts = ManagedIdentifierOpts(
method: .keyAlias,
identifier: "dpop-key"
)
let initialProof = try await dpopService.createDpopProof(
options: CreateDpopProofOptions(
issuer: issuerOpts,
httpMethod: "POST",
httpUrl: metadata.tokenEndpoint
),
publicJwk: publicJwk
).get()
// 3. Build the DPoP context
let dpopCtx = DpopContext(
publicJwk: publicJwk,
issuer: issuerOpts,
jwkThumbprint: initialProof.jwkThumbprint,
dpopNonce: nil
)
// 4. Initiate authorization with both PKCE (automatic) and DPoP
let authorization = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid api:read",
dpopContext: dpopCtx,
additionalParameters: [:]
).get()
openBrowser(url: authorization.authorizationUrl)
// 5. Exchange the code - PKCE verifier and DPoP context both included
let authResponse = try await oauth2Client.parseAuthorizationResponse(
redirectUrl: callbackUrl
).get()
let tokenResponse = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: nil,
authorizationCode: authResponse.code,
redirectUri: "myapp://callback",
pkceData: authorization.pkceData, // PKCE protection
dpopContext: dpopCtx // DPoP token binding
).get()
// 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.