Securiser votre application Vue.js avec Vue Router et les jetons JWT

Implementez une authentification securisee avec Vue Router et Vuex. Gerez les jetons JWT et protegez vos routes sensibles efficacement.

Mahmoud DEVO
Mahmoud DEVO
December 27, 2025 11 min read
Securiser votre application Vue.js avec Vue Router et les jetons JWT
Table of Contents

Securiser votre application Vue.js avec JWT : Guide complet

Introduction a la securite des applications web

La securite des applications web est devenue un enjeu majeur dans le developpement moderne. Avec la multiplication des cyberattaques et des fuites de donnees, les developpeurs doivent imperativement maitriser les techniques d’authentification et d’autorisation pour proteger leurs utilisateurs.

Dans le contexte des Single Page Applications (SPA) comme celles developpees avec Vue.js, la gestion de l’authentification presente des defis specifiques. Contrairement aux applications traditionnelles qui utilisent des sessions cote serveur, les SPA doivent gerer l’etat d’authentification cote client tout en maintenant une communication securisee avec le backend.

Les JSON Web Tokens (JWT) sont devenus la solution de reference pour l’authentification dans les architectures modernes. Ils offrent plusieurs avantages :

  • Stateless : Le serveur n’a pas besoin de maintenir une session
  • Scalable : Parfait pour les architectures distribuees et microservices
  • Portable : Peut etre utilise entre differents domaines et services
  • Auto-contenu : Le token contient toutes les informations necessaires

Dans ce tutoriel complet, nous allons explorer en profondeur comment implementer une authentification robuste dans une application Vue.js en utilisant JWT, Vue Router et Pinia (ou Vuex).

Comprendre les JSON Web Tokens (JWT)

Structure d’un JWT

Un JWT est compose de trois parties separees par des points :

xxxxx.yyyyy.zzzzz
Header.Payload.Signature

Voici un exemple concret de JWT decode :

// Header (Base64 decode)
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload (Base64 decode)
{
  "sub": "1234567890",
  "name": "Mahmoud DEVO",
  "email": "contact@fullopenaiblog.com",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

// Signature
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

Les claims standards du JWT

Les claims sont les donnees contenues dans le payload. Il existe trois types :

Claims reserves (standard) :

  • iss (Issuer) : Identifiant de l’emetteur du token
  • sub (Subject) : Identifiant unique de l’utilisateur
  • aud (Audience) : Destinataire prevu du token
  • exp (Expiration) : Timestamp d’expiration
  • nbf (Not Before) : Timestamp avant lequel le token n’est pas valide
  • iat (Issued At) : Timestamp de creation
  • jti (JWT ID) : Identifiant unique du token

Claims publics : Definis dans le registre IANA ou avec URI de collision.

Claims prives : Claims personnalises pour votre application.

// types/auth.ts
interface JWTPayload {
  sub: string;
  email: string;
  name: string;
  role: 'user' | 'admin' | 'moderator';
  permissions: string[];
  iat: number;
  exp: number;
}

interface User {
  id: string;
  email: string;
  name: string;
  role: string;
  permissions: string[];
}

Verification et expiration des tokens

La verification d’un JWT se fait en trois etapes :

  1. Verification de la structure : Le token doit avoir trois parties
  2. Verification de la signature : Avec la cle secrete ou publique
  3. Verification des claims : Expiration, audience, etc.
// utils/jwt.ts
import { jwtDecode } from 'jwt-decode';
import type { JWTPayload } from '@/types/auth';

export function isTokenExpired(token: string): boolean {
  try {
    const decoded = jwtDecode<JWTPayload>(token);
    const currentTime = Date.now() / 1000;

    // Ajouter une marge de 60 secondes pour eviter les problemes de synchronisation
    return decoded.exp < currentTime - 60;
  } catch {
    return true;
  }
}

export function getTokenExpirationDate(token: string): Date | null {
  try {
    const decoded = jwtDecode<JWTPayload>(token);
    return new Date(decoded.exp * 1000);
  } catch {
    return null;
  }
}

export function decodeToken(token: string): JWTPayload | null {
  try {
    return jwtDecode<JWTPayload>(token);
  } catch {
    return null;
  }
}

export function getTimeUntilExpiration(token: string): number {
  try {
    const decoded = jwtDecode<JWTPayload>(token);
    const currentTime = Date.now() / 1000;
    return Math.max(0, decoded.exp - currentTime);
  } catch {
    return 0;
  }
}

Flux d’authentification complet

Schema du flux d’authentification

Le flux d’authentification avec JWT suit generalement ce schema :

1. Utilisateur -> Login Form -> POST /api/auth/login
2. Serveur valide credentials -> Genere Access Token + Refresh Token
3. Client stocke les tokens -> Redirige vers page protegee
4. Client envoie Access Token dans Authorization header
5. Serveur valide le token -> Retourne les donnees
6. Si Access Token expire -> Utilise Refresh Token pour en obtenir un nouveau
7. Si Refresh Token expire -> Redirige vers login

Implementation du store d’authentification avec Pinia

// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { authApi } from '@/api/auth';
import {
  isTokenExpired,
  decodeToken,
  getTimeUntilExpiration
} from '@/utils/jwt';
import type { User, LoginCredentials, RegisterData } from '@/types/auth';

export const useAuthStore = defineStore('auth', () => {
  const router = useRouter();

  // State
  const user = ref<User | null>(null);
  const accessToken = ref<string | null>(null);
  const refreshToken = ref<string | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);
  const refreshTokenTimeout = ref<number | null>(null);

  // Getters
  const isAuthenticated = computed(() => {
    return !!accessToken.value && !isTokenExpired(accessToken.value);
  });

  const isAdmin = computed(() => {
    return user.value?.role === 'admin';
  });

  const hasPermission = (permission: string) => {
    return user.value?.permissions?.includes(permission) ?? false;
  };

  // Actions
  async function login(credentials: LoginCredentials): Promise<boolean> {
    loading.value = true;
    error.value = null;

    try {
      const response = await authApi.login(credentials);

      setTokens(response.accessToken, response.refreshToken);
      user.value = response.user;

      // Configurer le rafraichissement automatique
      setupRefreshTokenTimer();

      return true;
    } catch (err: any) {
      error.value = err.response?.data?.message || 'Erreur de connexion';
      return false;
    } finally {
      loading.value = false;
    }
  }

  async function register(data: RegisterData): Promise<boolean> {
    loading.value = true;
    error.value = null;

    try {
      const response = await authApi.register(data);

      setTokens(response.accessToken, response.refreshToken);
      user.value = response.user;

      setupRefreshTokenTimer();

      return true;
    } catch (err: any) {
      error.value = err.response?.data?.message || 'Erreur d\'inscription';
      return false;
    } finally {
      loading.value = false;
    }
  }

  async function refreshAccessToken(): Promise<boolean> {
    if (!refreshToken.value) {
      logout();
      return false;
    }

    try {
      const response = await authApi.refreshToken(refreshToken.value);

      setTokens(response.accessToken, response.refreshToken);

      setupRefreshTokenTimer();

      return true;
    } catch {
      logout();
      return false;
    }
  }

  function setTokens(access: string, refresh: string): void {
    accessToken.value = access;
    refreshToken.value = refresh;

    // Stocker de maniere securisee (voir section suivante)
    localStorage.setItem('accessToken', access);
    localStorage.setItem('refreshToken', refresh);

    // Mettre a jour les informations utilisateur depuis le token
    const payload = decodeToken(access);
    if (payload) {
      user.value = {
        id: payload.sub,
        email: payload.email,
        name: payload.name,
        role: payload.role,
        permissions: payload.permissions
      };
    }
  }

  function setupRefreshTokenTimer(): void {
    // Annuler le timer existant
    if (refreshTokenTimeout.value) {
      clearTimeout(refreshTokenTimeout.value);
    }

    if (!accessToken.value) return;

    // Rafraichir 5 minutes avant l'expiration
    const timeUntilExpiration = getTimeUntilExpiration(accessToken.value);
    const refreshTime = Math.max(0, (timeUntilExpiration - 300) * 1000);

    refreshTokenTimeout.value = window.setTimeout(() => {
      refreshAccessToken();
    }, refreshTime);
  }

  async function logout(): Promise<void> {
    // Appeler l'API de deconnexion pour invalider le refresh token
    if (refreshToken.value) {
      try {
        await authApi.logout(refreshToken.value);
      } catch {
        // Ignorer les erreurs de deconnexion
      }
    }

    // Nettoyer l'etat local
    clearAuthState();

    // Rediriger vers la page de connexion
    router.push({ name: 'login' });
  }

  function clearAuthState(): void {
    user.value = null;
    accessToken.value = null;
    refreshToken.value = null;

    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');

    if (refreshTokenTimeout.value) {
      clearTimeout(refreshTokenTimeout.value);
      refreshTokenTimeout.value = null;
    }
  }

  // Initialisation au chargement de l'application
  function initializeAuth(): void {
    const storedAccessToken = localStorage.getItem('accessToken');
    const storedRefreshToken = localStorage.getItem('refreshToken');

    if (storedAccessToken && storedRefreshToken) {
      if (!isTokenExpired(storedAccessToken)) {
        setTokens(storedAccessToken, storedRefreshToken);
        setupRefreshTokenTimer();
      } else if (!isTokenExpired(storedRefreshToken)) {
        // Access token expire mais refresh token valide
        refreshToken.value = storedRefreshToken;
        refreshAccessToken();
      } else {
        // Les deux tokens expires
        clearAuthState();
      }
    }
  }

  return {
    // State
    user,
    accessToken,
    loading,
    error,
    // Getters
    isAuthenticated,
    isAdmin,
    hasPermission,
    // Actions
    login,
    register,
    logout,
    refreshAccessToken,
    initializeAuth
  };
});

Stockage securise des tokens : localStorage vs Cookies

Comparaison des methodes de stockage

Le choix du stockage des tokens est crucial pour la securite de votre application :

CriterelocalStorageCookies HttpOnlyMemory (RAM)
Vulnerable XSSOuiNonOui
Vulnerable CSRFNonOui (sans SameSite)Non
PersistanceOuiOuiNon
Acces JavaScriptOuiNonOui
Taille max~5MB~4KBIllimitee
Multi-ongletsOuiOuiNon

Strategie recommandee : Approche hybride

La meilleure approche combine plusieurs techniques :

// services/tokenStorage.ts
import Cookies from 'js-cookie';

interface TokenStorage {
  getAccessToken(): string | null;
  setAccessToken(token: string): void;
  getRefreshToken(): string | null;
  setRefreshToken(token: string): void;
  clearTokens(): void;
}

// Option 1: localStorage (simple mais vulnerable XSS)
export const localStorageTokenStorage: TokenStorage = {
  getAccessToken: () => localStorage.getItem('accessToken'),
  setAccessToken: (token) => localStorage.setItem('accessToken', token),
  getRefreshToken: () => localStorage.getItem('refreshToken'),
  setRefreshToken: (token) => localStorage.setItem('refreshToken', token),
  clearTokens: () => {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  }
};

// Option 2: Cookies avec configuration securisee
export const cookieTokenStorage: TokenStorage = {
  getAccessToken: () => Cookies.get('accessToken') || null,
  setAccessToken: (token) => {
    Cookies.set('accessToken', token, {
      expires: 1/24, // 1 heure
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict'
    });
  },
  getRefreshToken: () => Cookies.get('refreshToken') || null,
  setRefreshToken: (token) => {
    Cookies.set('refreshToken', token, {
      expires: 7, // 7 jours
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict'
    });
  },
  clearTokens: () => {
    Cookies.remove('accessToken');
    Cookies.remove('refreshToken');
  }
};

// Option 3: Memory storage (plus securise mais non persistant)
class MemoryTokenStorage implements TokenStorage {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;

  getAccessToken = () => this.accessToken;
  setAccessToken = (token: string) => { this.accessToken = token; };
  getRefreshToken = () => this.refreshToken;
  setRefreshToken = (token: string) => { this.refreshToken = token; };
  clearTokens = () => {
    this.accessToken = null;
    this.refreshToken = null;
  };
}

export const memoryTokenStorage = new MemoryTokenStorage();

// Service principal avec strategie configurable
class TokenService {
  private storage: TokenStorage;

  constructor(storage: TokenStorage) {
    this.storage = storage;
  }

  getAccessToken(): string | null {
    return this.storage.getAccessToken();
  }

  setAccessToken(token: string): void {
    this.storage.setAccessToken(token);
  }

  getRefreshToken(): string | null {
    return this.storage.getRefreshToken();
  }

  setRefreshToken(token: string): void {
    this.storage.setRefreshToken(token);
  }

  clearTokens(): void {
    this.storage.clearTokens();
  }
}

// Utiliser cookies en production, localStorage en dev
const storage = process.env.NODE_ENV === 'production'
  ? cookieTokenStorage
  : localStorageTokenStorage;

export const tokenService = new TokenService(storage);

Guards de navigation avec Vue Router

Configuration des routes protegees

Les guards de navigation permettent de proteger vos routes et de gerer les redirections :

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

// Definition des meta types
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean;
    requiresGuest?: boolean;
    roles?: string[];
    permissions?: string[];
  }
}

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue')
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/auth/LoginView.vue'),
    meta: { requiresGuest: true }
  },
  {
    path: '/register',
    name: 'register',
    component: () => import('@/views/auth/RegisterView.vue'),
    meta: { requiresGuest: true }
  },
  {
    path: '/dashboard',
    name: 'dashboard',
    component: () => import('@/views/DashboardView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/profile',
    name: 'profile',
    component: () => import('@/views/ProfileView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/admin',
    name: 'admin',
    component: () => import('@/views/admin/AdminView.vue'),
    meta: {
      requiresAuth: true,
      roles: ['admin']
    },
    children: [
      {
        path: 'users',
        name: 'admin-users',
        component: () => import('@/views/admin/UsersView.vue'),
        meta: {
          requiresAuth: true,
          permissions: ['users:read']
        }
      },
      {
        path: 'settings',
        name: 'admin-settings',
        component: () => import('@/views/admin/SettingsView.vue'),
        meta: {
          requiresAuth: true,
          permissions: ['settings:write']
        }
      }
    ]
  },
  {
    path: '/products',
    name: 'products',
    component: () => import('@/views/ProductsView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/products/:id',
    name: 'product-detail',
    component: () => import('@/views/ProductDetailView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFoundView.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

// Guard global de navigation
router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore();

  // Initialiser l'auth si necessaire
  if (!authStore.isAuthenticated && localStorage.getItem('accessToken')) {
    authStore.initializeAuth();
  }

  const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
  const requiresGuest = to.matched.some(record => record.meta.requiresGuest);
  const requiredRoles = to.meta.roles as string[] | undefined;
  const requiredPermissions = to.meta.permissions as string[] | undefined;

  // Route pour invites seulement (login, register)
  if (requiresGuest && authStore.isAuthenticated) {
    return next({ name: 'dashboard' });
  }

  // Route protegee
  if (requiresAuth) {
    // Verifier l'authentification
    if (!authStore.isAuthenticated) {
      // Essayer de rafraichir le token
      const refreshed = await authStore.refreshAccessToken();
      if (!refreshed) {
        return next({
          name: 'login',
          query: { redirect: to.fullPath }
        });
      }
    }

    // Verifier les roles
    if (requiredRoles && requiredRoles.length > 0) {
      const userRole = authStore.user?.role;
      if (!userRole || !requiredRoles.includes(userRole)) {
        return next({ name: 'dashboard' });
      }
    }

    // Verifier les permissions
    if (requiredPermissions && requiredPermissions.length > 0) {
      const hasAllPermissions = requiredPermissions.every(
        permission => authStore.hasPermission(permission)
      );
      if (!hasAllPermissions) {
        return next({ name: 'dashboard' });
      }
    }
  }

  next();
});

// Guard apres navigation pour le tracking
router.afterEach((to, from) => {
  // Analytics, logging, etc.
  console.log(`Navigation: ${from.path} -> ${to.path}`);
});

export default router;

Composable pour les verifications d’acces

// composables/useAuth.ts
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter, useRoute } from 'vue-router';

export function useAuth() {
  const authStore = useAuthStore();
  const router = useRouter();
  const route = useRoute();

  const isAuthenticated = computed(() => authStore.isAuthenticated);
  const user = computed(() => authStore.user);
  const isAdmin = computed(() => authStore.isAdmin);

  function requireAuth() {
    if (!authStore.isAuthenticated) {
      router.push({
        name: 'login',
        query: { redirect: route.fullPath }
      });
      return false;
    }
    return true;
  }

  function requireRole(role: string | string[]) {
    if (!requireAuth()) return false;

    const roles = Array.isArray(role) ? role : [role];
    const userRole = authStore.user?.role;

    if (!userRole || !roles.includes(userRole)) {
      router.push({ name: 'dashboard' });
      return false;
    }
    return true;
  }

  function requirePermission(permission: string | string[]) {
    if (!requireAuth()) return false;

    const permissions = Array.isArray(permission) ? permission : [permission];
    const hasAll = permissions.every(p => authStore.hasPermission(p));

    if (!hasAll) {
      router.push({ name: 'dashboard' });
      return false;
    }
    return true;
  }

  async function loginAndRedirect(credentials: { email: string; password: string }) {
    const success = await authStore.login(credentials);
    if (success) {
      const redirect = route.query.redirect as string || '/dashboard';
      router.push(redirect);
    }
    return success;
  }

  return {
    isAuthenticated,
    user,
    isAdmin,
    requireAuth,
    requireRole,
    requirePermission,
    loginAndRedirect,
    login: authStore.login,
    logout: authStore.logout,
    error: computed(() => authStore.error),
    loading: computed(() => authStore.loading)
  };
}

Interceptors Axios pour les tokens

Configuration complete d’Axios

// api/axios.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import { useAuthStore } from '@/stores/auth';
import { tokenService } from '@/services/tokenStorage';
import { isTokenExpired } from '@/utils/jwt';

// Creer l'instance Axios
const apiClient: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json'
  }
});

// File d'attente pour les requetes pendant le refresh
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (value: unknown) => void;
  reject: (reason?: unknown) => void;
}> = [];

const processQueue = (error: Error | null, token: string | null = null) => {
  failedQueue.forEach(promise => {
    if (error) {
      promise.reject(error);
    } else {
      promise.resolve(token);
    }
  });
  failedQueue = [];
};

// Interceptor de requete
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = tokenService.getAccessToken();

    if (token && !config.headers['Authorization']) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }

    // Ajouter des headers de securite supplementaires
    config.headers['X-Requested-With'] = 'XMLHttpRequest';

    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Interceptor de reponse
apiClient.interceptors.response.use(
  (response: AxiosResponse) => {
    return response;
  },
  async (error) => {
    const originalRequest = error.config;

    // Si erreur 401 et pas deja en retry
    if (error.response?.status === 401 && !originalRequest._retry) {
      // Si deja en train de refresh, mettre en queue
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest.headers['Authorization'] = `Bearer ${token}`;
          return apiClient(originalRequest);
        }).catch(err => {
          return Promise.reject(err);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      const refreshToken = tokenService.getRefreshToken();

      if (!refreshToken || isTokenExpired(refreshToken)) {
        // Refresh token invalide, deconnecter
        const authStore = useAuthStore();
        authStore.logout();
        processQueue(new Error('Session expiree'), null);
        isRefreshing = false;
        return Promise.reject(error);
      }

      try {
        // Appel direct a l'API de refresh (sans interceptors)
        const response = await axios.post(
          `${apiClient.defaults.baseURL}/auth/refresh`,
          { refreshToken },
          { headers: { 'Content-Type': 'application/json' } }
        );

        const { accessToken, refreshToken: newRefreshToken } = response.data;

        tokenService.setAccessToken(accessToken);
        tokenService.setRefreshToken(newRefreshToken);

        // Mettre a jour le header de la requete originale
        originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;

        // Traiter la queue
        processQueue(null, accessToken);

        return apiClient(originalRequest);
      } catch (refreshError) {
        // Echec du refresh, deconnecter
        const authStore = useAuthStore();
        authStore.logout();
        processQueue(refreshError as Error, null);
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    // Gestion des autres erreurs
    if (error.response?.status === 403) {
      console.error('Acces refuse');
    } else if (error.response?.status === 500) {
      console.error('Erreur serveur');
    }

    return Promise.reject(error);
  }
);

export default apiClient;

API d’authentification

// api/auth.ts
import apiClient from './axios';
import type {
  LoginCredentials,
  RegisterData,
  AuthResponse
} from '@/types/auth';

export const authApi = {
  async login(credentials: LoginCredentials): Promise<AuthResponse> {
    const response = await apiClient.post<AuthResponse>('/auth/login', credentials);
    return response.data;
  },

  async register(data: RegisterData): Promise<AuthResponse> {
    const response = await apiClient.post<AuthResponse>('/auth/register', data);
    return response.data;
  },

  async refreshToken(refreshToken: string): Promise<AuthResponse> {
    const response = await apiClient.post<AuthResponse>('/auth/refresh', {
      refreshToken
    });
    return response.data;
  },

  async logout(refreshToken: string): Promise<void> {
    await apiClient.post('/auth/logout', { refreshToken });
  },

  async getProfile(): Promise<{ user: User }> {
    const response = await apiClient.get('/auth/me');
    return response.data;
  },

  async updateProfile(data: Partial<User>): Promise<{ user: User }> {
    const response = await apiClient.put('/auth/profile', data);
    return response.data;
  },

  async changePassword(data: {
    currentPassword: string;
    newPassword: string
  }): Promise<void> {
    await apiClient.post('/auth/change-password', data);
  },

  async requestPasswordReset(email: string): Promise<void> {
    await apiClient.post('/auth/forgot-password', { email });
  },

  async resetPassword(token: string, password: string): Promise<void> {
    await apiClient.post('/auth/reset-password', { token, password });
  }
};

Gestion du Refresh Token

Strategie de rafraichissement automatique

Le refresh token permet de maintenir la session utilisateur sans demander une nouvelle authentification :

// services/tokenRefreshService.ts
import { useAuthStore } from '@/stores/auth';
import { tokenService } from './tokenStorage';
import { getTimeUntilExpiration, isTokenExpired } from '@/utils/jwt';

class TokenRefreshService {
  private refreshTimeout: number | null = null;
  private readonly REFRESH_MARGIN = 5 * 60; // 5 minutes avant expiration

  start(): void {
    this.scheduleRefresh();

    // Ecouter les changements de visibilite de la page
    document.addEventListener('visibilitychange', this.handleVisibilityChange);

    // Ecouter les events de focus
    window.addEventListener('focus', this.handleWindowFocus);
  }

  stop(): void {
    if (this.refreshTimeout) {
      clearTimeout(this.refreshTimeout);
      this.refreshTimeout = null;
    }

    document.removeEventListener('visibilitychange', this.handleVisibilityChange);
    window.removeEventListener('focus', this.handleWindowFocus);
  }

  private scheduleRefresh(): void {
    const accessToken = tokenService.getAccessToken();
    if (!accessToken) return;

    const timeUntilExpiration = getTimeUntilExpiration(accessToken);
    const refreshIn = Math.max(0, timeUntilExpiration - this.REFRESH_MARGIN);

    if (this.refreshTimeout) {
      clearTimeout(this.refreshTimeout);
    }

    this.refreshTimeout = window.setTimeout(async () => {
      await this.refreshToken();
    }, refreshIn * 1000);

    console.log(`Token refresh scheduled in ${Math.round(refreshIn / 60)} minutes`);
  }

  private async refreshToken(): Promise<void> {
    const authStore = useAuthStore();
    const success = await authStore.refreshAccessToken();

    if (success) {
      this.scheduleRefresh();
    }
  }

  private handleVisibilityChange = async (): Promise<void> => {
    if (document.visibilityState === 'visible') {
      const accessToken = tokenService.getAccessToken();

      if (accessToken && isTokenExpired(accessToken)) {
        await this.refreshToken();
      } else {
        this.scheduleRefresh();
      }
    }
  };

  private handleWindowFocus = async (): Promise<void> => {
    const accessToken = tokenService.getAccessToken();

    if (accessToken && isTokenExpired(accessToken)) {
      await this.refreshToken();
    }
  };
}

export const tokenRefreshService = new TokenRefreshService();

Integration dans l’application

// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useAuthStore } from './stores/auth';
import { tokenRefreshService } from './services/tokenRefreshService';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.use(router);

// Initialiser l'authentification avant le montage
const authStore = useAuthStore();
authStore.initializeAuth();

// Demarrer le service de refresh si authentifie
if (authStore.isAuthenticated) {
  tokenRefreshService.start();
}

// Observer les changements d'authentification
watch(
  () => authStore.isAuthenticated,
  (isAuthenticated) => {
    if (isAuthenticated) {
      tokenRefreshService.start();
    } else {
      tokenRefreshService.stop();
    }
  }
);

app.mount('#app');

Deconnexion et revocation des tokens

Implementation complete de la deconnexion

// stores/auth.ts (methode logout complete)
async function logout(options: {
  revokeAllSessions?: boolean;
  reason?: string;
} = {}): Promise<void> {
  const { revokeAllSessions = false, reason } = options;

  try {
    if (refreshToken.value) {
      if (revokeAllSessions) {
        // Revoquer tous les refresh tokens de l'utilisateur
        await authApi.revokeAllSessions();
      } else {
        // Revoquer uniquement ce refresh token
        await authApi.logout(refreshToken.value);
      }
    }
  } catch (error) {
    console.error('Erreur lors de la revocation du token:', error);
  } finally {
    // Toujours nettoyer l'etat local
    clearAuthState();

    // Arreter le service de refresh
    tokenRefreshService.stop();

    // Emettre un event pour les autres composants
    window.dispatchEvent(new CustomEvent('auth:logout', {
      detail: { reason }
    }));

    // Rediriger vers login
    router.push({
      name: 'login',
      query: reason ? { message: reason } : undefined
    });
  }
}

// Deconnexion forcee (session expiree, etc.)
function forceLogout(reason: string): void {
  clearAuthState();
  tokenRefreshService.stop();

  router.push({
    name: 'login',
    query: { message: reason }
  });
}

Composant de deconnexion

<!-- components/LogoutButton.vue -->
<template>
  <button
    @click="handleLogout"
    :disabled="loading"
    class="logout-button"
  >
    <span v-if="loading">Deconnexion...</span>
    <span v-else>Se deconnecter</span>
  </button>

  <!-- Modal de confirmation -->
  <Teleport to="body">
    <div v-if="showConfirmation" class="modal-overlay">
      <div class="modal">
        <h3>Confirmer la deconnexion</h3>
        <p>Voulez-vous vous deconnecter de tous les appareils ?</p>
        <div class="modal-actions">
          <button @click="confirmLogout(false)">
            Cet appareil uniquement
          </button>
          <button @click="confirmLogout(true)">
            Tous les appareils
          </button>
          <button @click="showConfirmation = false">
            Annuler
          </button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useAuth } from '@/composables/useAuth';

const props = defineProps<{
  requireConfirmation?: boolean;
}>();

const { logout, loading } = useAuth();
const showConfirmation = ref(false);

function handleLogout() {
  if (props.requireConfirmation) {
    showConfirmation.value = true;
  } else {
    confirmLogout(false);
  }
}

async function confirmLogout(revokeAll: boolean) {
  showConfirmation.value = false;
  await logout({ revokeAllSessions: revokeAll });
}
</script>

Comparatif JWT vs Sessions

AspectJWTSessions
Stockage serveurAucun (stateless)Base de donnees ou memoire
ScalabiliteExcellenteNecessite partage de session
PerformanceRapide (pas de lookup DB)Lookup requis a chaque requete
RevocationComplexe (blacklist)Simple (supprimer session)
Taille payloadPlus grand (token dans header)Petit (session ID)
Cross-domainFacileComplexe (CORS, cookies)
Mobile/APIIdealMoins adapte
Securite XSSVulnerable si localStorageCookies HttpOnly proteges
Securite CSRFImmuniseVulnerable sans protection
ExpirationIntegree au tokenGeree serveur

Quand utiliser JWT ?

  • Applications Single Page (SPA)
  • APIs RESTful
  • Architecture microservices
  • Authentification mobile
  • Systemes distribues

Quand utiliser les sessions ?

  • Applications web traditionnelles
  • Besoin de revocation immediate
  • Donnees sensibles ne devant pas etre exposees
  • Infrastructure simple (monolithe)

Bonnes pratiques de securite

1. Utiliser HTTPS partout

// Verifier HTTPS en production
if (process.env.NODE_ENV === 'production' && location.protocol !== 'https:') {
  location.replace(`https:${location.href.substring(location.protocol.length)}`);
}

2. Duree d’expiration appropriee

// Configuration recommandee
const TOKEN_EXPIRATION = {
  ACCESS_TOKEN: '15m',   // 15 minutes
  REFRESH_TOKEN: '7d',   // 7 jours
  REMEMBER_ME: '30d'     // 30 jours si "Se souvenir de moi"
};

3. Valider les tokens cote serveur

// Exemple Node.js avec jsonwebtoken
const jwt = require('jsonwebtoken');

function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'fullopenaiblog.com',
      audience: 'web-app'
    });
    return decoded;
  } catch (error) {
    throw new Error('Token invalide');
  }
}

4. Implementer le rate limiting

// Limiter les tentatives de connexion
const loginAttempts = new Map<string, { count: number; lastAttempt: Date }>();

function checkRateLimit(ip: string): boolean {
  const attempt = loginAttempts.get(ip);
  const now = new Date();

  if (!attempt) {
    loginAttempts.set(ip, { count: 1, lastAttempt: now });
    return true;
  }

  // Reset apres 15 minutes
  if (now.getTime() - attempt.lastAttempt.getTime() > 15 * 60 * 1000) {
    loginAttempts.set(ip, { count: 1, lastAttempt: now });
    return true;
  }

  // Bloquer apres 5 tentatives
  if (attempt.count >= 5) {
    return false;
  }

  attempt.count++;
  attempt.lastAttempt = now;
  return true;
}

5. Sanitizer les inputs utilisateur

// composables/useFormValidation.ts
import DOMPurify from 'dompurify';

export function sanitizeInput(input: string): string {
  return DOMPurify.sanitize(input.trim());
}

export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

export function validatePassword(password: string): {
  valid: boolean;
  errors: string[];
} {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('Au moins 8 caracteres');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Au moins une majuscule');
  }
  if (!/[a-z]/.test(password)) {
    errors.push('Au moins une minuscule');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('Au moins un chiffre');
  }
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('Au moins un caractere special');
  }

  return { valid: errors.length === 0, errors };
}

6. Logger les evenements de securite

// services/securityLogger.ts
interface SecurityEvent {
  type: 'login' | 'logout' | 'failed_login' | 'token_refresh' | 'permission_denied';
  userId?: string;
  ip?: string;
  userAgent?: string;
  details?: Record<string, unknown>;
  timestamp: Date;
}

class SecurityLogger {
  private events: SecurityEvent[] = [];

  log(event: Omit<SecurityEvent, 'timestamp'>): void {
    const fullEvent: SecurityEvent = {
      ...event,
      timestamp: new Date()
    };

    this.events.push(fullEvent);

    // Envoyer au backend pour analyse
    this.sendToBackend(fullEvent);

    // Log console en dev
    if (process.env.NODE_ENV === 'development') {
      console.log('[Security]', fullEvent);
    }
  }

  private async sendToBackend(event: SecurityEvent): Promise<void> {
    try {
      await fetch('/api/security/log', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(event)
      });
    } catch (error) {
      console.error('Failed to log security event:', error);
    }
  }
}

export const securityLogger = new SecurityLogger();

Pieges courants a eviter

1. Stocker des donnees sensibles dans le JWT

// MAUVAIS - Ne jamais faire cela !
const badPayload = {
  sub: '123',
  password: 'secret123',  // JAMAIS !
  creditCard: '4111...',  // JAMAIS !
  ssn: '123-45-6789'      // JAMAIS !
};

// BON - Donnees minimales necessaires
const goodPayload = {
  sub: '123',
  email: 'user@example.com',
  role: 'user',
  exp: Math.floor(Date.now() / 1000) + 3600
};

2. Ne pas verifier l’expiration cote client

// MAUVAIS - Utiliser le token sans verification
async function fetchData() {
  const token = localStorage.getItem('token');
  // Pas de verification d'expiration !
  return await api.get('/data', {
    headers: { Authorization: `Bearer ${token}` }
  });
}

// BON - Toujours verifier avant utilisation
async function fetchDataSafe() {
  const token = localStorage.getItem('token');

  if (!token || isTokenExpired(token)) {
    const refreshed = await refreshToken();
    if (!refreshed) {
      throw new Error('Session expiree');
    }
  }

  return await api.get('/data');
}

3. Utiliser un secret JWT faible

// MAUVAIS - Secret previsible
const SECRET = 'secret123';
const SECRET = 'password';
const SECRET = process.env.APP_NAME;

// BON - Secret fort et aleatoire
const SECRET = 'xK9#mP2$vL5@nQ8&wR3*jT6^hY1!bF4%cD7'; // 32+ caracteres
// Ou generer avec crypto
const crypto = require('crypto');
const SECRET = crypto.randomBytes(64).toString('hex');

4. Ignorer les erreurs de verification

// MAUVAIS - Catch silencieux
function getUser(token: string) {
  try {
    return jwt.verify(token, SECRET);
  } catch {
    return null;  // L'erreur est ignoree
  }
}

// BON - Gerer correctement les erreurs
function getUserSafe(token: string): User | null {
  try {
    return jwt.verify(token, SECRET) as User;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      securityLogger.log({ type: 'token_expired' });
    } else if (error instanceof jwt.JsonWebTokenError) {
      securityLogger.log({ type: 'invalid_token', details: { error: error.message } });
    }
    return null;
  }
}

5. Ne pas proteger contre le CSRF avec les cookies

// Si vous utilisez des cookies pour les tokens
// MAUVAIS - Cookie sans protection CSRF
Cookies.set('token', token);

// BON - Cookie avec SameSite et CSRF token
Cookies.set('token', token, {
  secure: true,
  sameSite: 'strict',
  httpOnly: true  // Cote serveur uniquement
});

// Ajouter un token CSRF dans les headers
axios.defaults.headers.common['X-CSRF-Token'] = getCsrfToken();

Exemple complet : Formulaire de connexion

<!-- views/auth/LoginView.vue -->
<template>
  <div class="login-container">
    <form @submit.prevent="handleSubmit" class="login-form">
      <h1>Connexion</h1>

      <div v-if="error" class="error-message">
        {{ error }}
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input
          id="email"
          v-model="form.email"
          type="email"
          required
          autocomplete="email"
          :disabled="loading"
        />
        <span v-if="errors.email" class="field-error">
          {{ errors.email }}
        </span>
      </div>

      <div class="form-group">
        <label for="password">Mot de passe</label>
        <input
          id="password"
          v-model="form.password"
          type="password"
          required
          autocomplete="current-password"
          :disabled="loading"
        />
        <span v-if="errors.password" class="field-error">
          {{ errors.password }}
        </span>
      </div>

      <div class="form-group checkbox">
        <label>
          <input type="checkbox" v-model="form.rememberMe" />
          Se souvenir de moi
        </label>
      </div>

      <button type="submit" :disabled="loading || !isValid">
        <span v-if="loading">Connexion en cours...</span>
        <span v-else>Se connecter</span>
      </button>

      <div class="form-links">
        <router-link to="/forgot-password">
          Mot de passe oublie ?
        </router-link>
        <router-link to="/register">
          Creer un compte
        </router-link>
      </div>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, reactive } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { validateEmail, sanitizeInput } from '@/composables/useFormValidation';
import { securityLogger } from '@/services/securityLogger';

const { loginAndRedirect, error, loading } = useAuth();

const form = reactive({
  email: '',
  password: '',
  rememberMe: false
});

const errors = reactive({
  email: '',
  password: ''
});

const isValid = computed(() => {
  return form.email && form.password && !errors.email && !errors.password;
});

function validateForm(): boolean {
  errors.email = '';
  errors.password = '';

  if (!validateEmail(form.email)) {
    errors.email = 'Email invalide';
    return false;
  }

  if (form.password.length < 6) {
    errors.password = 'Le mot de passe doit contenir au moins 6 caracteres';
    return false;
  }

  return true;
}

async function handleSubmit() {
  if (!validateForm()) return;

  const sanitizedEmail = sanitizeInput(form.email);

  const success = await loginAndRedirect({
    email: sanitizedEmail,
    password: form.password,
    rememberMe: form.rememberMe
  });

  if (success) {
    securityLogger.log({
      type: 'login',
      details: { email: sanitizedEmail }
    });
  } else {
    securityLogger.log({
      type: 'failed_login',
      details: { email: sanitizedEmail }
    });
  }
}
</script>

<style scoped>
.login-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.login-form {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
  width: 100%;
  max-width: 400px;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input[type="email"],
.form-group input[type="password"] {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.form-group input:focus {
  outline: none;
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.error-message {
  background: #fee;
  color: #c00;
  padding: 0.75rem;
  border-radius: 4px;
  margin-bottom: 1rem;
}

.field-error {
  color: #c00;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

button[type="submit"] {
  width: 100%;
  padding: 0.75rem;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  transition: opacity 0.2s;
}

button[type="submit"]:hover:not(:disabled) {
  opacity: 0.9;
}

button[type="submit"]:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.form-links {
  margin-top: 1rem;
  display: flex;
  justify-content: space-between;
}

.form-links a {
  color: #667eea;
  text-decoration: none;
}

.form-links a:hover {
  text-decoration: underline;
}
</style>

Conclusion

La securisation d’une application Vue.js avec JWT est un processus multi-facettes qui requiert une attention particuliere a chaque etape du flux d’authentification. Dans ce guide complet, nous avons couvert :

  • La structure et le fonctionnement des JWT : Comprendre les trois parties du token et les claims standards est essentiel pour implementer correctement l’authentification.

  • Le flux d’authentification complet : De la connexion initiale jusqu’a la deconnexion, en passant par le rafraichissement automatique des tokens.

  • Le stockage securise : Les avantages et inconvenients de localStorage, cookies et memory storage, avec une strategie hybride recommandee.

  • Les guards Vue Router : Protection des routes avec verification des roles et permissions.

  • Les interceptors Axios : Injection automatique des tokens et gestion transparente du rafraichissement.

  • La gestion du refresh token : Maintien de sessions longues tout en gardant des access tokens de courte duree.

  • Les bonnes pratiques : HTTPS, expiration appropriee, validation serveur, rate limiting, sanitization et logging.

  • Les pieges courants : Erreurs de securite frequentes et comment les eviter.

L’authentification est un domaine en constante evolution. Restez informe des nouvelles vulnerabilites et mettez regulierement a jour vos implementations. N’hesitez pas a faire auditer votre code par des experts en securite pour les applications critiques.

Points cles a retenir :

  1. Ne jamais stocker de donnees sensibles dans le JWT
  2. Toujours utiliser HTTPS en production
  3. Implementer le rafraichissement automatique des tokens
  4. Valider les tokens cote serveur ET cote client
  5. Logger tous les evenements de securite
  6. Tester regulierement votre implementation

En suivant ces recommandations, vous construirez des applications Vue.js robustes et securisees, offrant a vos utilisateurs une experience fluide tout en protegeant leurs donnees.

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

Mahmoud DEVO

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles