Next.js Authentication with NamoID Auth
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.
- The environment's hosted-auth URL.
- An auth secret key with a
namoid_auth_sk_test_ornamoid_auth_sk_live_prefix.
Use the Test environment while integrating. A Test key cannot authenticate against Live, 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_HOSTED_AUTH_URL=https://your-project-test.id.namoid.in
NAMOID_AUTH_SECRET_KEY=namoid_auth_sk_test_replace_me
NEXT_PUBLIC_APP_URL=http://localhost:3000NAMOID_AUTH_SECRET_KEY must stay on the server. Do not prefix it with NEXT_PUBLIC_, put it in browser code, or commit it to Git. NamoID also issues publishable keys for browser-safe configuration, but the Next.js callback uses the secret key to exchange the one-time code and validate the returned access token.
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({
hostedAuthBaseUrl: process.env.NAMOID_HOSTED_AUTH_URL!,
authSecretKey: process.env.NAMOID_AUTH_SECRET_KEY!,
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 in an HttpOnly, SameSite=Lax cookie. That binds the callback to the browser that started sign-in. You do not need to construct or persist these 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 the transaction state, exchanges the one-time hosted-auth code, and validates the returned access token. 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),
);
response.cookies.set("app_session", tokens.access_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 { validateAuthToken } from "@namoidhq/js/server";
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 result = await validateAuthToken({
token,
apiKey: process.env.NAMOID_AUTH_SECRET_KEY!,
});
if (!result.valid || !result.user_id) {
redirect("/api/auth/login");
}
return <main>Signed in as {result.user_id}</main>;
}The server helper returns a validated user_id, session_id, scopes, and an error code when validation fails. 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, and time claims.
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 { namoid } from "@/lib/namoid";
export async function GET() {
const hostedLogout = await namoid.logout({
postLogoutRedirectUri: `${process.env.NEXT_PUBLIC_APP_URL}/`,
});
const response = NextResponse.redirect(hostedLogout.headers.get("location")!);
response.cookies.delete("app_session");
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_hosted_auth_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 key is rejected
Confirm the key belongs to the same project environment as NAMOID_HOSTED_AUTH_URL. Test and Live keys are deliberately not interchangeable. If you rotated the key, 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, copy its hosted-auth URL and auth secret key, and use this guide to get the first protected Next.js route running.