← Security
Technique Authentication, credentials, and sessions

OAuth flow security:
PKCE, state, and redirect URIs

Bind each callback to the login that started it.

At 09:14, a user starts sign-in with Acme Identity in one tab. After switching accounts in another tab, the first tab lands in an unexpected Acme workspace. A valid OAuth response arrived; that alone does not prove it belongs to the browser action the user remembers. We’ll trace one transaction, test competing explanations, and see what state, PKCE, and redirect URI checks each establish.

The skill to keep

Follow the authorization response back to the transaction that created it. Diagnose the mismatch from preserved evidence, then verify each control at the boundary where it acts.

TypeScriptGo One confusing sign-in · state correlation · PKCE · redirect URI validation
01 / Read the report

A successful callback can still belong to the wrong attempt.

The support report is narrow: one person expected the Acme workspace selected in the first tab, but saw the account chosen in the second. We do not yet know whether the application crossed transactions, the identity provider returned a different account than expected, or the user switched sessions before starting the first flow.

Do not paste authorization codes, access tokens, or full callback URLs into a ticket. In a controlled reproduction, record a non-secret transaction identifier, browser-session binding, provider issuer, callback route, and whether the exchange succeeded. Keep the code and tokens out of logs.

Case file / Acme sign-inThe callback succeeds, but the selected account is unexpected.
Asset
The application session and account identity created by sign-in.
Actor
A user with two sign-in tabs and access to more than one Acme account.
Observed
One tab completed sign-in and displayed an account the user did not expect.
Unknown
Which identity-provider session, pending transaction, callback, and verifier produced the application session.
Invariant
A callback may complete only the unexpired, one-use login transaction started by this browser session.
01 / StartApplication

Creates transaction state and a PKCE verifier.

02 / AuthorizeIdentity provider

Authenticates the user and returns a short-lived code.

03 / CallbackApplication endpoint

Correlates the response to the pending browser transaction.

04 / RedeemToken endpoint

Checks the code, client, redirect URI, and PKCE proof.

OAuth is an authorization framework: it lets a client obtain delegated access. When a product uses the flow to sign a person in, it commonly also uses OpenID Connect (OIDC), which adds identity claims. OAuth callback checks do not, by themselves, validate an OIDC ID token; issuer, audience, signature, expiry, and nonce checks remain part of that separate identity validation.

Leave with: the exact report, the transaction invariant, and the facts not established yet.
02 / Trace the transaction

Keep three explanations alive until a check separates them.

A wrong account can result from a provider account choice the user did not notice, an application session already associated with another account, or a callback that consumes another tab’s pending transaction. A fourth possibility is that the callback was correctly correlated but the OIDC identity response was accepted without validating its issuer or audience.

Reproduce with two disposable provider accounts and two tabs. Preserve transaction IDs that contain no secrets. For each start, record a hash or opaque reference for the state, the server-side session reference, registered redirect URI, issuer, and pending transaction status. At callback time, check which pending record was consumed, whether it belonged to that same session, which redirect URI the client sends to the token endpoint, and which verifier was paired with the challenge. Do not use raw state, codes, verifier values, or tokens as log fields.

Compare a predicted result with the investigationReveal after listing a competing explanation
Diagnostic checkpointWhat would change your mind?
Observation to establish
The first tab’s callback completed a server-side login transaction and created a session for the unexpected account.
Competing causes
Provider account selection; stale or shared application session; cross-tab transaction mix-up; or unvalidated OIDC identity claims.
Discriminating check
Use separate disposable accounts. Compare the session-bound pending record created by each tab with the state received at each callback; inspect the issuer and redirect URI; then check which transaction’s verifier was used for the exchange and which validated identity claims created the session.
Conclusion if confirmed
If a callback consumes another tab’s transaction or a transaction from another session, correlation is broken on this path. That does not establish token theft or cross-tenant access; assess those against actual callback, session, and granted-scope behavior.
Checkpoint: one observation, at least two plausible causes, and a check that could disprove your leading theory.
03 / Bind state to the browser

State is useful only when the response matches a pending transaction.

The client creates an unpredictable state value before redirecting the browser to the identity provider. It stores that value with the login transaction and checks the returned value at the callback. This lets the application reject an unsolicited or mismatched response. The transaction should be short-lived, tied to the initiating browser session, and consumed once; a global map that accepts any live state from any session leaves the binding incomplete.

