Fix: Auth session missing in Next.js Route Handlers
AuthSessionMissingError: Auth session missing! inside a Route Handler while middleware works means the Route Handler built its own Supabase client that never read the request cookies — the session was never "in" the handler to begin with. Fix it by constructing a createServerClient from @supabase/ssr inside the handler, wired to await cookies(), and making sure your middleware matcher actually covers the route.
The confusing part is the split. Your middleware protects /dashboard, refreshes the token, redirects anonymous users — all correct. Then POST /api/notes runs three milliseconds later, in the same browser, with the same cookie header on the wire, and getUser() hands back an AuthSessionMissingError.
This is an open issue on @supabase/ssr, reported against Next.js 14.2+/15 and @supabase/ssr 0.4.1 through 0.6.1:
"AuthSessionMissingError in Next.js 14.2+/15 API Routes/Server Components Despite Valid Cookie" — the reporter confirms middleware handles session refresh and route protection correctly, while
getUser()called from an API Route consistently fails.
Why does middleware work but the Route Handler fail?
Because they are two independent Supabase clients, and only one of them was given cookies. Middleware receives a NextRequest and reads request.cookies. A Route Handler does not inherit that — it gets a fresh execution context, and whatever client you construct there starts with an empty cookie jar unless you hand it one.
There are four ways that goes wrong, and they're worth ruling out in order.
1. The handler imports the browser client. createClient from @supabase/supabase-js stores its session in localStorage. On the server there is no localStorage, so the session is always empty. This is the generic version of the problem, and it's covered in depth in why getUser() returns null on the server — if your Server Components are broken too, start there instead of here.
2. The route isn't in the middleware matcher. A matcher like ["/dashboard/:path*"] never runs on /api/notes. Middleware is where the access token gets refreshed, so a route outside the matcher only ever sees whatever cookie the browser had — including an hour-old expired one. getUser() then fails to revalidate, and depending on the SDK version you get either a 401 or Auth session missing!.
3. The cookie is chunked and the handler reads it by name. Supabase splits large session cookies into sb-<ref>-auth-token.0, .1, and so on. cookieStore.get("sb-<ref>-auth-token") returns undefined for a chunked session. Always pass the full getAll() list to createServerClient and let it reassemble. Leftover chunks cause a related failure — see stale cookie data in @supabase/ssr.
4. Middleware refreshed the token but only wrote it to the response. If your setAll writes to the outgoing response and not back onto request.cookies, a Route Handler running later in the same request still reads the old, now-retired token. That's the same plumbing bug that produces Refresh Token Not Found, seen from a different angle.
The fix
Give the Route Handler its own cookie-wired client. In Next.js 15 and later, cookies() is async, so the helper must be async and awaited at every call site:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (list) => {
try {
list.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component — middleware owns the write.
}
},
},
}
);
}
// app/api/notes/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { data, error: insertError } = await supabase
.from("notes")
.insert({ body: body.text, user_id: user.id })
.select()
.single();
if (insertError) {
return NextResponse.json({ error: insertError.message }, { status: 400 });
}
return NextResponse.json(data);
}
Then widen the matcher so /api routes actually get their token refreshed:
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
If you're on an older @supabase/ssr, upgrade while you're here. PR #131, merged in February 2026, sets skipAutoInitialize: true on the server client so the GoTrue constructor stops kicking off a token refresh on its own.
The same PR added a note to the package README that's worth reading if this error is intermittent rather than constant. Refresh tokens are single-use, so when two requests arrive at once carrying the same expired cookie, both try to refresh:
"The second request's refresh will fail because the token was already consumed by the first. The second request will receive
session: nulluntil the browser syncs the updated cookie from the first response."
That is a genuine, documented way to see a missing session in a Route Handler that has everything wired correctly — a page load that fires several parallel API calls is exactly the shape that triggers it. The mitigation is the middleware pattern: middleware runs once per navigation and refreshes before anything else runs, so the requests behind it see a valid token. The persistent variant of that race is Invalid Refresh Token: Already Used.
The version of this bug that is also a security bug
The tempting shortcut, once getUser() has thrown at you a few times, is to swap it for getSession() — it returns something, so it looks like progress. Here is middleware doing exactly that:
- Warningmiddleware.ts:23
getSession() trusted in server code
In server code (middleware, route handlers, server actions) authorize with supabase.auth.getUser() — it revalidates the JWT — not getSession(). getSession() is fine on the client, where the session is already trusted.
getSession() decodes the JWT out of the cookie and hands it back without asking the Auth server whether it is real. On the client that's fine; the session got there through a trusted code path. On the server the cookie is attacker-supplied input, so a forged one passes. It also doesn't do what you need here: getUser() is what forces the revalidate-and-refresh round trip your /api routes depend on. The full comparison of getSession() and getUser() covers where each one belongs.
Note the matcher in that file, too. ["/dashboard/:path*"] leaves every /api route unrefreshed — cause #2 and a weak auth check, shipped together in nine lines.
Quick self-check
# 1. Any server file importing the browser client?
grep -rn "from \"@supabase/supabase-js\"" app/api lib
# 2. Does the matcher cover your API routes?
grep -n -A 5 "export const config" middleware.ts
# 3. Any handler reading the auth cookie by name instead of getAll()?
grep -rn "cookieStore.get(\"sb-" app lib
If all three come back clean and getUser() still throws, log request.headers.get("cookie") at the top of the handler. If the sb- cookies are present in that header but absent from your client, the wiring is the problem. If they're missing from the header entirely, it's a browser-side issue — a cross-origin fetch without credentials: "include", or a cookie domain/sameSite mismatch between your app and its API.
FAQ
Is AuthSessionMissingError the same as an expired session?
No. Expired sessions surface as a 401 with PGRST301 / JWT expired from PostgREST, or as a refresh-token error from Auth. Auth session missing! means no token reached the Auth server at all.
Why does it work in Server Components but not Route Handlers?
Usually because the two use different helpers. Server Components often use a shared createClient() that reads cookies(), while the API route was written later with the browser client. Use one helper everywhere.
Do I need middleware if every route already builds its own server client? Yes. A per-route client can read the session, but only middleware can reliably write the rotated cookie back to the browser, because Server Components can't set cookies at all. Without it, sessions die at the first token expiry.
Should I use getClaims() instead?
getClaims() is a reasonable choice on newer SDKs. It verifies the JWT against your project's JWKS at /.well-known/jwks.json, which is cached — so on a project using asymmetric signing keys it's a local check and much faster. On a project still using a symmetric secret it always calls the Auth server anyway, so the win depends on your key setup. Either way it's safe, which getSession() is not.
Can a Route Handler use the service role key to sidestep all this? It can, and you almost never want to. The service role bypasses every RLS policy, so the handler becomes fully responsible for authorization on every query — and it still doesn't tell you which user is calling. Resolve the user first, then decide.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.