Caching Authenticated API Routes in Nuxt with Nitro
I added caching to an admin endpoint and it broke authentication, and almost leaked user session.
Background
I have an admin panel that fetches a list of users (and manages them, obviously 😅). The endpoint looked something like this:
// server/api/admin/users/index.get.ts
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const user = await userService.findById(session.user.id);
if (!user?.isAdmin) {
throw Errors.forbidden("Admin access required");
}
const users = await userService.listAll();
return { users };
});
Auth check, admin check, fetch users.
The database query runs every request to that endpoint, even when the user list hasn't changed. This will be great candidate for caching.
The Mistake
Nitro has a defineCachedEventHandler that makes caching easy. So, initially, I did this:
// DON'T DO THIS
export default defineCachedEventHandler(
async (event) => {
const session = await requireUserSession(event);
// ... rest of handler
},
{ maxAge: 60 * 10 } // 10 minutes
);
Deployed it. (Partially) tested it. Worked great... until I logged out.
The entire response was cached, including the successful auth check from my session.
Logged back in as a non admin user, hit the endpoint, and got the full users list. What freaked me out even more was that I logged in as the admin user in another browser, and got a 401 error when hitting the same endpoint. The cached response was served to the non-admin user, while the admin user got blocked!
Why This Happens
defineCachedEventHandler caches the entire response (if it doesn't throw an error). EVERYTHING. The handler runs once, the result gets stored, and subsequent requests skip the handler entirely.
That means:
- Auth checks don't run
- Session checks don't run
- Permission checks don't run
The cache in this case doesn't know or care about authentication. It just sees "same URL, same method" and serves the cached response.
The Fix: Cache Data, Not the Handler
The solution was to separate concerns:
- Auth runs every request (never cached)
- Data fetch is cached (the expensive part)
Nitro again to the rescue (thanks Pooya for your thoughtfulness around this). It provides a defineCachedFunction method to cache just a function's result. Here's how I used it.
import type { H3Event } from "h3";
const getCachedUsersList = defineCachedFunction(
async (_event: H3Event, userService: UserService) => {
return userService.listAll();
},
{
maxAge: 60 * 10, // 10 minutes
name: "usersList",
getKey: () => "all",
}
);
Then in the handler:
export default defineEventHandler(async (event) => {
// Authentication and authorization runs on EVERY request - do not cache
const session = await requireUserSession(event);
const user = await userService.findById(session.user.id);
if (!user?.isAdmin) {
throw Errors.forbidden("Admin access required");
}
// Data fetch is cached
const users = await getCachedUsersList(event, userService);
return { users };
});
With this, unauthorized requests get rejected immediately. Only the database query benefits from caching.
Edge Worker Gotcha
If you're deploying to Cloudflare Workers (or any edge runtime), there's one more thing: pass event as the first argument to your cached function.
// Good. Works on edge
const getCachedData = defineCachedFunction(
async (event: H3Event, ...args) => {
/* ... */
},
{
/* options */
}
);
// Bad. May fail on edge workers
const getCachedData = defineCachedFunction(
async (...args) => {
/* ... */
},
{
/* options */
}
);
The Nitro docs mention this, but it's easy to miss. In factt, I missed it when I was trying to figure out why my app was broken. Maybe because I was panicking already or just plain ol' skill issue on my end 😅. Edge workers need the event context to properly manage caching.
Great, caching works now, but that is just one part of the story.
Cache Invalidation
Normally, you always want to ensure you cache has up-to-date data. Nitro stores cached function results with a predictable key format (see the Nitro caching docs for details):
${options.group}:${options.name}:${options.getKey(...args)}.json
So for our users list with name: "usersList" and getKey: () => "all", the cache key is:
nitro:functions:usersList:all.json;
The invalidation function looks something like this:
export async function invalidateUsersCache() {
const storage = useStorage("cache");
await storage.removeItem("nitro:functions:usersList:all.json");
}
I call it whereever I need a fresh state for my user resource, e.g., when users are created, blocked, or unblocked, updated their account info etc.
// In your auth callback when a new user signs up
if (isNewUser) {
await invalidateUsersCache();
}
// In your block/unblock handlers
await userService.setBlocked({ userId: id, blocked: true });
await invalidateUsersCache();
Keep It Simple
One more thing I learned: don't over-engineer the cache key. I initially had the cache key include pagination params:
getKey: (event, service, limit, offset) => `${limit}_${offset}`,
This created separate cache entries for every limit/offset combination. It looked smart at first, but my admin panel always fetches all users anyway (and to be honest, the app has less than 100 users).
A single cache entry is simpler to reason about and simpler to invalidate:
getKey: () => "all",
Conclusion
defineCachedEventHandlercaches everything, including auth results. Don't use it for authenticated routes.defineCachedFunctionlets you cache just the data fetch while auth runs every request.- Pass
eventas the first argument for edge worker compatibility. - Keep cache keys simple unless you have a real need for granularity.
The lesson I learned from this could be summarized as: cache data, not decisions.