---
title: "Authentication basics: tokens, cookies and where to keep them · vuejs.school"
description: "Session cookies or bearer tokens, HttpOnly and SameSite, why localStorage tokens leak, and a Vue login flow whose Pinia store holds only the profile."
canonical_url: "https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them"
meta:
  "og:description": "Session cookies or bearer tokens, HttpOnly and SameSite, why localStorage tokens leak, and a Vue login flow whose Pinia store holds only the profile."
  "og:image": "https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbQ%2Cdescription_~U2Vzc2lvbiBjb29raWVzIG9yIGJlYXJlciB0b2tlbnMsIEh0dHBPbmx5IGFuZCBTYW1lU2l0ZSwgd2h5IGxvY2FsU3RvcmFnZSB0b2tlbnMgbGVhaywgYW5kIGEgVnVlIGxvZ2luIGZsb3cgd2hvc2UgUGluaWEgc3RvcmUgaG9sZHMgb25seSB0aGUgcHJvZmlsZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1iYXNpY3MtdG9rZW5zLWNvb2tpZXMtYW5kLXdoZXJlLXRvLWtlZXAtdGhlbSI%2Cs_ObPx3ThuPE1K81z5.png"
  "og:image:alt": "Authentication basics: tokens, cookies and where to keep them · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Authentication basics: tokens, cookies and where to keep them · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbQ%2Cdescription_~U2Vzc2lvbiBjb29raWVzIG9yIGJlYXJlciB0b2tlbnMsIEh0dHBPbmx5IGFuZCBTYW1lU2l0ZSwgd2h5IGxvY2FsU3RvcmFnZSB0b2tlbnMgbGVhaywgYW5kIGEgVnVlIGxvZ2luIGZsb3cgd2hvc2UgUGluaWEgc3RvcmUgaG9sZHMgb25seSB0aGUgcHJvZmlsZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1iYXNpY3MtdG9rZW5zLWNvb2tpZXMtYW5kLXdoZXJlLXRvLWtlZXAtdGhlbSI%2Cs_ObPx3ThuPE1K81z5.png"
  "twitter:image:alt": "Authentication basics: tokens, cookies and where to keep them · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.

## What each session cookie attribute does

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.

## Send the cookie from a plain Vue app

`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.ts**Breaks***

```
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](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html) 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.ts**Works***

```
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](https://pinia.vuejs.org/core-concepts/outside-component-usage.html) 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](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) 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](https://www.rfc-editor.org/rfc/rfc9700), 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 stuck**3 questions from the Middleware, sessions and auth quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/middleware-sessions-and-auth?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**Streaming AI responses: fetch, ReadableStream and the AI SDK**](https://vuejs.school/community/blog/streaming-ai-responses-fetch-readablestream-and-the-ai-sdk) [Tutorial · 6 min read**TanStack Query or Pinia Colada: caching your server data**](https://vuejs.school/community/blog/tanstack-query-or-pinia-colada)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-12","description":"Session cookies or bearer tokens, HttpOnly and SameSite, why localStorage tokens leak, and a Vue login flow whose Pinia store holds only the profile.","name":"Authentication basics: tokens, cookies and where to keep them","url":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them","breadcrumb":{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-12","description":"Session cookies or bearer tokens, HttpOnly and SameSite, why localStorage tokens leak, and a Vue login flow whose Pinia store holds only the profile.","headline":"Authentication basics: tokens, cookies and where to keep them","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbQ%2Cdescription_~U2Vzc2lvbiBjb29raWVzIG9yIGJlYXJlciB0b2tlbnMsIEh0dHBPbmx5IGFuZCBTYW1lU2l0ZSwgd2h5IGxvY2FsU3RvcmFnZSB0b2tlbnMgbGVhaywgYW5kIGEgVnVlIGxvZ2luIGZsb3cgd2hvc2UgUGluaWEgc3RvcmUgaG9sZHMgb25seSB0aGUgcHJvZmlsZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1iYXNpY3MtdG9rZW5zLWNvb2tpZXMtYW5kLXdoZXJlLXRvLWtlZXAtdGhlbSI%2Cs_ObPx3ThuPE1K81z5.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Authentication basics: tokens, cookies and where to keep them","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbQ%2Cdescription_~U2Vzc2lvbiBjb29raWVzIG9yIGJlYXJlciB0b2tlbnMsIEh0dHBPbmx5IGFuZCBTYW1lU2l0ZSwgd2h5IGxvY2FsU3RvcmFnZSB0b2tlbnMgbGVhaywgYW5kIGEgVnVlIGxvZ2luIGZsb3cgd2hvc2UgUGluaWEgc3RvcmUgaG9sZHMgb25seSB0aGUgcHJvZmlsZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1iYXNpY3MtdG9rZW5zLWNvb2tpZXMtYW5kLXdoZXJlLXRvLWtlZXAtdGhlbSI%2Cs_ObPx3ThuPE1K81z5.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QXV0aGVudGljYXRpb24gYmFzaWNzOiB0b2tlbnMsIGNvb2tpZXMgYW5kIHdoZXJlIHRvIGtlZXAgdGhlbQ%2Cdescription_~U2Vzc2lvbiBjb29raWVzIG9yIGJlYXJlciB0b2tlbnMsIEh0dHBPbmx5IGFuZCBTYW1lU2l0ZSwgd2h5IGxvY2FsU3RvcmFnZSB0b2tlbnMgbGVhaywgYW5kIGEgVnVlIGxvZ2luIGZsb3cgd2hvc2UgUGluaWEgc3RvcmUgaG9sZHMgb25seSB0aGUgcHJvZmlsZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1iYXNpY3MtdG9rZW5zLWNvb2tpZXMtYW5kLXdoZXJlLXRvLWtlZXAtdGhlbSI%2Cs_ObPx3ThuPE1K81z5.png"}]}
```