State is not a password, access token, or authorization decision. Keep it out of analytics and logs. For a browser app, the transaction can live in server-side session storage or in a carefully designed short-lived, integrity-protected cookie. Cookie attributes and cross-site callback behavior depend on the deployment, so validate the real browser flow.

Read the flow in TypeScript and Go.

These excerpts show the transaction boundary; provider SDK and storage adapters are intentionally omitted.

TypeScriptIncomplete callback · code is accepted without matching state
flow.ts · illustrative OAuth transaction
// Intentionally incomplete. Do not use as an OAuth client.
export function vulnerableCallback(query: URLSearchParams, pending: PendingLogin | undefined) {
	const code = query.get('code');
	if (!code) throw new Error('Missing authorization code');
	// It never checks that this browser started the matching authorization request.
	return exchangeCode(code, pending?.redirectUri ?? 'https://app.example.test/callback');
}
GoIncomplete callback · code is accepted without matching state
flow.go · illustrative OAuth transaction
// Intentionally incomplete: the callback never matches a returned state to a
// pending transaction for the current browser session.
func VulnerableCompleteLogin(ctx context.Context, code string, pending *PendingLogin) (any, error) {
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}
TypeScriptCorrelated callback · session-bound, short-lived transaction
flow.ts · illustrative OAuth transaction
export type PendingLogin = {
	state: string;
	verifier: string;
	redirectUri: string;
	createdAt: number;
};

const redirectUri = 'https://app.example.test/oauth/callback';

export async function beginLogin(store: TransactionStore) {
	const state = randomBase64Url(32);
	const verifier = randomBase64Url(32);
	const challenge = base64Url(sha256(verifier));
	await store.save(state, { state, verifier, redirectUri, createdAt: Date.now() });
	return authorizationUrl({
		response_type: 'code',
		client_id: 'web-client',
		redirect_uri: redirectUri,
		state,
		code_challenge: challenge,
		code_challenge_method: 'S256'
	});
}

export async function completeLogin(query: URLSearchParams, store: TransactionStore) {
	const code = query.get('code');
	const returnedState = query.get('state');
	if (!code || !returnedState) throw new Error('Incomplete authorization response');

	// Atomically take and delete this one-use transaction, scoped to this browser session.
	const pending = await store.consumeForCurrentSession(returnedState);
	if (!pending || pending.state !== returnedState || Date.now() - pending.createdAt > 10 * 60_000) {
		throw new Error('Authorization response does not match a current login');
	}
	return exchangeCode({
		code,
		redirectUri: pending.redirectUri,
		codeVerifier: pending.verifier
	});
}
GoCorrelated callback · session-bound, short-lived transaction
flow.go · illustrative OAuth transaction
func BeginLogin(ctx context.Context, store Store, authorizationEndpoint string) (string, error) {
	state, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	verifier, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	challengeBytes := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])

	if err := store.Save(ctx, PendingLogin{State: state, Verifier: verifier, Redirect: redirectURI, CreatedAt: time.Now()}); err != nil {
		return "", err
	}
	u, err := url.Parse(authorizationEndpoint)
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("response_type", "code")
	q.Set("client_id", "web-client")
	q.Set("redirect_uri", redirectURI)
	q.Set("state", state)
	q.Set("code_challenge", challenge)
	q.Set("code_challenge_method", "S256")
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func CompleteLogin(ctx context.Context, store Store, code, returnedState string) (any, error) {
	if code == "" || returnedState == "" {
		return nil, errors.New("incomplete authorization response")
	}
	pending, ok, err := store.ConsumeForSession(ctx, returnedState)
	if err != nil {
		return nil, err
	}
	if !ok || pending.State != returnedState || time.Since(pending.CreatedAt) > 10*time.Minute {
		return nil, errors.New("authorization response does not match a current login")
	}
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}
Leave with: state from a one-use pending transaction owned by the browser session that started the flow.
04 / Prove possession of the code

PKCE makes a stolen authorization code insufficient on its own.

At transaction start, the client creates a high-entropy code verifier and derives a code challenge using SHA-256 with base64url encoding (S256). The authorization request carries the challenge. The token request later carries the original verifier. The provider checks that it hashes to the challenge attached to that authorization code.

If a code is copied from a redirect, browser history, or another channel, a party without the verifier cannot redeem it. The verifier belongs to the same session-bound transaction as state. Never use a constant verifier or the plain method when S256 is available.

01 / Secret per attemptVerifier

Random and retained by the client.

02 / Derived valueS256 challenge

Sent with the authorization request.

03 / Bound codeProvider stores challenge

