Server middleware — Runs before every server route
When we talk about Nuxt server middleware and auth: lessons from production, we're addressing one of the most critical layers in your application's security stack. Server middleware in Nuxt sits at the entry point of your server-side request handling, executing before any route handler processes the incoming request. This is where you catch, validate, and either permit or reject requests before they touch your business logic.
In production environments handling thousands of concurrent requests, we've learned that placement matters enormously. Server middleware runs in your server/middleware/ directory and executes in alphabetical order. Files prefixed with a dot (like .auth.ts) are processed first, making them ideal for authentication checks that should run before everything else.
Consider a typical e-commerce scenario: a user places an order, the frontend sends a POST request to /api/orders, and your server middleware intercepts it before the route handler even runs. At this point, you have access to cookies, headers, and the raw event object. No database queries have happened yet. No side effects have occurred. This is where you decide whether the request continues or terminates with a 401 response.
// server/middleware/.auth.ts
export default defineEventHandler((event) => {
const path = getRouterParams(event).path || '';
// Skip public routes
if (isPublicRoute(path)) {
return;
}
// For protected routes, middleware runs here before handlers
console.log('Middleware executing for:', path);
});
function isPublicRoute(path: string): boolean {
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health'];
return publicPaths.some(p => path.startsWith(p));
}
The key insight from running this in production is that middleware should be fast and lightweight. Heavy database queries here will slow every single request. We typically keep middleware focused on reading session data from memory or cache, not fetching user records from the database on every call.
Read sessions — Validate cookies or tokens
Session validation is where most authentication happens in server middleware. Whether you're using JWT tokens in headers, session cookies, or refresh tokens, you need to extract and verify the credential early. In our production systems, we've standardized on a pattern: read the cookie or header, decode it, check expiry, and attach the result to the event context for downstream handlers to use.
The challenge in production is handling multiple session formats simultaneously. A mobile app might send a Bearer token in the Authorization header, while a web browser sends an httpOnly cookie. Your middleware should gracefully handle both:
// server/middleware/.auth.ts
export default defineEventHandler(async (event) => {
const path = getRouterParams(event).path || '';
if (isPublicRoute(path)) {
return;
}
// Try to extract session from multiple sources
let session = null;
// Check JWT in Authorization header (mobile/SPA)
const authHeader = getHeader(event, 'authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
session = verifyJWT(token);
}
// Check session cookie (web browser)
if (!session) {
const sessionCookie = getCookie(event, 'session_id');
if (sessionCookie) {
session = await getSessionFromRedis(sessionCookie);
}
}
// At this point, if no valid session, return 401
if (!session) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
});
function verifyJWT(token: string): any {
try {
// Verify signature and expiry
return jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return null;
}
}
async function getSessionFromRedis(sessionId: string): Promise {
// Redis lookup for active sessions
const session = await redis.get(`session:${sessionId}`);
return session ? JSON.parse(session) : null;
}
function isPublicRoute(path: string): boolean {
return ['/api/auth/login', '/api/auth/register'].includes(path);
}
A production lesson: always validate token expiry in middleware, not just signature. We've seen systems where expired tokens were accepted because developers checked only the signature. Set a reasonable expiry window—30 minutes for access tokens is common—and let refresh tokens handle longer-lived sessions.
Attach context — event.context.user for handlers
Once middleware validates a session, the next step is making that user information available to route handlers without re-parsing or re-validating. This is where event.context comes in. It's a shared object that persists across middleware and handlers for a single request, letting you attach validated data once and access it everywhere downstream.
In a real production scenario, you're running dozens of route handlers. Each one needs to know who the authenticated user is. Rather than duplicating session validation logic in every handler, you attach it once in middleware:
// server/middleware/.auth.ts
export default defineEventHandler(async (event) => {
const path = getRouterParams(event).path || '';
if (isPublicRoute(path)) {
return;
}
const session = await readAndValidateSession(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
// Attach to event.context for handlers to access
event.context.user = {
id: session.userId,
email: session.email,
role: session.role,
permissions: session.permissions,
};
});
async function readAndValidateSession(event: any): Promise {
const authHeader = getHeader(event, 'authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
return verifyJWT(token);
}
const sessionCookie = getCookie(event, 'session_id');
if (sessionCookie) {
return await getSessionFromRedis(sessionCookie);
}
return null;
}
Now, in any route handler, you access the user directly:
// server/api/orders/[id].get.ts
export default defineEventHandler(async (event) => {
const user = event.context.user; // Already validated in middleware
const orderId = getRouterParams(event).id;
// Fetch order and verify ownership
const order = await db.order.findUnique({ where: { id: orderId } });
if (order.userId !== user.id) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
});
}
return order;
});
This pattern eliminates repeated validation logic and keeps handlers clean. In production systems handling payment processing or sensitive data access, this centralized approach also makes auditing and security updates easier—you change the validation logic once in middleware and it applies everywhere.
Return 401 — Stop unauthorized requests early
When middleware detects an unauthorized request, it must stop execution immediately and return a 401 response. This is non-negotiable for security. In Nuxt, you use createError() with a 401 status code:
// server/middleware/.auth.ts
export default defineEventHandler(async (event) => {
const path = getRouterParams(event).path || '';
if (isPublicRoute(path)) {
return;
}
const session = await readAndValidateSession(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
data: { reason: 'Invalid or expired session' },
});
}
event.context.user = {
id: session.userId,
email: session.email,
role: session.role,
};
});
When createError() is thrown with a 401 status, Nuxt stops the request pipeline and returns a JSON response. The client receives:
{
"statusCode": 401,
"statusMessage": "Unauthorized",
"data": {
"reason": "Invalid or expired session"
}
}
In a production e-commerce system, this is critical. If a user's session expires mid-request, you don't want that request silently proceeding as anonymous. You want it to fail loudly so the frontend can redirect to login. This prevents data leaks, unauthorized transactions, and confused user experiences.
One production gotcha: 401 vs. 403. A 401 means "you're not authenticated." A 403 means "you're authenticated but don't have permission." Middleware typically handles 401 checks (is there a valid session?). Route handlers handle 403 checks (does this user have permission to access this resource?). Mixing them confuses clients and makes debugging harder.
Common mistakes
- Blocking public routes incorrectly. Forgetting to whitelist login, register, or health check endpoints in your public routes list will lock out legitimate traffic. Maintain a clear, documented list of routes that don't require authentication.
- Expensive operations in middleware. Database queries for every request will kill performance. Keep middleware in-memory or Redis-backed. If you need user data beyond the session ID, cache it.
- Ignoring token expiry. Validating only the signature without checking expiry leads to accepting expired tokens. Always verify the
expclaim in JWTs. - Not handling middleware errors. If
getSessionFromRedis()throws an error, the whole request fails. Wrap session reads in try-catch and fail securely (return 401, not 500). - Attaching sensitive data to event.context. Store only what route handlers need. Don't attach the raw JWT or database password to the context.
- Middleware order confusion. Alphabetical execution means a file named
z-logging.tsruns aftera-auth.ts. If you have multiple middleware files, name them clearly and document the order.
Key takeaways
- Server middleware runs before route handlers and is the ideal place to enforce authentication checks on protected routes.
- Read sessions from headers (Bearer tokens) and cookies consistently, validating both signature and expiry for JWTs.
- Attach validated user information to
event.contextonce in middleware so handlers don't re-validate or duplicate logic. - Return 401 immediately when a session is invalid or missing; don't let unauthorized requests proceed to handlers.
- Keep middleware fast by avoiding database queries; use in-memory or cache-backed session storage instead.
- Test your public route whitelist thoroughly and distinguish between 401 (unauthenticated) and 403 (insufficient permissions) errors.






Comments
Loading comments…