Skip to main content
Back to Blog
API Testing
2026-07-10

OAuth2 PKCE Flow Testing Guide

OAuth2 PKCE flow testing guide for validating redirects, code challenges, token exchange, replay defenses, and negative auth cases before release.

OAuth2 PKCE Flow Testing Guide

The bug usually arrives as a redirect that looks almost right. The authorize URL has the client id, scope, and callback, but the code_challenge_method is missing. Or the token endpoint accepts a reused authorization code. Or the mobile app sends the verifier from a previous login because state handling was tested only on the happy path.

PKCE testing is about proving that the authorization code flow cannot be completed unless the client that started the flow also finishes it with the matching verifier. That means your tests need to cover browser redirects, state and nonce handling, token exchange, replay attempts, and error responses. A unit test for a URL builder is useful, but it does not prove the flow.

This guide is written for QA engineers and SDETs testing OAuth2 clients, identity provider integrations, BFF login flows, and mobile or SPA authentication. It complements API testing best practices for request coverage and the security testing complete guide for broader abuse cases.

PKCE fields that deserve direct assertions

PKCE adds two important values to the authorization code flow: the code verifier and the code challenge. The verifier is a high-entropy secret generated by the client. The challenge is derived from the verifier, typically with SHA-256 and base64url encoding. The authorization request sends the challenge. The token request sends the verifier. The server compares them before issuing tokens.

Do not treat those fields as incidental URL parameters. They are the control that protects public clients from intercepted authorization codes.

FieldAppears inTest expectation
code_challengeAuthorization requestPresent, base64url encoded, derived from verifier
code_challenge_methodAuthorization requestS256 unless a specific legacy exception exists
code_verifierToken requestPresent only at token exchange, never in redirect URL
stateAuthorization request and callbackRound-tripped and validated before token exchange
redirect_uriAuthorization and token requestExact registered value, no loose prefix matching

The most important negative assertion is that code_verifier never appears in the browser redirect. If it leaks there, PKCE loses much of its value.

Testing the verifier and challenge generator

Start with the deterministic piece: generating and transforming PKCE values. The helper below uses Node's crypto module to create a verifier and derive an S256 challenge. It avoids padding because PKCE uses base64url encoding without equals signs.

// src/auth/pkce.ts
import { createHash, randomBytes } from 'node:crypto';

