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

  1. In the dashboard, create a web application in your environment.
  2. Choose private_key_jwt client authentication and register your public key. A client secret also works.
  3. Add the redirect URI https://app.acme.example/auth/callback and the post-logout redirect URI https://app.acme.example/.
  4. 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:

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:

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:

  1. deletes the session cookie;
  2. revokes the refresh token;
  3. 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.

  1. Generate one ES256 (P-256) key for the application and keep the private JWK in your secret store.
  2. Pass it as dpop:
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});

The SDK then:

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 }

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.

  1. Register https://app.acme.example/auth/backchannel-logout as the application's back-channel logout URI.
  2. 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).

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