Flujo de autenticación con JWT
JSON Web Token (JWT) es el estándar más utilizado para autenticación en aplicaciones SPA. El flujo completo sigue estos pasos:
- El usuario envía sus credenciales (email/contraseña) al servidor.
- El servidor valida las credenciales y devuelve un access token (JWT) y opcionalmente un refresh token.
- El cliente almacena los tokens y los envía en cada petición posterior mediante la cabecera
Authorization. - Cuando el access token expira, el cliente usa el refresh token para obtener uno nuevo sin que el usuario tenga que volver a autenticarse.
- Al cerrar sesión, se eliminan los tokens del cliente y opcionalmente se invalidan en el servidor.

Un JWT consta de tres partes separadas por puntos: header (algoritmo y tipo), payload (datos del usuario y metadatos como la expiración) y signature (firma para verificar la integridad).
Servicio de autenticación con Axios
El primer paso es crear un servicio centralizado que encapsule todas las operaciones de autenticación:
// src/services/authService.ts
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL
export interface LoginCredentials {
email: string
password: string
}
export interface RegisterData {
name: string
email: string
password: string
}
export interface AuthResponse {
accessToken: string
refreshToken: string
user: {
id: number
name: string
email: string
role: string
}
}
export const authService = {
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await axios.post<AuthResponse>(
`${API_URL}/auth/login`,
credentials
)
return response.data
},
async register(data: RegisterData): Promise<AuthResponse> {
const response = await axios.post<AuthResponse>(
`${API_URL}/auth/register`,
data
)
return response.data
},
async refreshToken(refreshToken: string): Promise<AuthResponse> {
const response = await axios.post<AuthResponse>(
`${API_URL}/auth/refresh`,
{ refreshToken }
)
return response.data
},
async logout(refreshToken: string): Promise<void> {
await axios.post(`${API_URL}/auth/logout`, { refreshToken })
}
}
Este servicio utiliza la variable de entorno VITE_API_URL para la URL base del backend. Las funciones son asíncronas y retornan las interfaces tipadas correspondientes.
Interceptores de Axios
Los interceptores permiten transformar las peticiones y respuestas de forma global. Son esenciales para inyectar automáticamente el token en cada petición y gestionar los errores de autenticación.
Instancia de Axios configurada
Es recomendable crear una instancia de Axios independiente con los interceptores configurados:
// src/services/api.ts
import axios from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { authService } from './authService'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Interceptor de petición: añade el token
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore()
const token = authStore.accessToken
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Interceptor de respuesta: gestiona 401 y refresco de token
let isRefreshing = false
let failedQueue: Array<{
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}> = []
function processQueue(error: unknown, token: string | null = null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
const authStore = useAuthStore()
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const data = await authService.refreshToken(authStore.refreshToken)
authStore.setTokens(data.accessToken, data.refreshToken)
processQueue(null, data.accessToken)
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
authStore.clearAuth()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
export default api
El interceptor de respuesta implementa un patrón de cola de peticiones fallidas: cuando el token expira, se pone en pausa todas las peticiones con 401, se refresca el token una sola vez y luego se reintentan todas las peticiones en cola con el nuevo token.
Estrategias de almacenamiento de tokens
Existen varias opciones para almacenar tokens en el cliente, cada una con sus ventajas e inconvenientes:
| Estrategia | Ventajas | Inconvenientes |
|---|---|---|
| localStorage | Persiste entre pestañas y recargas | Vulnerable a XSS (accesible desde JavaScript) |
| sessionStorage | Se limpia al cerrar la pestaña | Vulnerable a XSS, no persiste entre pestañas |
| Cookies httpOnly | Inaccesible desde JavaScript (protegido contra XSS) | Requiere configuración del servidor, vulnerable a CSRF sin protección adicional |
La opción más segura es usar cookies httpOnly para el refresh token (que el servidor establece en la respuesta) y mantener el access token solo en memoria (en el store de Pinia). De esta forma, el access token desaparece al recargar la página y se obtiene uno nuevo mediante el refresh token almacenado en la cookie httpOnly.
Store de autenticación con Pinia
El store centraliza todo el estado de autenticación de la aplicación:
// src/stores/authStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/services/authService'
import type { LoginCredentials, RegisterData } from '@/services/authService'
export interface User {
id: number
name: string
email: string
role: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const accessToken = ref('')
const refreshToken = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
const userRole = computed(() => user.value?.role ?? '')
const userName = computed(() => user.value?.name ?? '')
function setTokens(access: string, refresh: string) {
accessToken.value = access
refreshToken.value = refresh
}
function clearAuth() {
user.value = null
accessToken.value = ''
refreshToken.value = ''
error.value = null
}
async function login(credentials: LoginCredentials) {
loading.value = true
error.value = null
try {
const data = await authService.login(credentials)
user.value = data.user
setTokens(data.accessToken, data.refreshToken)
scheduleTokenRefresh(data.accessToken)
} catch (err) {
error.value = 'Credenciales incorrectas'
throw err
} finally {
loading.value = false
}
}
async function register(data: RegisterData) {
loading.value = true
error.value = null
try {
const response = await authService.register(data)
user.value = response.user
setTokens(response.accessToken, response.refreshToken)
scheduleTokenRefresh(response.accessToken)
} catch (err) {
error.value = 'Error en el registro'
throw err
} finally {
loading.value = false
}
}
async function logout() {
try {
if (refreshToken.value) {
await authService.logout(refreshToken.value)
}
} finally {
clearAuth()
if (tokenRefreshTimer) {
clearTimeout(tokenRefreshTimer)
tokenRefreshTimer = null
}
}
}
// Auto-logout al expirar el token
let tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleTokenRefresh(token: string) {
if (tokenRefreshTimer) {
clearTimeout(tokenRefreshTimer)
}
try {
const payload = JSON.parse(atob(token.split('.')[1]))
const expiresIn = payload.exp * 1000 - Date.now()
// Refrescar 60 segundos antes de que expire
const refreshIn = Math.max(expiresIn - 60_000, 0)
tokenRefreshTimer = setTimeout(async () => {
try {
const data = await authService.refreshToken(refreshToken.value)
setTokens(data.accessToken, data.refreshToken)
scheduleTokenRefresh(data.accessToken)
} catch {
clearAuth()
}
}, refreshIn)
} catch {
// Token malformado, no se programa refresco
}
}
return {
user,
accessToken,
refreshToken,
loading,
error,
isAuthenticated,
userRole,
userName,
setTokens,
clearAuth,
login,
register,
logout
}
})
La función scheduleTokenRefresh decodifica el payload del JWT para extraer la fecha de expiración y programa automáticamente el refresco del token 60 segundos antes de que expire. Si el refresco falla, se limpia la sesión.
Componente de login
Un componente completo de login que utiliza el store de autenticación:
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const showPassword = ref(false)
async function handleLogin() {
try {
await authStore.login({ email: email.value, password: password.value })
// Redirigir a la URL original o al dashboard
const redirectTo = (route.query.redirect as string) || '/dashboard'
router.push(redirectTo)
} catch {
// El error ya se gestiona en el store
}
}
</script>
<template>
<div class="login-container">
<h1>Iniciar sesión</h1>
<div v-if="authStore.error" class="error-message">
{{ authStore.error }}
</div>
<form @submit.prevent="handleLogin">
<div>
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="tu@email.com"
/>
</div>
<div>
<label for="password">Contraseña</label>
<div class="password-field">
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
placeholder="Tu contraseña"
/>
<button type="button" @click="showPassword = !showPassword">
{{ showPassword ? 'Ocultar' : 'Mostrar' }}
</button>
</div>
</div>
<button type="submit" :disabled="authStore.loading">
{{ authStore.loading ? 'Iniciando sesión...' : 'Entrar' }}
</button>
</form>
<p>
¿No tienes cuenta?
<router-link to="/register">Regístrate</router-link>
</p>
</div>
</template>
El componente captura el parámetro redirect de la URL para redirigir al usuario a la página que intentaba visitar antes de ser enviado al login. El estado de carga y los errores se leen directamente del store.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Vuejs
Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje