How to test oauth login flow

Walk the flow with curl and assert at four points. The client answers 302 with a state value and a PKCE challenge, the consent form posts back an authorization code, and the token endpoint trades that code for an access token. Then replay the code, alter the redirect_uri, and drop the state.

Why check this

Run this on staging sign-off and after any change to client registration, the callback route or the consent screen. A login that completes proves the happy path, which is the one part of an authorization code flow that almost always works.

Four defects are structural and invisible to a test that stops at "the user is signed in": a code that can be exchanged twice, a redirect_uri compared loosely, a state value nobody verifies, and a code that outlives its advertised lifetime. The steps below produce each one.

Prerequisites

// oauth-demo.js - local authorization server (8963) and relying party (8964). Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');

const AS = 'http://127.0.0.1:8963';
const RP = 'http://127.0.0.1:8964';

// Three clients over one code path. "strict" follows RFC 6749 and RFC 7636.
// "legacy" is the same server with the four checks a real deployment most often skips.
// "flat" checks everything and reports every failure as one error code.
const CLIENTS = {
  'strict-client': { secret: 's3cr3t+value', redirect: RP + '/callback', exactRedirect: true, singleUse: true, enforceTtl: true, pkce: true },
  'legacy-client': { secret: 'legacy-secret', redirect: RP + '/callback', exactRedirect: false, singleUse: false, enforceTtl: false, pkce: false },
  'flat-client': { secret: 'flat-secret', redirect: RP + '/callback', exactRedirect: true, singleUse: true, enforceTtl: true, pkce: false, flat: true },
};

const codes = new Map();    // code -> { client_id, redirect_uri, challenge, exp, ttl, used, sub, nonce }
const pending = new Map();  // sid  -> the /authorize query, held across the consent page
const KEY = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const KID = 'demo-key-1';
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const now = () => Math.floor(Date.now() / 1000);
const rnd = (n) => crypto.randomBytes(n).toString('hex');
const s256 = (v) => crypto.createHash('sha256').update(v).digest('base64url');
const body = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s))); });
const json = (res, code, obj, extra = {}) => res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra }).end(JSON.stringify(obj, null, 1) + '\n');

function idToken(sub, aud, nonce) {
  const claims = { iss: AS, sub, aud, iat: now(), exp: now() + 300 };
  if (nonce) claims.nonce = nonce;
  const d = b64({ alg: 'RS256', typ: 'JWT', kid: KID }) + '.' + b64(claims);
  return d + '.' + crypto.sign('sha256', Buffer.from(d), KEY.privateKey).toString('base64url');
}

// RFC 6749 section 2.3.1: the client encodes id and secret with application/x-www-form-urlencoded
// before base64. Decoding has to undo that, which is why a raw "+" in a secret arrives as a space.
function basicAuth(req) {
  const m = /^Basic\s+(.+)$/i.exec(req.headers.authorization || '');
  if (!m) return null;
  const raw = Buffer.from(m[1], 'base64').toString('utf8');
  const i = raw.indexOf(':');
  const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
  return { id: dec(raw.slice(0, i)), secret: dec(raw.slice(i + 1)), viaHeader: true };
}

