@okia/react — provider, hooks, and the server routes a Next.js App Router site needs. Sole dependency: jose; Node 20 or later. It needs Next.js: the login runs through route handlers on your own server. For a plain React SPA (Vite, Create React App), use the JS snippet in SPA mode instead.
npm install @okia/react
Exports
| Entry | Exports | Runs |
|---|---|---|
@okia/react |
OkiaProvider, LoginButton, useOkia, useBiometricLabel |
client |
@okia/react/next |
createOkiaHandlers (catch-all route: login|callback|logout|session), getOkiaSession, okiaMiddleware |
server, edge-compatible |
@okia/react/server |
verifyOkiaToken (jose JWKS verify: checks iss, aud, exp, token_use), verifyOkiaWebhook |
server |
Session: encrypted stateless JWT cookie (jose A256GCM, keyed by OKIA_COOKIE_SECRET), httpOnly, 7 days from sign-in (not rolling — nothing re-issues it), edge-compatible. No refresh tokens in MVP — re-auth is one Face ID tap.
Env vars: OKIA_CLIENT_ID, OKIA_CLIENT_SECRET, OKIA_COOKIE_SECRET. (OKIA_AUTH_ORIGIN too, if you are pointing at anything other than https://auth.okia.io.)
Behind a proxy — Docker, Coolify, next start behind nginx or Traefik — the handlers build every URL from the proxy's x-forwarded-proto and x-forwarded-host headers, so the callback is your public https:// address and not the container's. Set OKIA_APP_ORIGIN (or the appOrigin option) to be explicit, and always for a proxy that forwards neither header or a tunnel in front of next dev.
Generate the cookie secret with openssl rand -base64 32. It encrypts the session cookie, so rotating it signs everyone out — which is also how you sign everyone out. getOkiaSession and okiaMiddleware read it too: without it they throw OkiaConfigError rather than treat everyone as signed out.
Sketch 1: App Router route
One catch-all route wires login, callback, logout, and session:
// app/api/okia/[action]/route.ts
import { createOkiaHandlers } from "@okia/react/next";
export const { GET, POST } = createOkiaHandlers({
clientId: process.env.OKIA_CLIENT_ID!,
clientSecret: process.env.OKIA_CLIENT_SECRET!,
});
Register https://yoursite.com/api/okia/callback as the redirect URI in the dashboard. Choosing "Next.js" when you create the site fills that in, and registers http://localhost:3000/api/okia/callback beside it so next dev signs in on the first run — edit or clear it on the form if your dev server lives elsewhere.
Sketch 2: provider + hook
Two files. The hook runs in the browser, so it lives in a client component:
// app/account.tsx
"use client";
import { LoginButton, useOkia } from "@okia/react";
export function Account() {
const { user, logout } = useOkia();
if (!user) return <LoginButton />;
return <button onClick={logout}>Sign out ({user.sub})</button>;
}
The root layout stays a Server Component and hands the provider what the server already knows:
// app/layout.tsx
import { OkiaProvider } from "@okia/react";
import { getOkiaSession } from "@okia/react/next";
import { Account } from "./account";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = (await getOkiaSession())?.user ?? null;
return (
<html lang="en">
<body>
<OkiaProvider initialUser={user}>
<Account />
{children}
</OkiaProvider>
</body>
</html>
);
}
initialUser makes the first paint final — no /api/okia/session request, no "loading" flash on a signed-out page. It has three states: a user (signed in), null (the server checked — signed out), and omitted (unknown — the provider asks the session route once). Since 2.0.0 null means signed out, not unknown; if you passed null to mean "go and check", drop the prop.
user.sub is your site's pairwise subject (pw_…) — stable for this site, meaningless anywhere else. Check user.amr if you want to force step-up after an OTP fallback login (amr: ["otp"]).
LoginButton is unstyled, so it takes your own button styles. It says "Sign in", then "Sign in with Face ID" or "Sign in with fingerprint" once the browser confirms the device has one — the same label as the JS snippet's button. Pass children to say something else, or call useBiometricLabel() to put that label on a button of your own.
In a Server Component, skip the hook and read the session directly:
import { getOkiaSession } from "@okia/react/next";
export default async function Page() {
const session = await getOkiaSession();
return <p>{session ? session.user.sub : "Signed out"}</p>;
}
When sign-in fails
A failed sign-in lands on afterLogout (/ unless you set it) with ?okia_error=<code>, and useOkia().error carries the code; the details go to your server log as [okia] callback rejected.
| Code | What happened |
|---|---|
access_denied |
The person declined or closed the sign-in sheet |
expired |
The sign-in took longer than 10 minutes, or finished in a different browser from the one that started it |
state_mismatch |
The callback did not belong to this browser's sign-in — the CSRF check |
exchange_failed |
The token endpoint refused the code or could not be reached: a wrong client secret, a redirect URI that does not match, a 10-second timeout |
invalid_token |
The ID token failed verification, or carried another sign-in's nonce |
failed |
Any other error the auth server sent back |
error is only ever one of these codes — anything else in the URL reads as null, because anyone can craft that link. Show your own words for each, never the raw value.
Sketch 3: middleware guard
// middleware.ts
import { okiaMiddleware } from "@okia/react/next";
export const middleware = okiaMiddleware();
export const config = { matcher: ["/dashboard/:path*"] };
It remembers where the visitor was going and sends them back there after they sign in. It never guards /api/okia/* or its own login path, so a catch-all matcher is safe. It takes the same options object as createOkiaHandlers — share one if you pass cookieSecret in code rather than the environment. Runs on the Edge runtime — the session cookie is decrypted with Web Crypto, so there is nothing Node-only in the path. Write it by hand with getOkiaSession(req) if you want different behaviour.
Server-side token verification (API routes, custom Node backends):
import { verifyOkiaToken } from "@okia/react/server";
const claims = await verifyOkiaToken(idToken); // throws unless iss, aud, exp, and token_use === "id" all pass
verifyOkiaWebhook(request, secret) checks a webhook's Okia-Signature the same way — see webhooks.
Install to working login (~8 min)
- Dashboard → your site → copy client ID and secret; register
/api/okia/callbackas the redirect URI. npm install @okia/react; setOKIA_CLIENT_ID,OKIA_CLIENT_SECRET,OKIA_COOKIE_SECRET.- Add the catch-all route (sketch 1).
- Add
app/account.tsxand wrap your layout inOkiaProvider(sketch 2). - Optional: add the middleware guard for protected paths (sketch 3).
- Run the app, click the button, complete the WhatsApp code + passkey flow.
getOkiaSessionnow returns your user.