By AndyPublished

Next.js JWT Authentication with the App Router: jose, Cookies and proxy.ts

For Next.js JWT authentication, sign and verify tokens with jose, store the token in anHttpOnly cookie set on the server with cookies() fromnext/headers, and verify it wherever data is read or changed: Server Components, Route Handlers and Server Actions. Next.js 16 renamed middleware.ts toproxy.ts; use it for quick redirects, not as your only check. Everything below was built and run with Next.js 16.3.6, React 19.3.0 and jose 6.2.12 on Node.js 22.

How JWT Auth Works in the Next.js App Router

A Next.js app is several servers in one: pages rendered on the server, API routes, Server Actions that the browser calls as POST requests, and the proxy layer that runs before all of them. A JWT gives each of those places a way to identify the user without a database lookup. The login step signs a token; every later request presents it, usually in a cookie; and each entry point verifies the signature, expiry, issuer and audience before trusting any claim.

The mistake that causes most Next.js auth bugs is checking in one place only. The official Next.js authentication guide describes proxy checks as optimistic and recommends putting the secure checks in a data access layer close to the data. The table below is the model this guide follows.

WhereRoleNotes
proxy.ts (middleware.ts before v16)Optimistic redirect for pagesFast and central, but not a security boundary on its own. Only reads the cookie.
Server Components / DALAuthoritative check before reading dataCall getSession() next to the data access. Redirect or return null.
Route Handlers (app/api/.../route.ts)Authoritative check for API callsReturn 401 for no or bad token, 403 for insufficient role.
Server ActionsAuthoritative check for mutationsEach action is a public POST endpoint. Verify inside every action.
Client ComponentsUI onlyCannot read an HttpOnly cookie. Ask the server who the user is.

Why jose and Not jsonwebtoken

Until Next.js 15.5, middleware ran on the Edge runtime (Node.js support was experimental from 15.2), which has Web APIs but not Node.js'scrypto module. jsonwebtoken depends on Node.js crypto, so importing it into middleware.ts failed. jose is built on Web Crypto and runs on Node.js, Edge, Deno, Bun and in browsers. Node.js-runtime middleware became stable in 15.5, and in Next.js 16 the proxy defaults to Node.js. Even so, jose is the sensible single choice: one library works in every runtime your code might end up in, and it is the library the Next.js docs use. If you are coming from Express, the Node.js and Express guide shows the samejose calls outside Next.js.

npm install jose server-only
# 32 random bytes, stored in .env.local (never committed) and in your host's secret store
echo "AUTH_SECRET=$(openssl rand -base64 32)" >> .env.local

Signing and Verifying the Token

Keep the token logic in a plain module with no Next.js imports, so both the proxy and server code can use it. The verify function pins the algorithm, checks issuer and audience, allows 30 seconds of clock skew and returnsnull on any failure.

// lib/jwt.ts
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';

if (!process.env.AUTH_SECRET || process.env.AUTH_SECRET.length < 32) {
  throw new Error('AUTH_SECRET must be set to at least 32 random characters');
}
const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
const ISSUER = 'https://app.example.com';
const AUDIENCE = 'https://app.example.com';

export const ACCESS_COOKIE = 'access_token';

export type AccessClaims = JWTPayload & { sub: string; role: 'user' | 'admin' };

export async function signAccessToken(userId: string, role: AccessClaims['role']) {
  return new SignJWT({ role })
    .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(secret);
}

export async function verifyAccessToken(token: string | undefined): Promise<AccessClaims | null> {
  if (!token) return null;
  try {
    const { payload } = await jwtVerify<AccessClaims>(token, secret, {
      algorithms: ['HS256'],
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30,
    });
    return payload;
  } catch {
    return null; // expired, bad signature, wrong iss/aud, malformed
  }
}

If your tokens come from an external identity provider instead, swap the secret forcreateRemoteJWKSet(new URL(jwksUri)) and algorithms: ['RS256']; the rest is unchanged. See what is JWKS for how key sets work.

In the App Router, cookies() is asynchronous (since Next.js 15), so alwaysawait it. Cookies can only be set from Server Actions and Route Handlers, not while a Server Component is rendering. The server-only import makes the build fail if a Client Component ever imports this file.

// lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
import { ACCESS_COOKIE, signAccessToken, verifyAccessToken, type AccessClaims } from './jwt';

export async function createSession(userId: string, role: AccessClaims['role']) {
  const token = await signAccessToken(userId, role);
  const cookieStore = await cookies();
  cookieStore.set(ACCESS_COOKIE, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 15 * 60, // match the token's exp
  });
}

export async function getSession() {
  const cookieStore = await cookies();
  return verifyAccessToken(cookieStore.get(ACCESS_COOKIE)?.value);
}

export async function deleteSession() {
  const cookieStore = await cookies();
  cookieStore.delete(ACCESS_COOKIE);
}

HttpOnly keeps the token away from any script injected into the page, andSameSite=Lax stops the cookie being sent on cross-site POSTs. The trade-offs againstlocalStorage are laid out in JWT storage: localStorage vs cookie.

A login Route Handler

// app/api/login/route.ts
import { createSession } from '@/lib/session';

// Replace with a real lookup and an argon2/bcrypt password check.
async function checkCredentials(email: string, password: string) {
  return email === 'ada@example.com' && password === 'correct horse'
    ? { id: '42', role: 'admin' as const }
    : null;
}

export async function POST(request: Request) {
  const { email, password } = await request.json().catch(() => ({}));
  const user = await checkCredentials(String(email ?? ''), String(password ?? ''));
  if (!user) {
    return Response.json({ error: 'invalid_credentials' }, { status: 401 });
  }
  await createSession(user.id, user.role);
  return Response.json({ ok: true });
}

The response carries Set-Cookie: access_token=...; Path=/; Max-Age=900; Secure; HttpOnly; SameSite=lax. A Server Action works equally well for a form-based login; call createSession and thenredirect().

Next.js JWT Middleware: proxy.ts in Next.js 16

Next.js 16 deprecated the middleware file convention and renamed it toproxy. The file is proxy.ts in the project root (orsrc/), and it exports a function named proxy (or a default export) plus an optional config.matcher. It runs on the Node.js runtime, and theruntime option is not available in it. The official codemod,npx @next/codemod@canary middleware-to-proxy ., renames both the file and the function.

// proxy.ts  (Next.js 16+; in Next.js 15 name it middleware.ts and export `middleware`)
import { NextResponse, type NextRequest } from 'next/server';
import { verifyAccessToken, ACCESS_COOKIE } from '@/lib/jwt';

export async function proxy(request: NextRequest) {
  const claims = await verifyAccessToken(request.cookies.get(ACCESS_COOKIE)?.value);
  if (!claims) {
    const login = new URL('/login', request.url);
    login.searchParams.set('next', request.nextUrl.pathname);
    return NextResponse.redirect(login);
  }
  return NextResponse.next();
}

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

With this in place, an unauthenticated request to /dashboard received a 307 redirect to/login?next=%2Fdashboard in testing. The build output lists it asProxy (Middleware).

⚠
Do not treat the proxy as your only check. Matchers are easy to get wrong, Server Actions on excluded paths skip it, and CVE-2025-29927 showed that middleware itself can be bypassed: a crafted x-middleware-subrequestheader skipped middleware entirely in unpatched versions (fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5). Verify again wherever data is read or written.

Protecting Route Handlers

Route Handlers are ordinary HTTP endpoints, so they should return 401 rather than redirect. This one accepts either aBearer header, for mobile apps and scripts, or the cookie, for the browser.

// app/api/me/route.ts
import { type NextRequest } from 'next/server';
import { verifyAccessToken, ACCESS_COOKIE } from '@/lib/jwt';

export async function GET(request: NextRequest) {
  const bearer = request.headers.get('authorization')?.match(/^Bearer (.+)$/)?.[1];
  const token = bearer ?? request.cookies.get(ACCESS_COOKIE)?.value;

  const claims = await verifyAccessToken(token);
  if (!claims) {
    return Response.json(
      { error: 'invalid_token' },
      { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } },
    );
  }
  return Response.json({ userId: claims.sub, role: claims.role });
}

Checking the JWT in Server Actions

