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.

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:
- Automatically attaches the
Authorizationheader. - Intercepts
401responses and silently refreshes the token. - Retries the original request with the new token.
- 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
$apidirectly? 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