Next.js Middleware

Verify Twingate Identity Firewall JWTs in Next.js Edge Middleware.

This guide shows how to verify Twingate Identity Firewall JWTs in Next.js using the jose library. A thin Edge Middleware gates the routes you choose, and a small helper verifies the JWT wherever you need the user’s identity. For architecture details and the full JWT reference, see the Identity Firewall for Web Apps overview.

For session-based integration with useSession() and auth(), see the Next.js + Auth.js guide.

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. The examples below read the JWT from Authorization using the Bearer scheme. If you configure a different header, adjust the helper to read that header instead. See Request Headers for the full list of options and template variables.

Install Dependencies

npm install jose

jose runs in the Edge Runtime, which is required for Next.js middleware.

Add the Verification Helper

Save this file as src/twingate.ts. It verifies the JWT and returns the identity, and you reuse it in both the middleware and your route handlers and Server Components.

import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const jwks = createRemoteJWKSet(new URL(process.env.TWINGATE_JWKS_URL!));
export interface TwingateIdentity extends JWTPayload {
user: {
id: string;
username: string;
email?: string;
groups: string[];
};
device?: { id: string };
resource?: { id: string; type: string; address: string; aliases: string[] };
}
// Reads the JWT from the Authorization header (Bearer scheme) and verifies it.
// Returns the identity on success, or null when the header is missing,
// malformed, or the token fails verification.
export async function verifyTwingateJWT(
authHeader: string | null,
): Promise<TwingateIdentity | null> {
if (!authHeader) return null;
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
return null;
}
try {
const { payload } = await jwtVerify(parts[1], jwks, {
algorithms: ["ES256"],
requiredClaims: ["exp", "iat"],
clockTolerance: "30s",
});
return payload as TwingateIdentity;
} catch {
return null;
}
}

Add the Middleware

The middleware is a thin gate: it verifies the JWT on the routes you match and returns 401 when the token is missing or invalid. It does not forward identity downstream. Your handlers and Server Components re-verify with the same helper, which is cheap because jose caches the JWKS.

Save this file as src/middleware.ts (or middleware.ts at your project root).

import { NextResponse, type NextRequest } from "next/server";
import { verifyTwingateJWT } from "@/twingate";
export async function middleware(request: NextRequest) {
const identity = await verifyTwingateJWT(
request.headers.get("authorization"),
);
if (!identity) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*"],
};

Toggle Auth per Route

config.matcher controls which routes the middleware gates. List one or more patterns to protect only those paths:

export const config = {
matcher: ["/api/:path*", "/dashboard/:path*"],
};

Or invert it with a negative lookahead to gate everything except public paths such as health checks and static assets:

export const config = {
matcher: ["/((?!health|_next/static|_next/image|favicon.ico).*)"],
};

For finer control than path patterns allow, branch inside the middleware on request.nextUrl.pathname and return early for routes that should stay public:

export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/public")) {
return NextResponse.next();
}
// ...verify and gate
}

How It Works

JWKS and Verification

createRemoteJWKSet fetches and caches the Gateway’s public keys, so verifyTwingateJWT checks the ES256 signature against a cached key with no network call after the first. It requires the exp and iat claims and allows 30 seconds of clock tolerance, returning the identity on success or null on any failure.

Middleware and Helper

The middleware gates matched routes with a 401. The helper is what actually reads identity, and you call it again in your handlers and Server Components, re-verifying there rather than forwarding an identity header from the middleware. This keeps the verified JWT as the single source of truth for who the user is.

Read Identity in Route Handlers

Call the helper with the request’s Authorization header and the Gateway attaches the JWT to every request. This allows you to get the verified identity:

import { NextResponse, type NextRequest } from "next/server";
import { verifyTwingateJWT } from "@/twingate";
export async function GET(request: NextRequest) {
const identity = await verifyTwingateJWT(
request.headers.get("authorization"),
);
if (!identity) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({
username: identity.user.username,
groups: identity.user.groups,
});
}

Read the identity in a Server Component with next/headers:

import { headers } from "next/headers";
import { verifyTwingateJWT } from "@/twingate";
export default async function DashboardPage() {
const headersList = await headers();
const identity = await verifyTwingateJWT(
headersList.get("authorization"),
);
if (!identity) {
return <p>Not authenticated</p>;
}
return <p>Hello, {identity.user.username}</p>;
}

Group-Based Authorization

import { NextResponse, type NextRequest } from "next/server";
import { verifyTwingateJWT } from "@/twingate";
export async function GET(request: NextRequest) {
const identity = await verifyTwingateJWT(
request.headers.get("authorization"),
);
if (!identity) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!identity.user.groups.includes("engineering")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json({ message: "Welcome" });
}

Configuration

VariableDescription
TWINGATE_JWKS_URLYour Twingate tenant’s JWKS endpoint: https://<your-tenant>.twingate.com/api/v1/jwk/ec

Add this variable to .env.local in your Next.js project root.

Next Steps

Last updated 3 hours ago