Skip to main content
Nuxt Secure uses JSON Web Tokens (JWT) for stateless session management combined with Cloudflare Turnstile CAPTCHA to block automated login attempts. Sessions are stored in an HTTP cookie so they survive page refreshes and work during server-side rendering.

Login flow

1

User submits the login form

The login page collects strNombreUsuario (username), strPwd (password), and a Turnstile token generated automatically by the @nuxtjs/turnstile widget when the user solves the challenge.
2

Turnstile token is validated

The API handler posts the token to https://challenges.cloudflare.com/turnstile/v0/siteverify using the server-side TURNSTILE_SECRET_KEY. If validation fails, the request is rejected with HTTP 400 before the database is ever queried.
3

Database lookup by username

Drizzle ORM queries the usuario table using eq(usuario.strNombreUsuario, strNombreUsuario). If no row is found, or the user’s idEstadoUsuario is false (inactive), HTTP 401 is returned.
4

Password verification

bcrypt.compare() compares the submitted plain-text password against the hashed value stored in strPwd. A mismatch returns HTTP 401.
5

JWT is issued

jwt.sign() creates a token containing { id, idPerfil, nombre } signed with JWT_SECRET. The token expires in 8 hours.
6

Token stored in cookie

The client composable writes the token to the auth_token cookie with maxAge: 60 * 60 * 8 (28 800 seconds). useCookie from Nuxt ensures the cookie is accessible both server-side and client-side.
7

User data persisted

The user object (id, nombre, idPerfil, correo, celular, imagenUrl) is serialised to localStorage under the key usuario and stored in useState('usuarioLogueado') for reactive access across all components.
8

Permissions loaded

cargarMisPermisos(idPerfil) fetches /api/permisos/mis-permisos/:idPerfil and stores the result in useState('misPermisos') as a Record<string, PermisosAccion> keyed by module name in uppercase.
9

Redirect to the main page

The router navigates to /principal-1 on success.

Login handler source

server/api/auth/login.post.ts
import { db } from '~~/server/database'
import { usuario } from '~~/server/database/schema'
import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const config = useRuntimeConfig()

  const { strNombreUsuario, strPwd, turnstileToken } = body

  // 1. Validate Cloudflare Turnstile token
  const verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
  const turnstileResponse = await fetch(verifyUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${config.turnstile.secretKey}&response=${turnstileToken}`
  })

  const turnstileData = await turnstileResponse.json()

  if (!turnstileData.success) {
    throw createError({ statusCode: 400, message: 'Fallo en la validación del captcha.' })
  }

  // 2. Look up the user
  const [user] = await db.select()
    .from(usuario)
    .where(eq(usuario.strNombreUsuario, strNombreUsuario))

  // 3. Validate existence and active status
  if (!user || !user.idEstadoUsuario) {
    throw createError({
      statusCode: 401,
      message: 'El usuario no existe o su estado es inactivo.'
    })
  }

  // 4. Validate password
  const isValidPassword = await bcrypt.compare(strPwd, user.strPwd)

  if (!isValidPassword) {
    throw createError({
      statusCode: 401,
      message: 'Usuario o contraseña incorrectos.'
    })
  }

  // 5. Issue JWT
  const token = jwt.sign(
    {
      id: user.id,
      idPerfil: user.idPerfil,
      nombre: user.strNombreUsuario
    },
    config.jwtSecret,
    { expiresIn: '8h' }
  )

  return {
    success: true,
    token: token,
    user: {
      id: user.id,
      nombre: user.strNombreUsuario,
      idPerfil: user.idPerfil,
      correo: user.strCorreo,
      celular: user.strNumeroCelular,
      imagenUrl: user.imagenUrl
    }
  }
})

Global route middleware

app/middleware/auth.global.ts runs before every route navigation — on the server during SSR and on the client during SPA navigation. It enforces three rules:
  1. Root redirect — visiting / always redirects to /login.
  2. Route protection — any route other than /login requires a valid auth_token cookie; missing token redirects to /login.
  3. Double-login prevention — a user who already has a token and tries to visit /login is sent to /principal-1 instead.
app/middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const token = useCookie('auth_token')

  // Redirect root to login
  if (to.path === '/') {
    return navigateTo('/login')
  }

  // Protect all non-login routes
  if (!token.value && to.path !== '/login') {
    return navigateTo('/login')
  }

  // Prevent navigating to login when already authenticated
  if (token.value && to.path === '/login') {
    return navigateTo('/principal-1')
  }
})
useCookie works in both SSR and client contexts. Avoid reading auth_token from document.cookie directly — it will not be available during server rendering.

Session persistence

Nuxt’s useState is reset when the page is hard-refreshed (F5). restaurarSesion() in useAuth.ts handles this by reading the user object back from localStorage and re-fetching permissions if the in-memory state is empty:
app/composables/useAuth.ts
const restaurarSesion = async () => {
  if (!usuarioLogueado.value) {
    const userLocal = localStorage.getItem('usuario')
    if (userLocal) {
      usuarioLogueado.value = JSON.parse(userLocal)

      if (Object.keys(misPermisos.value).length === 0 && usuarioLogueado.value?.idPerfil) {
        await cargarMisPermisos(usuarioLogueado.value.idPerfil)
      }
    }
  }
}
Call restaurarSesion() in the onMounted hook of any page or layout that needs reactive user data after a refresh.

JWT token structure

FieldTypeDescription
idnumberUser’s database row ID
idPerfilnumberProfile (role) ID — used to load permissions
nombrestringUsername (strNombreUsuario)
expnumberUnix timestamp — automatically set to 8 hours from issue time
The secret used to sign and verify tokens is read from the JWT_SECRET environment variable via useRuntimeConfig().jwtSecret.

Security notes

The auth_token cookie is not set with httpOnly by default in the current implementation. If you expose this app to the public internet, consider adding the httpOnly and secure flags to prevent client-side JavaScript from reading the token.
  • bcrypt hashing — passwords are never stored in plain text; bcrypt’s work factor makes brute-force attacks computationally expensive.
  • Turnstile CAPTCHA — the Cloudflare challenge runs before any database query, blocking credential-stuffing bots without storing any user fingerprint.
  • Short token lifetime — the 8-hour expiry limits the window of exposure if a token is leaked.
  • Status check — inactive users (idEstadoUsuario: false) are rejected at login without revealing which check failed.

Build docs developers (and LLMs) love