Skip to main content
Version: v0.13

DPoP and PKCE

The IDK supports modern OAuth 2.0 security extensions that protect against token theft and interception attacks.

PKCE (Proof Key for Code Exchange)

PKCE (RFC 7636) protects the authorization code flow from interception attacks. It's essential for public clients like mobile apps.

How PKCE Works

  1. Client generates a random code_verifier
  2. Client creates a code_challenge by hashing the verifier
  3. Authorization request includes the challenge
  4. Token request includes the original verifier
  5. Server verifies that hash(verifier) == challenge

Using PKCE

// PKCE is enabled by default
val authRequest = oauth2Client.buildAuthorizationRequest {
clientId = "my-client"
redirectUri = "myapp://callback"
scopes = setOf("openid", "profile")
usePkce = true // Default: true
// challengeMethod = "S256" // Default: S256
}

// The code verifier is generated automatically
val codeVerifier = authRequest.codeVerifier

// Code challenge is included in authorization URL
val authUrl = authRequest.authorizationUrl
// ...includes code_challenge and code_challenge_method parameters

// When exchanging the code, include the verifier
val tokens = oauth2Client.exchangeCode(
code = authorizationCode,
redirectUri = "myapp://callback",
codeVerifier = codeVerifier, // Must match the challenge
clientId = "my-client"
)

PKCE Challenge Methods

MethodDescription
S256SHA-256 hash (recommended)
plainNo transformation (not recommended)

Always use S256 unless the server only supports plain.

DPoP (Demonstration of Proof of Possession)

DPoP (RFC 9449) binds access tokens to a specific client by requiring proof of key possession for each request.

How DPoP Works

  1. Client generates a DPoP key pair
  2. Token request includes a DPoP proof JWT
  3. Server issues a DPoP-bound access token
  4. Each API request includes a fresh DPoP proof
  5. Resource server verifies the token is bound to the proof

Enabling DPoP

// Generate or retrieve a DPoP key
val dpopKey = keyManager.generateKeyAsync(
alg = SignatureAlgorithm.ES256,
keyId = "dpop-key"
)

// Build authorization request with DPoP
val authRequest = oauth2Client.buildAuthorizationRequest {
clientId = "my-client"
redirectUri = "myapp://callback"
scopes = setOf("openid", "api:read")
useDpop = true
dpopKey = dpopKey
}

// Exchange code with DPoP proof
val tokens = oauth2Client.exchangeCodeWithDpop(
code = authorizationCode,
redirectUri = "myapp://callback",
codeVerifier = codeVerifier,
clientId = "my-client",
dpopKey = dpopKey,
tokenEndpoint = "https://auth.example.com/token"
)

// The access token is now DPoP-bound
// tokens.tokenType == "DPoP"

Making DPoP-Protected Requests

// Create a DPoP proof for each request
val dpopProof = oauth2Client.createDpopProof(
key = dpopKey,
httpMethod = "GET",
httpUri = "https://api.example.com/resource",
accessToken = tokens.accessToken // Optional: for token binding
)

// Include both the token and proof in the request
val request = HttpRequest.builder()
.url("https://api.example.com/resource")
.header("Authorization", "DPoP ${tokens.accessToken}")
.header("DPoP", dpopProof)
.build()

val response = httpClient.execute(request)

DPoP Proof Structure

A DPoP proof is a JWT with these claims:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { /* public key */ }
}
{
"jti": "unique-id",
"htm": "POST",
"htu": "https://auth.example.com/token",
"iat": 1234567890,
"ath": "base64url(sha256(access_token))" // If binding to token
}

DPoP Nonce Handling

Some servers require a nonce in DPoP proofs:

try {
val tokens = oauth2Client.exchangeCodeWithDpop(...)
} catch (e: DpopNonceRequiredException) {
// Server provided a nonce, retry with it
val tokens = oauth2Client.exchangeCodeWithDpop(
code = authorizationCode,
redirectUri = "myapp://callback",
codeVerifier = codeVerifier,
clientId = "my-client",
dpopKey = dpopKey,
tokenEndpoint = "https://auth.example.com/token",
dpopNonce = e.nonce // Use the provided nonce
)
}

Combining PKCE and DPoP

For maximum security, use both PKCE and DPoP:

// Generate DPoP key
val dpopKey = keyManager.generateKeyAsync(
alg = SignatureAlgorithm.ES256,
keyId = "dpop-key"
)

// Authorization request with both
val authRequest = oauth2Client.buildAuthorizationRequest {
clientId = "my-client"
redirectUri = "myapp://callback"
scopes = setOf("openid", "api:read")
usePkce = true // Protect authorization code
useDpop = true // Bind tokens to key
dpopKey = dpopKey
}

// After callback, exchange with both protections
val tokens = oauth2Client.exchangeCodeWithDpop(
code = authorizationCode,
redirectUri = "myapp://callback",
codeVerifier = authRequest.codeVerifier, // PKCE
clientId = "my-client",
dpopKey = dpopKey, // DPoP
tokenEndpoint = "https://auth.example.com/token"
)

Best Practices

Always use PKCE for public clients. This protects against authorization code interception.

Use S256 challenge method. Plain challenge method provides no security benefit.

Consider DPoP for high-security scenarios. DPoP prevents token theft even if the token is leaked.

Protect DPoP keys appropriately. Use hardware-backed storage when available.

Handle nonce requirements gracefully. Some servers require nonces; be prepared to retry.

Generate fresh proofs for each request. Reusing DPoP proofs is a security risk.