Use Wacht as an OpenID Connect Provider
Authenticate users through Wacht using OpenID Connect — discovery, id_tokens, UserInfo, RP-initiated logout.
Use Wacht as an OpenID Connect Provider
Every OAuth app in Wacht is an OpenID Connect provider. The same OAuth FQDN that serves /oauth/authorize and /oauth/token also serves .well-known/openid-configuration, .well-known/jwks.json, /oauth/userinfo, and /oauth/logout. There's no separate toggle to enable, no second product to set up, and no deployment-level flag.
This guide covers what Wacht implements, how the flow looks end to end, and what to expect from each endpoint. Use it when you're integrating an OIDC client library — Auth.js, oidc-client-ts, AppAuth, MSAL, the Spring Security OIDC client, anything that speaks the spec.
What's implemented
- Authorization code flow with PKCE — the only flow Wacht supports, and what current best practice (RFC 9700) recommends for every client type.
- Discovery + JWKS — clients can self-configure from
https://<oauth-fqdn>/.well-known/openid-configuration. - id_token with the standard claims —
iss,sub,aud,exp,iat,auth_time,nonce,at_hash,sid, plus scope-gated identity claims (name,email,email_verified, etc.). - Per-OAuth-app RSA signing keys — provisioned automatically at app creation, rotatable from console.
/oauth/userinfo— bearer-token-protected, returns claims gated by the access_token's scopes.- RP-Initiated Logout — cascades through the user's Wacht session and revokes every token derived from that session.
- Refresh tokens with rotation + replay detection — reusing a captured refresh token triggers full family revocation.
promptandmax_ageparameter handling —none,login,consent,select_accountare all honored;max_ageis enforced against the actual authentication time.
What's not implemented
These are optional in OIDC Core, and most client libraries don't require them. Listed for completeness:
- Implicit and hybrid flows — authorization code flow only.
- Pairwise subject identifiers — every client receives the same
subfor the same user. claimsrequest parameter / request objects /request_uri.- Front-channel and back-channel logout — RP-Initiated logout is the only logout flow.
- Signed or encrypted UserInfo responses — JSON over HTTPS only.
Setup
You need an OAuth app and at least one OAuth client. If you've already done the OAuth Apps create flow, skip ahead.
1. Create the OAuth app
In Console under OAuth → New App:
- Set a stable
slug. Don't rename it later — your discovery URL bakes it into the FQDN. - Pick a production FQDN (production deployments) or accept the auto-generated
<random>.oapi.trywacht.xyz(staging). - You don't need to add
openid,profile,email,offline_accessto the supported-scopes catalog. They're accepted on every app implicitly.
2. Create the OAuth client
For a typical SPA or native app, register a public client:
client_auth_method:none(means no client_secret — PKCE is mandatory)grant_types:authorization_code, optionallyrefresh_tokenredirect_uris: every redirect URI your app will usepost_logout_redirect_uris: every URL your app will land users on after logoutid_token_signing_alg: leave default (RS256)
For a confidential server-side client, use client_secret_basic and the same fields plus a generated client_secret.
3. Note your endpoints
From the Console Runtime tab on your OAuth app, copy these. They're also discoverable at /.well-known/openid-configuration:
issuer = https://<oauth-fqdn>
authorization_endpoint = https://<oauth-fqdn>/oauth/authorize
token_endpoint = https://<oauth-fqdn>/oauth/token
userinfo_endpoint = https://<oauth-fqdn>/oauth/userinfo
end_session_endpoint = https://<oauth-fqdn>/oauth/logout
jwks_uri = https://<oauth-fqdn>/.well-known/jwks.jsonThe auth flow, end to end
The full sequence for an RP authenticating a user:
- RP builds an
/oauth/authorizeURL withresponse_type=code,client_id,redirect_uri,scope=openid profile email, a randomstate, a randomnonce, and PKCE (code_challenge+code_challenge_method=S256). - Browser navigates to that URL on Wacht's OAuth FQDN.
- Wacht handles the user — sign-in if needed, consent UI if the user hasn't approved these scopes for this client before.
- Wacht redirects to the RP's
redirect_uriwith?code=…&state=…. - RP exchanges the code at
/oauth/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id, the matchingcode_verifier, andclient_secret(confidential clients only). - Wacht returns an
access_token(opaque by default, or a signed JWT if the client is configured for it), an opaquerefresh_token(ifoffline_accesswas requested or the client hasrefresh_tokengrant enabled), and a signedid_token.
That's the entire flow your library needs to understand. Everything after step 6 — calling UserInfo, refreshing, logging out — is independent and orthogonal.
Access token format is a per-client setting. See Verify OAuth Access Tokens for how to verify either format and which one to pick.
The id_token
The id_token is a signed JWT (RS256). Verify it against the JWKS endpoint, then trust the claims.
A typical payload looks like:
{
"iss": "https://<oauth-fqdn>",
"sub": "62015145345353729",
"aud": "oc_4PtfpJ9M1mfgMhBb5YMYSLKDrHY2LOVH",
"exp": 1778707315,
"iat": 1778706415,
"auth_time": 1778706402,
"nonce": "pF3IdC-ebIEUbOAjMEhqxg",
"at_hash": "4Y8D82ALW5BvkcMuRH1D3Q",
"sid": "72170182881052673",
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"email": "jane@example.com",
"email_verified": true
}What every claim means in Wacht's specific case:
iss— your OAuth FQDN withhttps://prefix. Verify it matches what you got from discovery.sub— Wacht's user ID, stringified. Stable across sessions and clients. Use this as your primary user key.aud— your client_id. Verify it matches.auth_time— the timestamp of the user's most recent sign-in, read fromsignins.created_at. Not the time/authorizeran. This matters formax_ageenforcement.nonce— echoes back the nonce you sent at/authorize. Verify it matches. Use a fresh nonce per request.at_hash— base64url of the leftmost 128 bits ofSHA-256(access_token). Defends against token substitution; most libraries verify this automatically.sid— the Wacht session ID. Required for RP-Initiated Logout to cascade properly. Don't drop it.name/given_name/family_name/picture/preferred_username— only present when the request hadprofilescope.email/email_verified— only present when the request hademailscope.
Verifying the id_token
Standard JWT validation:
- Decode the header, pull
kid. - Fetch
/.well-known/jwks.json, find the key with thatkid. - Verify the signature with that key (RS256).
- Check
iss,aud,exp,iat,nonce, andat_hashagainst your expected values.
Every mainstream OIDC client library does this for you. Don't roll your own.
Key rotation
Wacht publishes both active and retired keys in JWKS. When you rotate a key in console:
- The current key flips to
retired(still in JWKS, still verifies old id_tokens). - A new key becomes
active(signs new id_tokens going forward).
This grace window lets in-flight tokens keep working. If a key is marked compromised, it's removed from JWKS immediately and every id_token it signed stops verifying.
Cache JWKS responses for at most an hour and refresh on kid miss.
/oauth/userinfo
Call this when you need claims that weren't in the id_token, or to confirm the user is still valid against the current session.
GET /oauth/userinfo
Authorization: Bearer <access_token>It returns a JSON object with sub (always) plus whatever profile/email claims the access_token's scopes permit.
Error model
Errors follow RFC 6750. The HTTP status is set, plus a WWW-Authenticate header. Examples:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="wacht", error="invalid_token", error_description="Access token expired"HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="wacht", error="insufficient_scope", scope="openid", error_description="userinfo requires the `openid` scope"Wacht's UserInfo verifies more than just the token signature — it also confirms:
- The token's underlying OAuth grant is still active and not expired.
- The Wacht session that approved the original consent hasn't been logged out.
If the session has been terminated via RP-Initiated Logout, UserInfo will 401 with invalid_token even though the access_token's own expiry hasn't passed. This is intentional — it's how the logout cascade reaches your resource server.
Refresh tokens
Refresh tokens are opaque. They rotate on every use: each successful refresh returns a new refresh_token and revokes the old one. Replaying an old refresh token is detected and revokes the entire family (every token descended from the original auth code).
To get a refresh_token, ask for the offline_access scope at /authorize, or ensure your client has the refresh_token grant enabled (most do by default).
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=<old>&client_id=<id>If the access_token is opaque (the default), verify it with /oauth/userinfo or /oauth/introspect. If you've set the client to JWT access tokens, verify locally against the JWKS — see Verify OAuth Access Tokens.
RP-Initiated Logout
When the user clicks "Sign out" in your app, send them to /oauth/logout. This is the only logout flow Wacht implements — there's no front-channel or back-channel logout.
https://<oauth-fqdn>/oauth/logout
?id_token_hint=<id_token>
&client_id=<client_id>
&post_logout_redirect_uri=<your-registered-uri>
&state=<random>What Wacht does on this URL:
- Validates the id_token_hint's signature (against any active or retired key, by
kid). - Verifies
issmatches your issuer. - When
client_idis supplied, verifies the id_token'saudmatches. - Skips expiry validation — old id_tokens are explicitly allowed for logout (the spec mandates this).
- Reads
sidfrom the id_token. - Marks that Wacht session as deleted, then revokes every access_token and refresh_token that referenced that session, across every OAuth client the user authorized.
- Validates
post_logout_redirect_uriagainst the client's allowlist and redirects there with?state=<your-state>.
If id_token_hint is missing, Wacht still redirects to the post-logout URL but can't cascade the session revoke. Always send the hint when you have one.
Endpoint accepts both GET (preferred for browser navigation) and POST (for form posts).
prompt and max_age
These are OIDC parameters your library may surface. Wacht honors them:
| Parameter | Behavior |
|---|---|
prompt=login | Forces re-authentication even if the user has a live session. The user goes through sign-in again before consent. |
prompt=consent | Forces the consent UI to render even if the user has already approved these scopes. |
prompt=none | Returns the auth code silently if the user has an active session and has previously approved these exact scopes for this client and resource. Otherwise the RP gets error=consent_required (or login_required) on the redirect — no UI is shown. |
prompt=select_account | Renders the multi-account picker when the user is signed into multiple accounts. |
max_age=N | If the user's last sign-in is older than N seconds, the request is rejected with error=login_required. |
Unknown prompt values are rejected with 400 invalid_request. Negative max_age is rejected. Send prompt and max_age in /authorize exactly as your library asks for them.
The "skip consent when already covered" default
Wacht's consent UI shows once per (user, client, scopes, resource) tuple. After the user approves, subsequent /authorize calls for the same combination skip the consent screen — the auth code is issued silently and the user returns to your app without seeing a prompt.
This matches what Google, Auth0, Okta do. If you want to force a re-prompt (consent screen changed, app got more sensitive, …), send prompt=consent.
Identity-only vs tenant-scoped requests
Wacht has a concept of "resources" — workspaces and organizations the user is a member of. Pure-identity OIDC requests (any subset of openid profile email offline_access) always grant against the personal user resource and never show a resource picker.
If your scope parameter includes non-identity scopes (workspace:read, custom Wacht scopes), the consent UI shows the resource picker so the user can choose which workspace/org you're acting on behalf of. The chosen resource shows up in the access_token introspection response and constrains what your server-side code can act on.
For most pure OIDC use cases, this isn't something you need to think about — just request identity scopes and you get the user's profile.
Signing keys
Each OAuth app gets its own RSA-2048 keypair, generated when the app is created. You can rotate or compromise keys from console without restarting anything:
- Rotate when you want to refresh keys on a schedule. The old key retires (stays in JWKS for grace), a new key signs going forward.
- Compromise when you suspect a private-key leak. The key is pulled from JWKS immediately, every id_token it signed stops verifying.
The private half never leaves Wacht's database. The console exposes only the public PEM for download (for use in offline verification or trust-chain documentation).
Programmatic access
Both rotate and compromise are scriptable via the backend SDKs — useful for scheduled key rotation pipelines or incident-response automation.
Node
import { oauth } from "@wacht/backend";
// List both active and retired keys (compromised keys are removed from JWKS
// and not returned).
const keys = await oauth.listOAuthAppSigningKeys("my-app");
// Routine rotation — old key retires gracefully, new key signs going forward.
const rotated = await oauth.rotateOAuthAppSigningKey("my-app");
console.log("now signing with kid:", rotated.new.kid);
// Emergency: a leak is suspected. Pulls the key from JWKS immediately;
// every id_token it signed stops verifying.
await oauth.compromiseOAuthAppSigningKey("my-app", "oas-1234-5678");Rust
let keys = client.oauth().list_signing_keys("my-app").send().await?;
let rotated = client.oauth().rotate_signing_key("my-app").send().await?;
println!("now signing with kid: {}", rotated.new.kid);
client
.oauth()
.compromise_signing_key("my-app", "oas-1234-5678")
.send()
.await?;Use rotate_signing_key for scheduled rotation and compromise_signing_key only on suspected leak — the latter breaks any in-flight tokens signed by that key.
Trying it locally
Below is a self-contained Node script (zero dependencies, Node 18+) that walks the full flow: opens a browser, listens on localhost, exchanges the code, verifies the id_token against JWKS, and calls UserInfo. Edit OAUTH_HOST and CLIENT_ID at the top and run node oidc-test.mjs.
Register http://localhost:8765/callback as a redirect URI on the OAuth client first.
import http from "node:http";
import crypto from "node:crypto";
import { exec } from "node:child_process";
const OAUTH_HOST = "<your-oauth-fqdn>"; // e.g. abc.oapi.trywacht.xyz
const CLIENT_ID = "<your-client-id>"; // e.g. oc_xxxxxxxxxxxx
const PORT = 8765;
const REDIRECT = `http://localhost:${PORT}/callback`;
const SCOPE = "openid profile email offline_access";
const b64url = (b) =>
Buffer.from(b).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
const decodePart = (p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"));
const open = (url) => {
const cmd = process.platform === "darwin" ? `open` : process.platform === "win32" ? `start ""` : `xdg-open`;
exec(`${cmd} "${url}"`);
};
const discovery = await fetch(`https://${OAUTH_HOST}/.well-known/openid-configuration`).then((r) => r.json());
const jwks = await fetch(discovery.jwks_uri).then((r) => r.json());
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
const state = b64url(crypto.randomBytes(16));
const nonce = b64url(crypto.randomBytes(16));
const authorizeUrl = new URL(discovery.authorization_endpoint);
Object.entries({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
scope: SCOPE,
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
}).forEach(([k, v]) => authorizeUrl.searchParams.set(k, v));
const code = await new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
if (url.pathname !== "/callback") return res.writeHead(404).end();
res.writeHead(200, { "content-type": "text/html" });
res.end("<h1>Done — you can close this tab.</h1>");
server.close();
if (url.searchParams.get("state") !== state) return reject(new Error("state mismatch"));
const c = url.searchParams.get("code");
if (!c) return reject(new Error(url.searchParams.get("error") || "no code"));
resolve(c);
}).listen(PORT);
open(authorizeUrl.toString());
});
const tokens = await fetch(discovery.token_endpoint, {
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: verifier,
}),
}).then((r) => r.json());
// Verify id_token signature against the JWKS key matching its kid.
const [headerB64, payloadB64, sigB64] = tokens.id_token.split(".");
const header = decodePart(headerB64);
const jwk = jwks.keys.find((k) => k.kid === header.kid);
const ok = crypto.verify(
"RSA-SHA256",
Buffer.from(`${headerB64}.${payloadB64}`),
crypto.createPublicKey({ key: jwk, format: "jwk" }),
Buffer.from(sigB64, "base64url"),
);
if (!ok) throw new Error("id_token signature invalid");
console.log("id_token claims:", decodePart(payloadB64));
console.log("userinfo:", await fetch(discovery.userinfo_endpoint, {
headers: { authorization: `Bearer ${tokens.access_token}` },
}).then((r) => r.json()));To exercise other paths add params to the authorize URL: prompt=consent forces the consent UI, prompt=none requires a previously-granted scope, max_age=10 rejects sessions older than 10 seconds.
Production checklist
- RP library configured from discovery, not hardcoded endpoints.
- JWKS cache short (≤ 1 hour) and re-fetches on
kidmiss. - id_token signature,
iss,aud,exp,nonceall verified per request. stateand PKCE used on every/authorize.post_logout_redirect_uriregistered in console matches what your app sends.- Refresh tokens stored server-side or in secure storage; replay protection means a leak triggers full family revoke automatically.
id_token_hintalways sent on/oauth/logoutso the session cascade fires.