Web security

Authentication, sessions and CSRF

Server sessions versus stateless sealed cookies versus JWTs and where each breaks, OAuth 2.1 and OIDC with PKCE, passkeys, password hashing and login hardening, CSRF defences from SameSite to origin checks and tokens, and authorisation done on the server for every object.

"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.

Know

  • Session cookie models: a server-side session stores state in a database or KV and the cookie holds only an id (revocable, small cookie, needs storage); a stateless sealed cookie encrypts and signs the session data into the cookie itself (no storage, revocation only by expiry or a version bump; this is 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).
  • JWT pitfalls: storing it where scripts can read it (XSS exfiltration), long lifetimes without revocation, accepting 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.
  • Recommendation for a Nuxt app: an 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).
  • OAuth 2.1 / OIDC: the authorization code flow with PKCE for every client type (the implicit flow is gone); 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.
  • Passkeys (WebAuthn): phishing-resistant, no shared secret, synced across devices by platform vendors; nuxt-auth-utils includes WebAuthn registration and authentication helpers. Offer them alongside passwords in 2026, with recovery paths.
  • Passwords: hash with argon2id or scrypt (the 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.
  • Session hygiene: regenerate the session id on login (fixation), absolute and idle timeouts, invalidate on password change and logout (server-side for stored sessions; version bump for sealed), bind to nothing brittle (IP changes on mobile), secure cookies only.
  • CSRF: a cross-site page can make the browser send a state-changing request with the victim's cookies. Defences, in layers: 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.
  • Authorisation is per request, per object, on the server. Route middleware hides pages; 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.
  • Logout everywhere, account recovery, email change are the flows attackers target; treat them as authentication events with re-authentication and notifications.

How it works

The Nuxt recipe hardened: login with rate limiting, session regeneration, a CSRF origin check, and object-level authorisation:

server/api/login.post.ts
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 }
})
server/utils/csrf.ts
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' })
}
server/api/orders/[id].patch.ts
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:

server/routes/auth/github.get.ts
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:

app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { loggedIn } = useUserSession()
  if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
Gotcha· A JWT in localStorage

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.

Gotcha· Checking the role but not the object

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.

Verify before the interview:

The current nuxt-auth-utils API (setUserSession, requireUserSession, clearUserSession, hashPassword, WebAuthn helpers, OAuth handler names) and the session password requirement.

docs ↗

Exercise

Exercise
  • Implement the hardened login route and hit it with a script from another origin using the victim's cookies; confirm the Sec-Fetch-Site check rejects it and that SameSite=Lax already withheld the cookie on a cross-site POST.
  • Write a PATCH route that spreads the body into the update, send { "status": "paid" }, then fix it with schema.pick and observe the 400.
  • Add passkey registration with the module's WebAuthn helpers and log in with Touch ID or a phone; compare the flow with the password path.

Be able to say

Be able to say· Sessions or JWTs for a Nuxt application?

"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."

Be able to say· How do you defend against CSRF, and how do you handle authorisation?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.