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

// 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

  1. 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/json

    application/json and a one hour cache. That max-age is 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.

  2. 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_supported

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

  3. Step 3.

    Run the assertions against the same provider.

    node oidc-check.mjs https://accounts.google.com
    
    ok   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 imports

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

  4. Step 4.

    Run the same assertions against a multi-tenant document.

    node oidc-check.mjs https://login.microsoftonline.com/common/v2.0 --no-jwks
    
    ok   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       undefined

    Neither 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_supported is OPTIONAL in Discovery section 3, so its absence reports nothing either way.

  5. Step 5.

    Point the same script at a document with four planted defects.

    node oidc-check.mjs http://127.0.0.1:8965/broken
    
    ok   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 keys

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

  6. Step 6.

    Run it against the correct local provider for the contrast.

    node oidc-check.mjs http://127.0.0.1:8965/good
    
    ok   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 imports

    The https line fails because loopback is plain HTTP. Every other assertion passes, which is the shape a real provider should produce.

  7. 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/good

    Reachable keys and usable keys are different claims. This is the one that closes the loop: the kid in the token header resolves in the published set, the signature verifies with it, and the iss claim 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

Sign: An issuer check fails against a provider that is working correctly.Cause: Multi-tenant documents are templates. login.microsoftonline.com/common/v2.0 returns an issuer of https://login.microsoftonline.com/{tenantid}/v2.0, exactly as step 4 prints. Compare the issuer against the tenant specific URL, and keep the equality check, because the template is the only legitimate exception.
Sign: The absence of code_challenge_methods_supported is filed as no PKCE support.Cause: Discovery section 3 marks that field OPTIONAL, so its absence carries no information. Step 4 shows a working provider omitting it. Settle the question by sending an S256 challenge to the authorization endpoint and reading what comes back, never by reading the document.
Sign: The document check passes and token validation still fails in production.Cause: Reaching jwks_uri is not the same as being able to use the keys. Step 7 is the assertion that matters: the kid in the token header resolves in the published set and the signature verifies with that key.
Sign: A key rotation breaks login once and then fixes itself before anyone can reproduce it.Cause: Providers publish the next key before signing with it, which is why step 3 shows two. A client caching one key, or caching the document past the max-age in step 1, fails only during the overlap.

What to check next

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.

basic6 minpublished updated Maks Verny