
# Add sign-in to a Next.js app

This quickstart adds KeenAuth sign-in to a Next.js App Router app with the `@keenauth/nextjs` SDK. Your app is the OAuth client and keeps every token on the server; the browser gets only an encrypted, HttpOnly session cookie. You add the sign-in routes, protect a page, call a protected API with the user's access token and test the app offline. The App Router is the only router the SDK supports. The SDK is described in [Next.js SDK](/docs/sdk/nextjs).

The app has these pages and routes:

| Route | What it does |
|---|---|
| `/` | Public home page: a sign-in link, or the user's name and a sign-out button |
| `/dashboard` | Needs a session. Shows the user and the messages that your API returns |
| `/api/messages` | Needs a session. Calls your API with the user's access token |
| `/auth/login`, `/auth/callback`, `/auth/logout` | Served by the SDK |

## Before you begin

- A KeenAuth environment. Its issuer looks like `https://your-environment.eu.keenauth.app`.
- A web application in the environment ([Create an application](/docs/api/management/applications/create)) with `private_key_jwt` or a client secret as client authentication. In a `development` environment, register the redirect URI `http://localhost/auth/callback` and the post-logout redirect URI `http://localhost/`; a loopback URI is registered without a port and matches any port, such as `3000`.
- An API registered in the environment ([Create an API](/docs/api/management/apis/create)) with the `messages:read` scope. Its `identifier`, for example `https://api.example.com`, is the audience of its access tokens. The [Express quickstart](/docs/quickstarts/express) builds an API that accepts these tokens.
- Node.js 22.18 or later.

## 1. Install

```sh
npm install @keenauth/nextjs next@16.4.2 react@19.3.1 react-dom@19.3.1
```

The SDK is not published yet. Until it is, the example links to the SDK source in its `package.json`.

## 2. Configure

The app reads its configuration from environment variables only. Put them in `.env.local`, which Next.js loads, and never commit that file.

| Variable | Required | Meaning |
|---|---|---|
| `KEENAUTH_ISSUER` | yes | Your environment's issuer |
| `KEENAUTH_CLIENT_ID` | yes | The `client_id` of the web application |
| `KEENAUTH_CLIENT_KEY` | one of the two | The private key (a JSON private JWK) for `private_key_jwt`, recommended |
| `KEENAUTH_CLIENT_SECRET` | one of the two | A client secret |
| `APP_BASE_URL` | yes | The public URL of the app. The SDK builds the redirect URI and every redirect from it. `http` only for `localhost`, `127.0.0.1` or `[::1]` |
| `KEENAUTH_COOKIE_SECRET` | yes | At least 32 characters; seals the session cookie. Generate one with `openssl rand -base64 32` |
| `API_URL` | yes | The API the app calls, for example `http://localhost:8080` |
| `KEENAUTH_API_AUDIENCE` | yes | The identifier of that API in KeenAuth |

`loadConfig` reads and checks these values. It refuses to continue when one is missing or when both or neither of the client credentials are set, and its error messages never contain a secret.

```ts
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
  const need = (name: string): string => {
    const value = env[name];
    if (!value) throw new Error(`${name} is required`);
    return value;
  };
```

## 3. Create the SDK instance

`createAuth` creates the SDK instance, and the SDK checks every option when it is created. `getApp` creates one instance per process on first use, so that `next build` needs no secrets.

```ts
/** Builds the SDK instance. The options are checked here, so a configuration error stops the app at start-up. */
export function createAuth(config: AppConfig, overrides: Partial<KeenAuthOptions> = {}): KeenAuth {
  return createKeenAuth({ ...config.auth, ...overrides });
}

let instance: { auth: KeenAuth; config: AppConfig } | undefined;

/** One instance per process, created on first use so that `next build` needs no secrets. */
export function getApp(): { auth: KeenAuth; config: AppConfig } {
  if (!instance) {
    const config = loadConfig(process.env);
    instance = { auth: createAuth(config), config };
  }
  return instance;
}
```

The configuration sets `resource` to the identifier of your API (RFC 8707). Without it, the access token is not meant for your API and the API rejects it.

## 4. Add the sign-in routes

One catch-all route handler serves `/auth/login`, `/auth/callback` and `/auth/logout`:

```ts
// Serves /auth/login, /auth/callback and /auth/logout.
export const GET = (request: Request) => getAuth().handlers.GET(request);
export const POST = (request: Request) => getAuth().handlers.POST(request);
```

