Nuxt Middleware, Auth and Protected Routes - Complete Guide
AI generated
<v/>
{ }
Vue.js · Nuxt 3 · Auth · Middleware · JWT
Nuxt Middleware, Auth and Protected Routes
Route Guards, Tokens and Role-Based Access Control

Nuxt middleware is the central mechanism for protecting routes, enforcing authentication and cleanly implementing role-based access control at the app layer, without copying security logic into every single page.

15 min read Nuxt 3 · defineNuxtRouteMiddleware · useAuth · JWT · RBAC Vue 3 · Pinia · Composables

1. Why Nuxt Middleware for Auth?

Authentication in a Nuxt application can be implemented in many ways, directly in the setup function of a page, as a plugin, or as dedicated Nuxt middleware. The decisive difference lies in where the check happens: middleware runs before the target page renders and can abort or redirect the navigation without the user ever getting so much as a brief glimpse of protected content. In-page checks, on the other hand, only react after the component has already been initialized, a conceptual security gap that leads to a brief flicker in the best case, and a race condition with visible data in the worst.

The Nuxt middleware layer is also the only place where you can enforce access control centrally and consistently. If the permission model changes, for example when a new role is introduced, you only need to adjust the middleware, not every single page. That drastically reduces maintenance effort and eliminates a common source of bugs: forgotten guards on new routes. Combined with Pinia's store architecture for the auth state, you get a clean separation between state management, access logic and presentation, which is the foundation of production-ready Nuxt middleware for auth.

2. Middleware Types: Inline, Named and Global

Nuxt 3 knows three kinds of middleware that differ in scope and configuration. Inline middleware is defined directly inside the definePageMeta function of a page and applies exclusively to that one route. It is suited for edge cases where a page needs specific checks that are not relevant anywhere else. The downside: logic gets scattered across many pages, which makes maintenance harder.

Named middleware is placed in a separate file under middleware/auth.ts and applied to pages via definePageMeta({ middleware: ['auth'] }). This is the recommended pattern for Nuxt middleware in an auth context: the logic lives in a single place, can be tested, and is only activated on pages that explicitly reference it. Global middleware with the .global.ts suffix runs automatically on every route change. It is suited for cross-cutting concerns such as analytics tracking or universal session checks, but not for granular access control, because then every public page would run through the guard logic as well, creating unnecessary overhead.


// middleware/auth.ts - Named route middleware for protected areas
import { useAuthStore } from '~/stores/auth'

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

  // Redirect to login if no valid token is present
  if (!auth.isAuthenticated) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath }, // preserve intended destination
    })
  }
})

The file lives under middleware/auth.ts and is activated on protected pages via definePageMeta({ middleware: ['auth'] }). The return value of navigateTo stops navigation to the original target route and redirects to the login page instead, passing along the originally requested path as a query parameter. After a successful login, the app can then redirect the user straight to the original destination, an important UX detail that many implementations forget.

3. Auth Composable and Token Management

Before Nuxt middleware can do meaningful work, it needs a reliable source for the auth state. That works best with a dedicated Pinia store that centrally manages the token, user data and the authentication status. A useAuthStore encapsulates all token operations: storing it after login, loading it on app start and clearing it on logout. Tokens are stored in an HTTP-only cookie, never in localStorage, where XSS attacks would have direct access.

The useAuth composable abstracts the interaction with the store and the API: it contains the login function, which sends credentials to the backend endpoint, sets the token and loads the user into the store. The logout function clears the token and store state and redirects to the login page. Thanks to this abstraction, Nuxt middleware stays lean, it only checks auth.isAuthenticated and leaves the complex token logic to the composable. This follows the single-responsibility principle and makes unit tests for both layers considerably easier.

4. Route Guard with defineNuxtRouteMiddleware

The defineNuxtRouteMiddleware function is the official Nuxt 3 wrapper for middleware functions. It gives the Nuxt runtime the necessary context object and ensures that composables like useAuthStore run correctly within the Nuxt context, which is not guaranteed outside of defineNuxtRouteMiddleware. The guard receives to and from as route objects and can return either undefined (continue navigation), navigateTo('/path') (redirect) or abortNavigation() (abort navigation).

A complete route guard for a protected page checks not only whether a token exists, but also whether it is still valid. To do so, the Nuxt middleware calls a helper function that decodes the JWT and checks the exp claim against the current time. If the token has expired, the middleware does not redirect to the login page right away, it first attempts a refresh. Only once the refresh fails does the redirect happen. This pattern avoids unnecessary login prompts for users whose refresh token is still valid.


