Authentication

User login, registration, and password management flows

This page is for frontend developers wiring up authentication in a storefront. It covers buyer-side identity — login, registration, password recovery for the people placing orders.

Two different identity systems, easy to mix up: buyers in storefronts authenticate through Customers, the platform's buyer identity reached via the gateway. Admin users in Cockpit sign in through ID (id.revenexx.com), the platform's SSO. This page is about buyers only. If you're integrating Cockpit / Console / SSO, that's a different topic and not covered here.

You don't roll your own password hashing or JWT signing — the platform SDK handles that. Your job is the UI (forms, validation, error states) and the BFF endpoints that call the SDK. The code examples below show the pattern; the actual primitives (bcrypt, jwt.sign, the raw token store) live inside the SDK, not in your storefront code.

The flows on this page — login, registration, forgot/reset password — are what the default template provides. Every B2B project ends up extending at least one of them: tying registration to a customer-approval workflow, adding SAML for enterprise customers, supporting magic-link login, or restricting login to invited users only.

Authentication Architecture

text
┌──────────────────┐
│  Storefront      │
│  (Login UI)      │
└────────┬─────────┘
         │
    HTTP/HTTPS
         │
┌────────▼────────────────┐
│  Auth Server (Cockpit)  │
│  - Validate credentials │
│  - Issue JWT tokens     │
│  - Tenant isolation     │
└────────┬────────────────┘
         │
┌────────▼────────────────┐
│  Platform data          │
│  - Users                │
│  - Tenant scoped        │
└─────────────────────────┘

Login Flow

Login has three parts, and it's worth seeing them as a chain: the browser collects credentials, a BFF endpoint verifies them and mints a token, and that token then rides along on every subsequent request to prove who the user is. The code below walks each part in turn; the thing to watch is that the password only ever exists on the server side of that chain.

1. User Enters Credentials

The form is deliberately ordinary — an email field, a password field, and links out to the forgot-password and registration flows. Its only real job is to collect the two values and POST them to the BFF, while showing a loading state and surfacing an error if the call fails:

Vue
<template>
  <form @submit="handleLogin">
    <input 
      v-model="email" 
      type="email" 
      placeholder="your@email.com" 
      required
    />
    <input 
      v-model="password" 
      type="password" 
      placeholder="Password" 
      required
    />
    <button type="submit" :disabled="loading">Sign In</button>
    <a href="/forgot-password">Forgot password?</a>
    <p>Don't have an account? <a href="/register">Sign up</a></p>
  </form>
</template>

<script setup>
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')

const handleLogin = async () => {
  loading.value = true
  error.value = ''
  
  try {
    const { token, user } = await $fetch('/api/auth/login', {
      method: 'POST',
      body: { email, password }
    })
    
    // Store token
    sessionStorage.setItem('auth_token', token)
    
    // Redirect to account page
    navigateTo('/account')
  } catch (err) {
    error.value = err.data.message || 'Invalid email or password'
  } finally {
    loading.value = false
  }
}
</script>

2. Server-Side Authentication

This is where the actual verification happens, and where the security-sensitive work stays. The route looks the user up (automatically scoped to the tenant, so a login can only match a user in the current tenant), compares the submitted password against the stored hash, checks the account is active, and only then signs a JWT carrying the user's identity and tenant. Note the deliberately vague "invalid email or password" message in both failure branches — it avoids telling an attacker whether an email exists.

Backend Route: /api/auth/login

TypeScript
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)
  
  // Fetch user from Cockpit (automatically scoped to the tenant)
  const user = await cockpit.users.findByEmail(email)
  if (!user) {
    throw createError({
      statusCode: 401,
      message: 'Invalid email or password'
    })
  }
  
  // Verify password
  const passwordValid = await bcrypt.compare(password, user.password_hash)
  if (!passwordValid) {
    throw createError({
      statusCode: 401,
      message: 'Invalid email or password'
    })
  }
  
  // Check if account is active
  if (user.status !== 'active') {
    throw createError({
      statusCode: 403,
      message: 'Account is inactive. Contact support.'
    })
  }
  
  // Generate JWT token
  const token = jwt.sign(
    {
      user_id: user.id,
      tenant_id: user.tenant_id,
      email: user.email,
      role: user.role
    },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  )
  
  // Set secure httpOnly cookie (optional, for session)
  setCookie(event, 'auth_token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 // 7 days
  })
  
  return {
    token,
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role
    }
  }
})

3. Token Storage & Usage

Once you have a token, where you keep it is a genuine security decision rather than a detail. Storing it in localStorage is simple and works, but the token is readable by any JavaScript on the page — so a single XSS bug hands it to an attacker:

TypeScript
localStorage.setItem('auth_token', token)

The safer option, and the recommended one, is an httpOnly cookie. The server sets it, JavaScript can't read it (which neutralizes the XSS risk), and the browser attaches it to every request automatically — at the cost of needing CSRF protection, which you want anyway:

TypeScript
// Server sets httpOnly cookie automatically (no JS access)
// Browser sends it automatically with all requests

When you do read the token in JS — for the localStorage approach — you attach it as a bearer header on outgoing requests, which is cleanest to centralize in a $fetch wrapper so you set it once rather than on every call:

TypeScript
const $fetch = $fetch.create({
  onRequest({ request, options }) {
    const token = sessionStorage.getItem('auth_token')
    if (token) {
      options.headers = options.headers || {}
      options.headers.Authorization = `Bearer ${token}`
    }
  }
})

// All requests now include auth header
const cart = await $fetch('/api/cart')

Registration Flow

Registration mirrors login closely — a form that POSTs to a BFF endpoint, which creates the user and signs a token — with two extra concerns. Client-side, you validate the obvious things early (passwords match, minimum length, terms accepted) so the user gets instant feedback without a round trip. Server-side, you re-validate everything regardless, because client checks are a convenience, not a security boundary.

1. User Creates Account

The form gathers the usual fields and does its first-pass validation right in the submit handler before calling the API:

Vue
<template>
  <form @submit="handleRegister">
    <input 
      v-model="firstName" 
      type="text" 
      placeholder="First Name" 
      required
    />
    <input 
      v-model="lastName" 
      type="text" 
      placeholder="Last Name" 
      required
    />
    <input 
      v-model="email" 
      type="email" 
      placeholder="your@email.com" 
      required
    />
    <input 
      v-model="password" 
      type="password" 
      placeholder="Password (min 8 chars)" 
      minlength="8"
      required
    />
    <input 
      v-model="passwordConfirm" 
      type="password" 
      placeholder="Confirm Password" 
      required
    />
    <label>
      <input v-model="agreeTerms" type="checkbox" required />
      I agree to the Terms & Conditions
    </label>
    <button type="submit" :disabled="loading">Create Account</button>
  </form>
</template>

<script setup>
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const password = ref('')
const passwordConfirm = ref('')
const agreeTerms = ref(false)
const loading = ref(false)

const handleRegister = async () => {
  if (password.value !== passwordConfirm.value) {
    error.value = 'Passwords do not match'
    return
  }
  
  if (password.value.length < 8) {
    error.value = 'Password must be at least 8 characters'
    return
  }
  
  loading.value = true
  
  try {
    const { token } = await $fetch('/api/auth/register', {
      method: 'POST',
      body: {
        first_name: firstName.value,
        last_name: lastName.value,
        email: email.value,
        password: password.value,
        agree_terms: agreeTerms.value
      }
    })
    
    sessionStorage.setItem('auth_token', token)
    navigateTo('/account')
  } catch (err) {
    error.value = err.data.message
  } finally {
    loading.value = false
  }
}
</script>

2. Server-Side Registration

The server repeats the validation in earnest, rejects an email that's already registered, hashes the password before it ever touches the database, and creates the user — with tenant_id set automatically from context, so a registration always lands in the right tenant. Only then does it issue a token, exactly as login does:

Backend Route: /api/auth/register