Associated with the authorization code.

04 / Proof at exchangeVerifier checked

Token endpoint compares its derived challenge.

Inspect the TypeScript and Go PKCE constructionS256 and URL-safe encoding

Both examples derive the challenge as base64url(SHA-256(verifier)) without padding and send code_challenge_method=S256. A real application should use its vetted OAuth/OIDC library for nonce and protocol validation, secure transaction storage, token response validation, and provider-specific error handling. These teaching excerpts are not a complete client.

examples/flow.ts
examples/flow.ts
// Intentionally incomplete. Do not use as an OAuth client.
export function vulnerableCallback(query: URLSearchParams, pending: PendingLogin | undefined) {
	const code = query.get('code');
	if (!code) throw new Error('Missing authorization code');
	// It never checks that this browser started the matching authorization request.
	return exchangeCode(code, pending?.redirectUri ?? 'https://app.example.test/callback');
}

export type PendingLogin = {
	state: string;
	verifier: string;
	redirectUri: string;
	createdAt: number;
};

const redirectUri = 'https://app.example.test/oauth/callback';

export async function beginLogin(store: TransactionStore) {
	const state = randomBase64Url(32);
	const verifier = randomBase64Url(32);
	const challenge = base64Url(sha256(verifier));
	await store.save(state, { state, verifier, redirectUri, createdAt: Date.now() });
	return authorizationUrl({
		response_type: 'code',
		client_id: 'web-client',
		redirect_uri: redirectUri,
		state,
		code_challenge: challenge,
		code_challenge_method: 'S256'
	});
}

export async function completeLogin(query: URLSearchParams, store: TransactionStore) {
	const code = query.get('code');
	const returnedState = query.get('state');
	if (!code || !returnedState) throw new Error('Incomplete authorization response');

	// Atomically take and delete this one-use transaction, scoped to this browser session.
	const pending = await store.consumeForCurrentSession(returnedState);
	if (!pending || pending.state !== returnedState || Date.now() - pending.createdAt > 10 * 60_000) {
		throw new Error('Authorization response does not match a current login');
	}
	return exchangeCode({
		code,
		redirectUri: pending.redirectUri,
		codeVerifier: pending.verifier
	});
}

// Provider and framework adapters are deliberately omitted: this file teaches the
// transaction invariant, not a drop-in OAuth implementation.
declare function randomBase64Url(bytes: number): string;
declare function sha256(value: string): Uint8Array;
declare function base64Url(value: Uint8Array): string;
declare function authorizationUrl(parameters: Record<string, string>): string;
declare function exchangeCode(input: {
	code: string;
	redirectUri: string;
	codeVerifier?: string;
}): Promise<unknown>;
interface TransactionStore {
	save(state: string, value: PendingLogin): Promise<void>;
	consumeForCurrentSession(state: string): Promise<PendingLogin | undefined>;
}
examples/go/flow.go
examples/go/flow.go
package oauthflow

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/url"
	"time"
)

const redirectURI = "https://app.example.test/oauth/callback"

type PendingLogin struct {
	State     string
	Verifier  string
	Redirect  string
	CreatedAt time.Time
}

type Store interface {
	Save(context.Context, PendingLogin) error
	// ConsumeForSession must atomically delete the one-use transaction and scope it
	// to the current browser session; a plain global lookup is not sufficient.
	ConsumeForSession(context.Context, string) (PendingLogin, bool, error)
}

// Intentionally incomplete: the callback never matches a returned state to a
// pending transaction for the current browser session.
func VulnerableCompleteLogin(ctx context.Context, code string, pending *PendingLogin) (any, error) {
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}


func BeginLogin(ctx context.Context, store Store, authorizationEndpoint string) (string, error) {
	state, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	verifier, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	challengeBytes := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])

	if err := store.Save(ctx, PendingLogin{State: state, Verifier: verifier, Redirect: redirectURI, CreatedAt: time.Now()}); err != nil {
		return "", err
	}
	u, err := url.Parse(authorizationEndpoint)
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("response_type", "code")
	q.Set("client_id", "web-client")
	q.Set("redirect_uri", redirectURI)
	q.Set("state", state)
	q.Set("code_challenge", challenge)
	q.Set("code_challenge_method", "S256")
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func CompleteLogin(ctx context.Context, store Store, code, returnedState string) (any, error) {
	if code == "" || returnedState == "" {
		return nil, errors.New("incomplete authorization response")
	}
	pending, ok, err := store.ConsumeForSession(ctx, returnedState)
	if err != nil {
		return nil, err
	}
	if !ok || pending.State != returnedState || time.Since(pending.CreatedAt) > 10*time.Minute {
		return nil, errors.New("authorization response does not match a current login")
	}
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}


