How to test authorization code flow with curl

Post the code to the token endpoint as a form. curl http://127.0.0.1:8963/token -d grant_type=authorization_code -d code=... --data-urlencode client_secret=... answers 200 with an access_token. Then read what curl actually put on the wire, move the same credential into the Authorization header, and watch the status change.

Why check this

Run this when a token exchange fails and the client library will not say why, and on any integration where the client secret or the authentication method changed. curl shows the exact bytes, which puts the fault on the server, the encoding or your own command.

The token endpoint is also the one place where a server's error reporting is worth testing on its own. RFC 6749 section 5.2 defines separate codes for a failed client authentication, a bad grant and a malformed request. A client that retries on the wrong one loops until the code expires.

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

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 afterwards: powershell -Command "Stop-Process -Id 3180", or kill 3180 elsewhere.

Every code is single use, so each step needs a fresh one. Save this as token.sh, then source ./token.sh.

# token.sh - helpers for the token endpoint steps. Run `source ./token.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> - walks the browser half of the flow and prints one authorization code
newcode () {
  local sid loc
  sid=$(curl -s "$AS/authorize?response_type=code&client_id=$1&scope=openid&state=st1&redirect_uri=$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
}

# post <client_id> [extra curl args] - one token request. Extra args come first, so they win:
# URLSearchParams.get returns the first value of a repeated field.
post () {
  local c=$1; shift
  curl -s -o e.json -w '%{http_code} ' "$AS/token" "$@" \
    -d grant_type=authorization_code -d "redirect_uri=$RP/callback" \
    -d "client_id=$c" --data-urlencode "client_secret=$(secret $c)" -d "code_verifier=$VERIFIER"
  node -e "const j=require('./e.json');console.log(j.error?[j.error,j.error_description].filter(Boolean).join(': '):'access_token issued')"
}

# run <client_id> - the same six requests against one client
run () {
  local c=$1; local C=$(newcode $c)
  printf '%-20s ' 'valid request';        post $c -d "code=$C"
  printf '%-20s ' 'code sent twice';      post $c -d "code=$C"
  printf '%-20s ' 'wrong secret';         post $c --data-urlencode 'client_secret=nope' -d "code=$(newcode $c)"
  printf '%-20s ' 'redirect_uri differs'; post $c -d "redirect_uri=$RP/other" -d "code=$(newcode $c)"
  printf '%-20s ' 'code missing';         post $c
  printf '%-20s ' 'grant_type=password';  post $c -d grant_type=password -d "code=$(newcode $c)"
}

Steps

  1. Step 1.

    Exchange one code and read the bytes curl sent.

    C=$(newcode strict-client); curl -s --trace-ascii - -o token.json "$AS/token" -d grant_type=authorization_code -d "code=$C" -d "redirect_uri=$RP/callback" -d client_id=strict-client --data-urlencode 'client_secret=s3cr3t+value' -d "code_verifier=$VERIFIER" | sed -n '/Send header/,/upload completely/p'
    
    => Send header, 154 bytes (0x9a)
    0000: POST /token HTTP/1.1
    0016: Host: 127.0.0.1:8963
    002c: User-Agent: curl/8.21.0
    0045: Accept: */*
    0052: Content-Length: 225
    0067: Content-Type: application/x-www-form-urlencoded
    0098: 
    => Send data, 225 bytes (0xe1)
    0000: grant_type=authorization_code&code=37960bf00169e9da95273e7ba0888
    0040: 5e8&redirect_uri=http://127.0.0.1:8964/callback&client_id=strict
    0080: -client&client_secret=s3cr3t%2Bvalue&code_verifier=demo-verifier
    00c0: -0123456789abcdef0123456789abcdef
    * upload completely sent off: 225 bytes

    Two facts here. -d sets Content-Type: application/x-www-form-urlencoded unasked, which RFC 6749 section 4.1.3 requires. And the secret went out as s3cr3t%2Bvalue, because --data-urlencode encoded the +. Plain -d sends it raw, and a form decoder reads that as a space.

  2. Step 2.

    List the fields that came back.

    node -e "const j=require('./token.json');for(const k of Object.keys(j))console.log(k.padEnd(14), String(j[k]).slice(0,48)+(String(j[k]).length>48?'...':''))"
    
    access_token   at_ec3ec45036dcd2ee814b732b33c9e5a0
    token_type     Bearer
    expires_in     300
    refresh_token  rt_52b038e4c6fbac3827a5ebec30661edf
    scope          openid profile
    id_token       eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRl...

    access_token and token_type are the required fields. scope is required only when it differs from the request, so compare it rather than assuming.

  3. Step 3.

    Move the credential into the Authorization header with -u, unencoded.

    C=$(newcode strict-client); curl -s -D - -u 'strict-client:s3cr3t+value' "$AS/token" -d grant_type=authorization_code -d "code=$C" -d "redirect_uri=$RP/callback" -d "code_verifier=$VERIFIER" | tr -d '\r' | grep -viE '^(date|connection|keep-alive|transfer-encoding|content-type|cache-control)'
    
    HTTP/1.1 401 Unauthorized
    www-authenticate: Basic realm="token"
    
    {
    "error": "invalid_client",
    "error_description": "Client authentication failed"
    }

    The secret that worked in step 1 is refused. curl base64 encodes what you typed, and RFC 6749 section 2.3.1 requires form encoding first, so the server reads the + as a space.

  4. Step 4.

    Encode the + yourself and send it again.

    C=$(newcode strict-client); curl -s -D - -u 'strict-client:s3cr3t%2Bvalue' "$AS/token" -d grant_type=authorization_code -d "code=$C" -d "redirect_uri=$RP/callback" -d "code_verifier=$VERIFIER" | tr -d '\r' | grep -viE '^(date|connection|keep-alive|transfer-encoding|content-type|cache-control)' | head -6
    
    HTTP/1.1 200 OK
    
    {
    "access_token": "at_b81b8ec0998de123a23c68d9669f6562",
    "token_type": "Bearer",
    "expires_in": 300,

    Percent encoding applies to +, space and every other reserved character in either half. Providers differ on whether they decode, so one secret can work in one integration and not another.

  5. Step 5.

    Put the same wrong value back in the body and compare the refusal.

    C=$(newcode strict-client); curl -s -D - "$AS/token" -d grant_type=authorization_code -d "code=$C" -d "redirect_uri=$RP/callback" -d client_id=strict-client -d 'client_secret=s3cr3t+value' -d "code_verifier=$VERIFIER" | tr -d '\r' | grep -viE '^(date|connection|keep-alive|transfer-encoding|content-type|cache-control)'
    
    HTTP/1.1 400 Bad Request
    
    {
    "error": "invalid_client",
    "error_description": "Client authentication failed"
    }

    One credential, one fault, two statuses. RFC 6749 section 5.2 asks for 401 with WWW-Authenticate when the client used the header, 400 when it did not. A test pinning one status for a bad secret breaks when the client switches method.

  6. Step 6.

    Send the same parameters as JSON.

    C=$(newcode strict-client); curl -s -u 'strict-client:s3cr3t%2Bvalue' -H 'content-type: application/json' "$AS/token" --data "{\"grant_type\":\"authorization_code\",\"code\":\"$C\",\"redirect_uri\":\"$RP/callback\",\"code_verifier\":\"$VERIFIER\"}" -w 'status %{http_code}\n'
    
    {
    "error": "unsupported_grant_type"
    }
    status 400

    The token endpoint takes a form, never JSON. The server read no grant_type and answered with the first failure it reached, naming a grant type problem rather than an encoding one.

  7. Step 7.

    Run six failure conditions against a server that separates them.

    run strict-client
    
    valid request        200 access_token issued
    code sent twice      400 invalid_grant: Authorization code already used
    wrong secret         400 invalid_client: Client authentication failed
    redirect_uri differs 400 invalid_grant: redirect_uri does not match the authorization request
    code missing         400 invalid_request: code is required
    grant_type=password  400 unsupported_grant_type

    Four codes across five failures, and a client can act on each. invalid_client means fix the configuration, invalid_grant means start a new authorization, invalid_request means fix the call.

  8. Step 8.

    Run the same six against a server that collapses them.

    run flat-client
    
    valid request        200 access_token issued
    code sent twice      400 invalid_request
    wrong secret         400 invalid_request
    redirect_uri differs 400 invalid_request
    code missing         400 invalid_request
    grant_type=password  400 invalid_request

    Every check still ran, and all report the same thing. A client cannot tell a wrong secret from a spent code, so it retries a dead code or drops a session over a configuration error.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Content-Type: application/x-www-form-urlencoded in step 1 | curl set it from -d | Nothing. Sending JSON instead produces step 6. | | client_secret=s3cr3t+value in the trace, unencoded | -d passed the + through | Switch to --data-urlencode. The server will read the + as a space. | | 401 with WWW-Authenticate | Client authentication failed, and the credential was in the header | Percent encode both halves before -u, as in step 4. | | 400 invalid_client | The same failure, credential in the body | Check the secret and the client_id field name. | | 400 invalid_grant | The code is spent, expired, or bound to another redirect_uri | Start a new authorization. Retrying the same code cannot succeed. | | 400 invalid_request for every failure | The server reports one code for all causes | File it against RFC 6749 section 5.2. Until it is fixed, no client retry logic can be correct. | | 200 with no refresh_token | The grant did not include one, often because offline_access was absent | Read scope in the response, then compare with the authorization request. |

Common mistakes

Sign: The secret works in the request body and fails in the Authorization header, or the reverse.Cause: RFC 6749 section 2.3.1 requires form encoding of both halves before base64. curl -u does not do it, and neither does -d for the body value. Steps 3 to 5 show one secret producing 401, 200 and 400 from the same server depending on how it was encoded.
Sign: A retry loop hammers the token endpoint with a code that already failed.Cause: The client treats every 400 the same. invalid_grant means the code is dead and only a new authorization helps, while invalid_client is a configuration fault that will not resolve by retrying. Step 8 is the server shape that makes this impossible to get right.
Sign: curl prints 'Unnecessary use of -X or --request, POST is already inferred'.Cause: -d already implies POST, so -X changes nothing about this request. It does change redirects: -X pins the method string, so curl keeps POST even on a 303 where it would otherwise switch to GET. Drop -X and let curl infer.
Sign: The exchange returns 200 and the test stops there.Cause: A 200 says the grant was accepted. It does not say the access token opens anything, nor that the code was retired. Step 7 line two is the assertion most exchange tests are missing.

What to check next

FAQ

How do I test the authorization code flow?

Get a code from the authorization endpoint, then post it as a form with grant_type=authorization_code, the code, the same redirect_uri and client credentials. Step 1 is the whole request. Steps 3 to 8 are the failures worth asserting.

How do I get an access token with curl?

curl <token endpoint> -d grant_type=authorization_code -d code=<code> -d redirect_uri=<uri> -d client_id=<id> --data-urlencode client_secret=<secret>. Use --data-urlencode for any value holding a reserved character. The token is the access_token field.

How do I check the response of the token endpoint?

Confirm access_token and token_type are present, then compare scope and expires_in with the request. Step 2 lists them. Check Cache-Control: no-store too, which RFC 6749 section 5.1 requires here.

Why does the same client secret work in the body and fail with -u?

Because the header form is encoded twice. RFC 6749 section 2.3.1 asks the client to form encode each half before base64, so a raw + decodes to a space. Step 3 fails and step 4 passes on one secret.

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.

intermediate10 minpublished updated Maks Verny