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.
Table of Contents
- 1. Why Nuxt Middleware for Auth?
- 2. Middleware Types: Inline, Named and Global
- 3. Auth Composable and Token Management
- 4. Route Guard with defineNuxtRouteMiddleware
- 5. Token Refresh and Silent Renewal
- 6. Role-Based Access Control (RBAC)
- 7. Server-Side Middleware and API Protection
- 8. Common Mistakes and How to Avoid Them
- 9. Comparing Middleware Strategies
- 10. Summary
- 11. FAQ
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.