func randomURLToken(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// Provider SDK and durable session-store implementations are omitted.
func exchangeCode(ctx context.Context, code, redirect, verifier string) (any, error) { return nil, nil }
Leave with: a fresh verifier, its S256 challenge, and the same transaction record carrying both until code exchange.
05 / Constrain the return path

A redirect URI chooses where the browser and authorization code go.

The registered redirect URI is not a convenient place to carry arbitrary navigation. If an authorization server accepts a prefix or wildcard where exact matching is required, a crafted URI may direct a response to an unintended endpoint. A client-side callback that forwards to an arbitrary next URL can create a related open redirect, potentially making an otherwise trusted link useful for phishing or credential leakage.

For web clients, register and compare the complete redirect URI exactly. Use a fixed application-owned callback and store the matching URI with the pending transaction; send that same URI in the code exchange when the protocol/provider requires it. Validate any post-login destination against a small set of local routes or a fixed mapping. Native loopback redirects have a specific variable-port exception; do not generalize it to web callbacks.

Controls apply at different points in the flow
ControlQuestion it answersWhat to verify
StateDoes this callback match a login this browser started?Session-bound pending record, expiry, one-time consumption.
PKCE S256Can this client redeem this authorization code?Fresh verifier per attempt; provider binds and checks the challenge.
Exact redirect URIWhere may this response be delivered?Authorization server exact registered match; client uses the same fixed value at exchange.
OIDC issuer, audience, nonceIs this identity token for this client and this authentication transaction?Validate signature, issuer, audience, expiry, and nonce with a maintained OIDC library.
Leave with: a fixed callback URI, exact provider registration, same-URI exchange, and a separately constrained post-login destination.
06 / Verify the whole round trip

Exercise wrong, stale, replayed, and ordinary responses.

Test the provider integration in a disposable client registration and accounts. A mocked token exchange can check that your handler passes a verifier; it cannot establish that the authorization server enforces PKCE or exact redirect matching. Verify provider behavior separately, then run the application callback through its real session and OIDC validation path.

Expected outcomes for one authorization transaction
CaseExpected outcomeProperty
Correct state, code, session, verifier, and fixed redirectOne login completes for the validated identity.Ordinary sign-in still works.
Missing, unknown, expired, or already consumed stateCallback is rejected; no application session is created.Response correlation and one-time use.
Valid state sent from another browser sessionCallback is rejected.Session binding.
Correct state but wrong verifierToken exchange fails; no authenticated session.PKCE proof.
Unregistered or modified web redirect URIAuthorization request is rejected by provider.Exact registered URI matching.
Issuer, audience, expiry, or OIDC nonce mismatchIdentity response is rejected.OIDC token validation.
Read the isolated examples and their limitsProtocol excerpts, not a drop-in client
examples/flow.ts
examples/flow.ts
// Intentionally incomplete. Do not use as an OAuth client.
export function vulnerableCallback(query: URLSearchParams, pending: PendingLogin | undefined) {
	const code = query.get('code');
	if (!code) throw new Error('Missing authorization code');
	// It never checks that this browser started the matching authorization request.
	return exchangeCode(code, pending?.redirectUri ?? 'https://app.example.test/callback');
}

export type PendingLogin = {
	state: string;
	verifier: string;
	redirectUri: string;
	createdAt: number;
};

const redirectUri = 'https://app.example.test/oauth/callback';

export async function beginLogin(store: TransactionStore) {
	const state = randomBase64Url(32);
	const verifier = randomBase64Url(32);
	const challenge = base64Url(sha256(verifier));
	await store.save(state, { state, verifier, redirectUri, createdAt: Date.now() });
	return authorizationUrl({
		response_type: 'code',
		client_id: 'web-client',
		redirect_uri: redirectUri,
		state,
		code_challenge: challenge,
		code_challenge_method: 'S256'
	});
}

export async function completeLogin(query: URLSearchParams, store: TransactionStore) {
	const code = query.get('code');
	const returnedState = query.get('state');
	if (!code || !returnedState) throw new Error('Incomplete authorization response');

	// Atomically take and delete this one-use transaction, scoped to this browser session.
	const pending = await store.consumeForCurrentSession(returnedState);
	if (!pending || pending.state !== returnedState || Date.now() - pending.createdAt > 10 * 60_000) {
		throw new Error('Authorization response does not match a current login');
	}
	return exchangeCode({
		code,
		redirectUri: pending.redirectUri,
		codeVerifier: pending.verifier
	});
}

// Provider and framework adapters are deliberately omitted: this file teaches the
// transaction invariant, not a drop-in OAuth implementation.
declare function randomBase64Url(bytes: number): string;
declare function sha256(value: string): Uint8Array;
declare function base64Url(value: Uint8Array): string;
declare function authorizationUrl(parameters: Record<string, string>): string;
declare function exchangeCode(input: {
	code: string;
	redirectUri: string;
	codeVerifier?: string;
}): Promise<unknown>;
interface TransactionStore {
	save(state: string, value: PendingLogin): Promise<void>;
	consumeForCurrentSession(state: string): Promise<PendingLogin | undefined>;
}
examples/go/flow.go
examples/go/flow.go
package oauthflow

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/url"
	"time"
)

