Next.js + Auth.js Integration

Combine Twingate Identity Firewall with Auth.js sessions in Next.js.

This guide combines Twingate Identity Firewall with Auth.js (NextAuth v5) to give your Next.js app session-based authentication. To authenticate a user, the middleware verifies the Twingate JWT and mints an Auth.js session cookie. This gives you auth() in Server Components and useSession() in Client Components. For architecture details and the full JWT reference, see the Identity Firewall for Web Apps overview.

When to Use This Guide

Use the plain Next.js middleware if you only need identity in API route handlers or a few Server Components, and you don’t need Client Components to access user info.

Use this guide if you need useSession() in Client Components, want session persistence across page navigation, or are already using Auth.js in your project.

Prerequisites

  • Next.js 14+ with App Router

Configure the Gateway to Inject the JWT

The Gateway injects no headers by default. On the Web App Resource, add a request header so the Gateway sends the signed JWT on every request. The recommended convention is a Bearer token in the Authorization header:

Header KeyValue Template
AuthorizationBearer {{jwt}}

The header name and format are configurable at the Gateway/Resource level. This guide reads the JWT from Authorization using the Bearer scheme. See Request Headers for the full list of options and template variables.

Install Dependencies

npm install next-auth@beta jose

Auth.js v5 (next-auth@beta) is required.

Generate the AUTH_SECRET that Auth.js uses to encrypt session cookies:

npx auth secret

This creates a .env.local file with a random AUTH_SECRET value.

Add the Middleware

Save this file as src/middleware.ts.

import { NextResponse, type NextRequest } from "next/server";
import { encode, decode } from "next-auth/jwt";
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL(process.env.TWINGATE_JWKS_URL!)
);
const SESSION_COOKIE = process.env.NODE_ENV === "production"
? "__Secure-authjs.session-token"
: "authjs.session-token";
const MAX_SESSION_AGE = 3600; // 1 hour
export async function middleware(request: NextRequest) {
const existingSession = request.cookies.get(SESSION_COOKIE);
if (existingSession) {
// If an existing session is found, attempt to decode and validate it before checking the Twingate JWT.
try {
const token = await decode({
token: existingSession.value,
secret: process.env.AUTH_SECRET!,
salt: SESSION_COOKIE,
});
// If the session token is still valid, continue to the next middleware.
if (token && token.exp && token.exp > Date.now() / 1000) {
return NextResponse.next();
}
} catch {
// Handle caught error but fall through to re-verify the Twingate JWT.
}
}
const auth = request.headers.get("authorization");
if (!auth) {
// No auth header, continue to the next middleware.
return NextResponse.next();
}
const parts = auth.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
return NextResponse.json(
{ error: "Malformed Authorization header" },
{ status: 401 },
);
}
try {
const { payload } = await jwtVerify(parts[1], jwks, {
algorithms: ["ES256"],
requiredClaims: ["exp", "iat"],
clockTolerance: "30s",
});
const user = payload.user as
| { id: string; username: string; groups?: string[] }
| undefined;
if (!user || !user.id || !user.username) {
return NextResponse.json(
{ error: "Invalid token claims" },
{ status: 401 },
);
}
// Encode the user information into a new session token
const sessionToken = await encode({
token: {
sub: user.id,
email: user.username,
name: user.username,
twingateGroups: user.groups ?? [],
},
secret: process.env.AUTH_SECRET!,
salt: SESSION_COOKIE,
maxAge: MAX_SESSION_AGE,
});
// Make the new session cookie visible to downstream server components and
// route handlers within this same request (auth() reads request cookies).
request.cookies.set(SESSION_COOKIE, sessionToken);
// Set the headers to the original request for downstream middleware and route handlers. (current request)
const response = NextResponse.next({
request: { headers: request.headers },
});
// Also set the session cookie on the response so the browser persists it. (future requests)
response.cookies.set(SESSION_COOKIE, sessionToken, {
httpOnly: true,
secure: request.nextUrl.protocol === "https:",
sameSite: "lax",
path: "/",
maxAge: MAX_SESSION_AGE,
});
// Return the response to the client with the new session cookie set
return response;
} catch {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
}
export const config = {
// Don't apply the middleware to static files, images, favicon, and health check endpoint.
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/health).*)"],
};

Add the Auth Configuration

Save this file as src/auth.ts.

import NextAuth from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
twingateGroups: string[];
};
}
}
export const { auth, handlers } = NextAuth({
providers: [], // Session is created by middleware, not a provider
session: { strategy: "jwt" },
callbacks: {
session({ session, token }) {
session.user.id = token.sub!;
session.user.twingateGroups = (token.twingateGroups as string[]) ?? [];
return session;
},
},
});

How It Works

On each request, the middleware first checks for an existing Auth.js session cookie. If the cookie exists and hasn’t expired, the request passes through without re-verifying the Twingate JWT. This avoids redundant verification on every navigation. The cookie name changes between development (authjs.session-token) and production (__Secure-authjs.session-token).

JWT Verification and Session Minting

If no valid session cookie exists, the middleware verifies the Twingate JWT from the Authorization header. On success, it extracts the user’s identity and encodes an Auth.js session token with a 1-hour lifetime. The middleware sets the cookie on both the request (so auth() can read it within the same request) and the response (so the browser persists it for future requests).

Auth.js Configuration

The auth.ts file configures Auth.js with no providers because the session is created entirely by the middleware, not by an OAuth flow. The session callback maps the twingateGroups field from the token into the session object so it’s accessible via auth() and useSession(). The TypeScript module augmentation adds twingateGroups to the Session type.

Set Up the Session Provider

Wrap your root layout with SessionProvider to make the session available to Client Components via the useSession() hook.

// src/app/layout.tsx
import { SessionProvider } from "next-auth/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}

Use Identity in Your App

Server Components

import { auth } from "@/auth";
export default async function ProfilePage() {
const session = await auth();
if (!session) {
return <p>Not authenticated</p>;
}
return (
<div>
<p>Hello, {session.user.name}</p>
<p>Groups: {session.user.twingateGroups.join(", ")}</p>
</div>
);
}

Client Components

"use client";
import { useSession } from "next-auth/react";
export default function Dashboard() {
const { data: session, status } = useSession();
if (status === "loading") return <p>Loading...</p>;
if (!session) return <p>Not authenticated</p>;
return (
<div>
<p>Hello, {session.user.name}</p>
<p>Groups: {session.user.twingateGroups.join(", ")}</p>
</div>
);
}

Group-Based Authorization

import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (!session || !session.user.twingateGroups.includes("admin")) {
redirect("/");
}
return <p>Admin dashboard</p>;
}

Configuration

VariableDescription
TWINGATE_JWKS_URLYour Twingate tenant’s JWKS endpoint: https://<your-tenant>.twingate.com/api/v1/jwk/ec
AUTH_SECRETA random secret used to encrypt session cookies. Generate with npx auth secret.

Both variables go in .env.local for Next.js projects.

Next Steps

Last updated 3 hours ago