Skip to content
TutorialData & APIs

Authentication basics: tokens, cookies and where to keep them

Your app has to prove who the user is on every request. Where that proof lives decides what a single injected script can steal, so keep it where JavaScript cannot reach.

vuejs.school team12 Mar 20268 min readVue 3.5 · Nuxt 4.3

Once a user has logged in, the proof of who they are is either a session cookie or a token, and it has to live somewhere: in a cookie the browser manages, or in JavaScript memory, localStorage or sessionStorage. For a Vue app in the browser, use an HttpOnly session cookie set by your API. No script on the page can read it, and the app itself keeps only the user's profile.

The examples build a login flow around that cookie for a plain Vue 3.5 app with Pinia and Vue Router, then add CSRF protection and a logout that works on every device. The Vue app is served from app.example.com and the API from api.example.com. The API's side is described in prose and headers, so it applies whatever language your server is written in.

Session cookies or bearer tokens

A session cookie holds a random id. The server stores the session itself (which user, when it expires) in a database, and looks it up on every request. The browser attaches the cookie automatically; your JavaScript never touches it.

A bearer token is a string, often a JWT (a signed JSON object), that the server hands to your code. Your code stores it and adds it to every request as Authorization: Bearer <token>. Whoever holds it is treated as the user, which is where the name comes from.

The trade-off is about who carries the proof:

Session cookie

The browser sends it by itself. Marked HttpOnly, it is out of JavaScript's reach, so no script can leak it. But the browser may also attach it to requests another site triggers: the CSRF problem covered below.

Bearer token

Only your code sends it, so another site cannot make the browser send it. But your code must store it where JavaScript can read it, so an injected script can read it too.

For a browser app talking to its own API, use an HttpOnly session cookie. Keep bearer tokens for mobile apps, server-to-server calls and command-line tools, which do not run inside a web page.

A successful login is a response with a Set-Cookie header. You can see it with curl:

curl -i https://api.example.com/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse battery staple"}'

# HTTP/2 200
# set-cookie: __Host-session=k3J9xQ2v...; Path=/; Max-Age=604800; Secure; HttpOnly; SameSite=Lax
# content-type: application/json
#
# {"id":"u_42","name":"Ada","email":"ada@example.com"}

Each attribute closes a door:

  • HttpOnly hides the cookie from JavaScript. document.cookie does not list it, so no script on the page can read it.
  • Secure sends it over HTTPS only.
  • SameSite=Lax keeps it off requests started by other sites, such as a fetch or a form POST from evil.example. It is still sent when the user follows a plain link to your site.
  • The __Host- prefix makes the browser reject the cookie unless it is Secure, has Path=/ and has no Domain attribute. The cookie is then locked to api.example.com and no sibling subdomain can overwrite it.
  • Max-Age is its lifetime in seconds, here a week. The server expires the session record on the same schedule.

The body carries the user's public profile. That is all the Vue app needs.

app.example.com and api.example.com are the same site, because they share the registrable domain example.com, so a SameSite=Lax cookie travels between them. They are different origins, so the browser applies CORS, the rules for cross-origin requests. By default fetch does not send cookies to another origin, and ignores any Set-Cookie it gets back. credentials: 'include' changes both, so the app's API client sets it on every request:

src/api/client.ts
const API_URL = 'https://api.example.com'

export class ApiError extends Error {
  status: number

  constructor(status: number) {
    super(`Request failed with status ${status}`)
    this.status = status
  }
}

export async function api<T>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
  const res = await fetch(`${API_URL}${path}`, {
    method: options.method ?? 'GET',
    credentials: 'include',
    headers: options.body === undefined ? undefined : { 'Content-Type': 'application/json' },
    body: options.body === undefined ? undefined : JSON.stringify(options.body),
  })
  if (!res.ok) throw new ApiError(res.status)
  return (res.status === 204 ? undefined : await res.json()) as T
}

ApiError keeps the status code, so the store can tell "not logged in" (401) from a real failure. A 204 No Content answer has no body, so the client returns undefined.

The API has to agree. Every response to the app, even the answer to the preflight OPTIONS request that the browser sends before a JSON POST, must include:

  • Access-Control-Allow-Origin: https://app.example.com, the exact origin. The wildcard * is not allowed with credentials, and the API must never echo back whatever Origin the request sent.
  • Access-Control-Allow-Credentials: true.

The preflight answer also needs Access-Control-Allow-Headers: Content-Type, or the browser never sends the JSON request.

If the API sits on a different site altogether, its cookie needs SameSite=None and becomes a third-party cookie, which Safari blocks by default and Firefox partitions. Keep the API on a subdomain, or behind the same origin; in development, Vite's server.proxy does that.

In a Nuxt 4.3 app the API is usually your own server/ routes on the same origin, so none of the CORS setup applies. nuxt-auth-utils sets a sealed HttpOnly cookie for you, and during server rendering useFetch forwards the browser's cookie to your API.

Why the token does not go in localStorage

The most common version of this code keeps a token in localStorage:

src/stores/auth.tsBreaks
async function login(email: string, password: string) {
  const res = await api<{ token: string; user: User }>('/auth/login', {
    method: 'POST',
    body: { email, password },
  })
  localStorage.setItem('token', res.token)
  user.value = res.user
}

The API client then reads it back for an Authorization header on every call. It works, and it survives a reload.

It also means that any script running on your origin can read the token: one injected through an XSS bug, a compromised npm package, a third-party widget. That script sends the token to its own server, and the attacker uses it from their own machine until it expires. The OWASP HTML5 Security Cheat Sheet says it directly: do not store session identifiers in local storage, because the data is always accessible by JavaScript.

sessionStorage has the same exposure, and a token kept only in memory can still be captured by a script that wraps fetch.

With the cookie, the login function has nothing to store:

src/stores/auth.tsWorks
async function login(email: string, password: string) {
  user.value = await api<User>('/auth/login', {
    method: 'POST',
    body: { email, password },
  })
}

An HttpOnly cookie does not make XSS harmless: an injected script can still send requests as the user while the tab is open. It does mean the attacker cannot carry the credential away, and logging out or closing the session on the server ends the attack.

Keep only the profile in a Pinia store

The full store keeps who the user is, for the interface, and nothing that proves it:

src/stores/auth.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api, ApiError } from '../api/client'

export interface User {
  id: string
  name: string
  email: string
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const checked = ref(false)
  const isLoggedIn = computed(() => user.value !== null)

  async function fetchUser() {
    try {
      user.value = await api<User>('/auth/me')
    }
    catch (error) {
      if (!(error instanceof ApiError && error.status === 401)) throw error
      user.value = null
    }
    finally {
      checked.value = true
    }
  }

  async function login(email: string, password: string) {
    user.value = await api<User>('/auth/login', { method: 'POST', body: { email, password } })
  }

  async function logout(everywhere = false) {
    await api(everywhere ? '/auth/logout-all' : '/auth/logout', { method: 'POST' })
    user.value = null
  }

  return { user, checked, isLoggedIn, fetchUser, login, logout }
})

After a reload the store is empty, but the cookie is still there. fetchUser() asks /auth/me who the cookie belongs to, and a 401 simply means nobody is logged in. Any other error, such as the API being down, is thrown again rather than read as "logged out". Either way, checked records that the question has been asked.

Guard routes with the store

The route guard calls fetchUser() once, before the first navigation finishes. A visitor who is not logged in and opens a route marked requiresAuth goes to /login, with the page they wanted kept in redirect:

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

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

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('../views/HomeView.vue') },
    { path: '/login', component: () => import('../views/LoginView.vue') },
    { path: '/account', component: () => import('../views/AccountView.vue'), meta: { requiresAuth: true } },
  ],
})

router.beforeEach(async (to) => {
  const auth = useAuthStore()
  if (!auth.checked) await auth.fetchUser()
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

The declare module block types to.meta.requiresAuth as boolean | undefined.

Call useAuthStore() inside the guard, as here. At the top of the file it would run when main.ts imports the router, before app.use(pinia), and fail. In development the error reads "getActivePinia()" was called but there was no active Pinia. Inside the guard it runs with the first navigation, once main.ts has installed both plugins, as Pinia's docs explain.

The guard is a convenience, not security. A user can set user in the devtools and open /account, and they will see an empty page, because every request for data still goes through the cookie and the API refuses it. Treat a 401 from any call the same way: clear user and send the user to the login page.

Stop CSRF with SameSite and an Origin check

Cross-site request forgery (CSRF) is a page on another site making the user's browser send a request to your API, with the user's cookie attached. SameSite=Lax stops the classic forms of it: a hidden form that posts to your API from another site, or a background fetch, arrives without the cookie.

It does not cover everything:

  • Lax still sends the cookie on top-level GET navigations, so any GET endpoint that changes data is exposed. Logging out, deleting and voting are POST or DELETE, never GET.
  • SameSite works on the site, not the origin. A page on any subdomain of example.com, including one you forgot about, counts as same-site.
  • It does nothing when your own JavaScript is tricked into sending the request.

So add one server-side check: on every request that changes state, compare the Origin header with your app's origin and reject a mismatch or a missing header. Browsers set Origin themselves, and no script on a page can change it.

If you share example.com with hosts you do not control, add a CSRF token as well; the OWASP CSRF Prevention Cheat Sheet describes both techniques.

Refresh tokens and logging out everywhere

Refresh tokens belong to the token approach. Access tokens are kept short-lived, a few minutes, because a stateless token cannot be cancelled once issued. A long-lived refresh token, in an HttpOnly cookie limited to Path=/auth/refresh, gets a new access token when the old one expires.

RFC 9700, the OAuth security best practice published in January 2025, requires servers to detect a stolen refresh token for public clients such as browser apps, either by binding it to the client or by rotation. With rotation, every refresh returns a new refresh token and invalidates the old one. If an old one is ever used again, the server revokes the current one too, and the user must log in again.

With server-side session cookies you need none of this, since the server can extend or end a session whenever it likes. That control is what makes "log out on all devices" possible. The account page offers both kinds of logout:

src/views/AccountView.vue
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'

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

async function logout(everywhere: boolean) {
  await auth.logout(everywhere)
  await router.push('/login')
}
</script>

<template>
  <p>Signed in as {{ auth.user?.name }}</p>
  <button type="button" @click="logout(false)">Log out</button>
  <button type="button" @click="logout(true)">Log out on all devices</button>
</template>

/auth/logout deletes the current session row and answers with an expired cookie. /auth/logout-all deletes every session for the user, so a laptop left at a library is logged out on its next request. Run the same deletion when a user changes their password. With bearer tokens the best you can do is revoke every refresh token and wait out the access tokens, another reason to prefer sessions.

Check it stuck3 questions from the Middleware, sessions and auth quiz.
Practice now
Was this helpful?

Keep reading