Sign-in uses the authorization code flow with PKCE, and a pushed authorization request when your environment offers it. The callback checks `state`, `iss` and the ID token. A `returnTo` that is not a path of your app is ignored.

## 5. Protect pages with the proxy

`proxy.ts` (`middleware.ts` before Next.js 16) refreshes the session before a page renders and protects the paths you choose. A page request without a session is redirected to `/auth/login` with `returnTo`; a request to `/api/…` gets `401`.

```ts
export async function proxy(request: NextRequest) {
  const result = await getAuth().proxy(request, { protect: isProtected });
  if (result.response) return result.response;
  const response = NextResponse.next({ request: { headers: result.requestHeaders } });
  result.apply(response);
  return response;
}
```

The paths that need a session are in one function:

```ts
export function isProtected(pathname: string): boolean {
  return pathname === "/dashboard" || pathname.startsWith("/dashboard/") || pathname.startsWith("/api/");
}
```

A proxy matcher can miss a path, so every page and route handler that needs a user also checks the session itself.

## 6. Call your API

In a Server Component, read the session and take the access token from the cookie store. The token stays on the server; the browser receives only the HTML.

```tsx
  // The proxy has already sent users without a session to the sign-in; check again here, because a matcher can miss a path.
  const session = await auth.getSession(cookieStore);
  if (!session) return <a href="/auth/login?returnTo=/dashboard">Sign in</a>;

  // The access token stays on the server. The browser receives only the HTML below.
  const response = await fetchMessages(config.apiUrl, await auth.getAccessToken(cookieStore));
```

`session.user` holds the claims of the ID token, such as `subject`, `email` and `name`. The session never contains a token.

The call itself is a plain `fetch` with a bearer token:

```ts
export async function fetchMessages(apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch): Promise<Response> {
  return fetchImpl(new URL("/api/messages", apiUrl), {
    headers: { Authorization: `Bearer ${accessToken}` },
    cache: "no-store",
  });
}
```

A route handler can also set cookies, so it uses `updateSession`, which refreshes an access token that is about to expire:

```ts
  // A route handler can set cookies, so updateSession also refreshes an access token that is about to expire.
  const { accessToken, apply } = await auth.updateSession(request);
  if (!accessToken) return new Response(null, { status: 401 });
```

Do not pass the access token to a Client Component.

## 7. Sign out

Sign-out is a form that posts to `/auth/logout`. The SDK deletes the session cookie, revokes the refresh token and 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.

```tsx
      <form action="/auth/logout" method="post">
        <button type="submit">Sign out</button>
      </form>
```

## 8. Run the app

Start the API from the Express quickstart (or any API that accepts the tokens) and run the app:

```sh
npm run dev
```

Open `http://localhost:3000`, choose **Sign in**, sign in at KeenAuth and open the dashboard.

## 9. Test without a network

The tests need no network and no KeenAuth account. A fake `fetch` plays the authorization server (discovery and the pushed authorization request), and Node runs the TypeScript tests directly.

```ts
test("proxy: a page request without a session is redirected to sign-in with returnTo", async () => {
  const { auth } = app();
  const result = await auth.proxy(new Request(`${APP}/dashboard?tab=1`), { protect: isProtected });
  assert.equal(result.response?.status, 307);
```

```sh
npm test
npm run deps    # pinned versions, lockfile sources and licences
```

The tests check that the configuration is validated at start-up, that a page without a session redirects to sign-in and an API path answers `401`, that sign-in sends a PKCE S256 pushed request and sets a sealed HttpOnly cookie, that a callback with an unknown state is refused, that a sign-out from another site is refused and that the access token goes to the API in the `Authorization` header.

The full sign-in with a real browser needs a KeenAuth environment. It is not part of the offline tests.

## Behind a proxy or load balancer

Set `APP_BASE_URL` to the public URL of the app. The SDK never reads `Host` or `X-Forwarded-*` to build redirects. With several server instances, a refresh token can be refreshed by two of them at once; see [Current limits](/docs/sdk/nextjs#current-limits).

## Next steps

- [Next.js SDK](/docs/sdk/nextjs): every option, the session cookie and the current limits.
- [Protect an Express API](/docs/quickstarts/express): the API this app calls.
- [Verifying tokens](/docs/sdk/token-verification): the verifier under the middleware of your API.
