Skip to main content
Web DevelopmentSoftware Engineering

Authentication Patterns for Single-Page Applications

Implement secure authentication in SPAs with token rotation, silent refresh, secure storage, and protection against common attacks.

Mbeah Essilfie

Mbeah Essilfie

April 18, 2026 at 06:04 AM

10 min read 1223 views
Authentication Patterns for Single-Page Applications

The SPA Auth Challenge

SPAs run in the browser — an inherently untrusted environment. Tokens stored in JavaScript are vulnerable to XSS. Cookies have CSRF risks. Here's how to navigate this securely.

// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  const user = await authenticateUser(email, password)
  if (!user) throw createError({ statusCode: 401 })

  const { accessToken, refreshToken } = generateTokens(user)

  // Set tokens as HttpOnly cookies — inaccessible to JavaScript
  setCookie(event, 'access_token', accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 900, // 15 minutes
    path: '/',
  })

  setCookie(event, 'refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 604800, // 7 days
    path: '/api/auth/refresh', // Only sent to refresh endpoint
  })

  return { user: { id: user.id, name: user.name, email: user.email } }
})
HttpOnly cookies cannot be read by JavaScript (immune to XSS). SameSite=Lax prevents CSRF on state-changing requests. This is the most secure pattern for SPAs with a same-origin API.

Silent Token Refresh

// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
  const refreshToken = getCookie(event, 'refresh_token')
  if (!refreshToken) throw createError({ statusCode: 401 })

  try {
    const payload = verifyRefreshToken(refreshToken)
    const user = await getUser(payload.sub)

    // Rotate refresh token (one-time use)
    const newTokens = generateTokens(user)

    setCookie(event, 'access_token', newTokens.accessToken, {
      httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900, path: '/',
    })
    setCookie(event, 'refresh_token', newTokens.refreshToken, {
      httpOnly: true, secure: true, sameSite: 'lax', maxAge: 604800, path: '/api/auth/refresh',
    })

    return { user: { id: user.id, name: user.name } }
  } catch {
    // Invalid refresh token — force re-login
    deleteCookie(event, 'access_token')
    deleteCookie(event, 'refresh_token')
    throw createError({ statusCode: 401 })
  }
})

Client-Side Auth Composable

// composables/useAuth.ts
export function useAuth() {
  const user = useState<User | null>('auth-user', () => null)
  const isAuthenticated = computed(() => !!user.value)

  async function login(email: string, password: string) {
    const { user: userData } = await $fetch('/api/auth/login', {
      method: 'POST',
      body: { email, password },
    })
    user.value = userData
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }

  async function refreshSession() {
    try {
      const { user: userData } = await $fetch('/api/auth/refresh', { method: 'POST' })
      user.value = userData
    } catch {
      user.value = null
    }
  }

  return { user, isAuthenticated, login, logout, refreshSession }
}

Route Protection

// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { isAuthenticated, refreshSession } = useAuth()

  if (!isAuthenticated.value) {
    await refreshSession()
  }

  if (!isAuthenticated.value && to.meta.requiresAuth) {
    return navigateTo(`/login?redirect=${to.fullPath}`)
  }
})

Common Attacks and Mitigations

AttackMitigation
XSS stealing tokensHttpOnly cookies (JS can't read them)
CSRFSameSite=Lax + CSRF tokens for mutations
Token theftShort expiry + rotation
Brute forceRate limiting + account lockout
Session fixationGenerate new session on login

Summary

  1. Store tokens in HttpOnly, Secure, SameSite cookies
  2. Short-lived access tokens (15 min) + longer refresh tokens (7 days)
  3. Rotate refresh tokens on every use (detect reuse = breach)
  4. Server-side session validation on every request
  5. Clear all tokens on logout (both client and server)
JavaScriptVueSecurity
Mbeah Essilfie

Written by Mbeah Essilfie

Fullstack Software Developer

Read next

The Complete Guide to Vue 3 Composables
12 min read

The Complete Guide to Vue 3 Composables

Understand the power of Vue 3's Composition API through practical, real-world composable patterns that you can use today.

Mbeah EssilfieMbeah Essilfie
986