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.

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

1. Install

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.

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.

/** 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:

// 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.

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:

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.

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

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:

  // 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.

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

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.

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

Next steps