>_devkit
zitadel-production
skills/zitadel-production/

references/testing.md

Testing a deployment

Verify in layers, cheapest first. Each step isolates one thing, so a failure tells you where you are rather than that something is wrong.

1. Is the deployment configured correctly

curl -s https://<domain>/ui/console/assets/environment.json

Both api and issuer must be https. This one call catches the entire class of reverse-proxy and TLS-mode failures. See troubleshooting.md.

2. Does the client exist, and is the redirect registered

No browser needed. Hit the authorize endpoint and read the error:

curl -s "https://<domain>/oauth/v2/authorize\
?client_id=<id>\
&redirect_uri=<urlencoded>\
&response_type=code\
&scope=openid\
&code_challenge=<any>&code_challenge_method=S256"
ResponseMeaning
302 to /ui/login/login?authRequestID=...Client and redirect URI both valid
"The requested redirect_uri is missing in the client configuration"Client exists, redirect URI is not registered
invalid_clientNo such client on this instance

That middle error is more informative than it looks: ZITADEL only reaches the redirect check after resolving the client, so it confirms the client ID is right while telling you the URI is wrong.

3. Does the whole flow work

You do not need a finished application. A loopback redirect (http://localhost:8888/callback, sanctioned by RFC 8252) lets you drive a complete Authorization Code + PKCE exchange from a script. Register that URI on the application with Development Mode on, then:

const http = require('http');
const crypto = require('crypto');
const { createRemoteJWKSet, jwtVerify, decodeJwt } = require('jose');

const ISSUER = 'https://auth.client.com';
const CLIENT_ID = '<application client id>';
const REDIRECT = 'http://localhost:8888/callback';

const b64 = (b) => b.toString('base64url');
const verifier = b64(crypto.randomBytes(64));
const challenge = b64(crypto.createHash('sha256').update(verifier).digest());
const state = b64(crypto.randomBytes(16));

const authUrl = `${ISSUER}/oauth/v2/authorize?` + new URLSearchParams({
  client_id: CLIENT_ID, redirect_uri: REDIRECT, response_type: 'code',
  scope: 'openid profile email offline_access',
  code_challenge: challenge, code_challenge_method: 'S256', state,
});

const exchange = (code, cv) => fetch(`${ISSUER}/oauth/v2/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
    client_id: CLIENT_ID, code_verifier: cv,
  }),
});

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost:8888');

  // Serve a link rather than printing the URL. A long authorize URL WILL get
  // clipped on copy, and a missing `state` then looks like a mismatch bug.
  if (url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    return res.end(`<a href="${authUrl}">Start login</a>`);
  }
  if (url.pathname !== '/callback') return res.writeHead(404).end();

  const code = url.searchParams.get('code');
  res.writeHead(200).end('Done. Return to the terminal.');
  console.log('state', url.searchParams.get('state') === state ? 'ok' : 'MISMATCH');

  // PKCE proof: wrong verifier FIRST, while the code is still unspent.
  const wrong = await exchange(code, b64(crypto.randomBytes(64)));
  console.log('[pkce] wrong verifier ->', wrong.status,
    wrong.ok ? 'ACCEPTED - NOT ENFORCED' : (await wrong.json()).error_description);

  const tok = await (await exchange(code, verifier)).json();
  console.log('expires_in', tok.expires_in, '| refresh', !!tok.refresh_token);

  const jwks = createRemoteJWKSet(new URL(`${ISSUER}/oauth/v2/keys`));
  const { payload, protectedHeader } = await jwtVerify(tok.access_token, jwks, { issuer: ISSUER });
  console.log('alg', protectedHeader.alg, '| sub', payload.sub, '| aud', payload.aud);
  console.log('id_token', decodeJwt(tok.id_token).email);
  process.exit(0);
}).listen(8888);

This validates, without a client application: that a public client completes the exchange with no client secret, that offline_access returns a refresh token, that the access token is RS256 and verifies against JWKS, that the audience is what your API will check, and what the real token lifetime is.

Test PKCE properly

The naive test is to exchange the code successfully, then replay it with a wrong verifier and watch it fail. That proves nothing about PKCE, because the code was already spent and would be rejected regardless.

Send the wrong verifier first, while the code is still unspent. Only then can a rejection be attributed to PKCE:

[pkce] wrong verifier, code unspent -> 400 invalid code_verifier
[pkce] correct verifier             -> OK

Two separate properties, easily conflated: PKCE enforcement and single-use codes. Observed behaviour on ZITADEL v4.16.0 is that a failed verifier attempt does not burn the code, so the correct verifier still succeeds afterwards. Not a practical concern with a 64-byte verifier, but worth knowing before you interpret a test result.

Watch out for SSO masking your test

ZITADEL keeps its own session in the browser. If you are already signed in, the authorize step returns instantly with no login screen, and you can convince yourself login works without ever having exercised it.

The claims tell you which happened:

auth_time  1784957192   when a password was actually typed
iat        1784988789   when this token was minted
           31597 s      ~8.8 hours apart: session reuse, not a login

To force a real authentication, add prompt=login, or max_age=<seconds> to require a recent auth_time, or use a private window. Use auth_time rather than iat for any step-up decision in application code.

Verifying token configuration

Decode rather than trust the console. The access token's header alone answers the most important question:

eyJhbGciOiJSUzI1NiI...        RS256 JWT, locally verifiable
eyJhbGciOiJBMjU2R0NNS1ci...   JWE, still an opaque Bearer token

And the payload tells you whether the assertions took effect. Note that profile claims never appear in the access token, only in the ID token and the introspection response, so their absence there is expected rather than a misconfiguration.