X

How to Implement OAuth 2.0 with PKCE in Browser-Based Apps

If a browser application needs permission to call an API on a user's behalf, use the OAuth 2.0 authorization code flow with Proof Key for Code Exchange (PKCE). PKCE binds the authorization code to the browser transaction that started the sign-in, so an intercepted code cannot be redeemed without the original verifier.

PKCE does not protect an access token after it is issued, make JavaScript a confidential client, or fix cross-site scripting (XSS). This guide covers the flow, the browser-specific boundaries, and the decisions to make before adding it to a small web app.

OAuth authorization is not the same as user login

OAuth lets a client request scoped access to a protected API. It does not, by itself, define how an application verifies a user's identity. If your app also needs sign-in, use OpenID Connect (OIDC) on top of OAuth and validate the ID token according to the provider's library and documentation. Do not treat an access token as a profile or login token.

Our guide to OAuth and JWT explains how an authorization framework differs from a token format.

Choose where tokens should live

A JavaScript app downloaded to a browser is a public client. A value embedded in its source or bundle cannot be kept secret from the people who can download it. Do not put a client secret in frontend code.

Before implementing the flow, decide which architecture fits your app:

Architecture Where OAuth tokens live Trade-off
Backend for frontend (BFF) On a server that handles OAuth and forwards API requests Keeps OAuth tokens out of browser JavaScript, but adds a server component, request proxying, and cookie-based session and CSRF work
Browser-based client In the browser app, which calls the API directly Simpler to deploy as a static app, but same-origin JavaScript can access or use the app's tokens

The IETF's OAuth 2.0 for Browser-Based Applications Internet-Draft discusses these patterns and their security trade-offs. It is a work in progress, not a finalized RFC; check its status before relying on its recommendations.

For an app that already has a backend and handles sensitive data, a BFF is often worth evaluating. A browser-only app can still use authorization code with PKCE, but PKCE is not an XSS defense: malicious JavaScript running in the app's origin can act with the app's permissions even if it cannot read a token from one particular storage mechanism.

How the authorization code flow with PKCE works

The browser makes a one-time verifier for each authorization request. It sends a transformed challenge to the authorization server, then sends the original verifier with the returned code. The server issues tokens only when the verifier matches the challenge. See the PKCE specification, RFC 7636.

1. Register the client and callback

Register the app as a public client with your identity provider. Register the exact redirect URI the app will use, including its scheme, host, path, and any relevant port. Use HTTPS in production, and do not rely on wildcard callback URLs or a redirect endpoint that forwards to arbitrary destinations.

Use the provider's authorization-server metadata or supported SDK to find the authorization and token endpoints. If the browser app exchanges the code directly, the provider must also allow the app's origin at the token endpoint's CORS policy. These settings are provider-specific.

2. Generate a verifier and an S256 challenge

Create a new, high-entropy verifier for every flow. RFC 7636 requires 43 to 128 unreserved characters and recommends using 32 random bytes encoded as unpadded base64url. Derive the challenge as BASE64URL(SHA256(ASCII(code_verifier))) and send S256 as the challenge method. Do not reuse a verifier or use the plain method when S256 is available.

This browser example shows only the PKCE pair generation, not a complete authentication client:

function base64url(bytes) {
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

async function createPkcePair() {
  const random = crypto.getRandomValues(new Uint8Array(32));
  const verifier = base64url(random);
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(verifier)
  );

  return {
    verifier,
    challenge: base64url(new Uint8Array(digest))
  };
}

The Web Crypto APIs require a secure context in browsers. In a real app, prefer a maintained OAuth/OIDC SDK, which should also handle callback parsing, state, token responses, and provider-specific requirements.

3. Keep the transaction data until the callback

The verifier must still be available after the authorization server redirects the browser back. Keep it with the transaction's state value in the SDK's supported transaction storage, then remove it after the callback succeeds or fails. Do not put the verifier in a URL, log it, or use one fixed value for every login.

If you implement storage yourself, understand its boundary: sessionStorage and localStorage are readable by JavaScript on the same origin. sessionStorage is scoped to a tab and normally ends with that tab, but it is not a secure vault against XSS. Avoid long-lived token storage in localStorage; use the provider SDK's documented approach and consider a BFF when browser token exposure is unacceptable.

4. Redirect to the authorization endpoint

Build the authorization request using the provider's actual endpoint and registered values. Its important parameters look like this:

response_type=code
client_id=YOUR_PUBLIC_CLIENT_ID
redirect_uri=https://app.example.com/oauth/callback
scope=orders.read
state=ONE_TIME_RANDOM_STATE
code_challenge=BASE64URL_SHA256_CHALLENGE
code_challenge_method=S256

Request only the scopes the feature needs. If your app connects to more than one authorization server, use the provider's documented issuer-identification protections and validate which issuer returned the response. For an OIDC sign-in flow, also use and validate a transaction-specific nonce when required by the OIDC library and provider flow.

5. Validate the callback before exchanging the code

The redirect can contain an authorization code and the original state, or an OAuth error such as a denied request. Before using a code:

  1. Find the pending transaction and compare the returned state with its stored value. Reject a missing, unexpected, or mismatched value.
  2. Handle an error response as a normal cancellation or failure; do not try to exchange it as a code.
  3. If you support multiple authorization servers, verify the response issuer against the issuer used for that transaction.
  4. Exchange the code only with the token endpoint belonging to that issuer and send the verifier for the same transaction.
  5. Remove the transaction values and clear the code and state from the visible callback URL as soon as the SDK permits.

The OAuth 2.0 Security Best Current Practice, RFC 9700, requires exact redirect URI matching and protections against cross-site request forgery and authorization-code injection. When the authorization server is confirmed to support and enforce PKCE, RFC 9700 permits clients to rely on PKCE for CSRF protection; state remains useful for binding the callback to the right application transaction.

6. Exchange the code for tokens

Send the code to the provider's token endpoint over HTTPS with the same redirect URI and the matching code_verifier. A public browser client does not authenticate with a client secret. The request generally includes grant_type=authorization_code, client_id, code, redirect_uri, and code_verifier; follow the provider's SDK and documentation for the actual request.

The authorization server must have stored the challenge with the code and verify the supplied verifier before issuing tokens. An invalid_grant response can indicate an expired or already-used code, a verifier mismatch, or another provider-specific code-exchange problem. Do not retry a code exchange with a new verifier; start a new authorization flow.

7. Call the API with the access token

Send the access token to the intended resource server using the authorization method documented for that API, usually an Authorization: Bearer header. Request the narrowest scopes and API audience the app needs. Do not put access tokens in query strings, analytics, application logs, or error reports.

OAuth describes how the client obtains authorization; the resource server must still validate the token and enforce its scopes. A successful browser redirect does not prove that an API request is authorized for every resource.

Plan refresh, storage, and logout

An access token may expire before the user finishes working. The authorization server may issue a refresh token, but it does not have to issue one to a browser client. Under RFC 9700, refresh tokens for public clients must be sender-constrained or rotated. Follow the provider's current refresh-token rules rather than implementing rotation yourself.

In a browser-only app, prefer short-lived access tokens held in memory where the SDK supports that model. This limits persistence, but does not stop malicious same-origin JavaScript from using a token while the app is running. Persistent browser storage makes tokens available for longer and remains exposed to scripts executing in that origin. A BFF can keep access and refresh tokens on the server and give the browser a session cookie instead; that cookie must be configured securely, and cookie-authenticated requests need appropriate CSRF protections.

Logout has several parts:

  • Clear the app's local token and transaction state.
  • If the provider offers OAuth token revocation, revoke tokens the app no longer needs using its documented endpoint; RFC 7009 defines a token revocation mechanism.
  • If the app uses OpenID Connect and needs to end the provider's login session too, use the provider's supported RP-Initiated Logout flow. Clearing local app state alone does not necessarily sign the user out of the identity provider.

For a BFF, invalidate the server-side session and expire its cookie as well. Logout behavior and available endpoints vary by provider, so verify the current provider documentation.

Common problems to check

  • redirect_uri mismatch: Compare the URI sent in both requests with the exact registered callback.
  • Code exchange fails: Check that the original verifier belongs to this callback and that the code has not expired or already been exchanged.
  • The token request is blocked by CORS: Configure the authorization server's SPA settings for the deployed origin, or perform the exchange on your backend.
  • Login works but API requests fail: Confirm the API audience, scopes, access-token format, and the resource server's CORS policy.
  • Logout immediately signs the user back in: The identity provider may still have an active SSO session. Use its supported logout flow if the user expects provider sign-out.

A short implementation checklist

  • Use authorization code with PKCE and S256, not the implicit grant.
  • Use a supported OAuth/OIDC library where possible; never embed a client secret in browser code.
  • Register exact HTTPS callback URIs and request only required scopes.
  • Generate a fresh verifier and state for every authorization transaction; validate the callback before code exchange.
  • Decide whether a browser-only client or a BFF matches your risk and deployment constraints.
  • Treat refresh tokens, access tokens, browser storage, logs, and logout as separate security decisions.
  • Test cancellation, provider errors, expired codes, refresh failure, API authorization, and both local and provider logout.

Sources

Categories: Development
Related Post