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
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - A web application in the environment (Create an application) with
private_key_jwtor a client secret as client authentication. In adevelopmentenvironment, register the redirect URIhttp://localhost/auth/callbackand the post-logout redirect URIhttp://localhost/; a loopback URI is registered without a port and matches any port, such as3000. - An API registered in the environment (Create an API) with the
messages:readscope. Itsidentifier, for examplehttps://api.example.com, is the audience of its access tokens. The Express quickstart builds an API that accepts these tokens. - Node.js 22.18 or later.
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
- Next.js SDK: every option, the session cookie and the current limits.
- Protect an Express API: the API this app calls.
- Verifying tokens: the verifier under the middleware of your API.