TypeScript
export default defineEventHandler(async (event) => {
  const { first_name, last_name, email, password, agree_terms } = 
    await readBody(event)
  
  // Validate input
  if (!email.includes('@')) {
    throw createError({ statusCode: 400, message: 'Invalid email' })
  }
  if (password.length < 8) {
    throw createError({ statusCode: 400, message: 'Password too short' })
  }
  if (!agree_terms) {
    throw createError({ statusCode: 400, message: 'Must agree to terms' })
  }
  
  // Check if email already exists
  const existing = await cockpit.users.findByEmail(email)
  if (existing) {
    throw createError({
      statusCode: 409,
      message: 'Email already registered'
    })
  }
  
  // Hash password
  const passwordHash = await bcrypt.hash(password, 10)
  
  // Create user (automatically scoped to the tenant)
  const user = await cockpit.users.create({
    first_name,
    last_name,
    email,
    password_hash: passwordHash,
    status: 'active',
    // tenant_id is set automatically from context
  })
  
  // Generate token
  const token = jwt.sign(
    {
      user_id: user.id,
      tenant_id: user.tenant_id,
      email: user.email,
      role: 'customer'
    },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  )
  
  return { token, user }
})

Password Reset Flow

Password reset is the most security-sensitive of the three flows, because it's a way to take over an account if it's done carelessly. The design rests on a short-lived, single-use token that's emailed to the address on file: the user requests a reset, the server emails a link containing the token, and only someone with access to that inbox can complete it. Two details do the security work — the server never reveals whether an email exists, and it stores only a hash of the token, so a leaked database can't be used to reset anyone's password. The flow runs in four steps.

1. User Requests Password Reset

The first form just asks for an email. Whatever the outcome, the UI says the same reassuring thing — "check your email" — so it never leaks which addresses have accounts:

Vue
<template>
  <form @submit="handleForgot">
    <h1>Forgot Password?</h1>
    <p>Enter your email and we'll send you a link to reset your password.</p>
    <input 
      v-model="email" 
      type="email" 
      placeholder="your@email.com" 
      required
    />
    <button type="submit" :disabled="loading">Send Reset Link</button>
    <a href="/login">Back to Login</a>
  </form>
</template>

<script setup>
const email = ref('')
const loading = ref(false)
const sent = ref(false)

const handleForgot = async () => {
  loading.value = true
  
  try {
    await $fetch('/api/auth/forgot-password', {
      method: 'POST',
      body: { email: email.value }
    })
    
    sent.value = true
  } catch (err) {
    error.value = 'Email not found'
  } finally {
    loading.value = false
  }
}
</script>

2. Server Generates Reset Token

Backend Route: /api/auth/forgot-password

TypeScript
export default defineEventHandler(async (event) => {
  const { email } = await readBody(event)
  
  const user = await cockpit.users.findByEmail(email)
  if (!user) {
    // Don't reveal if email exists (security)
    return { message: 'Check your email for reset link' }
  }
  
  // Generate reset token (short-lived, one-time use)
  const resetToken = crypto.randomBytes(32).toString('hex')
  const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex')
  
  // Store in DB (expires in 1 hour)
  await cockpit.passwordReset.create({
    user_id: user.id,
    token_hash: resetTokenHash,
    expires_at: new Date(Date.now() + 60 * 60 * 1000)
  })
  
  // Send email with reset link
  const resetUrl = `https://storefront.example.com/reset-password?token=${resetToken}`
  await sendEmail({
    to: user.email,
    subject: 'Reset Your Password',
    html: `
      <p>Click the link below to reset your password (expires in 1 hour):</p>
      <a href="${resetUrl}">${resetUrl}</a>
    `
  })
  
  return { message: 'Reset email sent' }
})

Form (pages/reset-password.vue):

Vue
<template>
  <form @submit="handleResetPassword">
    <h1>Reset Password</h1>
    <input 
      v-model="newPassword" 
      type="password" 
      placeholder="New Password" 
      minlength="8"
      required
    />
    <input 
      v-model="confirmPassword" 
      type="password" 
      placeholder="Confirm Password" 
      required
    />
    <button type="submit" :disabled="loading">Set Password</button>
  </form>
</template>

<script setup>
const route = useRoute()
const resetToken = route.query.token

const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)

const handleResetPassword = async () => {
  if (newPassword.value !== confirmPassword.value) {
    error.value = 'Passwords do not match'
    return
  }
  
  loading.value = true
  
  try {
    await $fetch('/api/auth/reset-password', {
      method: 'POST',
      body: {
        reset_token: resetToken,
        new_password: newPassword.value
      }
    })
    
    navigateTo('/login?message=Password reset successful')
  } catch (err) {
    error.value = 'Reset link expired or invalid'
  } finally {
    loading.value = false
  }
}
</script>

