Use Cases (Updated: 6/2/2026)

Claude Code OAuth Implementation: PKCE, State, Nonce, and Safe Token Storage

Build OAuth 2.1/2.0 Authorization Code + PKCE with Claude Code, including state, nonce, redirects, and token storage.

Claude Code OAuth Implementation: PKCE, State, Nonce, and Safe Token Storage

OAuth is not finished when the “Sign in with Google” button appears. A small mistake can bind the wrong account, reuse an authorization code, leak a token to browser JavaScript, or let an attacker swap the redirect URI. This guide shows how to use Claude Code to scaffold OAuth without handing it real secrets, then review the result like a security-sensitive feature.

The practical default is OAuth 2.0 Authorization Code with PKCE, aligned with the direction of OAuth 2.1. PKCE means Proof Key for Code Exchange: the app starts login with a hashed challenge, then proves at the token endpoint that it still has the original verifier. For broader auth design, see Claude Code authentication implementation, Claude Code API development, and Claude Code getting started.

The Implementation Shape

Use Claude Code for scaffolding routes, sessions, tests, and review checklists. Do not paste real client secrets, refresh tokens, production .env files, or provider console screenshots. Give it variable names, expected behavior, and failure cases.

AreaRecommended choiceWhy it matters
FlowAuthorization Code + PKCEAvoids older implicit-style token exposure
CSRFStore and verify stateBlocks forged callback completion
OIDC replayStore and verify nonceDetects replayed identity results
Redirect URIExact allow-list matchPrevents redirect substitution
TokensServer-side session or encrypted storeKeeps long-lived tokens out of localStorage
Claude Code inputDummy values and specs onlyAvoids secret leakage into prompts and logs

Primary references: OAuth 2.1, RFC 7636 PKCE, RFC 9700 OAuth 2.0 Security BCP, OpenID Connect Core, OWASP OAuth2 Cheat Sheet, and Claude Code Security.

Three Real Use Cases

The first common case is a B2B SaaS admin console. Users sign in with Google Workspace or Microsoft Entra ID, but your app still maps them to an internal user, organization, and role. OAuth proves the login; your own authorization layer decides what they can do.

The second case is delegated API access. Calendar, mail, storage, and CRM integrations need user consent and refresh token handling. Keep scopes narrow, encrypt refresh tokens, and make disconnect or revocation a first-class workflow.

The third case is internal tools and MCP-style services. Teams often start with static API keys, then struggle with revocation and audit trails. OAuth/OIDC gives you clearer login, consent, expiry, and identity boundaries.

Mobile apps and SPAs are also important because they cannot keep a client secret. For them PKCE is mandatory. Even server-rendered web apps should use PKCE now because it makes code injection and mixed-session failures easier to prevent and review.

Copy-Paste Runnable Local Demo

This demo runs without Google, Microsoft, or Auth0 credentials. One Express process acts as both the OAuth client and a mock authorization server, so you can observe state, nonce, PKCE S256, exact redirect validation, one-time authorization codes, and server-side token storage.

Create these two files in an empty folder, run npm install && npm start, then open http://localhost:3000.

{
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.19.2", "express-session": "^1.18.0" },
  "engines": { "node": ">=20" }
}
// server.mjs
import crypto from "node:crypto";
import express from "express";
import session from "express-session";

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
  name: "oauth_demo_sid",
  secret: "dev-only-change-this-32-byte-secret",
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 },
}));

const client = {
  clientId: "claude-code-demo",
  redirectUri: "http://localhost:3000/callback",
  scope: "openid profile email",
};
const authorizationEndpoint = "http://localhost:3000/mock/authorize";
const tokenEndpoint = "http://localhost:3000/mock/token";
const registeredRedirectUris = new Set([client.redirectUri]);
const pendingCodes = new Map();

function randomUrlSafe(bytes = 32) {
  return crypto.randomBytes(bytes).toString("base64url");
}
function sha256Base64Url(value) {
  return crypto.createHash("sha256").update(value).digest("base64url");
}
function fail(res, status, message) {
  return res.status(status).type("text/plain").send(message);
}

app.get("/", (_req, res) => {
  res.type("html").send(`<h1>OAuth PKCE local demo</h1><p><a href="/auth/login">Start login</a></p>`);
});

app.get("/auth/login", (req, res) => {
  const state = randomUrlSafe();
  const nonce = randomUrlSafe();
  const codeVerifier = randomUrlSafe(48);
  const codeChallenge = sha256Base64Url(codeVerifier);
  req.session.oauth = { state, nonce, codeVerifier, createdAt: Date.now() };

  const params = new URLSearchParams({
    response_type: "code",
    client_id: client.clientId,
    redirect_uri: client.redirectUri,
    scope: client.scope,
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
  res.redirect(`${authorizationEndpoint}?${params}`);
});

app.get("/mock/authorize", (req, res) => {
  const p = req.query;
  const redirectUri = String(p.redirect_uri || "");
  if (p.response_type !== "code") return fail(res, 400, "response_type must be code");
  if (p.client_id !== client.clientId) return fail(res, 400, "unknown client_id");
  if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri is not registered exactly");
  if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE S256 is required");
  if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "missing state, nonce, or PKCE challenge");

  const code = randomUrlSafe(24);
  pendingCodes.set(code, {
    clientId: client.clientId,
    redirectUri,
    codeChallenge: String(p.code_challenge),
    nonce: String(p.nonce),
    expiresAt: Date.now() + 60_000,
    used: false,
  });

  const redirect = new URL(redirectUri);
  redirect.searchParams.set("code", code);
  redirect.searchParams.set("state", String(p.state));
  res.redirect(redirect.toString());
});

app.get("/callback", async (req, res) => {
  const oauth = req.session.oauth;
  const code = String(req.query.code || "");
  const returnedState = String(req.query.state || "");
  if (!oauth) return fail(res, 400, "missing OAuth session");
  if (returnedState !== oauth.state) return fail(res, 403, "state mismatch: possible CSRF or mixed login attempt");

  const response = await fetch(tokenEndpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: client.redirectUri,
      client_id: client.clientId,
      code_verifier: oauth.codeVerifier,
    }),
  });
  const tokens = await response.json();
  if (!response.ok) return fail(res, response.status, JSON.stringify(tokens, null, 2));
  if (tokens.nonce !== oauth.nonce) return fail(res, 403, "nonce mismatch: possible replay");

  req.session.oauth = undefined;
  req.session.tokenSet = {
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  };
  res.redirect("/dashboard");
});

