In short

Use the authorization code flow with PKCE (S256) through a maintained library, register exact redirect URIs, and bind state, nonce and the PKCE verifier to the browser’s server-side session. Check the issuer, then the ID token’s signature, audience, expiry and nonce, and map the user by issuer and subject, never by email. Do not use the implicit or password grants, and keep tokens out of browser JavaScript with a backend for frontend.

  • PKCE with S256 is required for public clients and recommended for every client (RFC 9700).
  • State, nonce and the PKCE verifier are per-transaction secrets bound to the browser that started the login.
  • Redirect URIs match by exact string, and neither client nor server may expose an open redirect.
  • A client that uses several providers checks the iss response parameter (RFC 9207) against the transaction.
  • Map accounts by iss and sub; an email claim can change or be unverified.
  • Never use the implicit or password grants; rotate or sender-constrain refresh tokens held by public clients.

Verify it

  1. Complete a login, then replay the same callback URL: the second attempt must fail and create no session.
  2. Start logins in two tabs and swap their callbacks, or change one character of state: both must fail.
  3. Ask the authorization server to redirect to a registered URI with an extra path or query: it must refuse.
  4. Return a callback whose iss names another provider: the client must refuse it before redeeming the code.
  5. Inspect the front end: no tokens in localStorage, sessionStorage, URLs or logs.
Applies to
  • openid-client (Node.js) 6.x (tested with 6.8.8)

OAuth 2.0 and OpenID Connect browser login and callback handling. Use the provider’s supported flow and exact redirect configuration.

Part of
Learning path: Identity through the whole lifecycle (step 1 of 14). Workflows: Fix a finding, Secure a feature, Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 28 minutes: 9 to read and 19 for the exercise

What you will learn

  • Trace the authorization code flow and its trust inputs.
  • Explain state, PKCE, nonce and the issuer parameter separately.
  • Name the OAuth attacks each control prevents.
  • Test concurrent tabs, replay, and stable identity mapping.

Before you start

Developers who add OAuth or OpenID Connect login to an application, and reviewers who check the callback and account-mapping decisions.

  • Distinguish a browser session, authorization code, identity token and API access token.

Refresh first: Session management: cookies, timeouts, rotation and revocation

Go to the guided practice

From browser redirect to an application session

Untrusted clientYour applicationExternal issuerBrowserApplication serverIdentity providerStart login1Store transaction: state,nonce, verifier1Redirect with state, nonce,S256 challenge1Authorization request1Sign-in, then redirect with code, state, iss2Callback: code, state and iss2Match the storedtransaction3Redeem the code with the PKCEverifier3Check verifier against thechallenge3ID token and access token3Check issuer, audience,signature, nonce4Fresh application session4Trust boundaryUntrusted input1Step, described below
  1. Start the loginThe application stores a transaction for this browser session with a fresh state, nonce and PKCE verifier, then redirects the browser to the authorization endpoint with the state, the nonce and the S256 challenge.
  2. Authenticate and return a codeThe identity provider signs the user in and redirects the browser to the exact registered redirect URI with a code, the state and iss.
  3. Check the callback and redeem the codeThe application matches the state and iss to the stored transaction, then sends the code, the redirect URI and the PKCE verifier to the token endpoint; the provider checks the verifier against the challenge and returns an ID token and an access token.
  4. Validate the ID token and start a sessionThe application checks the ID token’s signature, issuer, audience, expiry and nonce, consumes the transaction, maps the account by issuer and subject, and issues a fresh session.
A redirect or decoded token is not a session. Validate the expected issuer, audience, signature, nonce and transaction before binding the user.

The browser carries messages but does not choose what to trust

OAuth authorizes access to a resource; OpenID Connect adds an authentication layer and ID Token. The client application, authorization server, browser, and resource server have different responsibilities. A code returned through the browser is an input to a pending transaction, not proof by itself that the browser owns the account named by a later token.

Before redirecting, create a transaction bound to the initiating browser session: expected issuer, redirect URI, random state, PKCE verifier, OIDC nonce, creation time, and intended safe return destination. Store secrets server-side or in an appropriately protected transaction mechanism. The browser’s callback parameters must not overwrite those expectations.

