Autenticación JWT y tokens

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

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:

  1. El usuario envía sus credenciales (email/contraseña) al servidor.
  2. El servidor valida las credenciales y devuelve un access token (JWT) y opcionalmente un refresh token.
  3. El cliente almacena los tokens y los envía en cada petición posterior mediante la cabecera Authorization.
  4. Cuando el access token expira, el cliente usa el refresh token para obtener uno nuevo sin que el usuario tenga que volver a autenticarse.
  5. Al cerrar sesión, se eliminan los tokens del cliente y opcionalmente se invalidan en el servidor.

Flujo JWT: login, tokens, interceptores Axios y refresh automático

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 - Autor del tutorial

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