OAuth 2.0 PKCE

How to Implement OAuth 2.0 PKCE for Your SaaS

To implement OAuth 2.0 PKCE, generate a secret verifier, send its hashed challenge during authorization, and present the verifier when exchanging the authorization code. The authorization server checks that they match before issuing tokens.

PKCE protects intercepted authorization codes, but your SaaS still needs transaction validation, secure sessions, and tenant authorization. Start by deciding which part of your application will own the OAuth flow.

Key Takeaways

  • Use the Authorization Code grant with an S256 challenge and a fresh verifier for every attempt.
  • Bind one-time state to the initiating browser, tenant context, and an expiration.
  • Public browser clients can’t protect client secrets; server-side clients must also use their registered authentication method.
  • PKCE doesn’t replace token validation, membership checks, or session security.

Choose Your SaaS Client Architecture

The client type determines where you keep the verifier, exchange the code, and store tokens.

Browser-Based Public Clients

A single-page application that handles OAuth directly is a public client. Any secret bundled into its JavaScript is visible to users.

Generate the verifier in the browser and retain it through the redirect using your OAuth SDK’s transaction storage. The browser then exchanges the code without a client secret. This requires a token endpoint that supports the browser’s cross-origin request.

Registration settings vary. For example, the Microsoft authorization code documentation describes a dedicated spa redirect type for browser applications. That setting isn’t universal across providers.

Server-Side Clients and Backends for Frontends

A server-rendered application or backend for frontend (BFF) can keep credentials and tokens on the server. The browser receives an application session cookie instead.

For SaaS products with a backend, we recommend considering this architecture because it reduces token exposure to browser JavaScript. However, you still need protection against cross-site scripting and forged requests.

Store the verifier server-side, keyed to the login transaction. During redemption, use PKCE alongside the client’s registered authentication method.

Register the Client and Fix Your Redirect URIs

Create the application registration with your authorization provider. Select the appropriate client type, enable the Authorization Code grant, and configure the permitted scopes.

Register explicit production and development callback addresses. For production, use HTTPS. Keep the list narrow and avoid wildcard callbacks.

Your backend should select the callback from trusted configuration. Never construct it from an arbitrary request parameter.

Redirect matching includes the scheme, host, path, and query string. A trailing slash can change the URI. Also, when the authorization request includes redirect_uri, the token request must repeat the same value under the OAuth 2.0 authorization-code rules.

Configure authorization and token endpoints from trusted provider metadata or settings. Don’t let callback parameters choose the token endpoint.

Generate the OAuth 2.0 PKCE Verifier and Challenge

RFC 7636 requires a verifier containing 43 to 128 characters. Permitted characters are letters, digits, and -, ., _, and ~.

Generate it with a cryptographically secure random-number generator. Encoding 32 random bytes as unpadded base64url produces a 43-character verifier.

The S256 transformation is:

code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

This browser JavaScript generates both PKCE values and an independent state value:

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


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


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

Hash the verifier’s text, not the original random bytes. Likewise, base64url-encode the digest bytes, not a hexadecimal representation.

Use fresh values for every attempt. Send only the challenge in the authorization request; keep the verifier for code redemption.

Start Authorization and Save the Transaction

Before redirecting, persist the verifier and state together. Include an expiration, the expected issuer, the fixed redirect URI, and an approved return destination.

For a multi-tenant SaaS, also store the intended tenant and its approved identity connection. Resolve that connection through trusted application records rather than accepting an arbitrary browser-supplied connection ID.

A server-side transaction must bind to the initiating browser session. Browser clients should use their SDK’s transaction handling rather than a shared global verifier.

Build the authorization request with these fields:

ParameterValue
response_typecode
client_idRegistered application identifier
redirect_uriRegistered callback URI
scopeRequested API permissions, plus openid for OIDC login
stateFresh transaction value
code_challengeGenerated S256 challenge
code_challenge_methodS256

The challenge binds the eventual code to your verifier. Meanwhile, state correlates the callback with your saved transaction.

Use a URL-building library to encode parameters. Request only the permissions your application needs.

Validate the Callback Before Redeeming the Code

Treat the callback as untrusted input, even when it appears to come from a familiar provider.

Validate State and Transaction Context

Look up the returned state and require an unexpired, unused transaction bound to the initiating browser. Reject missing or mismatched values before exchanging any code.

Atomically consume the transaction so simultaneous callbacks can’t both proceed. Keep the retrieved verifier available only for that redemption attempt.

Apply state validation to error responses too. For multiple issuers, validate the expected issuer using your SDK’s supported response checks.

Restore tenant context from the saved transaction. Don’t infer it from an email domain or an arbitrary callback parameter.

Although PKCE can provide CSRF protection under defined conditions, RFC 9700 security guidance makes that conditional on established server support. Keeping explicit state validation provides clear transaction correlation.

Exchange the Code at the Token Endpoint

Send an HTTPS POST with an application/x-www-form-urlencoded body containing grant_type=authorization_code, the returned code, and the original code_verifier.

Repeat redirect_uri exactly when it appeared in the authorization request. Public clients also identify themselves with client_id. Confidential clients authenticate using their registered method, which may involve a secret, private key, or another supported mechanism.

The authorization server hashes the verifier and compares it with the challenge bound to that code. A mismatch prevents token issuance.

Never send the challenge instead of the verifier. After success or failure, discard transaction secrets. If redemption fails, start a fresh flow rather than repeatedly submitting the same code.

Create a Tenant-Aware Application Session

OAuth authorizes access to resources. For user login, use OpenID Connect, which adds identity information and validation rules.

An access token alone isn’t proof of identity for your application. Microsoft’s OpenID Connect documentation explains the distinction between OAuth authorization and OIDC authentication.

Use an OIDC library to validate ID-token signatures, issuer, audience, expiration, and the nonce when your request included one.

Next, resolve the local user under an explicit account-linking policy. Then evaluate membership in the tenant stored with the transaction. Successful authentication mustn’t automatically grant workspace access or administrator privileges.

For server-side sessions, issue a new session identifier after authentication. Set cookies with Secure, HttpOnly, and a SameSite policy compatible with your callback response mode. Rotate identifiers after privilege changes and revoke sessions when access ends.

Keep server-side tokens out of browser responses. Protect stored refresh tokens with encryption and restricted access. For browser clients, prefer short-lived, in-memory token storage where practical.

A valid identity token doesn’t establish membership in the workspace requested before login. Your application’s tenant authorization must make that decision separately.

Handle Errors and Test Real Failure Paths

When users deny consent, show a clear cancellation message and allow a fresh attempt. For invalid state or missing transaction data, reject the callback without creating a session.

An invalid_grant response can indicate an expired code, code reuse, or a verifier mismatch. Record a safe failure category and correlation ID, but exclude codes, verifiers, secrets, and tokens.

Callback URLs can leak through access logs and analytics. Keep third-party tracking off callback pages, redact sensitive query parameters, and redirect to a clean URL after processing.

Before release, test these failure groups:

  • Change the verifier, omit state, replay a callback, and redeem the same code twice. Every attempt must fail safely.
  • Open concurrent sign-in tabs and expire a transaction before callback. Each attempt must retain its own verifier and context.
  • Complete authentication without tenant membership, switch tenant parameters, and revoke an existing membership. Protected resources must remain inaccessible.

Also test provider outages and malformed token responses. Fail closed when required validation or membership lookup fails. A successful redirect alone must never count as successful authentication.

Frequently Asked Questions

Do Server-Side SaaS Applications Need PKCE?

RFC 9700 requires authorization servers to enforce PKCE for public clients and recommends PKCE for confidential clients. We recommend using it for server-side authorization-code flows when supported.

It adds protection if a code leaks, even though the client also authenticates at the token endpoint. Keep those controls together: PKCE doesn’t replace a confidential client’s registered authentication method or protect credentials exposed elsewhere.

Does PKCE Make Browser Token Storage Safe?

PKCE protects authorization-code redemption. It doesn’t stop malicious JavaScript from stealing tokens or acting through an authenticated browser.

An in-memory token avoids persistent local storage, but active cross-site scripting can still abuse the application. A BFF keeps tokens server-side, reducing direct token exposure. However, that architecture still requires secure cookies, request protections, and strong defenses against injected scripts.

Conclusion: Secure the Whole Login Transaction

Implementing OAuth 2.0 PKCE requires more than adding a challenge parameter. The verifier, callback, and token exchange must belong to the same one-time transaction.

Keep tenant context attached to that transaction, validate identity separately, and authorize workspace access before creating a session.

Test mismatched verifiers, replayed callbacks, and cross-tenant attempts before release. Those failures show whether your implementation protects access when the happy path breaks.

About the author

The SAAS Podium

View all posts

Leave a Reply

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