const as = http.createServer(async (req, res) => {
  const u = new URL(req.url, AS);
  const p = u.pathname;

  // --- authorization endpoint -------------------------------------------------
  if (p === '/authorize') {
    const q = u.searchParams;
    const c = CLIENTS[q.get('client_id')];
    // RFC 6749 section 4.1.2.1: on a bad client_id or redirect_uri, do NOT redirect.
    if (!c) return res.writeHead(400, { 'content-type': 'text/plain' }).end('invalid client_id\n');
    const ru = q.get('redirect_uri') || '';
    const ok = c.exactRedirect ? ru === c.redirect : ru.startsWith(c.redirect);
    if (!ok) return res.writeHead(400, { 'content-type': 'text/plain' }).end('invalid redirect_uri\n');
    if (c.pkce && q.get('code_challenge_method') !== 'S256') {
      return res.writeHead(302, { location: ru + '?error=invalid_request&error_description=PKCE+required&state=' + encodeURIComponent(q.get('state') || '') }).end();
    }
    const sid = rnd(8);
    pending.set(sid, q);
    return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(
      `<!doctype html><title>Consent</title>\n<h1>Sign in to ${q.get('client_id')}</h1>\n` +
      `<p>scope: ${q.get('scope') || '(none)'}</p>\n` +
      `<form method="post" action="/authorize/decide">\n` +
      `<input type="hidden" name="sid" value="${sid}">\n` +
      `<button name="decision" value="approve">Allow</button>\n` +
      `<button name="decision" value="deny">Deny</button>\n</form>\n`);
  }

  if (p === '/authorize/decide' && req.method === 'POST') {
    const f = await body(req);
    const q = pending.get(f.get('sid'));
    if (!q) return res.writeHead(400, { 'content-type': 'text/plain' }).end('unknown sid\n');
    pending.delete(f.get('sid'));
    const ru = q.get('redirect_uri');
    const st = q.get('state');
    if (f.get('decision') !== 'approve') {
      return res.writeHead(302, { location: ru + '?error=access_denied' + (st ? '&state=' + encodeURIComponent(st) : '') }).end();
    }
    const ttl = Number(q.get('code_ttl') || 60);
    const code = rnd(16);
    codes.set(code, { client_id: q.get('client_id'), redirect_uri: ru, challenge: q.get('code_challenge'), exp: now() + ttl, ttl, used: false, sub: 'alice', nonce: q.get('nonce') });
    return res.writeHead(302, {
      location: ru + '?code=' + code + (st ? '&state=' + encodeURIComponent(st) : ''),
      'x-code-lifetime': String(ttl),   // demo only: the lifetime this server says it applies
    }).end();
  }

  // --- token endpoint ---------------------------------------------------------
  if (p === '/token' && req.method === 'POST') {
    const f = await body(req);
    const basic = basicAuth(req);
    const id = basic ? basic.id : f.get('client_id');
    const secret = basic ? basic.secret : f.get('client_secret');
    const c = CLIENTS[id];
    // A client marked flat gets one error code for every failure, the shape a client cannot act on.
    const err = (code, obj, extra) => json(res, c && c.flat ? 400 : code, c && c.flat ? { error: 'invalid_request' } : obj, c && c.flat ? {} : extra);
    // RFC 6749 section 5.2: 401 when the client tried the Authorization header, 400 otherwise.
    if (!c || secret !== c.secret) {
      const extra = basic ? { 'www-authenticate': 'Basic realm="token"' } : {};
      return err(basic ? 401 : 400, { error: 'invalid_client', error_description: 'Client authentication failed' }, extra);
    }
    if (f.get('grant_type') !== 'authorization_code') return err(400, { error: 'unsupported_grant_type' });
    if (!f.get('code')) return err(400, { error: 'invalid_request', error_description: 'code is required' });
    const e = codes.get(f.get('code'));
    const bad = (d) => err(400, { error: 'invalid_grant', error_description: d });
    if (!e) return bad('Unknown authorization code');
    if (e.client_id !== id) return bad('Code was issued to another client');
    if (c.enforceTtl && e.exp <= now()) return bad('Authorization code expired');
    if (c.singleUse && e.used) { codes.delete(f.get('code')); return bad('Authorization code already used'); }
    if (f.get('redirect_uri') !== e.redirect_uri) return bad('redirect_uri does not match the authorization request');
    if (c.pkce && s256(f.get('code_verifier') || '') !== e.challenge) return bad('PKCE verification failed');
    e.used = true;
    const scope = 'openid profile';
    return json(res, 200, {
      access_token: 'at_' + rnd(16), token_type: 'Bearer', expires_in: 300,
      refresh_token: 'rt_' + rnd(16), scope, id_token: idToken(e.sub, id, e.nonce),
    });
  }

  // --- discovery and keys -----------------------------------------------------
  if (p === '/.well-known/openid-configuration') {
    return json(res, 200, {
      issuer: AS,
      authorization_endpoint: AS + '/authorize',
      token_endpoint: AS + '/token',
      userinfo_endpoint: AS + '/userinfo',
      jwks_uri: AS + '/jwks',
      response_types_supported: ['code'],
      subject_types_supported: ['public'],
      id_token_signing_alg_values_supported: ['RS256'],
      scopes_supported: ['openid', 'profile'],
      token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
      code_challenge_methods_supported: ['S256'],
      grant_types_supported: ['authorization_code'],
    });
  }
  if (p === '/jwks') {
    const j = KEY.publicKey.export({ format: 'jwk' });
    return json(res, 200, { keys: [{ ...j, kid: KID, use: 'sig', alg: 'RS256' }] });
  }
  if (p === '/userinfo') return json(res, 200, { sub: 'alice', name: 'Alice Tester' });
  return json(res, 404, { error: 'not_found' });
});