State correlates the callback to the initiated flow, PKCE binds code redemption to possession of a transaction secret, and nonce binds the ID Token to the authentication request. They are related defenses with distinct inputs. Using the same constant value for every login removes their transaction-specific value.

Follow one code flow in order

First, generate a high-entropy PKCE verifier and derive the S256 challenge: BASE64URL(SHA256(verifier)) without padding. Send the challenge, state, nonce, fixed redirect URI, and required scope to the expected authorization endpoint. The verifier stays with the client until the token request.

Second, receive the callback and locate the unexpired browser-bound transaction. Validate state and the iss response parameter defined by RFC 9207, where the provider sends it, before exchanging the code. Use the expected token endpoint from trusted issuer configuration, not a URL supplied in callback data. A transaction for issuer A must not become a token request to issuer B.

Third, redeem the code with the original redirect URI and verifier. The authorization server checks the code’s client and redirect bindings, expiry, single use, and PKCE challenge. A stolen code alone should not be redeemable without the verifier. Client authentication, where applicable, is an additional control rather than a replacement for those bindings.

Fourth, validate the ID Token using the expected issuer’s trusted keys and configured algorithm policy. Check issuer, audience/client binding, time claims, and the transaction nonce according to the chosen OIDC profile. Only then map the stable pair of issuer and subject to a local account and establish a fresh local session.

Finally, consume the transaction so a duplicate callback cannot create another login. If two callbacks race, a check-then-delete pattern may allow both through; use an atomic state transition. Retain enough diagnostic correlation to explain a failure without storing authorization codes, verifiers, or whole tokens in logs.

Which OAuth and OIDC attacks does each control stop?

RFC 9700, the OAuth 2.0 security best current practice published in January 2025, turns years of attacks into requirements. The table pairs each common attack with the control that stops it; the attacks guide at /domains/identity-crypto/authentication/oauth-oidc-attacks/ covers each one in depth.

Which OAuth and OIDC attacks does each control stop?
AttackControlReference
Authorization code interception or injectionPKCE with S256; for confidential OIDC clients the nonce is an alternativeRFC 9700 section 2.1.1
Login CSRFA one-time state bound to the browser, or PKCE or the nonceRFC 9700 section 2.1
Redirect URI manipulation and open redirectorsExact redirect URI matching; no open redirect endpoints in the client or the serverRFC 9700 sections 2.1 and 4.11
Mix-up between providersOne transaction per provider and the iss response parameterRFC 9207; RFC 9700 section 2.1
Tokens leaked from the implicit grantDo not use response_type=token; use the code flowRFC 9700 section 2.1.2
Password grant phishing and credential exposureDo not use the resource owner password grantRFC 9700 section 2.4
Replay of stolen access or refresh tokensSender-constrain with DPoP or mTLS; rotate public clients’ refresh tokensRFC 9449; RFC 8705; RFC 9700 section 2.2
Token theft through XSS in a single-page appBackend for frontend; tokens never reach browser JavaScriptRFC 10017
Account takeover through a mutable email claim (nOAuth, 2023)Key accounts on iss and sub; link accounts only after proving control of bothOpenID Connect Core section 5.7

Keep the application’s remaining decisions explicit

Do not identify users solely by an email claim. Emails can change, may have differing verification semantics, and are not a stable cross-issuer identifier. Account linking requires proof of control over the existing local account and the new external identity; a matching email is not that proof.

An ID Token is intended for the client’s authentication processing. An API access token is intended for a resource server. Similar JWT syntax does not make them interchangeable. The API must enforce its own audience and authorization policy rather than accepting any signed token returned during login; the JWT validation lab at /labs/token-validation/ practices that check.

Concurrent tabs need separate pending transactions so one tab does not overwrite another’s nonce or verifier. A lost transaction cookie should produce a bounded restart, not a bypass. Native applications follow RFC 8252 instead of copying a web-client callback design: use the system browser rather than an embedded web view, redirect to a claimed HTTPS URL, a private-use scheme or a loopback address, and always use PKCE.

