"Sessions or JWTs?" is a question with a right answer for most web apps and a lot of confident wrong ones. Together with "how do you protect against CSRF" and "where do you check permissions" it forms the authentication block of nearly every fullstack interview, and Nuxt's documented recipe (nuxt-auth-utils) gives you a concrete implementation to point at.
nuxt-auth-utils with NUXT_SESSION_PASSWORD, built on h3's useSession); a JWT is a signed, base64 token typically sent as a bearer header (stateless, verifiable by any service, hard to revoke, and dangerous when stored in localStorage).alg: none or algorithm confusion (pin the algorithm), not validating iss, aud, exp, nbf, putting sensitive data in the payload (it is only base64). If you use JWTs for a browser app: short-lived access tokens in memory, a rotating refresh token in an HttpOnly cookie, and a server-side denylist for logout.HttpOnly, Secure, SameSite=Lax, __Host- cookie session (sealed or server-side), because SSR needs the cookie to render authenticated pages, useRequestFetch forwards it to the API, and no token is ever exposed to JavaScript. JWTs belong at service-to-service boundaries and when a third party issues them (OIDC id tokens).state against CSRF on the callback, nonce in the id token; exchange the code on the server, create your own session, never forward provider tokens to the browser. nuxt-auth-utils ships defineOAuth<Provider>EventHandler helpers for the common providers.nuxt-auth-utils includes WebAuthn registration and authentication helpers. Offer them alongside passwords in 2026, with recovery paths.hashPassword/verifyPassword helpers use scrypt) or bcrypt with a proper cost; never MD5/SHA; check against breached-password lists; rate limit and add exponential delays on login; generic error messages ("invalid credentials"); MFA (TOTP, passkeys) for sensitive accounts; WCAG 2.2's Accessible Authentication rule forbids cognitive tests without alternatives.secure cookies only.SameSite=Lax (blocks cross-site POSTs by default), origin verification (Origin or Sec-Fetch-Site header must be same-site for state-changing requests), synchroniser or double-submit tokens (a per-session token in a hidden field or header compared on the server), and requiring a custom header for JSON APIs (a preflight then guards it). In Nuxt: nuxt-security's CSRF option or nuxt-csurf, and never state changes on GET.requireUserSession(event) authenticates; then the handler checks that this user may act on this object (order.userId === user.id, or a role/permission check). Broken object-level authorisation (BOLA/IDOR) is the number one API vulnerability. Model permissions as data (roles, or attribute rules), centralise checks in server utils, and deny by default. Mass assignment: validate PATCH bodies with a schema that lists writable fields (schema.pick), never spread the body into an update.The Nuxt recipe hardened: login with rate limiting, session regeneration, a CSRF origin check, and object-level authorisation:
import { z } from 'zod'
const body = z.object({ email: z.string().email(), password: z.string().min(8).max(200) })
export default defineEventHandler(async (event) => {
assertSameSite(event) // CSRF: Origin / Sec-Fetch-Site check for state changes
const { email, password } = await readValidatedBody(event, body.parse)
await assertRateLimit(event, `login:${email}`, { max: 5, windowSec: 300 }) // brute force
const user = await useDb().users.byEmail(email)
const ok = user ? await verifyPassword(user.passwordHash, password) : await verifyPassword(DUMMY_HASH, password) // constant time-ish: same work for unknown users
if (!user || !ok) throw createError({ status: 401, statusText: 'Invalid credentials' }) // generic message
await clearUserSession(event) // fixation: never reuse a pre-login session
await setUserSession(event, { user: { id: user.id, role: user.role }, loggedInAt: Date.now() })
return { ok: true }
})
export function assertSameSite(event: H3Event) {
const method = event.method
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return
const site = getHeader(event, 'sec-fetch-site') // 'same-origin' | 'same-site' | 'cross-site' | 'none'
const origin = getHeader(event, 'origin')
const allowed = useRuntimeConfig(event).public.siteUrl
if (site === 'cross-site' || (origin && origin !== allowed)) throw createError({ status: 403, statusText: 'Cross-site request rejected' })
}
import { z } from 'zod'
const patch = z.object({ note: z.string().max(500).optional(), shippingAddressId: z.string().uuid().optional() }) // writable fields only: no `status`, no `total`
export default defineEventHandler(async (event) => {
assertSameSite(event)
const { user } = await requireUserSession(event) // authentication
const id = getRouterParam(event, 'id')!
const order = await useDb().orders.find(id)
if (!order || order.userId !== user.id) throw createError({ status: 404, statusText: 'Not found' }) // object-level authorisation; 404 avoids leaking existence
const changes = await readValidatedBody(event, patch.parse) // mass assignment blocked by the schema
return useDb().orders.update(id, changes)
})
OAuth with PKCE via the module's helper, creating our own session rather than exposing provider tokens:
export default defineOAuthGitHubEventHandler({
config: { scope: ['user:email'] },
async onSuccess(event, { user: ghUser }) {
const user = await useDb().users.upsertFromProvider('github', ghUser.id, ghUser.email)
await clearUserSession(event)
await setUserSession(event, { user: { id: user.id, role: user.role } })
return sendRedirect(event, '/')
},
onError(event) {
return sendRedirect(event, '/login?error=oauth')
},
})
Client side, the middleware is for experience only:
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
It is readable by any script on the origin, so one XSS becomes full account takeover for as long as the token lives, and there is no server-side way to revoke it. Browser sessions belong in HttpOnly cookies; if a third party requires a bearer token, keep it in memory, short-lived, refreshed through a cookie-protected endpoint.
if (user.role !== 'customer') throw 403 and then orders.find(params.id) lets every customer read every order by guessing ids: broken object-level authorisation, the most common API vulnerability. The check is "does this user own or may access this object", on every request that touches it.
The current nuxt-auth-utils API (setUserSession, requireUserSession, clearUserSession, hashPassword, WebAuthn helpers, OAuth handler names) and the session password requirement.
Sec-Fetch-Site check rejects it and that SameSite=Lax already withheld the cookie on a cross-site POST.PATCH route that spreads the body into the update, send { "status": "paid" }, then fix it with schema.pick and observe the 400."Cookie sessions, HttpOnly, Secure, SameSite=Lax with the __Host- prefix, either server-side with an id or sealed into the cookie as nuxt-auth-utils does. Server rendering needs the cookie to render authenticated pages and forward the identity to the API with useRequestFetch, no token is ever exposed to JavaScript, and revocation is straightforward. JWTs in the browser invite the classic mistakes: storage in localStorage where XSS can read them, long lifetimes with no revocation, weak validation of algorithm and claims. Where I do use JWTs is between services and when an identity provider issues them: I run the OIDC authorization code flow with PKCE on the server, verify the id token, and create my own session cookie rather than hand the provider's tokens to the browser."
"CSRF in layers: SameSite=Lax on the session cookie stops the browser attaching it to cross-site POSTs; the server verifies Sec-Fetch-Site or Origin on every state-changing request; JSON APIs require a custom header so a preflight guards them; and where forms post from other origins by design, a synchroniser token. Nothing changes state on GET. Authorisation happens on the server for every request and every object: requireUserSession authenticates, then the handler checks that this user may act on this specific record, returning 404 rather than 403 when existence itself is sensitive, and PATCH bodies are validated against a schema of writable fields so nobody sets their own order to paid. Route middleware only decides what the user sees."
Nothing yet. Notes filed here show up on this page and the section overview.
CSP and security headers
Content Security Policy in practice — directives, nonces versus hashes versus strict-dynamic, report-only rollout, how Nuxt's inline payload and scripts interact with it and what nuxt-security automates — plus HSTS, nosniff, Referrer-Policy, Permissions-Policy, frame-ancestors, the cross-origin isolation headers and subresource integrity.
Server-side risks in Nitro routes
Injection and parameterised queries, SSRF through user-supplied URLs and image proxies, path traversal, prototype pollution, ReDoS, open redirects, file uploads, secrets and configuration, error handling that does not leak, logging without PII, timing-safe comparisons, webhooks, and denial-of-service by unbounded input.