// --- relying party ------------------------------------------------------------
const sessions = new Map();   // sid -> { state, verifier, client }

const rp = http.createServer(async (req, res) => {
  const u = new URL(req.url, RP);
  const q = u.searchParams;

  if (u.pathname === '/start') {
    const client = q.get('client') || 'strict-client';
    const sid = rnd(8), state = rnd(12), verifier = rnd(32);
    sessions.set(sid, { state, verifier, client });
    const a = new URL(AS + '/authorize');
    a.searchParams.set('response_type', 'code');
    a.searchParams.set('client_id', client);
    a.searchParams.set('redirect_uri', RP + '/callback');
    a.searchParams.set('scope', 'openid profile');
    a.searchParams.set('state', state);
    if (CLIENTS[client].pkce) { a.searchParams.set('code_challenge', s256(verifier)); a.searchParams.set('code_challenge_method', 'S256'); }
    return res.writeHead(302, { location: a.toString(), 'set-cookie': `rp_sid=${sid}; Path=/; HttpOnly` }).end();
  }

  // /callback checks state. /callback-nostate is the same handler with that check removed.
  if (u.pathname === '/callback' || u.pathname === '/callback-nostate') {
    const sid = /rp_sid=([a-f0-9]+)/.exec(req.headers.cookie || '')?.[1];
    const s = sessions.get(sid);
    const checks = u.pathname === '/callback';
    if (checks) {
      if (!s) return res.writeHead(400, { 'content-type': 'text/plain' }).end('no session for this callback\n');
      if (q.get('state') !== s.state) return res.writeHead(400, { 'content-type': 'text/plain' }).end(`state mismatch: got ${q.get('state')}, expected ${s.state}\n`);
    }
    if (q.get('error')) return res.writeHead(400, { 'content-type': 'text/plain' }).end(`authorization error: ${q.get('error')}\n`);
    const client = s ? s.client : 'legacy-client';
    const form = new URLSearchParams({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: RP + '/callback', client_id: client, client_secret: CLIENTS[client].secret });
    if (s && CLIENTS[client].pkce) form.set('code_verifier', s.verifier);
    const r = await fetch(AS + '/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: form });
    const t = await r.json();
    return res.writeHead(r.status, { 'content-type': 'text/plain' }).end(
      `state checked: ${checks}\ntoken endpoint: ${r.status}\n` + (t.access_token ? `access_token: ${t.access_token}\nid_token sub: ${JSON.parse(Buffer.from(t.id_token.split('.')[1], 'base64url')).sub}\n` : `${t.error}: ${t.error_description || ''}\n`));
  }

  // Stands in for any path on the client origin an attacker can reach.
  if (u.pathname.startsWith('/callback')) return res.writeHead(200, { 'content-type': 'text/plain' }).end(`attacker page received: ${u.search}\n`);
  return res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
});

as.listen(8963, '127.0.0.1');
rp.listen(8964, '127.0.0.1', () => console.log(`authorization server ${AS}, client ${RP}, pid ${process.pid}`));

Confirm both ports are free, then start the server.

netstat -ano | grep -E ':(8963|8964) ' ; node oauth-demo.js
authorization server http://127.0.0.1:8963, client http://127.0.0.1:8964, pid 3180

Stop that one id when you finish: powershell -Command "Stop-Process -Id 3180" on Windows, kill 3180 elsewhere. Stopping every process named node takes other servers with it.

Steps 6 onward need a fresh code each time. Save these helpers as flow.sh, then source ./flow.sh.

# flow.sh - three helpers for the steps below. Run `source ./flow.sh` once.
AS=http://127.0.0.1:8963
RP=http://127.0.0.1:8964
VERIFIER=demo-verifier-0123456789abcdef0123456789abcdef
CHALLENGE=suUF750mOWPl8jnH8wxOTJuQEY9IQwCTmtC_TpxS_tM   # base64url(sha256(VERIFIER))

secret () { case $1 in strict-client) printf 's3cr3t+value';; legacy-client) printf 'legacy-secret';; flat-client) printf 'flat-secret';; esac; }