function base64Url(buffer: Buffer) {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

export function createCodeVerifier() {
  return base64Url(randomBytes(32));
}

export function createS256CodeChallenge(verifier: string) {
  return base64Url(createHash('sha256').update(verifier).digest());
}

export function isValidVerifierShape(verifier: string) {
  return /^[A-Za-z0-9._~-]{43,128}$/.test(verifier);
}

Then test properties that matter to interoperability. Do not snapshot a random verifier. Assert shape, length, allowed characters, and a known challenge for a known verifier.

// test/pkce.test.ts
import { describe, expect, it } from 'vitest';
import {
  createCodeVerifier,
  createS256CodeChallenge,
  isValidVerifierShape,
} from '../src/auth/pkce';

describe('PKCE helper', () => {
  it('generates a verifier accepted by the PKCE character and length rules', () => {
    const verifier = createCodeVerifier();

    expect(isValidVerifierShape(verifier)).toBe(true);
    expect(verifier).not.toContain('=');
    expect(verifier).not.toContain('+');
    expect(verifier).not.toContain('/');
  });

  it('derives the RFC-style S256 challenge for a known verifier', () => {
    const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';

    expect(createS256CodeChallenge(verifier)).toBe(
      'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
    );
  });
});

This test is small, but it catches real production bugs: padding left in the challenge, standard base64 instead of base64url, short verifiers, and accidental plain challenge generation.

Redirect tests for authorization start

The authorization start endpoint is where many client bugs live. A BFF or web app typically creates a verifier, stores it in a server-side session or encrypted cookie, derives the challenge, and redirects to the identity provider.

Your test should inspect the redirect Location header. It should not call the identity provider. The client owns this URL, so the client can test it directly.

// test/login-start.test.ts
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../src/app';

describe('GET /login', () => {
  it('redirects to the authorization server with S256 PKCE parameters', async () => {
    const response = await request(app).get('/login').expect(302);
    const location = response.headers.location;
    const redirect = new URL(location);

    expect(redirect.origin).toBe('https://idp.example.test');
    expect(redirect.pathname).toBe('/oauth2/authorize');
    expect(redirect.searchParams.get('response_type')).toBe('code');
    expect(redirect.searchParams.get('client_id')).toBe('checkout-web');
    expect(redirect.searchParams.get('code_challenge_method')).toBe('S256');

    const challenge = redirect.searchParams.get('code_challenge');
    expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
    expect(location).not.toContain('code_verifier');
  });
});

Keep assertions specific enough to catch security regressions. A test that only expects status 302 will happily pass when PKCE is removed.

Callback tests before token exchange

The callback endpoint must validate state before exchanging the code. If state is missing, wrong, expired, or already consumed, the client should not call the token endpoint. This is both a security requirement and a useful reliability check, because bad callbacks happen in real browsers.

Test these callback cases explicitly:

Callback caseExpected behaviorWhy it matters
Valid code and matching stateExchange code with stored verifierHappy path
Missing stateReject before token requestCSRF defense
Unknown stateReject before token requestPrevents injected callback
Reused stateReject second attemptPrevents replay
Provider error parameterShow controlled login failureAvoids exchanging absent code

If your app uses cookies for state, use a test agent that preserves cookies between /login and /callback. If your app stores state server-side, use a test repository or session store with explicit fixtures. The important assertion is whether the token endpoint is called, not only what response the callback returns.

Token exchange contract

At token exchange, the client sends grant_type authorization_code, the code, redirect_uri, client_id for public clients when required by the provider, and code_verifier. The exact authentication method depends on client type and provider configuration. For a public SPA or native app, there is no client secret. For a confidential web client, PKCE may be used in addition to client authentication.

Mock the token endpoint at the HTTP boundary and assert the outgoing form body. In Node tests, nock is a common option. The example below verifies that code_verifier is sent to the token endpoint and that redirect_uri matches the authorization request.

// test/callback-token-exchange.test.ts
import nock from 'nock';
import request from 'supertest';
import { afterEach, describe, expect, it } from 'vitest';
import { app } from '../src/app';

afterEach(() => {
  nock.cleanAll();
});

describe('OAuth callback token exchange', () => {
  it('sends the stored verifier when exchanging the authorization code', async () => {
    const agent = request.agent(app);
    const login = await agent.get('/login').expect(302);
    const authorizeUrl = new URL(login.headers.location);
    const state = authorizeUrl.searchParams.get('state');

    const tokenRequest = nock('https://idp.example.test')
      .post('/oauth2/token', (body) => {
        const form = new URLSearchParams(body);

        return (
          form.get('grant_type') === 'authorization_code' &&
          form.get('code') === 'auth-code-123' &&
          form.get('redirect_uri') === 'https://app.example.test/oauth/callback' &&
          form.get('code_verifier') !== null
        );
      })
      .reply(200, {
        access_token: 'access-token',
        token_type: 'Bearer',
        expires_in: 3600,
      });

    await agent
      .get('/oauth/callback')
      .query({ code: 'auth-code-123', state })
      .expect(302);

    expect(tokenRequest.isDone()).toBe(true);
  });
});

This is a higher-value test than verifying an internal exchange function. It proves the user-visible login path stores the verifier and uses it at the correct moment.

Negative token cases

PKCE failures must be boring and controlled. The app should not retry with a different verifier, leak provider errors into the browser, or create a partial session after token exchange fails.

Cover these token endpoint responses:

Provider responseClient expectation
invalid_grant for wrong verifierNo session, controlled login error
invalid_grant for reused codeNo retry loop, state consumed
invalid_clientOperational alert or clear server error path
slow token endpointTimeout and no hanging browser response
malformed JSONNo session, structured error log

If the app uses a background session creation step, assert that user session records are not created on failed exchange. A test that only checks HTTP 500 may miss the dangerous part: partial login state.

Browser and mobile handoff details

Browser-based tests should focus on the handoff, not on re-testing the provider. In a real browser, verify that clicking Login navigates to the expected IdP origin and that callback handling creates an authenticated app session when the provider is stubbed or running in a test tenant.

For mobile apps, add tests around custom scheme or universal link callbacks. PKCE is often correct, but the redirect handler accepts the wrong host, ignores state, or processes the same callback twice after app resume. Those are flow bugs, not crypto bugs.

Test data and tenant safety

Never run destructive auth tests against a shared production identity tenant. Use a test tenant or a mocked provider for automated negative cases. If you must run a small smoke test against a real provider, keep it happy-path only, use dedicated test users, and clean up sessions.

Keep secrets out of test logs. Authorization URLs can contain state values. Token responses contain access tokens. Configure HTTP logging to redact Authorization headers, code_verifier, access_token, refresh_token, and id_token.

ID token and nonce checks after PKCE succeeds

PKCE proves that the token exchange is tied to the client that started the flow. It does not replace ID token validation. If your OpenID Connect flow receives an id_token, tests still need to cover issuer, audience, expiry, signature validation through the provider's keys, and nonce when your flow uses one.

Nonce is often confused with state. State protects the client callback from cross-site request forgery and flow confusion. Nonce binds the ID token to the authentication request so a replayed token from another flow is rejected. Some apps use both. Your tests should make the distinction visible.

Add a callback test where state is valid but nonce validation fails. The expected result is no session. Add another where the token response is missing id_token for a flow that requires user identity. The expected result is also no session, even if access_token is present. This catches a subtle class of bugs where the app treats token endpoint success as login success without validating the identity claims it actually depends on.

Refresh tokens and public clients

PKCE is frequently tested only at initial login, but refresh behavior can undermine the same security goals. If the provider issues refresh tokens to a public client, verify rotation and replay handling according to your provider configuration. If your application stores refresh tokens server-side, test encryption or secret storage boundaries separately from the PKCE redirect flow.

For browser apps, be precise about where tokens live. A BFF may keep tokens server-side and issue an application session cookie. A SPA may hold tokens in memory. The test plan differs. Do not borrow assertions from one architecture and apply them blindly to the other.

Important refresh cases include expired access token refresh, revoked refresh token, reused rotated refresh token, and logout cleanup. The PKCE test suite can own the initial code flow, while a session management suite owns refresh and logout. The two suites should share fixtures for token response shapes so they do not drift.

Redirect URI matching and open redirect traps

Redirect URI bugs are common because developers try to make local development convenient. A provider may be strict, but the client can still introduce risk if it accepts an arbitrary returnTo parameter after callback or builds redirect_uri from untrusted headers.

Test exact redirect_uri behavior. The authorization request should send the registered callback URI. The token request should send the same value. If the app supports post-login return paths, those paths should be relative or allowlisted. A returnTo value of https://attacker.example should not become the next browser location after login.

Also test forwarded headers if the app sits behind a proxy. Misconfigured X-Forwarded-Proto handling can make the app send http redirect_uri in production or accept callbacks for the wrong host. In a test, set forwarded headers deliberately and assert the generated authorization URL. This is mundane, but it catches deployment-specific auth failures before release.

Observability for failed auth flows

Authentication failures need logs, but the logs must be safe. A good PKCE failure log includes flow id, state validation result, provider error code, token endpoint status, and high-level reason. It does not include the code verifier, authorization code, access token, refresh token, or full ID token.

Add tests for redaction if your logging layer is easy to capture. Force a token endpoint invalid_grant response and assert the log contains invalid_grant but not code_verifier. If the logger is hard to test directly, wrap auth flow events in a small audit function and unit test that function.

Metrics are useful too. Track invalid state, token exchange failure, provider timeout, and successful login. A sudden rise in invalid state may indicate browser cookie changes, proxy issues, or attempted abuse. A rise in invalid_grant may indicate verifier storage bugs or replay attempts.

Manual probes that still matter

Automated tests cover most PKCE logic, but a few manual probes are worth doing during provider migrations. Open DevTools and verify the authorize URL does not contain code_verifier. Complete login in two tabs and confirm each callback is bound to its own state. Start login, clear cookies, then complete the callback and confirm the app rejects it cleanly. Attempt the callback twice and check the second response.

These probes are quick, concrete, and good at finding browser storage assumptions that mocks miss. Turn any repeated manual finding into an automated regression test.

They also reveal cookie, tab, and proxy behaviors that unit-level protocol tests cannot observe.

Record those observations as regression notes.

Frequently Asked Questions

Should I test PKCE against the real identity provider?

Use mocked provider tests for most cases and a small real-provider smoke test for configuration drift. Negative cases such as verifier mismatch, code replay, and malformed token responses are safer and faster against a mock.

Is plain code_challenge_method ever acceptable?

For modern clients, require S256 unless you have a documented legacy constraint. Tests should fail when code_challenge_method is absent or plain by accident. Plain does not provide the same protection if the challenge is observed.

Where should the code verifier be stored during the flow?

Store it somewhere the client can retrieve after redirect but attackers cannot read from the authorize URL. Server-side session storage or an encrypted, httpOnly, sameSite cookie are common web patterns. Test that the verifier is not present in the redirect Location.

What should happen when state is wrong?

Reject the callback before calling the token endpoint. The test should assert both the user-facing response and the absence of a token request. A wrong state followed by token exchange is a serious bug.

How do I test authorization code replay?

Complete one callback with a valid code and state, then call the callback again with the same values. The second attempt should fail without creating a new session. If the provider is mocked, return invalid_grant for the second token exchange and assert controlled handling.

OAuth2 PKCE Flow Testing Guide | QASkills.sh