Protocol libraries reduce implementation risk, but configuration still supplies the trusted issuer, client identity, redirect URI, algorithms, and token-purpose expectations. A library that verifies a signature cannot infer the account-linking rule or whether a return URL is allowed.

Where should a single-page app keep its tokens?

RFC 10017, OAuth 2.0 for Browser-Based Applications (BCP 212, August 2026), compares three architectures. A backend for frontend (BFF) runs the OAuth flow as a confidential client and gives the browser only an HttpOnly session cookie. A token-mediating backend obtains tokens but hands access tokens to the browser. A browser-only client runs the whole flow in JavaScript and is the most exposed, because any injected script can use or exfiltrate its tokens.

Prefer the BFF. If tokens must reach the browser, keep them out of localStorage, make access tokens short-lived and sender-constrained with DPoP, and rotate refresh tokens with reuse detection. Confidential clients should authenticate with private_key_jwt or mutual TLS, and can send pushed authorization requests (PAR, RFC 9126) so request parameters cannot be altered in the browser.

Implement the code flow in Node.js with openid-client

The library performs the protocol checks; your code keeps one transaction per browser session and consumes it once. Tested with openid-client 6.8.8 on Node.js 26 against a local HTTPS provider: an ordinary login succeeds once, while a replayed callback, a swapped state, a foreign iss, an ID token carrying another transaction’s nonce, a code redeemed without its verifier and discovery metadata naming another issuer all fail.

Fixedlogin.mjsJavaScript
import * as client from 'openid-client';

// Confidential web client. Discovery pins the issuer; the library refuses metadata for another one.
const config = await client.discovery(new URL('https://id.example'), 'client-7', process.env.CLIENT_SECRET);
const REDIRECT_URI = 'https://app.example/callback'; // registered exactly, no wildcards

// 1. Start: keep the transaction in the server-side session of this browser.
export async function startLogin(session) {
  const verifier = client.randomPKCECodeVerifier();
  session.login = {verifier, state: client.randomState(), nonce: client.randomNonce()};
  return client.buildAuthorizationUrl(config, {
    redirect_uri: REDIRECT_URI,
    scope: 'openid email',
    code_challenge: await client.calculatePKCECodeChallenge(verifier), // S256
    code_challenge_method: 'S256',
    state: session.login.state,
    nonce: session.login.nonce,
  });
}

// 2. Callback: checks state, the RFC 9207 iss parameter, PKCE, and the ID token's
// signature, issuer, audience, expiry and nonce before returning anything.
export async function finishLogin(session, callbackUrl) {
  const pending = session.login;
  delete session.login; // single use, even when the exchange fails
  if (!pending) throw new Error('no login in progress');
  const tokens = await client.authorizationCodeGrant(config, callbackUrl, {
    pkceCodeVerifier: pending.verifier,
    expectedState: pending.state,
    expectedNonce: pending.nonce,
    idTokenExpected: true,
  });
  const {iss, sub} = tokens.claims(); // map (iss, sub) to the local account, never the email
  return {iss, sub};
}

Implement the code flow in Python with Authlib and Flask

Authlib's Flask client keeps state, nonce and the PKCE verifier in the Flask session and checks them in authorize_access_token, which also verifies the ID token's signature against the provider's JWKS, its issuer against the discovery document, the audience, the expiry and the nonce. Your code still has to insist on a verified ID token, start a fresh session and map the account by issuer and subject. Flask's default session is a signed cookie the browser can read, so use a server-side session store in production. Tested with Authlib 1.8.0 and Flask 3.1.3 on Python 3.14 against a local provider: a login succeeds once and sends the verifier that matches the S256 challenge, while a replayed callback, a forged state, an ID token carrying another login's nonce and an ID token from another issuer all fail without creating a session.

Fixedlogin_app.pyPython
import os

from authlib.integrations.flask_client import OAuth
from flask import Flask, abort, redirect, session

ISSUER = os.environ.get("OIDC_ISSUER", "https://id.example")
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)
oauth.register(
    "idp",
    client_id="client-7",
    client_secret=os.environ["CLIENT_SECRET"],
    # Discovery supplies the endpoints and JWKS; Authlib checks the ID token's iss against it.
    server_metadata_url=f"{ISSUER}/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email", "code_challenge_method": "S256"},  # PKCE
)


