All posts

Fullstack Authentication with Next.js and Express — A Practical Walkthrough

How I wire up real authentication in a Next.js frontend talking to an Express backend — JWT, refresh tokens, and the gotchas nobody warns you about.

Authentication is one of those things every fullstack developer has built a dozen times and still gets wrong on the thirteenth. This post walks through the shape I use for Next.js frontends talking to Express backends — the pattern that survived real production use without me hating it.

The shape

  • Express backend exposes /auth/login, /auth/refresh, /auth/logout, and /me
  • On login, Express returns a short-lived access token (15 min) and a long-lived refresh token (7–30 days)
  • Access token goes in an HTTP-only cookie. Refresh token also in an HTTP-only cookie, on a separate path
  • Next.js middleware reads the access token cookie and protects routes
  • When the access token expires, a silent refresh call exchanges the refresh token for a new pair

Why access + refresh tokens

A single long-lived token is simple but dangerous — if it leaks, the attacker has weeks of access. A single short-lived token is secure but forces users to log in constantly. The two-token pattern gives you both: short access windows reduce leak blast radius, and refresh tokens let users stay logged in.

The cookie settings that actually matter

res.cookie("access", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 15 * 60 * 1000,
})

Three details that bite people:

  1. httpOnly: true — JavaScript can't read it. This is the point. If your frontend "needs" to read the token, you're doing it wrong.
  2. sameSite: "lax" — protects against CSRF on most requests. Use "strict" if you don't need cross-site navigation to stay logged in.
  3. secure: true in production — cookie only sent over HTTPS. Breaks local dev over plain HTTP unless you flip it conditionally.

Refreshing without a race condition

The classic bug: a user loads a page that fires five API calls in parallel, all hit expired access tokens, and now five refresh requests race each other. The first one succeeds and rotates the refresh token; the other four fail because they're trying to use a token that's been invalidated.

The fix: on the frontend, wrap the refresh call in a promise that's cached while in flight. Every concurrent caller awaits the same promise. When it resolves, they all retry their original request with the fresh access token.

Next.js middleware for route protection

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

export function middleware(req: NextRequest) {
  const token = req.cookies.get("access")?.value
  const isProtected = req.nextUrl.pathname.startsWith("/dashboard")

  if (isProtected && !token) {
    return NextResponse.redirect(new URL("/login", req.url))
  }

  return NextResponse.next()
}

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

Middleware runs on the edge before the page renders. It can't validate the token signature (no secret on the edge by design), but it can check presence — which is enough to redirect unauthenticated users without flashing protected UI.

What not to do

  • Don't store tokens in localStorage. XSS can read them. HTTP-only cookies exist for exactly this reason.
  • Don't put the refresh token in the Authorization header. Cookies are the right carrier.
  • Don't build your own password hashing. Use bcrypt or argon2. This isn't the place to be creative.
  • Don't forget to revoke refresh tokens on logout. Store a jti in your DB and blacklist on logout, or use a short enough window that revocation isn't critical.

Takeaway

Authentication isn't one big decision, it's twenty small ones — and most bugs come from getting the boring ones wrong. HTTP-only cookies, two-token rotation, and a properly-serialised refresh path will save you from 90% of the pain. The other 10% is specific to your product and worth writing down as you discover it.