// middleware/auth.ts - Full guard with token expiry check and refresh attempt
import { useAuthStore } from '~/stores/auth'
import { isTokenExpired, decodeJwt } from '~/utils/jwt'

export default defineNuxtRouteMiddleware(async (to) => {
  const auth = useAuthStore()

  // Allow unauthenticated access to public routes
  if (to.meta.public) return

  // No token at all → redirect to login
  if (!auth.accessToken) {
    return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
  }

  // Token expired → attempt silent refresh before redirecting
  if (isTokenExpired(auth.accessToken)) {
    const refreshed = await auth.refreshAccessToken()
    if (!refreshed) {
      auth.clearSession()
      return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
    }
  }

  // Token valid: navigation proceeds
})

5. Token Refresh and Silent Renewal

Silent token renewal is one of the most complex topics in auth implementation with Nuxt middleware. The principle: a short-lived access token (e.g. 15 minutes) is automatically renewed via a long-lived refresh token (e.g. 7 days) without the user noticing anything. The refresh endpoint accepts the refresh token, typically as an HTTP-only cookie, and returns a new access token. The access token, in turn, is kept in the Pinia store, not persisted, so it gets renewed automatically after a browser restart.

The critical problem with refresh logic: multiple concurrent requests can discover an expired token at the same time and trigger parallel refresh requests. This leads to race conditions: the first refresh invalidates the refresh token, all the others fail and redirect the user to login, even though the first refresh succeeded. The solution is a singleton promise in the auth store: as soon as a refresh starts, the promise is stored. Every further request that also wants to refresh attaches to that same promise and waits for the result instead of starting a new request.

6. Role-Based Access Control (RBAC)

When an application has multiple user roles, say admin, editor and viewer, a simple authentication check is not enough. Nuxt middleware for RBAC checks not only whether a token exists, but also whether the role claim contained in the token holds the permission required for the target route. The roles allowed per route are most elegantly defined via definePageMeta as meta data: definePageMeta({ middleware: ['auth'], roles: ['admin', 'editor'] }). The middleware reads to.meta.roles and compares it against the role from the auth store.

Important: the RBAC check in the middleware is purely a UI safeguard. It prevents unauthorized users from seeing the wrong page, but it cannot replace server-side enforcement. Every API endpoint must check the role claim from the JWT itself, regardless of what the Nuxt middleware decided client-side. The middleware check and the API check are two independent security layers, both are necessary, and neither is sufficient on its own.


// middleware/role-guard.ts - RBAC middleware using route meta
import { useAuthStore } from '~/stores/auth'

export default defineNuxtRouteMiddleware((to) => {
  const auth = useAuthStore()
  const requiredRoles = to.meta.roles as string[] | undefined

  // No role restriction defined → access granted
  if (!requiredRoles || requiredRoles.length === 0) return

  // Check if user holds at least one of the required roles
  const hasRole = requiredRoles.some(role => auth.user?.roles?.includes(role))

  if (!hasRole) {
    // Redirect to 403 page or dashboard, never expose route existence
    return navigateTo('/403')
  }
})

// Usage in a page component:
// definePageMeta({ middleware: ['auth', 'role-guard'], roles: ['admin'] })

7. Server-Side Middleware and API Protection

Besides client-side route middleware, Nuxt 3 also has a server middleware layer under server/middleware/. This middleware runs on the Nitro server for all requests, both for /api/ endpoints and for SSR page requests. This is the right place for API protection: here the Authorization header is read, the JWT is verified and the decoded payload is attached to the event context so that every subsequent API handler can consume it.

The decisive difference from client middleware: server middleware cannot be tampered with. An attacker who bypasses the client middleware (for example via direct API calls with curl) runs into the server middleware as a second line of defense. On the server side, Nuxt middleware uses getRequestHeader(event, 'authorization') to read the bearer token, verifies it with a JWT secret from the Nuxt runtime config, and returns a 401 error on failure. This way every API endpoint is protected without every handler having to implement the verification itself.

8. Common Mistakes and How to Avoid Them

The most common mistake with Nuxt middleware for auth: the token is stored in localStorage instead of an HTTP-only cookie. localStorage is accessible via JavaScript and therefore vulnerable to XSS. A compromised script within the app can read the token and send it to an attacker. HTTP-only cookies cannot be read from the JavaScript context and are sent automatically with requests. The refresh token should always be transmitted as an HTTP-only cookie, the access token can be kept in memory (the Pinia store) and gets renewed via refresh on a page reload.

A second common mistake: middleware is not applied on every page of a protected section. A layout component only protects the visual presentation, not the route itself. If a user navigates directly to a URL, no layout code runs, only the middleware is executed. That is why access protection must live exclusively in Nuxt middleware, never solely in layout components or onMounted hooks. A third mistake: navigateTo is called without return, so the middleware keeps running and the protected content gets loaded anyway.

9. Comparing Middleware Strategies

Choosing the right Nuxt middleware strategy depends on several factors: the granularity of access control, performance overhead and maintainability. The following table compares the main approaches.

Strategy Scope Recommendation Limitation
Named middleware Explicit per page Auth guards, RBAC Must be registered on every page
Global middleware All routes automatically Session checks, analytics Also runs on public pages
Inline middleware Exactly one page One-off special case Not reusable
Server middleware All server requests API protection, JWT verification No access to Pinia/client state
onMounted guard Single component Not recommended Too late, content visible before check

In practice, a combination is best: named middleware auth for the authentication check, named middleware role-guard for RBAC, and server middleware for API protection. Global middleware only for truly universal tasks such as session heartbeat or logging. Avoid inline middleware, since it scatters logic across page level and reduces maintainability.

Mironsoft

Vue.js · Nuxt 3 · Auth Architecture · API Protection

Need a secure auth architecture for your Nuxt application?

We design and implement robust authentication solutions with Nuxt middleware, from JWT token management through RBAC to server-side API hardening.

Auth Architecture

JWT, refresh token, HTTP-only cookies and Pinia store integration for secure token management

RBAC Implementation

Role-based middleware with route meta, server-side verification and granular permissions

Code Review

Auditing existing auth implementations for security gaps, race conditions and XSS exposure

10. Summary

Nuxt middleware is the right place for authentication logic in Nuxt 3 applications, not in components, not in onMounted hooks and not in layout files. The named middleware auth.ts protects explicitly marked routes, checks token validity and triggers a silent refresh when needed. The RBAC middleware extends the authentication check with role-based access control via route meta data. Server middleware under server/middleware/ protects the API layer independently of the client.

Tokens belong in HTTP-only cookies or in memory (Pinia), never in localStorage. Refresh requests must be serialized through a singleton promise to avoid race conditions. And the decisive ground rule: client-side Nuxt middleware is no substitute for server-side JWT verification, both layers are necessary and complement each other to form a complete security concept.

Nuxt Middleware Auth: the Essentials at a Glance

Route Guard

defineNuxtRouteMiddleware in middleware/auth.ts, checks the token before the page renders and redirects to login when needed.

Token Security

Refresh token as HTTP-only cookie, access token in the Pinia store. Never use localStorage for sensitive tokens.

RBAC

Define roles via definePageMeta({ roles: ['admin'] }), check against to.meta.roles in the middleware.

Server Protection

Server middleware under server/middleware/ verifies the JWT independently of the client, an indispensable second security layer.

11. FAQ: Nuxt Middleware, Auth and Protected Routes

1What is Nuxt middleware in auth?
Functions that run before a route renders, check the token and redirect unauthenticated users to login, before protected content becomes visible.
2Where to store the access token?
In the Pinia store (in memory), not in localStorage. Refresh token in an HTTP-only cookie. localStorage is vulnerable to XSS.
3Preventing race conditions on refresh?
Singleton promise in the auth store: all parallel refresh requests wait for the same promise instead of starting new requests.
4RBAC with Nuxt middleware?
Define roles in definePageMeta({ roles: ['admin'] }). Middleware reads to.meta.roles and compares against the user role from the store.
5Is client-side middleware enough protection?
No. API endpoints need server-side JWT verification in server/middleware/, independently of the client middleware.
6Named vs. global middleware?
Named middleware is activated explicitly and spares public pages. Global runs on every route change, only sensible for universal tasks.
7Redirect to the original destination after login?
Middleware passes to.fullPath as a redirect query parameter. The login page reads route.query.redirect and redirects there after success.
8Protecting API routes with middleware?
Server middleware under server/middleware/ reads the Authorization header, verifies the JWT and attaches the payload as event.context.user.
9Middleware as a file or inline?
Always as a separate file under middleware/. Testable, reusable, maintained centrally. Inline only for one-off special cases.
10navigateTo without return, what happens?
The middleware keeps running after navigateTo(). Always write return navigateTo() to stop the execution flow.