@app.get("/login")
def login():
    # Stores state, nonce and the PKCE verifier in this browser's session, then redirects.
    return oauth.idp.authorize_redirect("https://app.example/callback")  # registered exactly


@app.get("/callback")
def callback():
    # Checks and consumes state, redeems the code with the verifier, then verifies the ID
    # token's signature, issuer, audience, expiry and nonce. Any failure raises.
    token = oauth.idp.authorize_access_token()
    claims = token.get("userinfo")
    if claims is None:
        abort(401)  # no verified ID token, no login
    session.clear()  # a fresh session for the signed-in user
    session["account"] = [claims["iss"], claims["sub"]]  # map by (iss, sub), never by email
    return redirect("/")

Implement the code flow in Java with Spring Security

Spring Boot reads the client registration from properties: spring.security.oauth2.client.provider.id-example.issuer-uri=https://id.example makes Spring fetch the discovery document and check that its issuer matches, and spring.security.oauth2.client.registration.id-example.client-id, client-secret and scope=openid,email complete it. Register the default redirect URI, https://app.example/login/oauth2/code/id-example, exactly: exact matching is what stops redirect-URI manipulation and open-redirect abuse of the callback. Since Spring Security 7 a confidential client sends PKCE by default (ClientSettings.requireProofKey is true); in 6.x that setting was off by default.

AccountRepository stands for your own account store. Tested with Spring Boot 4.1.1 (Spring Security 7.1.1) on JDK 21 against a local provider: the authorization request carries code_challenge_method=S256, state and nonce; the token request carries the matching verifier; a login maps the account by issuer and subject and changes the session; and a replayed callback, a swapped state and an ID token with another login's nonce all end in a login error.

FixedLoginConfig.javaJava
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class LoginConfig {

    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests -> requests.requestMatchers("/", "/error").permitAll()
                        .anyRequest().authenticated())
                // Authorization code flow. Spring Security 7 adds PKCE (S256), state and, for the
                // openid scope, a nonce, and verifies the ID token before the user service runs.
                .oauth2Login(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(AccountRepository accounts) {
        OidcUserService delegate = new OidcUserService();
        return request -> {
            OidcUser user = delegate.loadUser(request); // signature, iss, aud, exp and nonce already checked
            // Key the local account on (iss, sub); an email address can change or be reassigned.
            accounts.findOrCreate(user.getIssuer().toString(), user.getSubject());
            return user;
        };
    }
}

Reject the wrong transaction while preserving the right one

Test the ordinary flow and independently alter state, verifier, nonce, issuer, audience, and callback age. A failure must create no local authenticated session. Replaying a consumed callback must fail; a second legitimate tab with its own pending transaction must still succeed.

The exercise supplies protocol observations rather than actual token signatures. Use it to rehearse decision ordering. For an integration test, exercise the chosen library against a controlled issuer and its real signed tokens, as the Node.js example above was, and document that separately from checking claims in a JSON example.

Guided practice