Every Server Action is reachable by a direct POST, whatever the UI shows. Verify the session and the role inside each action.

// app/actions.ts
'use server';
import { getSession } from '@/lib/session';

export async function deleteProject(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error('Unauthorised');
  if (session.role !== 'admin') throw new Error('Forbidden');

  const projectId = String(formData.get('projectId'));
  // await db.project.delete({ where: { id: projectId, ownerId: session.sub } });
  return { deleted: projectId };
}

Server Components use the same getSession() and call redirect('/login')from next/navigation when it returns null. Avoid doing the check only in a layout: layouts do not re-render on client navigation.

Next.js JWT Decode: Reading Claims on the Client

A Client Component cannot read an HttpOnly cookie, which is the point. To show the user's name or role, fetch it from a route such as /api/me above, or pass it down as props from a Server Component. If you hold a token in JavaScript for another reason (for example, one returned by a third-party API),decodeJwt(token) from jose reads the payload without verifying it. That is fine for display and never acceptable for access decisions; see decoder vs validator. For a quick look while debugging, the jwtdecode.app decoder decodes a pasted token locally in the browser.

Why an Auth.js (NextAuth) session token will not decode

If you use Auth.js (NextAuth.js), its default jwt session strategy stores an encrypted token, a JWE (RFC 7516), not a signed JWS. In the current @auth/corecode the header is alg: "dir", enc: "A256CBC-HS512", with the key derived from AUTH_SECRET. The cookie is named authjs.session-token(__Secure-authjs.session-token over HTTPS; NextAuth v4 usednext-auth.session-token). It has five dot-separated parts and cannot be read by a JWS decoder, including jwtdecode.app, which rejects five-part tokens. On the server, read it with auth() orgetToken(). JWS vs JWE explains the format difference.

Next.js JWT Refresh Tokens

With a 15-minute access cookie, users need silent renewal. Issue a second, opaque refresh token at login, store its hash in your database, and set it in its own cookie with httpOnly, secure,sameSite: 'strict' and path: '/api/auth/refresh' so it is only sent to the refresh endpoint. That Route Handler looks up the hash, rotates the refresh token, and callscreateSession() again. The client retries once on a 401 after calling refresh.

Do not refresh from inside the proxy by rewriting cookies on every request: it runs on prefetches and static asset requests too, and the Next.js docs advise against database calls there. Logout deletes both cookies and the stored refresh token. The full rotation and reuse-detection design is in the refresh token pattern guide, and revocation options are in JWT logout and revocation.

JWT in Next.js 15 vs Next.js 16

  • ·File name: middleware.ts exporting middleware in 15; proxy.ts exporting proxy in 16. The old name is deprecated.
  • ·Runtime: Edge by default in 15 (Node.js opt-in, stable from 15.5); Node.js in 16's proxy.
  • ·Request APIs: cookies() and headers() are async in both. Code written for Next.js 14 that calls cookies().get() synchronously needs an await.
  • ·Token code: the jose sign and verify code above is identical in both.

Common Problems

  • ·Everyone is logged out after deploying: AUTH_SECRET differs between build machines or instances, or is missing. The startup check in lib/jwt.ts turns a missing secret into a clear error instead of a silent 401 on every request.
  • ·Cookie never appears: it was set during Server Component rendering (not allowed), or secure: true was used over plain HTTP on a non-localhost address.
  • ·Login works, API calls from another origin fail: SameSite=Lax cookies are not sent on cross-site fetches. Send a Bearer header for those clients instead.
  • ·Token rejected with a valid signature: check iss, aud and the server clock. The JWT debugging routine walks through each check in order.

Summary

Next.js JWT authentication in the App Router is a small jose module that signs 15-minute HS256 (or verifies provider RS256) tokens with a pinned algorithm, issuer and audience; a session module that stores the token in an HttpOnly cookie via await cookies(); aproxy.ts (formerly middleware.ts) for optimistic redirects; and a real verification in every Route Handler, Server Action and data access function. Add an opaque, rotating refresh token for renewal, and remember that Auth.js session cookies are encrypted JWEs rather than readable JWTs. For broader hardening, see JWT security best practices.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder