Next.js Authentication with NamoID Auth
Add hosted authentication to a Next.js App Router project with NamoID Auth, secure callback handling, HttpOnly sessions, and protected routes.
Authentication in Next.js looks simple until your app has to own the parts around the form: one-time transactions, callback validation, session storage, logout, and the failure paths between them. A login page is the visible bit. The security work happens after the button is clicked.
This guide adds NamoID Auth to a Next.js App Router project using @namoidhq/nextjs. By the end, your app will redirect to a hosted sign-in page, receive the user through a validated callback, keep the resulting session in an HttpOnly cookie, protect a server-rendered page, and sign out cleanly.
What you will build
The browser moves through four boundaries:
Browser Your Next.js app NamoID Auth
| | |
| GET /api/auth/login | |
|---------------------->| create transaction |
| |-------------------------->|
|<----------------------| redirect to hosted UI |
| |
| sign in / sign up / waitlist |
|-------------------------------------------------->|
| |
|<--------------------------------------------------|
| redirect with one-time code + state |
| | |
| GET callback | validate + exchange code |
|---------------------->|-------------------------->|
| |<--------------------------|
|<----------------------| set HttpOnly app cookie |
| | |
| GET /dashboard | validate access token |
|---------------------->| |Your application never renders or processes a password. NamoID owns the authentication UI and method policy; your Next.js app owns its own session boundary and decides what an authenticated user may access.
Prerequisites
You need:
- Node.js 20 or later and a Next.js App Router project.
- A NamoID project and environment.
- A confidential Web application with one exact callback URL.
- Its Client ID and Client Secret.
Use the Test environment while integrating. Test and Live applications have separate credentials and issuers, which keeps development traffic separate from production users. The NamoID environment model explains why that boundary matters.
Install the SDK
Install the Next.js package and the shared server helpers:
npm install @namoidhq/nextjs @namoidhq/jsAdd the environment values to .env.local:
NAMOID_CLIENT_ID=namoid_client_test_replace_me
NAMOID_CLIENT_SECRET=replace_me
NEXT_PUBLIC_APP_URL=http://localhost:3000NAMOID_CLIENT_SECRET must stay on the server. Do not prefix it with NEXT_PUBLIC_, put it in browser code, or commit it to Git. The Client ID is safe to identify the application, but it is not a credential. The SDK resolves the environment issuer from that Client ID and then uses OpenID Connect discovery rather than hard-coded endpoints.
Configure one server client
Create one shared client rather than repeating configuration in every route:
// lib/namoid.ts
import { createNamoIDNextClient } from "@namoidhq/nextjs";
export const namoid = createNamoIDNextClient({
clientId: process.env.NAMOID_CLIENT_ID!,
clientSecret: process.env.NAMOID_CLIENT_SECRET!,
appBaseUrl: process.env.NEXT_PUBLIC_APP_URL!,
callbackPath: "/api/auth/callback/namoid",
postLoginRedirectPath: "/dashboard",
postLogoutRedirectPath: "/",
});The SDK creates a short-lived transaction and stores its state, nonce, and PKCE verifier in HttpOnly, SameSite=Lax cookies. That binds the callback to the browser that started sign-in and the ID token to the ceremony. You do not need to construct or persist these protocol parameters yourself.
Start hosted sign-in
Add a route handler that starts the transaction and redirects the browser:
// app/api/auth/login/route.ts
import { namoid } from "@/lib/namoid";
export const GET = () => namoid.login();Then link to it from any server or client component:
<a href="/api/auth/login">Sign in</a>You can preserve a safe, relative destination after login:
export const GET = () => namoid.login({ returnTo: "/settings" });The SDK rejects absolute returnTo values. That small constraint prevents your login route from becoming an open redirect.
Handle the callback
The callback verifies state and the authorization-response issuer, exchanges the one-time code with PKCE, validates the signed ID token and nonce, loads UserInfo, and verifies that both identities have the same subject. Its onSuccess hook is where your application creates its own session:
// app/api/auth/callback/namoid/route.ts
import { NextResponse } from "next/server";
import { namoid } from "@/lib/namoid";
export const GET = (request: Request) =>
namoid.callback(request, {
async onSuccess({ tokens, transaction }) {
const response = NextResponse.redirect(
new URL(transaction.returnTo, request.url),
);
// This compact example keeps the access token in an HttpOnly cookie.
// Production applications should normally store the full token set in a
// protected server-side session and put only an opaque session ID here.
response.cookies.set("app_session", tokens.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: tokens.expires_in ?? 900,
});
if (tokens.id_token) {
response.cookies.set("app_id_token", tokens.id_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: tokens.expires_in ?? 900,
});
}
return response;
},
});For a larger application, store an opaque session ID in the cookie and keep tokens in a server-side session store. The direct HttpOnly access-token cookie above is intentionally small and runnable, but it expires with the access token and cannot hold application-specific session state.
Do not put the access token in localStorage or a readable client-side cookie. An HttpOnly cookie keeps normal browser JavaScript from reading it, while Secure ensures production browsers send it only over HTTPS. Next.js documents the cookie API and its security options in the official cookies reference.
Protect a server-rendered page
Checking that a cookie exists is not authentication. Validate the token before trusting it:
// app/dashboard/page.tsx
import { createNamoIDClient } from "@namoidhq/js";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const token = (await cookies()).get("app_session")?.value;
if (!token) redirect("/api/auth/login");
const client = createNamoIDClient({
clientId: process.env.NAMOID_CLIENT_ID!,
});
const identity = await client.hostedAuth
.userInfo(token)
.catch(() => null);
if (!identity) redirect("/api/auth/login");
return <main>Signed in as {identity.sub}</main>;
}UserInfo is an online check: NamoID validates the access token against the application session and returns the authenticated subject. Build authorization on those verified values, not on fields decoded from an unverified JWT. If you validate JWTs locally instead, follow the full JWT validation order: pin the algorithm, verify the signature, then check issuer, audience, time claims, token use, and session semantics appropriate to your application.
Sign out both sessions
Your app has a local cookie, and the hosted page may have a NamoID session. Clear both:
// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { namoid } from "@/lib/namoid";
export async function GET() {
const cookieStore = await cookies();
const hostedLogout = await namoid.logout({
accessToken: cookieStore.get("app_session")?.value,
idTokenHint: cookieStore.get("app_id_token")?.value,
postLogoutRedirectUri: `${process.env.NEXT_PUBLIC_APP_URL}/`,
});
const response = NextResponse.redirect(hostedLogout.headers.get("location")!);
response.cookies.delete("app_session");
response.cookies.delete("app_id_token");
return response;
}Add a normal link or form action to /api/auth/logout. Clearing only the application cookie signs the user out of your app, but a still-active hosted session can make the next sign-in complete immediately. Clearing both produces the behavior users expect from “Sign out.”
Verify the integration
Start Next.js, open an incognito window, and visit the login route:
npm run devCheck these behaviors, not just the happy path:
/api/auth/loginredirects to your environment's branded NamoID Auth page.- A successful sign-in returns to
/dashboardand setsapp_sessionasHttpOnly. - Opening
/dashboardwithout that cookie starts sign-in. - Changing or removing the callback
statefails instead of creating a session. /api/auth/logoutclears the local cookie and redirects through hosted sign-out.
The route handlers use standard App Router primitives; see the official Next.js Route Handlers guide for runtime and caching behavior.
Common integration errors
The callback returns invalid_oidc_state
The transaction cookie is missing, expired, or belongs to another browser tab or origin. Start again at /api/auth/login; do not bookmark the hosted callback URL. Also confirm a proxy is not stripping Set-Cookie from the initial redirect.
The client is rejected
Confirm the Client ID and Client Secret belong to the same application and environment. Test and Live credentials are deliberately not interchangeable. If you rotated the secret, update the deployment secret and restart the server process that reads it.
Login works locally but loops in production
Set NEXT_PUBLIC_APP_URL to the exact public origin, including https:// and excluding an extra path. The SDK marks transaction cookies Secure when the app URL uses HTTPS. Forwarded host or protocol headers must also reflect the browser-facing origin when a reverse proxy sits in front of Next.js.
The user returns signed in but your app still shows a guest
NamoID has authenticated the user, but your callback did not create an application session. Hosted authentication and your app session are separate boundaries. Use onSuccess to set an HttpOnly cookie or create a record in your session store before redirecting.
Why use the SDK instead of wiring the flow directly?
You can call the Hosted Auth endpoints yourself, but then your application owns transaction expiry, state comparison, callback errors, code exchange, token validation, safe redirects, cookie attributes, and cleanup. These are small pieces with security consequences, and most are invisible during a successful demo.
@namoidhq/nextjs keeps that protocol plumbing in one reviewed package while leaving the important product decision with you: what counts as an application session and what the signed-in user may do. NamoID Auth hosts the sign-in experience and method policy; your app remains in control of authorization.
Create a Test project in the NamoID Console, register the exact callback URL, copy the application Client ID and Client Secret, and use this guide to get the first protected Next.js route running.