A web client (client-7, redirect https://app.example/callback) uses the code flow with PKCE against https://id.example. Compute the PKCE values yourself, decide what each callback below must do, and draw the flow. Work it out before reading the answers.

Supplied scenario material
Plain text
Transaction T1, browser B: state=S1, nonce=N1, verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk,
  created 10:00:00, expires 10:10:00 (reject at now >= expiry).
Authorization request logged for T1: code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, code_challenge_method=S256.
Transaction T2, browser B: state=S2, nonce=N2, verifier=Kz8sQ1p4Lw9dRm2NcT7vXa0bYe5fGh3iJk6lMn1oPq4,
  request logged with code_challenge=Kz8sQ1p4Lw9dRm2NcT7vXa0bYe5fGh3iJk6lMn1oPq4, code_challenge_method=plain.
C1 10:01:10 browser B: state=S1, iss=https://id.example, code=SplxlOBeZQQYbYS6WxSbIA.
   Token response: ID token iss=https://id.example, aud=client-7, nonce=N1, sub=user-9, email=alice@example.com.
C2 10:01:30 browser B: the same URL as C1 again.
C3 10:02:00 browser B: state=S9, which no transaction in B holds; the code came from the attacker's own login.
C4 10:03:00 browser B: state=S1 is already consumed; a fresh transaction T3 (state=S3) gets a callback with iss=https://other-idp.example.
  1. 1. Compute and check the PKCE challenges

    Compute the S256 challenge for T1's verifier and compare it with the logged request; then look at T2's request.

    Shell
    python3 -c 'import base64, hashlib, sys; print(base64.urlsafe_b64encode(hashlib.sha256(sys.argv[1].encode()).digest()).rstrip(b"=").decode())' dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
    Expected result and reasoning

    BASE64URL(SHA256(verifier)) without padding is E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, the RFC 7636 Appendix B test vector, so T1's request is correct. T2 sent the verifier itself with method plain: anyone who saw the authorization request (in browser history, logs or a proxy) can redeem a stolen code. The client must always send S256, and the authorization server should refuse plain.

  2. 2. Decide the four callbacks

    For C1 to C4, state whether a session is created and which check decides it.

    Expected result and reasoning

    C1 succeeds: state S1 matches a live transaction in B, iss matches, the code is redeemed with the verifier, and the ID token's iss, aud and nonce match; map the account as (https://id.example, user-9), consume T1 and issue a fresh session. C2 fails because T1 was consumed (and the code is single-use at the server). C3 fails: no transaction in B holds S9, which is the login-CSRF defense that stops the attacker signing the victim into the attacker's account. C4 fails the RFC 9207 issuer check before the code is redeemed, so the code is never sent to the wrong token endpoint.

  3. 3. Draw the flow

    Draw a sequence diagram with browser, client and authorization server, marking where state, nonce, the challenge, the verifier and iss travel and where each is checked.

    Expected result and reasoning

    Browser to client: start login. Client stores state, nonce, verifier against browser B, then redirects the browser to the authorization endpoint with state, nonce and the S256 challenge. Authorization server authenticates the user and redirects to the exact redirect URI with code, state and iss. Client checks state and iss against the stored transaction, then sends code, redirect URI and verifier to the token endpoint over a back channel; the server checks the verifier against the challenge. Client verifies the ID token signature, iss, aud, exp and nonce, consumes the transaction, maps (iss, sub) and sets a new session cookie.

  4. 4. Write the tests

    List the integration tests that prove these decisions in your client.

    Expected result and reasoning

    Against a local test provider: a normal login succeeds and the token request carries a verifier whose S256 hash equals the challenge; a repeated callback, an unknown state, a foreign iss, an ID token with another transaction's nonce, and a callback at exactly the expiry time all fail and create no session; and two concurrent logins in two tabs each succeed with their own transaction. The Node.js, Python and Java examples on this page were tested this way.

Your deliverable: The computed challenge, a decision table for C1 to C4 naming the deciding check, a sequence diagram and a list of integration tests.

The codes and tokens are labels, and the token response for C1 is stated rather than signed. Run the tests against a local provider or your identity provider's test tenant to exercise real signatures.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

OAuth redirect and PKCE lab: Match redirect URIs and verify PKCE

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. RFC 9700: OAuth 2.0 security best current practice. January 2025 (BCP 240).
  2. OpenID Connect Core 1.0. Final, incorporating errata set 2.
  3. RFC 7636: PKCE. September 2015.
  4. RFC 9207: Authorization server issuer identification. March 2022.
  5. RFC 9449: DPoP. September 2023.
  6. RFC 10017: OAuth 2.0 for browser-based applications. August 2026 (BCP 212).
  7. RFC 8252: OAuth 2.0 for native apps. October 2017 (BCP 212).
  8. OWASP OAuth 2.0 Cheat Sheet
  9. openid-client for Node.js
  10. Authlib: Flask OAuth client. Authlib 1.8.0.
  11. Spring Security: OAuth 2.0 login and PKCE for confidential clients

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-10.1.1 · v5.0.0-10.2.1 · v5.0.0-10.2.2 · v5.0.0-10.4.1 · v5.0.0-10.4.5 · v5.0.0-10.5.1 · v5.0.0-10.5.2 · v5.0.0-10.5.4

Download this guide as Markdown