# newcode <client_id> [code_ttl] [redirect_uri] - walks the browser half, prints one code
newcode () {
  local sid loc
  sid=$(curl -s "$AS/authorize?response_type=code&client_id=$1&scope=openid&state=st1&code_ttl=${2:-60}&redirect_uri=${3:-$RP/callback}&code_challenge=$CHALLENGE&code_challenge_method=S256" \
        | grep -o 'name="sid" value="[a-f0-9]*"' | cut -d'"' -f4)
  loc=$(curl -s -D - -o /dev/null -X POST "$AS/authorize/decide" -d "sid=$sid" -d decision=approve | grep -i '^location:' | tr -d '\r')
  printf '%s' "${loc#*code=}" | cut -d'&' -f1
}

# exchange <client_id> <code> [redirect_uri] - one token request, prints the status and the error
exchange () {
  curl -s -o body.json -w '%{http_code}' -X POST "$AS/token" \
    -d grant_type=authorization_code -d "code=$2" -d "redirect_uri=${3:-$RP/callback}" \
    -d "client_id=$1" --data-urlencode "client_secret=$(secret "$1")" -d "code_verifier=$VERIFIER"
  node -e "const j=require('./body.json');console.log(' '+(j.access_token?'access_token issued':j.error+': '+(j.error_description||'')))"
}

# csrf <client_id> - an attacker's code delivered into a victim session, to both callbacks
csrf () {
  local a
  curl -s -c jarV.txt -o /dev/null "$RP/start?client=$1"
  a=$(newcode "$1")
  curl -s -b jarV.txt "$RP/callback?code=$a"         -w 'status %{http_code}\n'
  curl -s -b jarV.txt "$RP/callback-nostate?code=$a" -w 'status %{http_code}\n'
}

Steps

  1. Step 1.

    Start the flow at the client and read the redirect it answers with.

    curl -s -c jar.txt -D - -o /dev/null 'http://127.0.0.1:8964/start?client=strict-client' | tee start.head | tr -d '\r' | grep -iE '^(HTTP/|location|set-cookie)'
    
    HTTP/1.1 302 Found
    location: http://127.0.0.1:8963/authorize?response_type=code&client_id=strict-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A8964%2Fcallback&scope=openid+profile&state=81742ae631a0a6e7423d8199&code_challenge=l9SXPl2dXEb1rE4X5wCw6Wnm1dnrSmGLi4IuLudz1cI&code_challenge_method=S256
    set-cookie: rp_sid=33b4c59cc48bdcff; Path=/; HttpOnly

    state and code_challenge go out in the URL. What they are compared against stays in that cookie.

  2. Step 2.

    Read the authorization request as a list of parameters.

    node -e "for (const [k,v] of new URL(process.argv[1]).searchParams) console.log(k.padEnd(20), v)" "$(grep -i '^location:' start.head | tr -d '\r' | cut -d' ' -f2)"
    
    response_type        code
    client_id            strict-client
    redirect_uri         http://127.0.0.1:8964/callback
    scope                openid profile
    state                81742ae631a0a6e7423d8199
    code_challenge       l9SXPl2dXEb1rE4X5wCw6Wnm1dnrSmGLi4IuLudz1cI
    code_challenge_method S256

    A missing state, or code_challenge_method=plain, is a defect you can file from this list.

  3. Step 3.

    Open the authorization endpoint. This is the consent screen a user sees.

    curl -s "$(grep -i '^location:' start.head | tr -d '\r' | cut -d' ' -f2)" | tee consent.html
    
    <!doctype html><title>Consent</title>
    <h1>Sign in to strict-client</h1>
    <p>scope: openid profile</p>
    <form method="post" action="/authorize/decide">
    <input type="hidden" name="sid" value="8d5b783b3f7a1561">
    <button name="decision" value="approve">Allow</button>
    <button name="decision" value="deny">Deny</button>
    </form>

    Compare the scope shown here with step 2. A screen that asks for less than the request is a defect users notice last.

  4. Step 4.

    Approve, and read the redirect back to the client.

    curl -s -D - -o /dev/null -X POST http://127.0.0.1:8963/authorize/decide -d "sid=$(grep -o 'name="sid" value="[a-f0-9]*"' consent.html | cut -d'"' -f4)" -d decision=approve | tee decide.head | tr -d '\r' | grep -iE '^(HTTP/|location|x-code-lifetime)'
    
    HTTP/1.1 302 Found
    location: http://127.0.0.1:8964/callback?code=be52baad63e754138fcd8f282704a95c&state=81742ae631a0a6e7423d8199
    x-code-lifetime: 60

    The state is the value from step 2, unchanged. x-code-lifetime is the server saying how long it honours the code. Step 11 asks whether it means it.

  5. Step 5.

    Deliver the callback to the client, with the cookie from step 1.

    curl -s -b jar.txt "$(grep -i '^location:' decide.head | tr -d '\r' | cut -d' ' -f2)" -w 'status %{http_code}\n'
    
    state checked: true
    token endpoint: 200
    access_token: at_c2c97c90ba2c0e79c09da3a2102b2e92
    id_token sub: alice
    status 200

    The flow is complete. The rest is what a passing login hides.

  6. Step 6.

    Spend one code twice on each client.

    for c in strict-client legacy-client; do C=$(newcode $c); for n in first second; do printf '%-14s %-6s ' "$c" "$n"; exchange $c "$C"; done; done
    
    strict-client  first  200 access_token issued
    strict-client  second 400 invalid_grant: Authorization code already used
    legacy-client  first  200 access_token issued
    legacy-client  second 200 access_token issued

    RFC 6749 section 4.1.2 requires single use. A code left in browser history or a proxy log is worth an account while it still works.

  7. Step 7.

    Send an unregistered redirect_uri to both clients.

    for c in strict-client legacy-client; do printf '%-14s ' "$c"; curl -s -o resp.txt -w '%{http_code} ' "$AS/authorize?response_type=code&client_id=$c&redirect_uri=$RP/callback.evil/collect&scope=openid&state=st1&code_challenge=$CHALLENGE&code_challenge_method=S256"; head -1 resp.txt; done
    
    strict-client  400 invalid redirect_uri
    legacy-client  200 <!doctype html><title>Consent</title>

    legacy-client compares with a prefix, so /callback.evil/collect matches /callback. The refusal is a plain 400, not a redirect (RFC 6749 section 4.1.2.1).

  8. Step 8.

    Follow that through and see where the code lands.

    curl -s "$RP/callback.evil/collect?code=$(newcode legacy-client 60 $RP/callback.evil/collect)"
    
    attacker page received: ?code=82629657ce4525850d9b86312cda7999

    A path the client never registered now holds a usable code.

  9. Step 9.

    Deliver an attacker's code into a victim session on the client that skips PKCE.

    csrf legacy-client
    
    state mismatch: got null, expected 7373684a5d0e3fc283dd4af3
    status 400
    state checked: false
    token endpoint: 200
    access_token: at_b45027a46a763faa00843478d3054f52
    id_token sub: alice
    status 200

    One code, two callbacks. The route that compares state refuses it. The other signs the victim into the attacker's account.

  10. Step 10.

    Run the same attack against the client that sends a PKCE challenge.

    csrf strict-client
    
    state mismatch: got null, expected 6adf8403887f4826387e6c3d
    status 400
    state checked: false
    token endpoint: 400
    invalid_grant: PKCE verification failed
    status 400

    The state check is gone and the attack still fails, one hop later. The code is bound to the attacker's code_challenge, and the victim's client holds another verifier.

  11. Step 11.

    Issue a code with a two second lifetime and exchange it after three.

    for c in strict-client legacy-client; do C=$(newcode $c 2); sleep 3; printf '%-14s ttl 2 s, exchanged after 3 s: ' "$c"; exchange $c "$C"; done
    
    strict-client  ttl 2 s, exchanged after 3 s: 400 invalid_grant: Authorization code expired
    legacy-client  ttl 2 s, exchanged after 3 s: 200 access_token issued

    Both advertised two seconds. One stores the expiry and never reads it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Step 2 has no state parameter | The client cannot tell its own callback from a forged one | File it against RFC 6749 section 10.12. Step 9 shows what it costs. | | Step 2 has code_challenge_method=plain | PKCE is present in name only, since the verifier travels in the clear | Ask for S256. RFC 7636 section 4.2 requires it where the client can compute a SHA-256. | | Step 6 returns 200 twice | Codes are not single use | Report against RFC 6749 section 4.1.2. Check the proxy and browser history for where the code is stored. | | Step 7 returns 200 for the unregistered URI | Redirect URI matching is a prefix or a pattern | Ask for string equality, per RFC 9700 section 2.1. | | Step 9 signs in on the second callback | The client accepts a code with no state comparison | Fix the client, not the server. The authorization server cannot see this. | | Step 10 fails at the token endpoint | PKCE is doing the work state was meant to do | Keep both. PKCE covers the code, state covers the request. | | Step 11 returns 200 after the advertised lifetime | The expiry is recorded and never enforced | Measure the real lifetime, then file the difference against the documented one. |

Common mistakes

Sign: The OAuth test passes because the user reaches the application and sees their name.Cause: Every check in this procedure happens after that point. Steps 6 to 11 all follow a login that already succeeded, and each of them found something the successful login did not.
Sign: The redirect URI check is tested with an obviously foreign host and declared correct.Cause: Prefix matching accepts a host it already trusts. Step 7 uses the registered origin and the registered path with four characters appended, which is what a real bypass looks like.
Sign: A missing state parameter is filed as low severity because the flow uses PKCE.Cause: Step 10 shows PKCE stopping the same attack, which is why the finding gets closed. PKCE binds the code to one client instance, so it holds only while the client keeps the verifier in a session the attacker cannot reach. A client that stores the verifier in localStorage shared across tabs loses that, and state is the second lock.
Sign: Code expiry is tested by waiting the documented time and getting a refusal.Cause: Servers that never enforce expiry still refuse a code that was already spent by the test itself. Use a fresh code for the expiry attempt, as step 11 does, or the single use check hides the missing expiry check.

What to check next

FAQ

How do I test OAuth?

Walk the four hops with curl: the redirect to the authorization endpoint, the consent page, the redirect back with a code, the token exchange. Steps 1 to 5 do that. Then attack the code, the redirect URI and state, in steps 6 to 11.

How do I test OAuth2 authentication locally?

Run an authorization server on loopback, as oauth-demo.js does. No provider account is needed, and a local server can be made to fail in ways a hosted one cannot.

How do I check an OAuth redirect URI mismatch?

Send a URI that shares a prefix with the registered one, as step 7 does. A correct server answers 400 and never redirects.

What does state do in an OAuth flow?

It ties the callback to the request the same browser started. The client stores it, the server echoes it, the client compares. Step 9 shows that comparison refusing an injected code, and step 10 shows PKCE catching what it misses.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny