Pavan Rangani

HomeBlogOAuth 2.1 with PKCE and DPoP: Implementing Modern Authentication Security

OAuth 2.1 with PKCE and DPoP: Implementing Modern Authentication Security

By Pavan Rangani · March 23, 2026 · 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 modern OAuth flows 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.

It helps to understand why each change was made. The implicit grant existed because, a decade ago, single-page apps could not safely keep a client secret and browsers lacked the crypto primitives to do PKCE. Both constraints are gone: the Web Crypto API is now universal, so there is no longer any reason to ship tokens through the front channel. Consequently, OAuth 2.1 is less a new protocol than a deletion of the dangerous paths, leaving one well-understood flow that every client type can use safely.

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(/=+$/, '');
  }
}

Two implementation details trip up teams repeatedly. First, the state parameter is not optional decoration — it is your CSRF defense for the redirect, and the callback handler must compare the returned state against the stored value before doing anything else. Second, where you persist the verifier matters. sessionStorage is acceptable for many web apps, but it is readable by any script on the page, so a XSS foothold defeats it. For higher-assurance flows, a worker-held or HttpOnly-cookie-backed verifier removes that exposure.

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");
        }
    }
}

Notice the use of MessageDigest.isEqual rather than String.equals for the final comparison. While the challenge is not strictly a secret, using a constant-time comparator is the right habit throughout an authorization server, since the same code paths often handle client secrets and token hashes where timing leakage is a real concern. Also note the deliberate rejection of the plain challenge method: OAuth 2.1 permits only S256 in practice, and accepting plain would let a network attacker who sees the challenge derive a usable verifier.

Authorization Code Lifecycle and Single-Use Enforcement

The authorization code is the most attacked artifact in the flow, so its lifecycle rules deserve to be explicit. A code must be single-use, short-lived (the spec recommends a maximum of ten minutes, and most providers issue codes valid for under sixty seconds), and bound to the exact client and redirect URI that requested it. The deletion in the code above enforces single use, but production servers go further: if a code is presented twice, the correct response is not merely to reject the second attempt but to revoke every token already issued from that code. A replayed code is a strong signal that the code was intercepted, and the only safe assumption is that the first exchange may have been the attacker.

Refresh tokens require parallel discipline. OAuth 2.1 makes rotation mandatory for public clients: each refresh returns a new refresh token and invalidates the old one. If an old refresh token is ever replayed, the server treats it as a compromise indicator and revokes the entire token family. This rotation-with-reuse-detection pattern is what turns a long-lived refresh token from a liability into a tractable risk.

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,
      },
    });
  }
}

The resource server’s job mirrors this. For every request it must verify the DPoP JWT’s signature against the embedded JWK, confirm that htm and htu match the actual request method and URL, check that iat is recent, and — critically — confirm that the token itself was issued to this exact public key. That last binding is what makes the scheme work: the access token carries a cnf (confirmation) claim containing the thumbprint of the holder’s key, and a proof signed by any other key is rejected. To stop replay of a captured proof, servers also cache the jti for the short acceptance window and reject duplicates.

Where DPoP Fits Against the Alternatives

DPoP is not the only way to constrain a token to its sender; it competes with mutual TLS (mTLS) client certificate binding, defined in RFC 8705. The trade-off is essentially application layer versus transport layer. mTLS is extremely robust and offloads the cryptography to the TLS stack, but it requires certificate provisioning and is awkward for browser-based public clients and for traffic that passes through TLS-terminating proxies and CDNs. DPoP, by contrast, lives entirely in HTTP headers, so it survives proxies and works from a browser with nothing but the Web Crypto API. A common pattern in production teams is mTLS for confidential server-to-server clients and DPoP for first-party web and mobile apps, choosing each where its operational profile fits.

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. Building a bespoke authorization server is one of the highest-risk things a team can do; a single subtle flaw in code validation or token binding undermines the entire system. The honest default is to lean on a mature, audited provider and adopt new capabilities as they ship them.

There is also a key-management cost that is easy to underestimate. Because the DPoP private key must be non-extractable and held in memory, every full-page reload in a browser generates a fresh key and therefore a fresh token binding. That is acceptable for many apps but interacts badly with naive token caching, so plan the session model deliberately rather than bolting DPoP onto an existing bearer-token frontend.

Security architecture decisions
Evaluating when DPoP adds genuine security value

Key Takeaways

Together, OAuth 2.1 PKCE DPoP 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.

Key Takeaways

  • Start with a solid foundation and build incrementally based on your requirements
  • Test thoroughly in staging before deploying to production environments
  • Monitor performance metrics and iterate based on real-world data
  • Follow security best practices and keep dependencies up to date
  • Document architectural decisions for future team members

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.

In conclusion, OAuth 2.1 PKCE DPoP is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles