← Back to list

Authentication in Nuxt 4 Without Third-Party Modules: A Complete Guide

Building a production-ready JWT auth system from scratch using only Pinia, useCookie, and ofetch.

Eugene Moskalenko · 2026-02-19 13:19 · 1 claps · 10.9 min read
#nuxt-4 #pinia #jwt #auth #nuxtauth
Open on Medium ↗

Authentication in Nuxt 4 Without Third-Party Modules: A Complete Guide

Building a production-ready JWT auth system from scratch using only Pinia, useCookie, and ofetch.

Every time I start a new Nuxt project and reach the authentication step, I see the same suggestion: “just install @sidebase/nuxt-auth" or "use nuxt-auth-utils." And while these modules are great, they often come with trade-offs — opinionated configurations, limited flexibility, or bloated abstractions that don't match your backend's token flow.

So I asked myself: what if we build it from scratch?

Turns out, Nuxt 4 already ships with everything you need — useCookie for SSR-safe token storage, ofetch for HTTP with interceptors, and Pinia for state management. No extra dependencies. No black boxes.

In this article, I’ll walk you through a complete, production-ready authentication system that handles:

  • ✅ JWT access + refresh token flow
  • ✅ SSR-compatible token storage via cookies
  • ✅ Automatic token refresh on 401 responses
  • ✅ Auth, Guest, and Role-based route middleware
  • ✅ App-level auth initialization plugin
  • ✅ Fully typed with TypeScript

Let’s get started.

Project Structure

Here’s the architecture we’ll build:

app/
├── composables/
│   └── useApi.ts
├── middleware/
│   ├── auth.ts                # Protect authenticated routes
│   ├── guest.ts               # Prevent logged-in users from login page
│   └── role.ts                # Role-based access control
├── plugins/
│   ├── api.ts                 # ofetch instance with interceptors
│   └── auth-init.ts           # Auto-check auth on app start│   └── auth-init.ts           # Auto-check auth on app start
├── shared/
│   ├── types
│   │   └── auth.types.ts      # TypeScript interfaces
│   └── utils/
│       └── jwt.utils.ts       # Client-side JWT decode helpers
├── stores/
│   └── useAuthStore.ts        # Pinia auth store
└── pages/
├── index.vue              # Login page
└── dashboard/
└── index.vue          # Protected page

Clean. Modular. No magic.

Step 1: Define Your Types

Start with solid TypeScript contracts. This ensures type safety across the entire auth flow.

// shared/types/auth.types.ts

Why types first? When your auth flow has a single source of truth for shapes, every layer - store, API, middleware - stays in sync. If your backend changes a response format, TypeScript catches it immediately.
export interface UserRole {
    id: number
    value: string
    description: string
}

export interface User {
    id: number
    email: string
    firstName: string
    lastName: string
    phone?: string
    picture?: string
    verified: boolean
    banned: boolean
    banReason?: string
    roles: UserRole[]
    bonusWallet: number
    wallet: number
    createdAt: string
    updatedAt: string
    tariff?: {
        id: number
        name: string
    }
}

export interface AuthTokens {
    access_token: string
    refresh_token: string
}

export interface LoginRequest {
    email: string
    password: string
}

export interface LoginResponse {
    data: {
        tokens: AuthTokens
    }
}

export interface RefreshRequest {
    token: string
}

export interface RefreshResponse {
    data: {
        tokens: AuthTokens
    }
}

export interface VerifyEmailRequest {
    email: string
    code: string
}

export interface CreateAuthRequest {
    email: string
    password: string
    firstName: string
    lastName: string
    role: 'OWNER' | 'PLAYER'
    phoneNumber: string
    ref?: number
}

export interface GoogleAuthRequest {
    access_token: string
    authuser: string
    expires_in: number
    scope: string
    token_type: string
}

export interface GoogleOneTapRequest {
    credential: string
    select_by: string
}

export interface CreateGoogleUserRequest {
    role: string
    tariff: number
    googleData: GoogleAuthRequest
}

export interface JwtPayload {
    id: number
    email: string
    roles: UserRole[]
    iat: number
    exp: number
}

Step 2: Create the API Plugin with Interceptors

This is the heart of the system. We create a custom ofetch instance that:

  1. Automatically attaches the Authorization header.
  2. Intercepts 401 responses and silently refreshes the token.
  3. Retries the original request with the new token.
  4. Logs the user out if refresh fails.
// plugins/api.ts
// plugins/api.ts
import { ofetch } from 'ofetch'

declare module '#app' {
 interface NuxtApp {
  $api: typeof ofetch
 }
}

declare module '@vue/runtime-core' {
 interface ComponentCustomProperties {
  $api: typeof ofetch
 }
}

declare global {
 var $api: typeof ofetch
}

export default defineNuxtPlugin((nuxtApp) => {
 const i18nRedirected = useCookie<string>('i18n_redirected')
 const {
  public: { BaseURL },
 } = useRuntimeConfig()

 // Single active refresh promise to avoid race conditions
 let refreshPromise: Promise<void> | null = null
 const $api = ofetch.create({
  baseURL: BaseURL,

  async onRequest({ options }) {
   const authStore = useAuthStore()
   const currentLang = i18nRedirected.value || 'uk'

   // Add Authorization header if we have access token
   const headers: Record<string, string> = {
    'Accept-Language': currentLang,
   }

   if (authStore.accessToken) {
    headers['Authorization'] = `Bearer ${authStore.accessToken}`
   }

   options.headers = {
    ...options.headers,
    ...headers,
   } as any
  },

  async onResponseError({ request, options, response }): Promise<any> {
   // Handle 401 Unauthorized - token expired
   if (response.status === 401 && !(options as any)._isRetry) {
    const authStore = useAuthStore()

    try {
     // Avoid multiple simultaneous refresh calls
     if (!refreshPromise) {
      refreshPromise = (async () => {
       await authStore.refreshTokens()
      })().finally(() => {
       refreshPromise = null
      })
     }

     // Wait for token refresh
     await refreshPromise

     // Retry the original request with new token
     return await ofetch(request, { ...options, _isRetry: true } as any)
    }
    catch (e) {
     // Refresh failed - logout user
     await authStore.logout()
     throw e
    }
   }

   // Other errors - throw unified error
   throw createError({
    statusCode: response.status,
    statusMessage: response._data?.message || 'Unknown error',
   })
  },
 })

 // Make available globally
 globalThis.$api = $api

 return {
  provide: {
   api: $api,
  },
 }
})

The refreshPromise pattern is crucial. Imagine three API calls fire simultaneously, and all three get a 401. Without this guard, you'd trigger three parallel refresh requests — likely invalidating each other. By storing a single promise, all waiting requests share the same refresh cycle.

The _isRetry flag prevents infinite loops: if the retried request also returns 401, we know the refresh itself failed and log the user out.

Step 3: Build the Typed API Composable

Wrap your endpoints in a composable for clean, reusable, typed API calls:

// composables/useApi.ts
import type {
 LoginRequest,
 LoginResponse,
 RefreshRequest,
 RefreshResponse,
} from '~/shared/types/auth.types'

import type {
 UserResponse,
} from '~/shared/types/user.types'

interface ApiMethods {
 login: (body: LoginRequest) => Promise<LoginResponse>
 refresh: (body: RefreshRequest) => Promise<RefreshResponse>
 logout: () => Promise<void>,
 fetchMe: () => Promise<UserResponse>

}

export default function (): ApiMethods {
 return {
  fetchMe: async (): Promise<UserResponse> => await $api<UserResponse>(`/api/users/me`),
  login: async (body: LoginRequest): Promise<LoginResponse> => await $api<LoginResponse>(`/api/auth/sign-in`, {
   method: 'POST',
   body,
  }),
  refresh: async (body: RefreshRequest): Promise<RefreshResponse> => await $api<RefreshResponse>(`/api/auth/refresh`, {
   method: 'GET',
   query: { token: body.token },
  }),
  logout: async (): Promise<void> => await $api(`/api/auth/logout`),
 }
}

Why a composable instead of calling $api directly? Centralized endpoint definitions. If a URL changes, you fix it in one place. Plus, TypeScript now knows exactly what each method accepts and returns.

