Next.js SDK
@keenauth/nextjs adds sign-in to a Next.js App Router application. Your app is the OAuth client and keeps every token on the server. The browser gets only an encrypted, HttpOnly session cookie and never sees an access, refresh or ID token. This is the backend-for-frontend (BFF) pattern. The package has no runtime dependencies. It is not published to npm yet.
Before you start
- In the dashboard, create a web application in your environment.
- Choose
private_key_jwtclient authentication and register your public key. A client secret also works. - Add the redirect URI
https://app.acme.example/auth/callbackand the post-logout redirect URIhttps://app.acme.example/. - Generate a cookie secret of at least 32 characters, for example with
openssl rand -base64 32. Keep it in your secret store, not in the repository.
Create the instance
// lib/auth.ts
import { createKeenAuth } from "@keenauth/nextjs";
export const auth = createKeenAuth({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
clientAuth: { method: "private_key_jwt", privateKey: JSON.parse(process.env.KEENAUTH_CLIENT_KEY!) },
appBaseUrl: "https://app.acme.example",
secret: process.env.KEENAUTH_COOKIE_SECRET!,
});
appBaseUrl is the public address of your app. The SDK builds the redirect URI and every redirect from it, never from the request's Host or X-Forwarded-* headers. Plain http is accepted only for localhost.
The options are checked when the instance is created, so a configuration error stops the app at start-up.
Add the routes
// app/auth/[...keenauth]/route.ts
import { auth } from "@/lib/auth";
export const { GET, POST } = auth.handlers;
This serves three routes:
| Route | What it does |
|---|---|
/auth/login |
Starts sign-in. An optional returnTo query parameter sets where the user lands afterwards. |
/auth/callback |
Finishes sign-in and sets the session cookie. |
/auth/logout |
Signs out at KeenAuth and returns to your app. |
Sign-in uses the authorization code flow with PKCE. The request is pushed to the server (PAR) when your environment offers it. On the callback the SDK checks state, iss and the ID token, including its nonce.
returnTo must be a path of your own app, such as /orders?tab=open. Any other value, such as //evil.example or a full URL, is ignored and the user lands on /.
Protect pages and refresh tokens
// proxy.ts (middleware.ts before Next.js 16)
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const result = await auth.proxy(request, {
protect: (path) => path.startsWith("/orders") || path.startsWith("/api/"),
});
if (result.response) return result.response;
const response = NextResponse.next({ request: { headers: result.requestHeaders } });
result.apply(response);
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
For a protected path without a session:
- a page request (
GETorHEAD) is redirected to/auth/loginwithreturnTo; - a request to
/api/…, or with another method, gets401with a problem document.
When the access token expires within 60 seconds, the proxy refreshes it. Each refresh rotates the refresh token, and the new session cookie is set on the response. requestHeaders passes the new session to the pages that render the same request.
If the server refuses the refresh token, the session ends. A temporary server error keeps the session unchanged.
Also check the session inside each Server Function and route handler. A proxy matcher can miss a path.
Read the session
In a Server Component, pass the cookie store:
// app/orders/page.tsx
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";
export default async function Orders() {
const session = await auth.getSession(await cookies());
if (!session) return <a href="/auth/login?returnTo=/orders">Sign in</a>;
return <p>Signed in as {session.user.email}</p>;
}
session.user holds typed claims of the ID token: subject, email, emailVerified, name, orgId, orgRoles and others. All claims, including custom ones, are in session.user.claims. The session object never contains a token.
A Server Component cannot set cookies, so reading the session there never refreshes it.
Call your API
In a Server Component, take the access token from the cookie store:
const token = await auth.getAccessToken(await cookies());
const res = await fetch("https://api.acme.example/v1/orders", {
headers: { Authorization: `Bearer ${token}` },
});
getAccessToken fails with:
refresh_requiredwhen the token is close to expiry and should have been refreshed by the proxy;login_requiredwhen there is no session.
In a route handler, updateSession also refreshes:
// app/api/orders/route.ts
import { auth } from "@/lib/auth";
export async function GET(request: Request) {
const { accessToken, apply } = await auth.updateSession(request);
if (!accessToken) return new Response(null, { status: 401 });
const upstream = await fetch("https://api.acme.example/v1/orders", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const response = new Response(upstream.body, { status: upstream.status });
apply(response);
return response;
}
auth.authorizedFetch does the same in one call. It reads the token like getAccessToken, adds the Authorization header and, with DPoP, the proof:
const res = await auth.authorizedFetch(await cookies(), "https://api.acme.example/v1/orders");
Do not pass the access token to a Client Component. Call your API from the server and send the browser only the data. To verify these tokens in your API, see Verifying tokens in your API.
Sign out
Link to /auth/logout, or send a POST to it from a form. The SDK:
- deletes the session cookie;
- revokes the refresh token;
- sends the browser to your environment's logout endpoint, which returns it to your app.
A request that the browser marks as coming from another site is refused, so another site cannot sign your users out. Browsers that do not send Sec-Fetch-Site can sign out only with a POST from your own pages; prefer a form that posts to /auth/logout.
Sender-constrained tokens (DPoP)
With DPoP (RFC 9449), your access token is bound to a key that only your server holds. A leaked token, for example from an API log, cannot be used without that key.
- Generate one ES256 (P-256) key for the application and keep the private JWK in your secret store.
- Pass it as
dpop:
export const auth = createKeenAuth({
// ...the options above
dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});
The SDK then:
- binds the authorization code to the key (
dpop_jkt, sent with the pushed authorization request); - sends a DPoP proof with every token and refresh request, and retries once when the server asks for a new nonce;
- refuses a token response whose
token_typeis notDPoP.
To call your API, use auth.authorizedFetch. It sends Authorization: DPoP <token> and a new proof with ath for each request, and retries once when your API answers 401 with error="use_dpop_nonce". If you use your own HTTP client, create the proof with auth.createDpopProof({ method, url, accessToken, nonce }).
Use the same key in every server process: your environment binds the tokens and the refresh token to it. If you replace the key, or turn DPoP on or off, existing sessions end and users sign in again.
Server-side sessions
By default the whole session is sealed into the cookie. Pass a sessionStore to keep the session on the server instead. The cookie then holds only an encrypted reference to a random session id.
import { createKeenAuth, memorySessionStore, type SessionStore } from "@keenauth/nextjs";
// Next.js bundles proxy.ts, route handlers and pages separately, and each bundle runs its own copy of this module.
// Keep the store on globalThis so that they all share one store in the server process.
const shared = globalThis as { keenauthSessions?: SessionStore };
const sessionStore = (shared.keenauthSessions ??= memorySessionStore());
export const auth = createKeenAuth({
// ...the options above
sessionStore,
});
Create the in-memory store once per process, as above. A memorySessionStore() called at module level, without globalThis, gives the proxy, each route handler and the pages their own empty store: the session the callback route saves is not found by the proxy or a page, and the user appears signed out. A store that keeps its data outside the process (Redis, a database) does not have this problem.
memorySessionStore keeps sessions in the memory of one process and loses them on restart. Use it for development and single-instance servers. With several instances or serverless functions, implement the SessionStore interface on a shared store such as Redis or your database:
interface SessionStore {
get(id: string): Promise<SessionRecord | null>;
set(id: string, record: SessionRecord): Promise<void>;
delete(id: string): Promise<void>;
deleteByLogout(target: { sub?: string; sid?: string }): Promise<void>;
}
// SessionRecord: { value: string; sub: string; sid?: string; expiresAt: number }
valueis still encrypted with yoursecret, so a copy of the store reveals no token. Do not log it.deleteByLogoutwithsiddeletes the sessions with thatsid; withsubas well, both must match. With onlysub, it deletes every session of that user.- Drop a record after
expiresAt. - The package's repository has a contract test (
clients/nextjs/test/store-contract.ts) that your store should pass.
If the store fails, the SDK throws a session_store_error instead of treating the user as signed out. Signing out still deletes the cookie.
Back-channel logout
With a sessionStore, the SDK also serves /auth/backchannel-logout. When a session ends in KeenAuth (for example, the user signs out of another application or an administrator revokes the session), your environment sends a signed logout token there. The SDK deletes the matching sessions, so the user's next request in your app has no session.
- Register
https://app.acme.example/auth/backchannel-logoutas the application's back-channel logout URI. - Make sure the route is reachable from the internet. Your proxy matcher may include it: the proxy lets it through.
The SDK checks the logout token as OpenID Connect Back-Channel Logout 1.0 requires: signature, issuer, audience, iat and exp, the logout event, sub or sid, no nonce, and the logout+jwt type. It answers 200 on success and 400 otherwise, and your environment retries a failed delivery.
Each logout token's jti is remembered until the token expires, so a captured token cannot be replayed. A repeated jti is answered 200 without acting again. The default cache lives in one process; with several instances, pass a shared logoutReplayCache that implements remember(jti, expiresAt) atomically (for example Redis SET NX) and forget(jti).
The session cookie
- The cookie is encrypted and authenticated with AES-256-GCM, using a key derived from
secret. A changed or copied cookie is rejected. - It is named
__Host-keenauth_session.0and set withSecure,HttpOnly,SameSite=LaxandPath=/. Onhttp://localhostthe__Host-prefix andSecureare left out. - A session lasts at most 7 days (
sessionMaxAge); refreshing does not extend it. - Large sessions are split into up to three cookies.
To change the secret, pass a list: secret: [newSecret, oldSecret]. The first secret encrypts and all of them decrypt. Remove the old one after sessionMaxAge.
Current limits
- Sign-in with embedded components is not available in this package yet.
- Refreshes are shared only within one server process. With several instances, two of them can refresh the same session at once. The application setting
refresh_reuse_interval_s(default 5 seconds, see Update an application) lets the second refresh succeed; if you set it to0, the second refresh ends the session. - Without a
sessionStore, back-channel logout is not available; a session then ends at the server when its refresh token is refused.