OAuth 2.1 with PKCE and DPoP: Implementing Modern Authentication Security

OAuth 2.1 with PKCE and DPoP Authentication

OAuth 2.1 PKCE DPoP together represent the most significant security upgrade to the OAuth ecosystem since its inception. OAuth 2.1 consolidates years of security best practices into a single specification, mandating PKCE (Proof Key for Code Exchange) for all authorization code flows and deprecating the implicit grant. DPoP (Demonstrating Proof-of-Possession) adds sender-constrained tokens — ensuring that stolen tokens cannot be used by attackers because they are cryptographically bound to the legitimate client.

This guide covers implementing OAuth 2.1 with PKCE and DPoP in production applications, from understanding the security threats they address to building client and server implementations. Moreover, you will learn migration strategies from OAuth 2.0, token lifecycle management, and how these mechanisms protect against the most common OAuth attack vectors.

The Security Problems OAuth 2.1 Solves

OAuth 2.0 had several well-known vulnerabilities. The implicit grant exposed tokens in URL fragments, making them visible in browser history, server logs, and referrer headers. Authorization code interception attacks could steal codes before the legitimate client exchanged them. Furthermore, bearer tokens — once stolen — could be used by anyone, anywhere, with no way to detect misuse.

OAuth 2.1 addresses these by mandating PKCE for all public and confidential clients, removing the implicit grant entirely, requiring exact redirect URI matching, and recommending sender-constrained tokens via DPoP. Additionally, refresh token rotation becomes mandatory, limiting the window of exposure if a refresh token is compromised.

Authentication security and encryption
OAuth 2.1 security model with PKCE and DPoP protection layers

PKCE: Protecting the Authorization Code Flow

PKCE prevents authorization code interception by binding the code to the client that initiated the request. The client generates a random code_verifier, creates a code_challenge (SHA-256 hash), sends the challenge with the authorization request, and proves possession of the verifier during token exchange.

// Client-side PKCE implementation
class PKCEClient {
  private codeVerifier: string = '';

  // Step 1: Generate code verifier and challenge
  async generateChallenge(): Promise<{ verifier: string; challenge: string }> {
    // Generate 43-128 character random string
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    this.codeVerifier = this.base64URLEncode(array);

    // Create SHA-256 hash for code challenge
    const encoder = new TextEncoder();
    const data = encoder.encode(this.codeVerifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    const challenge = this.base64URLEncode(new Uint8Array(hash));

    return { verifier: this.codeVerifier, challenge };
  }

  // Step 2: Build authorization URL with PKCE
  async buildAuthorizationUrl(config: OAuthConfig): Promise<string> {
    const { challenge } = await this.generateChallenge();
    const state = crypto.randomUUID();

    // Store verifier and state in session storage
    sessionStorage.setItem('pkce_verifier', this.codeVerifier);
    sessionStorage.setItem('oauth_state', state);

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: config.clientId,
      redirect_uri: config.redirectUri,
      scope: config.scope,
      state: state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
    });

    return `${config.authorizationEndpoint}?${params.toString()}`;
  }

  // Step 3: Exchange code for tokens with PKCE verifier
  async exchangeCode(code: string, config: OAuthConfig): Promise<TokenResponse> {
    const verifier = sessionStorage.getItem('pkce_verifier');
    if (!verifier) throw new Error('PKCE verifier not found');

    const response = await fetch(config.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: config.redirectUri,
        client_id: config.clientId,
        code_verifier: verifier,  // Proves we initiated the request
      }),
    });

    sessionStorage.removeItem('pkce_verifier');
    return response.json();
  }

  private base64URLEncode(buffer: Uint8Array): string {
    return btoa(String.fromCharCode(...buffer))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }
}

OAuth 2.1 PKCE: Server-Side Validation

// Authorization server — PKCE validation
@Service
public class AuthorizationCodeService {