Step 4: The Auth Store (Pinia)

This is where state lives. The store manages:

  • Token storage via useCookie (SSR-compatible)
  • User data
  • Login, logout, refresh, and auth-check flows
// stores/useAuthStore.ts
/**
 * Authentication Store
 * Pinia store for managing authentication state and actions
 * 
 * Tokens are stored in cookies via useCookie for SSR compatibility
 */

import { defineStore } from 'pinia'
import type { User, LoginRequest, LoginResponse } from '~/shared/types/auth.types'

export const useAuthStore = defineStore('auth', () => {
    // State
    const user = ref<User | null>(null)

    // Cookies for token storage (SSR-compatible)
    const accessToken = useCookie<string | null>('access_token', {
        maxAge: 15 * 60, // 15 minutes
        sameSite: 'lax',
        secure: process.env.NODE_ENV === 'production',
    })

    const refreshToken = useCookie<string | null>('refresh_token', {
        maxAge: 30 * 24 * 60 * 60, // 30 days
        sameSite: 'lax',
        secure: process.env.NODE_ENV === 'production',
    })

    // Getters
    const isAuthenticated = computed(() => !!user.value && !!accessToken.value)

    const userRoles = computed(() => {
        return user.value?.roles?.map(r => r.value) || []
    })

    const hasRole = (role: string | string[]): boolean => {
        const roles = Array.isArray(role) ? role : [role]
        return roles.some(r => userRoles.value.includes(r))
    }

    // Actions
    const login = async (credentials: LoginRequest) => {
        const api = useApi()

        try {
            // Backend returns tokens in response body
            const response = await api.login<LoginResponse>(credentials)

            // Store tokens in cookies
            accessToken.value = response.data.tokens.access_token
            refreshToken.value = response.data.tokens.refresh_token

            // Fetch user data
            await fetchUser()

            return response
        }
        catch (error) {
            console.error('Login failed:', error)
            throw error
        }
    }

    const logout = async () => {
        const api = useApi()

        try {
            // Call backend logout
            await api.logout()
        }
        catch (error) {
            console.error('Logout API call failed:', error)
        }
        finally {
            // Clear tokens and user state
            accessToken.value = null
            refreshToken.value = null
            user.value = null

            // Redirect to login
            await navigateTo('/')
        }
    }

    const refreshTokens = async () => {
        const api = useApi()

        if (!refreshToken.value) {
            throw new Error('No refresh token available')
        }

        try {
            const response = await api.refresh<LoginResponse>({ token: refreshToken.value })

            // Update tokens
            accessToken.value = response.data.tokens.access_token
            refreshToken.value = response.data.tokens.refresh_token

            return response
        }
        catch (error) {
            console.error('Token refresh failed:', error)
            // Clear tokens on refresh failure
            accessToken.value = null
            refreshToken.value = null
            throw error
        }
    }

    const fetchUser = async () => {
        const api = useApi()

        try {
            const userData = await api.fetchMe<{ data: User }>()
            user.value = userData.data
            return userData
        }
        catch (error) {
            // Don't log error here - it may be expected (e.g., expired token)
            // Let the caller handle the error
            user.value = null
            throw error
        }
    }

    const checkAuth = async (): Promise<boolean> => {
        // If user already loaded and have token, we're authenticated
        if (user.value && accessToken.value) {
            return true
        }

        // If no tokens at all, not authenticated
        if (!accessToken.value && !refreshToken.value) {
            return false
        }

        // If we have refresh_token but no access_token, try to refresh first
        if (!accessToken.value && refreshToken.value) {
            try {
                await refreshTokens()
            }
            catch (error) {
                // Refresh failed, clear everything
                accessToken.value = null
                refreshToken.value = null
                user.value = null
                return false
            }
        }

        // Now try to fetch user data
        // At this point we should have a valid access_token (fresh or refreshed)
        try {
            await fetchUser()
            return true
        }
        catch (error: any) {
            // If still getting 401, something is wrong - clear tokens
            if (error.statusCode === 401) {
                accessToken.value = null
                refreshToken.value = null
                user.value = null
            }
            return false
        }
    }

    return {
        // State
        user,
        accessToken,
        refreshToken,

        // Getters
        isAuthenticated,
        userRoles,
        hasRole,

        // Actions
        login,
        logout,
        refreshTokens,
        fetchUser,
        checkAuth,
    }
})

Key design decisions

Why useCookie instead of localStorage? Because localStorage doesn't exist on the server. With useCookie, tokens are available during SSR — meaning your middleware can check authentication before the page even renders. No flash of unauthorized content.

Why

checkAuth has a multi-step flow? Real-world scenarios are messy. The access token might be expired while the refresh token is still valid. Or both tokens might exist but the user data hasn’t been fetched yet. checkAuth handles every case gracefully.

Step 5: Route Middleware

Auth Middleware — Protect private pages

// middleware/auth.ts
/**
 * Authentication Middleware
 * Protects routes requiring authentication
 * SSR-compatible - uses Pinia store to check auth status
 */

export default defineNuxtRouteMiddleware(async (to, from) => {
    const authStore = useAuthStore()
    // Check if user is authenticated
    // This will try to fetch user data if not already loaded
    const isAuth = await authStore.checkAuth()

    // Not authenticated - redirect to login
    if (!isAuth) {
        return navigateTo({
            path: '/',
            query: { redirect: to.fullPath },
        })
    }
})

Note the redirect query parameter — after login, the user is sent back to where they originally wanted to go.

Guest Middleware — Redirect authenticated users away from login

// middleware/guest.ts
/**
 * Guest Middleware
 * Prevents authenticated users from accessing guest-only pages (e.g., login, register)
 * Redirects authenticated users to dashboard
 */

export default defineNuxtRouteMiddleware(async (to, from) => {
    const authStore = useAuthStore()

    // Check if user is authenticated
    // If user data is already loaded and we have tokens, user is authenticated
    if (authStore.isAuthenticated) {
        // Prevent authenticated users from accessing guest pages
        return navigateTo('/dashboard')
    }

    // If we have tokens but no user data yet, wait for auth check
    if ((authStore.accessToken || authStore.refreshToken) && !authStore.user) {
        try {
            await authStore.checkAuth()

            // After checking, if authenticated, redirect
            if (authStore.isAuthenticated) {
                return navigateTo('/dashboard')
            }
        }
        catch {
            // Auth check failed, allow access to guest page
        }
    }

    // User is not authenticated, allow access
})

Role Middleware — Fine-grained access control

// middleware/role.ts
/**
 * Role-based Authorization Middleware
 * Checks if user has required role(s)
 * SSR-compatible
 *
 * Usage in page:
 * definePageMeta({
 *   middleware: ['auth', 'role'],
 *   roles: ['ADMIN'] // or ['ADMIN', 'OWNER'] for multiple roles
 * })
 */

export default defineNuxtRouteMiddleware(async (to, from) => {
    const authStore = useAuthStore()

    // Ensure user is authenticated first
    await authStore.checkAuth()

    if (!authStore.user) {
        return navigateTo({
            path: '/',
            query: { redirect: to.fullPath },
        })
    }

    // Get required roles from route meta
    const requiredRoles = to.meta.roles as string[] | string | undefined

    if (!requiredRoles) {
        console.warn('Role middleware used but no roles defined in route meta')
        return
    }

    // Check if user has required role
    if (!authStore.hasRole(requiredRoles)) {
        // Redirect to error page
        return navigateTo({
            path: '/error',
            query: {
                code: '403',
                message: 'You do not have permission to access this page',
            },
        })
    }
})

Usage in a page component:

definePageMeta({
 layout: 'dashboard',
 middleware: ['role'],
 roles: ['ADMIN', 'OWNER', 'PLAYER'],
})

Combine middleware as needed: middleware: ['auth', 'role'] — auth runs first, role runs second.

Step 6: Auth Initialization Plugin

This plugin runs once when the app starts (on both server and client) and silently loads the user if tokens exist:

// plugins/auth-init.ts
/**
 * Auth Initialization Plugin
 * Automatically checks auth status and loads user data on app init
 * Runs on both server and client
 */

