Session missing in fetch requests to your API
The browser is signed in, but your own API route or backend sees no user. Send the session in cookies or as a Bearer token, and build a per-request client on the server.
What you see
The user is signed in on the page, but when the page calls your own API (a Next.js Route Handler, an Express server, a Python backend), the server finds no user:
AuthSessionMissingError: Auth session missing!
Or supabase.auth.getUser() on the server returns user: null, and queries return only rows that anonymous visitors can see.
Why it happens
Your server only knows who the user is if the request carries the session. Common reasons it doesn't:
- The session is in local storage. A browser client from plain
@supabase/supabase-jsstores the session inlocalStorage, which is never sent to a server. - The request goes to another origin.
fetchdoesn't send cookies to a different domain unless you setcredentials: "include"and the server's CORS allows credentials. - Server code calls your own API. A Server Component that does
fetch("/api/...")doesn't forward the visitor's cookies. - The server client ignores the request. It was created without reading the request's cookies or
Authorizationheader.
How to fix it
Pick the option that matches your setup.
Option A: cookies, same site. Use @supabase/ssr so the session lives in cookies: createBrowserClient in the browser and createServerClient (reading the request cookies) in your route. Same-origin fetch sends the cookies automatically. See Server-side auth and SSR.
Option B: a Bearer token, any backend. Send the access token in a header:
// Browser
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch("https://api.invoices.example/invoices", {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
On the server, build a client for each request with that token, so Row Level Security applies as that user:
// Express
import express from "express";
import { createClient } from "@supabase/supabase-js";
const app = express();
app.get("/invoices", async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, "");
if (!token) return res.status(401).json({ error: "Not signed in" });
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
global: { headers: { Authorization: `Bearer ${token}` } },
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) return res.status(401).json({ error: "Not signed in" });
const { data, error: queryError } = await supabase
.from("invoices")
.select("id, number, total, status");
if (queryError) return res.status(500).json({ error: queryError.message });
res.json(data);
});
Don't create one shared client for all requests and set the token on it. Concurrent requests would overwrite each other's user.
Option C: skip the extra hop. In a Server Component, query Supabase directly with the server client instead of calling your own API route.
Checks
- The token you send is
session.access_token, not the anon key. If you send the anon key, see 401 invalid claim: missing sub claim. - Get the session right before the request. A token saved earlier may have expired; see JWT expired.
- For cross-origin cookies, set
credentials: "include"onfetch, and on the server allow your exact origin (not*) with credentials.