Get started
API scopes
Integration guides
Features
Troubleshooting
Node.js / Express
For a server-side Node app outside Next.js, openid-client is the reference OIDC library — the same one Auth.js, NextAuth, and better-auth wrap internally.
Install
npm install openid-client express express-session jose
Discover and configure
Discovery happens once at startup and gives you a Configuration object you'll pass to every subsequent call.
import * as openid from 'openid-client';
const issuer = new URL('https://idp.kenni.is/<team-domain>');
const config = await openid.discovery(issuer, process.env.KENNI_CLIENT_ID!, process.env.KENNI_CLIENT_SECRET!);
openid-client v6 refuses non-HTTPS issuers by default — discovery against http://localhost:... throws OAUTH_HTTP_REQUEST_FORBIDDEN. For local-IdP development, opt in via the execute option (HTTPS-only is the right production default; this is a dev-only carve-out).
const config = await openid.discovery(issuer, clientId, secret, undefined, {
execute: [openid.allowInsecureRequests],
});
Note that execute is the fifth argument to discovery (after a usually-undefined client-authentication argument).
Login route
Generate a PKCE verifier and a state, store both on the session, then redirect to the authorization endpoint. The handler must be async because calculatePKCECodeChallenge returns a Promise.
app.get('/login', async (req, res) => {
const codeVerifier = openid.randomPKCECodeVerifier();
const codeChallenge = await openid.calculatePKCECodeChallenge(codeVerifier);
const state = openid.randomState();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
const url = openid.buildAuthorizationUrl(config, {
redirect_uri: 'http://localhost:3000/callback',
scope: 'openid profile national_id offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
res.redirect(url.href);
});
Forgetting await on calculatePKCECodeChallenge is the most common mistake — codeChallenge becomes a Promise stringified into the URL as [object Promise], and the IdP rejects the request with no useful hint.
Callback route
app.get('/callback', async (req, res) => {
const tokens = await openid.authorizationCodeGrant(config, new URL(req.url, 'http://localhost:3000'), {
pkceCodeVerifier: req.session.codeVerifier!,
expectedState: req.session.state!,
});
req.session.tokens = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
id_token: tokens.id_token,
};
const claims = (tokens.claims() ?? {}) as Record<string, unknown>;
req.session.user = { sub: claims.sub, name: claims.name };
res.redirect('/');
});
Register http://localhost:3000/callback as a redirect URI on your application in the developer portal.
Refresh
const refreshed = await openid.refreshTokenGrant(config, req.session.tokens.refresh_token);
req.session.tokens.access_token = refreshed.access_token;
req.session.tokens.refresh_token = refreshed.refresh_token ?? req.session.tokens.refresh_token;
Public clients
For a public Node client (rare — usually you'd use the SPA or Native type for public flows), pass openid.None() as the fourth argument to discovery():
const config = await openid.discovery(issuer, clientId, undefined, openid.None());
PKCE is then the only proof on the token request.
Sign out
req.session.destroy() clears your session but leaves the Kenni session alive — the next sign-in silently re-authenticates. Use openid-client's buildEndSessionUrl to redirect through Kenni's end_session_endpoint:
app.get('/logout', (req, res) => {
const idToken = req.session.tokens?.id_token;
const url = openid.buildEndSessionUrl(config, {
id_token_hint: idToken,
post_logout_redirect_uri: 'http://localhost:3000/logged-out',
});
req.session.destroy(() => res.redirect(url.href));
});
buildEndSessionUrl reads client_id from the Configuration, so you only need to pass id_token_hint and post_logout_redirect_uri. end_session_endpoint is OIDC-spec-optional; Kenni always advertises it, but if you're targeting other IdPs guard against the empty case.
Register http://localhost:3000/logged-out on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.
Verifying access tokens
The auth flow above gets a user signed in. The other half of the spec is the API server that receives a Kenni access token on an incoming request and has to validate it.
openid-client v6 is deliberately auth-flow-only — it doesn't expose a JWT-verification helper for arbitrary tokens. Pair it with jose for the resource-server side. config.serverMetadata() reads any field from the cached discovery doc without re-fetching:
import * as jose from 'jose';
const metadata = config.serverMetadata();
const jwks = jose.createRemoteJWKSet(new URL(metadata.jwks_uri!));
async function verifyAccessToken(raw: string) {
const { payload } = await jose.jwtVerify(raw, jwks, {
issuer: metadata.issuer,
audience: process.env.KENNI_API_AUDIENCE!, // e.g. "<client_id>-api"
});
const scopes = String(payload.scope ?? '').split(' ');
if (!scopes.includes(process.env.KENNI_API_SCOPE!)) {
throw new Error('insufficient scope');
}
return payload;
}
app.get('/api/protected', async (req, res) => {
const auth = req.header('authorization') ?? '';
const raw = auth.startsWith('Bearer ') ? auth.slice(7) : '';
try {
const claims = await verifyAccessToken(raw);
res.json({ sub: claims.sub });
} catch {
res.status(401).json({ error: 'invalid_token' });
}
});
createRemoteJWKSet caches keys and handles rotation transparently — reuse the same instance across requests. Access tokens are JWTs only when you request a custom API scope; see API scopes for the opaque-vs-JWT distinction.
Client credentials (M2M)
For machine-to-machine calls (no user, no browser), use the client-credentials grant. openid-client v6 ships a one-call helper:
const tokens = await openid.clientCredentialsGrant(config, {
scope: process.env.KENNI_API_SCOPE!,
});
// tokens.access_token is a JWT with `aud = <api-audience>`, ready to send to your API.
The same verifyAccessToken from the section above validates these tokens.