The Authentication Flow

Where a Vue session lives, a router guard that waits for it, and the redirect flash that happens when it does not.

Syntaxrouter.beforeEach(async (to) => { /* return true, false, or a route location */ })

Authentication in a single-page application is three questions: where the session is held, how the first page load learns about it, and what the user sees while that is being decided.

Where the session lives

Keep the access token and the current user in a Pinia store. A store is a module of shared reactive state, from Stage 3's Pinia lessons. Memory is the right home for a token, because a reload is meant to re-establish the session rather than trust whatever is in storage. The long-lived refresh token belongs in an HTTP-only cookie the browser sends and your code cannot read.

A guard that waits

On a reload the store starts empty, so a guard that checks auth.user immediately sends a signed-in user to the login screen. Give the store a ready flag and one restore() call, and have the guard await it. A guard may be an async function; Vue Router holds the navigation until it settles. Returning a route location redirects, exactly as router.push would:

return { name: 'login' }           // current form
next({ name: 'login' })            // the older callback form, still supported

The flash of the wrong screen

Mount the app before the first navigation resolves and the user sees the login form for a moment, then the dashboard. Mount inside router.isReady().then(...), which resolves after the initial navigation and its guards have finished. The example is the login view: it submits, then replaces the current history entry with the page the guard had asked for.

Example

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()
const router = useRouter()
const route = useRoute()

const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const pending = ref(false)

async function submit() {
  pending.value = true
  error.value = null
  try {
    await auth.login(email.value, password.value)
    const redirect = route.query.redirect
    await router.replace(typeof redirect === 'string' ? redirect : { name: 'dashboard' })
  } catch {
    error.value = 'Those details were not accepted.'
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="email">Email</label>
    <input id="email" v-model="email" type="email" autocomplete="username" />

    <label for="password">Password</label>
    <input id="password" v-model="password" type="password" autocomplete="current-password" />

    <p v-if="error" role="alert">{{ error }}</p>
    <button type="submit" :disabled="pending">Sign in</button>
  </form>
</template>

<style scoped>
form {
  display: grid;
  gap: 0.5rem;
  max-width: 20rem;
}
</style>

When to use it

  • Keeping a signed-in user on the page they bookmarked after a hard refresh.
  • Sending someone back to the page they wanted once they have signed in.
  • Clearing a session from one place so every component stops showing private data.

More examples

The session store

A Pinia 4 setup store: refs become state, computed values become getters, functions become actions, and every state ref must appear in the returned object or Pinia will not treat it as state. ready separates 'no session' from 'not asked yet', which is the distinction the guard needs.

Example · ts
// src/stores/auth.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

interface User {
  id: string
  email: string
}

interface SessionResponse {
  user: User
  accessToken: string
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const accessToken = ref<string | null>(null)
  const ready = ref(false)

  const isAuthenticated = computed(() => user.value !== null)

  async function restore() {
    try {
      const response = await fetch('/api/session', { credentials: 'include' })
      if (response.ok) {
        const data = (await response.json()) as SessionResponse
        user.value = data.user
        accessToken.value = data.accessToken
      }
    } finally {
      ready.value = true
    }
  }

  async function login(email: string, password: string) {
    const response = await fetch('/api/session', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })
    if (!response.ok) throw new Error('Sign in failed')
    const data = (await response.json()) as SessionResponse
    user.value = data.user
    accessToken.value = data.accessToken
    ready.value = true
  }

  async function logout() {
    await fetch('/api/session', { method: 'DELETE', credentials: 'include' })
    user.value = null
    accessToken.value = null
  }

  return { user, accessToken, ready, isAuthenticated, restore, login, logout }
})

The guard, and a typed meta field

The module augmentation gives route meta real types, so to.meta.requiresAuth is checked rather than guessed. The guard awaits restore() once per page load, then either lets the navigation through, or returns a location carrying the requested path as a redirect query.

Example · ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
  }
}

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue')
    },
    {
      path: '/',
      name: 'dashboard',
      component: () => import('@/views/DashboardView.vue'),
      meta: { requiresAuth: true }
    }
  ]
})

router.beforeEach(async (to) => {
  const auth = useAuthStore()

  if (!auth.ready) {
    await auth.restore()
  }
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  if (to.name === 'login' && auth.isAuthenticated) {
    return { name: 'dashboard' }
  }
})

export default router

Mounting after the first navigation

router.isReady() returns a Promise<void> that resolves once the initial navigation, including its async guards, has completed. Mounting inside it removes the flash of the login screen on reload. Pinia is installed before the router because the guard uses a store.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

router.isReady().then(() => {
  app.mount('#app')
})

Discussion

  • Be the first to comment on this lesson.