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.
- 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.
Creates transaction state and a PKCE verifier.
Authenticates the user and returns a short-lived code.
Correlates the response to the pending browser transaction.
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.
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
- 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.
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.
These excerpts show the transaction boundary; provider SDK and storage adapters are intentionally omitted.
// 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');
} // 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)
} 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
});
} 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)
} 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.
Random and retained by the client.
Sent with the authorization request.
Associated with the authorization code.
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
// 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
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 }
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.
| Control | Question it answers | What to verify |
|---|---|---|
| State | Does this callback match a login this browser started? | Session-bound pending record, expiry, one-time consumption. |
| PKCE S256 | Can this client redeem this authorization code? | Fresh verifier per attempt; provider binds and checks the challenge. |
| Exact redirect URI | Where may this response be delivered? | Authorization server exact registered match; client uses the same fixed value at exchange. |
| OIDC issuer, audience, nonce | Is this identity token for this client and this authentication transaction? | Validate signature, issuer, audience, expiry, and nonce with a maintained OIDC library. |
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.
| Case | Expected outcome | Property |
|---|---|---|
| Correct state, code, session, verifier, and fixed redirect | One login completes for the validated identity. | Ordinary sign-in still works. |
| Missing, unknown, expired, or already consumed state | Callback is rejected; no application session is created. | Response correlation and one-time use. |
| Valid state sent from another browser session | Callback is rejected. | Session binding. |
| Correct state but wrong verifier | Token exchange fails; no authenticated session. | PKCE proof. |
| Unregistered or modified web redirect URI | Authorization request is rejected by provider. | Exact registered URI matching. |
| Issuer, audience, expiry, or OIDC nonce mismatch | Identity response is rejected. | OIDC token validation. |
Read the isolated examples and their limitsProtocol excerpts, not a drop-in client
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
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.
Choose the repair that restores the transaction invariant.
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.
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
- RFC 9700: Best Current Practice for OAuth 2.0 Security — PKCE, transaction binding, exact redirect URI matching, and open redirector guidance.
- RFC 7636: Proof Key for Code Exchange (PKCE) — verifier and challenge construction.
- OpenID Connect Core 1.0 — identity token validation and nonce.
- These excerpts are educational pseudoadapters. They do not establish security of any provider, library, cookie configuration, or production deployment.