const redirectURI = "https://app.example.test/oauth/callback"

type PendingLogin struct {
	State     string
	Verifier  string
	Redirect  string
	CreatedAt time.Time
}

type Store interface {
	Save(context.Context, PendingLogin) error
	// ConsumeForSession must atomically delete the one-use transaction and scope it
	// to the current browser session; a plain global lookup is not sufficient.
	ConsumeForSession(context.Context, string) (PendingLogin, bool, error)
}

// Intentionally incomplete: the callback never matches a returned state to a
// pending transaction for the current browser session.
func VulnerableCompleteLogin(ctx context.Context, code string, pending *PendingLogin) (any, error) {
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}


func BeginLogin(ctx context.Context, store Store, authorizationEndpoint string) (string, error) {
	state, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	verifier, err := randomURLToken(32)
	if err != nil {
		return "", err
	}
	challengeBytes := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])

	if err := store.Save(ctx, PendingLogin{State: state, Verifier: verifier, Redirect: redirectURI, CreatedAt: time.Now()}); err != nil {
		return "", err
	}
	u, err := url.Parse(authorizationEndpoint)
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("response_type", "code")
	q.Set("client_id", "web-client")
	q.Set("redirect_uri", redirectURI)
	q.Set("state", state)
	q.Set("code_challenge", challenge)
	q.Set("code_challenge_method", "S256")
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func CompleteLogin(ctx context.Context, store Store, code, returnedState string) (any, error) {
	if code == "" || returnedState == "" {
		return nil, errors.New("incomplete authorization response")
	}
	pending, ok, err := store.ConsumeForSession(ctx, returnedState)
	if err != nil {
		return nil, err
	}
	if !ok || pending.State != returnedState || time.Since(pending.CreatedAt) > 10*time.Minute {
		return nil, errors.New("authorization response does not match a current login")
	}
	return exchangeCode(ctx, code, pending.Redirect, pending.Verifier)
}


func randomURLToken(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// Provider SDK and durable session-store implementations are omitted.
func exchangeCode(ctx context.Context, code, redirect, verifier string) (any, error) { return nil, nil }

The shown transaction operations are interfaces. A production store must use secure randomness, integrity and confidentiality protections appropriate to its storage, expiry, atomic one-time consumption, and a binding to the initiating session. The snippets do not run against an identity provider, validate signed OIDC tokens, set cookies, or establish provider configuration.

Leave with: tests for legitimate login and each broken binding, plus evidence from the real provider configuration.
07 / Make the next call

Choose the repair that restores the transaction invariant.

Try a decision

Two tabs complete sign-in into the unexpected account. What should the review require?

The callback receives a valid code. You have not yet established whether the issue is provider account selection or transaction mix-up.

Your next move

For the next review, trace one attempt from authorization request through callback and token exchange. Confirm the pending state and verifier are transaction-specific and browser-bound, redirect URIs are exact and fixed for the web client, and post-login routing cannot send users to arbitrary destinations. If this is OIDC sign-in, validate the identity token claims with a maintained library. Keep test evidence scoped to the path you observed.

The question to leave beside an OAuth callback: what proves this response belongs to the exact browser transaction that is about to create a session?

Connections to follow nextRelated Security lessons

API keys, bearer tokens, and signed tokens follows credentials after a session exists; OAuth callback integrity is an earlier boundary.

Idempotency and replay resistance considers one-use operations in application workflows. A one-time OAuth transaction has its own protocol lifecycle.

Sources and scopePrimary specifications · checked October 1, 2026