export default defineNuxtPlugin(async (nuxtApp) => {
    const authStore = useAuthStore()

    // Only check auth on client-side or during SSR
    // Skip on subsequent client-side navigations (handled by middleware)
    if (import.meta.server || !nuxtApp.payload.serverRendered) {
        // Only attempt to load user if we have tokens
        // This prevents unnecessary 401 errors for non-authenticated users
        if (authStore.accessToken || authStore.refreshToken) {
            try {
                await authStore.checkAuth()
            }
            catch (error) {
                // Silent fail - tokens likely expired
            }
        }
    }
})

Why this plugin?

Without it, the user would see a brief “unauthenticated” state on every page refresh before the middleware kicks in. The plugin pre-loads auth state so the UI is correct from the first render — both during SSR and client-side hydration.

The import.meta.server || !nuxtApp.payload.serverRendered guard ensures we don't double-check: if the server already resolved auth, the client skips it. But if the page wasn't server-rendered (e.g., SPA fallback), the client handles it.

Step 7: JWT Utilities (Bonus)

Sometimes you need to peek into a token without hitting the server — for example, to show the user’s email in a loading state or check expiry client-side:

/**
 * JWT Token Utilities
 * Client-side utilities for decoding and validating JWT tokens
 */

import type { JwtPayload } from '../types/auth.types'

/**
 * Decode JWT token payload (client-side only, no verification)
 */
export function decodeToken(token: string): JwtPayload | null {
    try {
        const parts = token.split('.')
        if (parts.length !== 3)
            return null

        const payload = parts[1]
        if (!payload)
            return null

        const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
        return JSON.parse(decoded)
    }
    catch {
        return null
    }
}

/**
 * Check if JWT token is expired
 */
export function isTokenExpired(token: string): boolean {
    const payload = decodeToken(token)
    if (!payload || !payload.exp)
        return true

    // Add 10 second buffer to account for clock skew
    return Date.now() >= (payload.exp * 1000 - 10000)
}

/**
 * Get token expiration time in milliseconds
 */
export function getTokenExpiration(token: string): number | null {
    const payload = decodeToken(token)
    if (!payload || !payload.exp)
        return null

    return payload.exp * 1000
}

⚠️ Important: This is client-side decoding only — it does not verify the token’s signature. Never trust decoded data for authorization decisions; that’s the server’s job.

Minimal Nuxt Config

The beauty of this approach — your config stays dead simple:

// nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config
const {
  BaseURL,
} = process.env
export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',
  devtools: { enabled: true },
  runtimeConfig: {
    public: {
      BaseURL,
    },
  },
  modules: ['@pinia/nuxt'],
})

One module: @pinia/nuxt. That's it. No auth modules, no OAuth adapters, no session managers.

✅ Use this when you have a custom backend with a JWT access/refresh token flow and want full control over the authentication behavior.

Use this when you want to understand exactly what’s happening under the hood — no abstractions, no “magic.”

Use this when third-party modules don’t fit your backend’s specific token format, refresh strategy, or role system.

Use a module when you need OAuth/social login out of the box and don’t want to implement the provider flows yourself.

Final Thoughts

Building authentication from scratch isn’t as scary as it sounds. Nuxt 4 gives you all the primitives: SSR-safe cookies, interceptors, middleware, and plugins. The patterns in this article — centralized token management, automatic refresh, role-based guards — are the same ones used in production applications serving thousands of users.

The full source code is available on GitHub: https://github.com/yevheniimoskalenko/nuxt4-auth

If you found this article useful, follow me for more practical Nuxt, Vue, and TypeScript guides. I write about real-world patterns — no fluff, just production code. Hit that Follow button and let’s build better apps together. 🚀


메타데이터
post_id
677999744ecf
slug
authentication-in-nuxt-4-without-third-party-modules-a-complete-guide-677999744ecf
url
https://medium.com/@testjokerqwerty/authentication-in-nuxt-4-without-third-party-modules-a-complete-guide-677999744ecf
canonical_url
https://medium.com/@testjokerqwerty/authentication-in-nuxt-4-without-third-party-modules-a-complete-guide-677999744ecf
author_url
https://medium.com/@testjokerqwerty
status
ok
fetched_at
2026-06-28 04:42:08