    public TokenResponse exchangeCode(TokenRequest request) {
        // Retrieve stored authorization code
        var authCode = authCodeRepository.findByCode(request.getCode())
            .orElseThrow(() -> new InvalidGrantException("Invalid code"));

        // Validate PKCE
        validatePKCE(request.getCodeVerifier(), authCode.getCodeChallenge(),
                     authCode.getCodeChallengeMethod());

        // Validate redirect URI (exact match required in OAuth 2.1)
        if (!authCode.getRedirectUri().equals(request.getRedirectUri())) {
            throw new InvalidGrantException("Redirect URI mismatch");
        }

        // Generate tokens
        var accessToken = tokenService.generateAccessToken(authCode.getSubject(),
                                                            authCode.getScopes());
        var refreshToken = tokenService.generateRefreshToken(authCode.getSubject());

        // Invalidate authorization code (single use)
        authCodeRepository.delete(authCode);

        return new TokenResponse(accessToken, refreshToken, 3600, "Bearer");
    }

    private void validatePKCE(String codeVerifier, String codeChallenge,
                               String method) {
        if (codeVerifier == null || codeChallenge == null) {
            throw new InvalidGrantException("PKCE required");
        }

        String computedChallenge;
        if ("S256".equals(method)) {
            byte[] hash = MessageDigest.getInstance("SHA-256")
                .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
            computedChallenge = Base64.getUrlEncoder()
                .withoutPadding()
                .encodeToString(hash);
        } else {
            throw new InvalidGrantException("Unsupported challenge method");
        }

        if (!MessageDigest.isEqual(
                computedChallenge.getBytes(), codeChallenge.getBytes())) {
            throw new InvalidGrantException("PKCE verification failed");
        }
    }
}
Token security and proof-of-possession
DPoP binds tokens cryptographically to the legitimate client

DPoP: Sender-Constrained Tokens

Therefore, DPoP provides the next layer of security by binding access tokens to the specific client that requested them. Even if an attacker steals a DPoP-bound token, they cannot use it without the client’s private key.

// DPoP proof generation
class DPoPClient {
  private keyPair: CryptoKeyPair | null = null;

  async initialize(): Promise<void> {
    // Generate asymmetric key pair for DPoP proofs
    this.keyPair = await crypto.subtle.generateKey(
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,  // Not extractable — private key stays in memory
      ['sign', 'verify']
    );
  }

  async createDPoPProof(method: string, url: string, accessToken?: string): Promise<string> {
    if (!this.keyPair) throw new Error('Keys not initialized');

    const jwk = await crypto.subtle.exportKey('jwk', this.keyPair.publicKey);

    const header = {
      typ: 'dpop+jwt',
      alg: 'ES256',
      jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }
    };

    const payload: Record<string, any> = {
      jti: crypto.randomUUID(),
      htm: method,           // HTTP method
      htu: url,              // Target URL
      iat: Math.floor(Date.now() / 1000),
    };

    // Include access token hash for token-bound proofs
    if (accessToken) {
      const tokenHash = await this.sha256(accessToken);
      payload.ath = tokenHash;
    }

    return this.signJWT(header, payload);
  }

  // Use DPoP proof with API request
  async authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
    const method = options.method || 'GET';
    const dpopProof = await this.createDPoPProof(
      method, url, this.accessToken
    );

    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `DPoP ${this.accessToken}`,  // Note: DPoP, not Bearer
        'DPoP': dpopProof,
      },
    });
  }
}

When NOT to Use OAuth 2.1 / DPoP

DPoP adds significant client-side complexity — key generation, proof creation, and key management. For internal APIs behind a VPN or service mesh where token theft risk is minimal, the overhead of DPoP may not be justified. Consequently, server-to-server communication using client credentials with mTLS provides equivalent sender-binding with less application-level complexity.

If your identity provider does not yet support OAuth 2.1 or DPoP, forcing it requires custom authorization server development. Wait for your provider (Auth0, Keycloak, Okta) to add native support rather than building custom DPoP handling on top of OAuth 2.0.

Security architecture decisions
Evaluating when DPoP adds genuine security value

Key Takeaways

OAuth 2.1 PKCE DPoP together provide defense-in-depth for modern authentication flows. PKCE prevents authorization code interception, mandatory refresh token rotation limits token reuse, and DPoP makes stolen tokens unusable. Furthermore, migrating from OAuth 2.0 can be incremental — start with PKCE (which most providers already support) and add DPoP when your threat model justifies it.

Begin by enabling PKCE on all your OAuth clients and removing any implicit grant flows. For specification details, see the OAuth 2.1 draft specification and the DPoP RFC 9449. Our guides on mTLS in Kubernetes and Sigstore cosign for supply chain security provide additional security hardening approaches.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top