---
key: "oauth-oidc-login"
title: "OAuth 2.0 and OpenID Connect login: authorization code with PKCE"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/oauth-oidc-login/"
api: "https://softwaresecurity.com/api/v1/articles/oauth-oidc-login.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-352","CWE-601","CWE-346","CWE-294","A07:2025","API2:2023","V10.1.1","V10.2.1","V10.2.2","V10.4.1","V10.4.5","V10.5.1","V10.5.2","V10.5.4"]
tags: ["Authentication and sessions","OAuth and OpenID Connect"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# OAuth 2.0 and OpenID Connect login: authorization code with PKCE

Implement OAuth 2.0 and OpenID Connect login with the authorization code flow and PKCE, keep each transaction bound to its browser, and block redirect, mix-up, code-injection and token-theft attacks.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/oauth-oidc-login/

## 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.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Grant | Authorization code with PKCE `S256` | Implicit (`response_type=token`) and resource owner password grants disabled. | RFC 9700 sections 2.1.1, 2.1.2 and 2.4 |
| Redirect URI matching | Exact string comparison against pre-registered URIs | Only the port of a native app’s loopback redirect may vary. | RFC 9700 section 2.1; ASVS 5.0 V10.4.1 |
| Authorization code | Single use, lifetime of 10 minutes or less | ASVS level 3 applications: 1 minute or less. | ASVS 5.0 V10.4.2 and V10.4.3 |
| State, nonce and PKCE verifier | Fresh per login, at least 128 bits from a CSPRNG | Library generators such as `randomState()` produce 256 bits. | RFC 7636; ASVS 5.0 V11.5.1 |
| Client authentication | `private_key_jwt` or mutual TLS | Avoids shared client secrets that can leak from either side. | RFC 9700 section 2.5 |
| Refresh tokens (public clients) | Rotation with reuse detection, or DPoP sender-constraining | Give every refresh token an absolute expiry, even with sliding renewal. | RFC 9700 section 2.2.2; ASVS 5.0 V10.4.8 |
| Browser applications | Backend for frontend: tokens on the server, an HttpOnly cookie in the browser | A browser-only client is the most exposed architecture. | RFC 10017 (BCP 212) |

## 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.

## 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.

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

Before you start:

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

Time: About 28 minutes: 9 to read and 19 for the exercise

## 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.

| Attack | Control | Reference |
| --- | --- | --- |
| Authorization code interception or injection | PKCE with `S256`; for confidential OIDC clients the nonce is an alternative | RFC 9700 section 2.1.1 |
| Login CSRF | A one-time `state` bound to the browser, or PKCE or the nonce | RFC 9700 section 2.1 |
| Redirect URI manipulation and open redirectors | Exact redirect URI matching; no open redirect endpoints in the client or the server | RFC 9700 sections 2.1 and 4.11 |
| Mix-up between providers | One transaction per provider and the `iss` response parameter | RFC 9207; RFC 9700 section 2.1 |
| Tokens leaked from the implicit grant | Do not use `response_type=token`; use the code flow | RFC 9700 section 2.1.2 |
| Password grant phishing and credential exposure | Do not use the resource owner password grant | RFC 9700 section 2.4 |
| Replay of stolen access or refresh tokens | Sender-constrain with DPoP or mTLS; rotate public clients’ refresh tokens | RFC 9449; RFC 8705; RFC 9700 section 2.2 |
| Token theft through XSS in a single-page app | Backend for frontend; tokens never reach browser JavaScript | RFC 10017 |
| Account takeover through a mutable email claim (nOAuth, 2023) | Key accounts on `iss` and `sub`; link accounts only after proving control of both | OpenID 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.

```javascript
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.

```python
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.

```java
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 material

```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. 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.

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

```

Worked answer: `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. Decide the four callbacks

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

Worked answer: 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. 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.

Worked answer: 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. Write the tests

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

Worked answer: 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.

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

Scope: 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.

## Executable practice

- [OAuth redirect and PKCE lab: Match redirect URIs and verify PKCE](https://softwaresecurity.com/labs/oauth-redirect-pkce/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 9700: OAuth 2.0 security best current practice](https://www.rfc-editor.org/rfc/rfc9700.html). January 2025 (BCP 240).
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html). Final, incorporating errata set 2.
- [RFC 7636: PKCE](https://www.rfc-editor.org/rfc/rfc7636.html). September 2015.
- [RFC 9207: Authorization server issuer identification](https://www.rfc-editor.org/rfc/rfc9207.html). March 2022.
- [RFC 9449: DPoP](https://www.rfc-editor.org/rfc/rfc9449.html). September 2023.
- [RFC 10017: OAuth 2.0 for browser-based applications](https://www.rfc-editor.org/rfc/rfc10017.html). August 2026 (BCP 212).
- [RFC 8252: OAuth 2.0 for native apps](https://www.rfc-editor.org/rfc/rfc8252.html). October 2017 (BCP 212).
- [OWASP OAuth 2.0 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)
- [openid-client for Node.js](https://github.com/panva/openid-client)
- [Authlib: Flask OAuth client](https://docs.authlib.org/en/latest/oauth2/client/web/flask.html). Authlib 1.8.0.
- [Spring Security: OAuth 2.0 login and PKCE for confidential clients](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.html)