How to test oidc
One GET answers most of it. curl https://accounts.google.com/.well-known/openid-configuration returns the document every OpenID client reads at startup. Check the seven required fields, that issuer equals the URL you fetched it from, that the signing algorithms exclude none, and that jwks_uri returns keys that import.
Why check this
Run this when an OpenID provider is added or moved, after a tenant migration, and on staging sign-off for any service that validates tokens. The discovery document is the only thing a client reads before it trusts anything, so an error here propagates to every token check downstream.
The failure it prevents is a client that accepts tokens it should not. If id_token_signing_alg_values_supported lists none and the client library follows the document, an unsigned token passes verification. If jwks_uri is unreachable, a library either fails closed and breaks login, or falls back to a cached key set that outlived its rotation.
Prerequisites
- curl 8 or later, any build.
- Node 22 or later for the two scripts below. Save them as
oidc-check.mjsandverify-idtoken.mjs. - OpenID Connect Discovery 1.0 section 3 for the field list, and section 4.3 for the issuer rule.
- Steps 1 to 4 read public documents from a provider, one GET each. Steps 5 to 7 need no network beyond loopback.
// oidc-check.mjs - reads one discovery document and reports what a tester has to assert.
// usage: node oidc-check.mjs <issuer-base-url> [--no-jwks]
import { createPublicKey } from 'node:crypto';
const base = process.argv[2].replace(/\/$/, '');
const url = base + '/.well-known/openid-configuration';
// OpenID Connect Discovery 1.0 section 3, the fields marked REQUIRED.
const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri',
'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'];
const line = (ok, label, detail) => console.log(`${ok ? 'ok ' : 'FAIL'} ${label.padEnd(26)} ${detail}`);
const r = await fetch(url);
line(r.status === 200, 'GET discovery', `${r.status} ${r.headers.get('content-type')}`);
const d = await r.json();
const missing = REQUIRED.filter((k) => d[k] === undefined);
line(missing.length === 0, 'required fields', missing.length ? `missing ${missing.join(', ')}` : `all ${REQUIRED.length} present`);
// Discovery section 4.3: the issuer value has to equal the URL the document was fetched from.
line(d.issuer === base, 'issuer matches fetch URL', `${d.issuer}`);
const eps = Object.entries(d).filter(([k]) => k.endsWith('_endpoint') || k === 'jwks_uri');
const plain = eps.filter(([, v]) => typeof v === 'string' && !v.startsWith('https://'));
line(plain.length === 0, 'endpoints over https', plain.length ? plain.map(([k]) => k).join(', ') : eps.map(([k]) => k.replace(/_endpoint$/, '')).join(' '));
const algs = d.id_token_signing_alg_values_supported || [];
line(algs.includes('RS256') && !algs.includes('none'), 'id_token signing algs', algs.join(' '));
line(!!d.code_challenge_methods_supported?.includes('S256'), 'PKCE S256 advertised', String(d.code_challenge_methods_supported));
if (process.argv.includes('--no-jwks')) process.exit(0);
const jr = await fetch(d.jwks_uri);
const jwks = await jr.json();
line(jr.status === 200 && Array.isArray(jwks.keys) && jwks.keys.length > 0, 'jwks_uri reachable', `${jr.status}, ${jwks.keys?.length ?? 0} keys`);
for (const k of jwks.keys ?? []) {
let usable = true;
try { createPublicKey({ key: k, format: 'jwk' }); } catch { usable = false; }
line(usable, ` key ${k.kid ?? '(no kid)'}`.slice(0, 30), `${k.kty} ${k.alg ?? ''} ${k.use ?? ''} ${usable ? 'imports' : 'does not import'}`);
}
// verify-idtoken.mjs - proves the advertised keys verify a token from the advertised issuer.
// usage: node verify-idtoken.mjs <issuer-base-url> <id_token>
import { createPublicKey, createVerify } from 'node:crypto';
const [base, tok] = process.argv.slice(2);
const d = await (await fetch(base.replace(/\/$/, '') + '/.well-known/openid-configuration')).json();
const { keys } = await (await fetch(d.jwks_uri)).json();
const [h, p, s] = tok.split('.');
const head = JSON.parse(Buffer.from(h, 'base64url'));
const claims = JSON.parse(Buffer.from(p, 'base64url'));
const key = keys.find((k) => k.kid === head.kid);
console.log('token header kid ', head.kid, '| alg', head.alg);
console.log('key found in jwks ', !!key);
const ok = key && createVerify('sha256').update(`${h}.${p}`).verify(createPublicKey({ key, format: 'jwk' }), Buffer.from(s, 'base64url'));
console.log('signature verifies', ok);
console.log('iss matches issuer', claims.iss === d.issuer, '|', claims.iss);
Steps 5 to 7 run against a local pair of providers. Save this as oidc-local.js, confirm the port is free, and start it.
// oidc-local.js - two OpenID providers on one port: /good is correct, /broken is not.
const http = require('node:http');
const crypto = require('node:crypto');
const PORT = 8965;
const ME = `http://127.0.0.1:${PORT}`;
const KEY = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const KID = 'local-key-1';
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const send = (res, o) => res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(o, null, 1) + '\n');
const now = () => Math.floor(Date.now() / 1000);
http.createServer((req, res) => {
switch (req.url) {
case '/good/.well-known/openid-configuration':
return send(res, {
issuer: ME + '/good',
authorization_endpoint: ME + '/good/authorize',
token_endpoint: ME + '/good/token',
jwks_uri: ME + '/good/jwks',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
});
case '/good/jwks':
return send(res, { keys: [{ ...KEY.publicKey.export({ format: 'jwk' }), kid: KID, use: 'sig', alg: 'RS256' }] });
case '/good/idtoken': { // stands in for the id_token a real token response carries
const d = b64({ alg: 'RS256', typ: 'JWT', kid: KID }) + '.' + b64({ iss: ME + '/good', sub: 'alice', aud: 'demo', iat: now(), exp: now() + 300 });
return res.writeHead(200, { 'content-type': 'text/plain' }).end(d + '.' + crypto.sign('sha256', Buffer.from(d), KEY.privateKey).toString('base64url'));
}
case '/broken/.well-known/openid-configuration':
return send(res, {
issuer: 'https://sso.example.com', // defect 1: not the URL this came from
authorization_endpoint: ME + '/broken/authorize',
token_endpoint: ME + '/broken/token',
jwks_uri: ME + '/broken/keys', // defect 2: that path is not served
response_types_supported: ['code', 'token'],
id_token_signing_alg_values_supported: ['RS256', 'HS256', 'none'], // defect 3: none
// defect 4: subject_types_supported is REQUIRED and absent
});
default:
return res.writeHead(404, { 'content-type': 'application/json' }).end('{"error":"not_found"}\n');
}
}).listen(PORT, '127.0.0.1', () => console.log(`local providers on ${ME}/good and ${ME}/broken pid ${process.pid}`));
netstat -ano | grep ':8965 ' ; node oidc-local.js
local providers on http://127.0.0.1:8965/good and http://127.0.0.1:8965/broken pid 28380
Stop that one id afterwards: powershell -Command "Stop-Process -Id 28380", or kill 28380 elsewhere.
Steps
- Step 1.
Fetch the document and read the response headers.
curl -s -D - https://accounts.google.com/.well-known/openid-configuration -o google.json | tr -d '\r' | grep -iE '^(HTTP/|content-type|cache-control|expires)'HTTP/2 200 expires: Fri, 11 Sep 2026 21:33:07 GMT cache-control: public, max-age=3600 content-type: application/jsonapplication/jsonand a one hour cache. Thatmax-ageis how long a stale endpoint or a retired key can keep circulating after the provider changes it, so note it before you plan a key rotation test. - Step 2.
List what the provider advertises.
node -e "const j=require('./google.json'); for (const k of Object.keys(j)) console.log(k)"issuer authorization_endpoint device_authorization_endpoint token_endpoint userinfo_endpoint revocation_endpoint jwks_uri response_types_supported response_modes_supported subject_types_supported id_token_signing_alg_values_supported scopes_supported token_endpoint_auth_methods_supported claims_supported code_challenge_methods_supported grant_types_supported authorization_response_iss_parameter_supportedSeven of these are required by Discovery section 3. The rest describe what the provider supports, and a client that needs one of them will fail at runtime if it is absent rather than at configuration time.
- Step 3.
Run the assertions against the same provider.
node oidc-check.mjs https://accounts.google.comok GET discovery 200 application/json ok required fields all 7 present ok issuer matches fetch URL https://accounts.google.com ok endpoints over https authorization device_authorization token userinfo revocation jwks_uri ok id_token signing algs RS256 ok PKCE S256 advertised plain,S256 ok jwks_uri reachable 200, 2 keys ok key 943a3a5d7d919625a454e489 RSA RS256 sig imports ok key f10f87405a979c1df36df266 RSA RS256 sig importsTwo keys, not one, because a provider publishes the next key before it starts signing with it. A client that caches a single key breaks at the rotation and recovers on its own, which makes the defect hard to catch twice.
- Step 4.
Run the same assertions against a multi-tenant document.
node oidc-check.mjs https://login.microsoftonline.com/common/v2.0 --no-jwksok GET discovery 200 application/json; charset=utf-8 ok required fields all 7 present FAIL issuer matches fetch URL https://login.microsoftonline.com/{tenantid}/v2.0 ok endpoints over https token jwks_uri userinfo authorization device_authorization end_session kerberos ok id_token signing algs RS256 FAIL PKCE S256 advertised undefinedNeither failure is a defect. The multi-tenant document is a template, and
{tenantid}resolves per tenant, so the issuer check belongs against the tenant specific URL.code_challenge_methods_supportedis OPTIONAL in Discovery section 3, so its absence reports nothing either way. - Step 5.
Point the same script at a document with four planted defects.
node oidc-check.mjs http://127.0.0.1:8965/brokenok GET discovery 200 application/json FAIL required fields missing subject_types_supported FAIL issuer matches fetch URL https://sso.example.com FAIL endpoints over https authorization_endpoint, token_endpoint, jwks_uri FAIL id_token signing algs RS256 HS256 none FAIL PKCE S256 advertised undefined FAIL jwks_uri reachable 404, 0 keysThe issuer line is the one that matters most. A document served from one host claiming to be another is how a client is pointed at keys the real issuer never published.
- Step 6.
Run it against the correct local provider for the contrast.
node oidc-check.mjs http://127.0.0.1:8965/goodok GET discovery 200 application/json ok required fields all 7 present ok issuer matches fetch URL http://127.0.0.1:8965/good FAIL endpoints over https authorization_endpoint, token_endpoint, jwks_uri ok id_token signing algs RS256 ok PKCE S256 advertised S256 ok jwks_uri reachable 200, 1 keys ok key local-key-1 RSA RS256 sig importsThe https line fails because loopback is plain HTTP. Every other assertion passes, which is the shape a real provider should produce.
- Step 7.
Prove the advertised keys verify a token from the advertised issuer.
node verify-idtoken.mjs http://127.0.0.1:8965/good "$(curl -s http://127.0.0.1:8965/good/idtoken)"token header kid local-key-1 | alg RS256 key found in jwks true signature verifies true iss matches issuer true | http://127.0.0.1:8965/goodReachable keys and usable keys are different claims. This is the one that closes the loop: the
kidin the token header resolves in the published set, the signature verifies with it, and theissclaim matches the document.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| issuer differs from the fetch URL | Discovery section 4.3 violated, or a tenant template | Resolve the template and refetch. If it is a fixed value pointing elsewhere, treat it as an open finding. |
| none in the signing algorithms | An unsigned token may pass a library that reads this list | Raise it as a defect. Pin the accepted algorithm in the client as well. |
| HS256 in the list beside RS256 | The provider accepts a symmetric algorithm, keyed on the client secret | Confirm the client rejects it. Algorithm confusion needs both sides to agree on asymmetric. |
| jwks_uri answers 404 or empty keys | No client can verify a token from this issuer | Stop. Nothing downstream can work until this resolves. |
| One key in jwks_uri | No rollover key published | Ask when the next key appears. A single key means the rotation is a cutover. |
| code_challenge_methods_supported absent | The field is OPTIONAL, so this says nothing about support | Send an S256 challenge to the authorization endpoint and read the response. |
| An endpoint on plain http | Only valid on loopback in a test rig | On anything reachable, treat it as a finding. |
Common mistakes
What to check next
- How to test oauth login flow: using the endpoints this document advertises.
- How to test authorization code flow with curl: the token endpoint named in the document.
- How to verify JWT signature: step 7 in detail, against a key set.
- How to check JWT algorithm: why
noneandHS256in that list are worth a defect. - How to check authorization header: spending the token the provider issued.
FAQ
How do I test OIDC authentication?
Start with the discovery document, since every client reads it first. Steps 1 to 3 check the required fields, the issuer, the algorithms and the keys. Then run a real flow against the endpoints it advertises and verify the id_token against the published key set.
What is /.well-known/openid-configuration?
The provider metadata document from OpenID Connect Discovery 1.0. It lists the endpoints, the supported response types, the signing algorithms and the jwks_uri. A client fetches it once at startup and trusts everything in it, which is why it is worth checking on its own.
How do I check the JWKS URI of an OpenID provider?
Read jwks_uri from the document, fetch it, and confirm it returns a keys array whose entries import as public keys. oidc-check.mjs does both in step 3. Then verify a real token against it, as in step 7.
How do I test OIDC locally?
Serve a discovery document and a JWKS from loopback, as oidc-local.js does. The /broken half gives the failing output no hosted provider will produce on demand, so the assertions can be tested against a known bad document.
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
basic6 minpublished updated Maks Verny