4. Server Validates & Updates Password

Backend Route: /api/auth/reset-password

TypeScript
export default defineEventHandler(async (event) => {
  const { reset_token, new_password } = await readBody(event)
  
  // Hash token to look up in DB
  const resetTokenHash = crypto.createHash('sha256').update(reset_token).digest('hex')
  
  const resetRecord = await cockpit.passwordReset.findByHash(resetTokenHash)
  if (!resetRecord || resetRecord.expires_at < new Date()) {
    throw createError({
      statusCode: 400,
      message: 'Reset link expired or invalid'
    })
  }
  
  // Update user password
  const newPasswordHash = await bcrypt.hash(new_password, 10)
  await cockpit.users.update(resetRecord.user_id, {
    password_hash: newPasswordHash
  })
  
  // Invalidate token (one-time use)
  await cockpit.passwordReset.delete(resetRecord.id)
  
  return { message: 'Password reset successful' }
})

Session Management

Issuing a token is only half the story; every protected request afterwards has to check it. That splits into two pieces of middleware with distinct jobs. The first runs on every request and identifies the user — it verifies the token and, if valid, attaches the decoded user to the request context (and quietly clears a bad cookie). It never blocks anything; it just establishes who, if anyone, is calling:

Server middleware (server/middleware/auth.ts):

TypeScript
export default defineEventHandler((event) => {
  const token = getCookie(event, 'auth_token') || 
                event.headers.authorization?.split(' ')[1]
  
  if (token) {
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET)
      event.context.user = decoded
    } catch (err) {
      // Token invalid or expired
      deleteCookie(event, 'auth_token')
    }
  }
})

Route Protection

The second piece enforces. Where the first middleware merely set event.context.user, this guard rejects any request that doesn't have one — so you keep the "who is calling" logic in one place and the "is calling allowed" logic in another:

TypeScript
export default defineEventHandler((event) => {
  if (!event.context.user) {
    throw createError({
      statusCode: 401,
      message: 'Not authenticated'
    })
  }
})

A protected route then trusts that guarantee and simply uses the authenticated user:

TypeScript
// server/api/account/profile.get.ts
export default defineEventHandler((event) => {
  // Middleware ensures user is authenticated
  const userId = event.context.user.user_id
  return cockpit.users.findById(userId)
})

Security Best Practices

Authentication is the one area where the defaults aren't optional polish — they're the difference between a safe storefront and a breached one. A handful of practices carry most of the weight.

Start with the transport and storage basics: serve every auth request over HTTPS, keep tokens in httpOnly cookies rather than localStorage so script can't read them, and pair that with CSRF protection on POST requests, since cookie-based auth is otherwise vulnerable to cross-site requests. Hash passwords with bcrypt at 10+ salt rounds, and give tokens a bounded life — a seven-day JWT expiry, after which the user logs in again — so a stolen token can't be used forever.

Then add the defences against abuse and misconfiguration: rate-limit login attempts (around five per minute per IP) to blunt brute-force and credential-stuffing attacks, set the secure response headers (HSTS, CSP, X-Frame-Options) that close off whole classes of attack, and log security events like failed logins and password resets so you can actually see an attack in progress.

Rate limiting is the one most often left out, so here's the shape of it:

Example Rate Limiting:

TypeScript
const loginAttempts = new Map()

const checkRateLimit = (ip) => {
  const attempts = loginAttempts.get(ip) || []
  const recentAttempts = attempts.filter(t => Date.now() - t < 60000)
  
  if (recentAttempts.length >= 5) {
    throw createError({
      statusCode: 429,
      message: 'Too many login attempts. Try again in 1 minute.'
    })
  }
  
  loginAttempts.set(ip, [...recentAttempts, Date.now()])
}

Logout

Frontend:

TypeScript
const logout = async () => {
  sessionStorage.removeItem('auth_token')
  deleteCookie('auth_token')
  navigateTo('/login')
}

Backend (optional token revocation):

TypeScript
export default defineEventHandler((event) => {
  // Invalidate token in blacklist
  const token = getCookie(event, 'auth_token')
  await cockpit.tokenBlacklist.add(token)
  
  deleteCookie(event, 'auth_token')
  return { message: 'Logged out' }
})

Next Steps

Was this page helpful?