app.post("/mock/token", (req, res) => {
  const body = req.body;
  const record = pendingCodes.get(body.code);
  if (body.grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
  if (!record || record.used || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
  if (body.client_id !== record.clientId) return res.status(400).json({ error: "invalid_client" });
  if (body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_redirect_uri" });
  if (sha256Base64Url(body.code_verifier || "") !== record.codeChallenge) return res.status(400).json({ error: "invalid_code_verifier" });

  record.used = true;
  res.json({ token_type: "Bearer", access_token: randomUrlSafe(32), refresh_token: randomUrlSafe(32), expires_in: 300, nonce: record.nonce });
});

app.get("/dashboard", (req, res) => {
  const tokenSet = req.session.tokenSet;
  if (!tokenSet) return res.redirect("/auth/login");
  const secondsLeft = Math.max(0, Math.floor((tokenSet.expiresAt - Date.now()) / 1000));
  res.type("html").send(`<h1>Logged in</h1><p>Access token is stored server-side, not in localStorage.</p><p>Expires in ${secondsLeft} seconds.</p>`);
});

app.listen(3000, () => console.log("Open http://localhost:3000"));

The important parts are visible: /auth/login stores state, nonce, and code_verifier in the server-side session; the authorization request sends only code_challenge; /callback verifies state; /mock/token recomputes the S256 challenge; and /dashboard reads tokens from the server session, not from localStorage.

Prompt Claude Code With Constraints

Implement OAuth 2.0 Authorization Code + PKCE in Express + TypeScript.
Requirements:
- Define provider settings as environment variable names only; do not include real secrets.
- Store state, nonce, and code_verifier in a server-side session.
- Validate redirect_uri with exact configured value matching.
- Allow only code_challenge_method=S256.
- Never store access or refresh tokens in localStorage.
- Store refresh tokens only in an encrypted database field or server-side session.
- Add tests for success, state mismatch, PKCE mismatch, expired code, and code reuse.
- Finish with a security review checklist.

For review, ask:

Review this OAuth implementation against RFC 9700, RFC 7636, and the OWASP OAuth2 Cheat Sheet.
Check whether secrets appear in logs, snapshots, frontend bundles, or Git diffs.
Classify findings as High, Medium, or Low and propose patches.

Concrete Pitfalls

Do not validate redirect URIs with prefix matching. https://app.example.com.evil.test/callback is not your app. Use exact registered redirect URIs.

Do not generate state without checking it. Save it at login start, compare it on callback, and remove it after use. If multiple tabs must be supported, store short-lived transactions keyed by state.

Do not allow PKCE plain unless you have a very specific compatibility reason. Prefer S256, never log the verifier, expire authorization codes quickly, and make codes one-time use.

For OIDC, nonce is not the whole ID token validation story. Validate the JWT signature, issuer, audience, expiry, and nonce. Treat ID tokens as identity evidence and access tokens as API authorization; mixing them creates subtle trust bugs.

Avoid localStorage for long-lived tokens. For normal web apps, use server-side sessions, encrypted token tables, short cookie lifetimes, and explicit revocation. Cookie details are covered in Claude Code cookie management.

Monetizable Delivery Checklist

A production-quality OAuth implementation should include documentation, tests, and review evidence, not just working routes.

  • Token lifetimes and refresh behavior are documented.
  • state, nonce, and PKCE are explained in team language.
  • Redirect URIs, scopes, and provider console settings are recorded.
  • Logs exclude codes, verifiers, tokens, and cookie values.
  • Failure messages do not expose internal provider details.
  • Tests cover success, mismatch, expiry, and reuse.
  • Claude Code output is reviewed against official specs.

ClaudeCodeLab training and consultation can turn this into a team workshop: bring an existing OAuth flow, map the risks, add tests, and leave with a review checklist. See ClaudeCodeLab training for the format.

Hands-On Verification

I wrote the demo so the full local path can be exercised: login start, mock authorization, callback, token exchange, and dashboard. If you change the returned state, the app stops with state mismatch. If the verifier is wrong, the token endpoint returns invalid_code_verifier. In past OAuth prototypes, the issues that slipped through review were rarely the happy path; they were multiple tabs, back-button retries, expired codes, and accidental logging. Put those cases into the first Claude Code prompt and the implementation becomes much easier to trust.

Summary

OAuth quality comes from protecting the whole transaction, not from rendering a login button. Use Authorization Code + PKCE, verify state and nonce, match redirect URIs exactly, and keep tokens server-side. Claude Code can accelerate the build, but only if you give it explicit security constraints and review the result against primary sources.

#Claude Code #OAuth #authentication #security #TypeScript
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.