
# 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

```ts
// 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

```ts
// 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

```ts
// 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 (`GET` or `HEAD`) is redirected to `/auth/login` with `returnTo`;
- a request to `/api/…`, or with another method, gets `401` with 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:

```tsx
// 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:

```ts
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_required` when the token is close to expiry and should have been refreshed by the proxy;
- `login_required` when there is no session.

In a route handler, `updateSession` also refreshes:

```ts
// 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:

```ts
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](/docs/sdk/token-verification).

## 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`:

```ts
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_type` is not `DPoP`.

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.

```ts
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:

```ts
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 }
```

- `value` is still encrypted with your `secret`, so a copy of the store reveals no token. Do not log it.
- `deleteByLogout` with `sid` deletes the sessions with that `sid`; with `sub` as well, both must match. With only `sub`, 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.

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)`.

## 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.0` and set with `Secure`, `HttpOnly`, `SameSite=Lax` and `Path=/`. On `http://localhost` the `__Host-` prefix and `Secure` are 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](/docs/api/management/applications/update)) lets the second refresh succeed; if you set it to `0`, 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.
