Get started
API scopes
Integration guides
Features
Troubleshooting
React (SPA)
For a single-page React app, react-oidc-context wraps the well-tested oidc-client-ts library and exposes a hook-based API. PKCE, silent refresh (with caveats — see below), and session storage are handled for you.
Use the Web (SPA) application type in the developer portal — public client, no secret.
Install
npm install react-oidc-context oidc-client-ts
Provider
Wrap your app once at the root.
// main.tsx
import { AuthProvider } from 'react-oidc-context';
import { WebStorageStateStore } from 'oidc-client-ts';
const oidcConfig = {
authority: import.meta.env.VITE_KENNI_AUTHORITY, // https://idp.kenni.is/<team-domain>
client_id: import.meta.env.VITE_KENNI_CLIENT_ID, // @<team-domain>/spa
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile national_id offline_access',
// Default is sessionStorage (clears on tab close). localStorage survives
// tab close and devtools refresh — pick whichever fits your app's session
// semantics. Both are fine for public clients.
userStore: new WebStorageStateStore({ store: window.localStorage }),
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
},
};
createRoot(document.getElementById('root')!).render(
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
);
This is a public client, so no client_secret — PKCE is the proof of possession, and oidc-client-ts enables it by default.
Vite only exposes env vars prefixed with VITE_ to the client bundle. If you'd rather use unprefixed names, set envPrefix: 'KENNI_' in vite.config.ts. See Vite's env-and-mode docs.
Register http://localhost:5173/callback (or whatever your dev origin is) as a redirect URI on your application. The library handles the code exchange when the user lands on /callback.
Pin your dev port. Vite falls back to 5174, 5175, … if 5173 is busy, which silently shifts the runtime redirect_uri and produces a "redirect_uri mismatch" with no obvious cause. Either set strictPort: true in vite.config.ts (and register the one port), or read redirect_uri from an env var so the registered URI and runtime URI can't drift.
If you're using a router (React Router, TanStack Router, etc.), you don't need a /callback route component — the provider's onSigninCallback rewrites the URL via history.replaceState before any route renders. If your router is strict about unknown paths, add a placeholder route at /callback so it doesn't 404 during the brief code-exchange window.
Using auth state
import { useAuth } from 'react-oidc-context';
export const App = () => {
const auth = useAuth();
if (auth.isLoading) return <p>Loading…</p>;
if (auth.error) return <p>Error: {auth.error.message}</p>;
if (!auth.isAuthenticated) {
return <button onClick={() => auth.signinRedirect()}>Sign in</button>;
}
return (
<>
<p>Hello, {auth.user?.profile.name}</p>
<button
onClick={() =>
auth.signoutRedirect({
extraQueryParams: { client_id: import.meta.env.VITE_KENNI_CLIENT_ID },
})
}
>
Sign out
</button>
</>
);
};
auth.user?.access_token is the access token you'd send to your APIs.
Silent refresh requires explicit setup. oidc-client-ts only renews the access token when one of these is configured:
offline_accessis inscopeAND Kenni issued a refresh token (the default config above does this — the refresh token sits inlocalStorage), orsilent_redirect_uriis set ANDautomaticSilentRenew: trueenables the iframe-based silent-renew flow.
If you drop offline_access (e.g. because storing a refresh token in localStorage is a trade-off you don't want) without configuring the iframe flow, the access token will expire mid-session and your API calls will start failing. Pick one of the two paths.
Calling your API
const res = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${auth.user?.access_token}` },
});
If you authorized at least one custom API scope on this application, the access token is a JWT your API can validate locally. Otherwise it's an opaque token only Kenni can introspect — see API scopes.
Sign out
auth.signoutRedirect() redirects to Kenni's end_session_endpoint with id_token_hint and post_logout_redirect_uri, lets Kenni clear its session, and comes back to your post_logout_redirect_uri. The local user state is cleared automatically.
Pass client_id explicitly. oidc-client-ts does not include client_id in the end-session URL by default, but Kenni's end-session endpoint requires it. Without it the user sees an error instead of the post-logout redirect.
auth.signoutRedirect({
extraQueryParams: { client_id: oidcConfig.client_id },
});
If you only call auth.removeUser() you'll clear the local session but the Kenni session stays live, and the next sign-in will silently re-authenticate. Always prefer signoutRedirect(). See the No framework (curl) guide for the full reasoning.
Register your post-logout URL on the application's Post logout redirect URIs list in the developer portal. For react-oidc-context it defaults to post_logout_redirect_uri from your config (we set it to window.location.origin in the provider above).
Verifying access tokens
If your API runs as a separate service, validate Kenni-issued JWT access tokens locally against the JWKS from discovery rather than introspecting on every request. See API scopes for the difference between JWT and opaque access tokens, and the No framework (curl) guide for the protocol view.