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
- Node 22 or later, and the
oauth-demo.jsfrom How to test oauth login flow, printed below. - curl 8 or later, any build.
- RFC 6749 section 4.1.3 for the request, section 5.2 for the error codes, section 2.3.1 for credential encoding.
strict-clientreports each failure separately.flat-clientruns the same checks and reports one code for all of them.
// 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
- 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 bytesTwo facts here.
-dsetsContent-Type: application/x-www-form-urlencodedunasked, which RFC 6749 section 4.1.3 requires. And the secret went out ass3cr3t%2Bvalue, because--data-urlencodeencoded the+. Plain-dsends it raw, and a form decoder reads that as a space. - 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_tokenandtoken_typeare the required fields.scopeis required only when it differs from the request, so compare it rather than assuming. - Step 3.
Move the credential into the
Authorizationheader 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. - 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 -6HTTP/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. - 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
401withWWW-Authenticatewhen the client used the header,400when it did not. A test pinning one status for a bad secret breaks when the client switches method. - 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 400The token endpoint takes a form, never JSON. The server read no
grant_typeand answered with the first failure it reached, naming a grant type problem rather than an encoding one. - Step 7.
Run six failure conditions against a server that separates them.
run strict-clientvalid 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_typeFour codes across five failures, and a client can act on each.
invalid_clientmeans fix the configuration,invalid_grantmeans start a new authorization,invalid_requestmeans fix the call. - Step 8.
Run the same six against a server that collapses them.
run flat-clientvalid 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_requestEvery 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
What to check next
- How to test oauth login flow: where the code in step 1 came from.
- How to test oidc: how a client finds this endpoint and the signing keys.
- How to check authorization header: spending the
access_token. - How to check if refresh token works: what the
refresh_tokenis worth. - How to test API authentication: proving the route enforces the token.
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.
Related on this site
intermediate10 minpublished updated Maks Verny