Get started
API scopes
Integration guides
Features
Troubleshooting
Next.js / better-auth
better-auth is the modern auth framework for the Next.js (and broader TypeScript) ecosystem. Its genericOAuth plugin lets you register Kenni as an OIDC provider with discovery, PKCE, and token storage handled for you.
This guide is tested against Next.js 16 and better-auth 1.x.
Install
npm install better-auth jose
better-auth runs database-less by default — session and account state live in signed cookies. If you need persistence (e.g. to invalidate sessions across deploys), pass a database: option. For most "let users sign in with Kenni" integrations, you don't need one.
Auth instance
Create the auth instance once and import it into your routes and middleware.
Two non-obvious tweaks are required to get sign-in working against Kenni: a custom getUserInfo (so the name claim isn't dropped) and a mapProfileToUser (so better-auth's required-email/name checks pass). Both are explained below.
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js';
import { decodeJwt } from 'jose';
const issuer = `https://idp.kenni.is/<team-domain>`;
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: 'kenni',
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId: process.env.KENNI_CLIENT_ID!,
clientSecret: process.env.KENNI_CLIENT_SECRET!,
// `offline_access` requests a refresh token so better-auth can
// refresh the access token without bouncing the user back to Kenni.
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
pkce: true,
// Better-auth's default user-info parser only trusts id_token claims
// when both `sub` AND `email` are present, then falls through to the
// userinfo endpoint. Kenni puts `name` in the id_token but doesn't
// include `email` (it's consent-gated), so the gate fails and the
// userinfo response — which doesn't carry `name` for most clients —
// wins. The result is a silently-empty `user.name`. Decoding the
// id_token directly fixes it.
getUserInfo: async (tokens) => {
if (!tokens.idToken) return null;
const claims = decodeJwt(tokens.idToken) as Record<string, unknown>;
return {
...claims,
id: String(claims.sub ?? ''),
email: claims.email as string | undefined,
emailVerified: Boolean(claims.email_verified),
name: claims.name as string | undefined,
image: claims.picture as string | undefined,
};
},
// Better-auth requires `email` and `name` on every user. Kenni
// guarantees neither: `email` is consent-gated, and `name` may be
// empty for some test accounts and delegation flows. Without this
// map, sign-in throws `email_is_missing` / `name_is_missing`.
mapProfileToUser: (profile) => ({
email: profile.email ?? `${profile.sub}@no-reply.users.kenni.is`,
emailVerified: Boolean(profile.email_verified),
name:
profile.name ||
[profile.given_name, profile.family_name].filter(Boolean).join(' ') ||
'Kenni user',
}),
},
],
}),
// `nextCookies` must be the last plugin. Without it, sign-in via a server
// action silently fails to set the session cookie.
nextCookies(),
],
});
Mount the handler
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);
Auth client
authClient.signIn.oauth2(...) is only available when you register the genericOAuthClient() plugin on the client.
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { genericOAuthClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});
Environment
KENNI_CLIENT_ID=@<team-domain>/web
KENNI_CLIENT_SECRET=<from-portal>
NEXT_PUBLIC_KENNI_CLIENT_ID=@<team-domain>/web
BETTER_AUTH_SECRET=<random>
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_URL is the public origin better-auth derives the redirect_uri from. If it's wrong (e.g. localhost:3000 set but the app is actually running on localhost:3001), sign-in fails with a redirect_uri mismatch and the cause is not obvious.
NEXT_PUBLIC_KENNI_CLIENT_ID is the same value, exposed to the browser for the sign-out snippet below. Next.js only inlines vars prefixed with NEXT_PUBLIC_ into client bundles.
Register http://localhost:3000/api/auth/oauth2/callback/kenni as a redirect URI on your application in the developer portal. The path comes from the genericOAuth plugin mounting /oauth2/callback/:providerId under the /api/auth/[...all] handler. Adjust the host for production.
Triggering sign-in
'use client';
import { authClient } from '@/lib/auth-client';
export const SignInButton = () => (
<button
onClick={() =>
authClient.signIn.oauth2({
providerId: 'kenni',
callbackURL: '/',
})
}
>
Continue with Kenni
</button>
);
callbackURL controls where the user lands after sign-in completes. Skip it and better-auth picks its own default, which is rarely what you want.
Public clients
For SPA or Native applications, drop clientSecret and set pkce: true (it already is in the snippet above). PKCE is recommended for confidential clients too as defence in depth; for public clients PKCE alone replaces the missing client_secret.
Reading the access and id tokens
Better-auth keeps the Kenni-issued tokens server-side. Retrieve them with auth.api.getAccessToken, which auto-refreshes the access token if it has expired:
// app/lib/kenni-tokens.ts
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
export async function getKenniTokens() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return auth.api.getAccessToken({
body: { providerId: 'kenni', userId: session.user.id },
});
// -> { accessToken, accessTokenExpiresAt, scopes, idToken }
}
Use accessToken for outbound API calls and idToken for RP-initiated logout (below). See API scopes for what kind of access token Kenni issues based on your scope configuration.
Sign out
authClient.signOut() clears the better-auth session but leaves the Kenni session untouched — users get silently re-authenticated on the next sign-in. After signing out locally, redirect the browser to Kenni's end_session_endpoint (RP-initiated logout) so Kenni also clears its session.
Clearing the local session before the redirect means a failed RP-initiated logout still leaves the user signed out locally. Most apps prefer that over the alternative.
'use client';
import { authClient } from '@/lib/auth-client';
const handleSignOut = async (idToken: string) => {
// Discover the end-session endpoint instead of hard-coding it.
const issuer = `https://idp.kenni.is/<team-domain>`;
const discovery = await fetch(`${issuer}/.well-known/openid-configuration`).then((r) => r.json());
await authClient.signOut();
const url = new URL(discovery.end_session_endpoint);
url.searchParams.set('id_token_hint', idToken);
url.searchParams.set('post_logout_redirect_uri', `${window.location.origin}/`);
url.searchParams.set('client_id', process.env.NEXT_PUBLIC_KENNI_CLIENT_ID!);
window.location.href = url.toString();
};
Pass idToken in from a server component that called getKenniTokens() above. Register your post-logout URL on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.
Per-call prompt (delegation, switch user)
authClient.signIn.oauth2 doesn't accept a prompt parameter — better-auth reads it from static provider config only. To vary prompt per-click (e.g. a "Switch delegation" button), pass authorizationUrlParams as a function that reads from additionalData:
genericOAuth({
config: [
{
// ... discoveryUrl, clientId, etc.
authorizationUrlParams: (ctx) => {
const prompt = (ctx.body as { additionalData?: { prompt?: string } } | undefined)?.additionalData
?.prompt;
return prompt ? { prompt } : ({} as Record<string, string>);
},
},
],
});
Then on the client:
authClient.signIn.oauth2({
providerId: 'kenni',
additionalData: { prompt: 'delegation' },
});
One provider, one redirect URI, no account-linking errors.
Client credentials (M2M)
Better-auth doesn't help with the client-credentials grant — it's not user-bound. POST to the token endpoint directly:
const res = await fetch(`${issuer}/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.KENNI_CLIENT_ID!,
client_secret: process.env.KENNI_CLIENT_SECRET!,
scope: 'your_api:read',
}),
});
const { access_token } = await res.json();
You'll need a separate Machine application registered in the developer portal, with the relevant API scopes authorized. See the No framework (curl) guide for the full protocol view.
Verifying access tokens
If your API runs as a separate service, validate Kenni-issued JWT access tokens locally against the JWKS from discovery — don't introspect on every request. See API scopes for when access tokens are JWTs vs. opaque references.