Supabase: Check If a User Is Logged In (getUser vs getClaims)
Three methods, one of which trusts an unverified cookie. Counted across this codebase: 14 getUser() calls, 3 getClaims(), and zero getSession().
Supabase gives you three ways to ask "is this user logged in", and they are not interchangeable. getSession() reads a cookie and trusts it. getClaims() verifies the JWT's signature. getUser() round-trips to the auth server for the full user record. This codebase calls the last two 17 times and the first one zero times.
The short answer
Use getUser() or getClaims() for anything that decides access, and getSession() only for UI that does not matter if it is wrong. getSession() returns whatever is in the cookie without verifying it, so on a server it can be forged. getClaims() validates the JWT signature locally; getUser() asks Supabase Auth and returns fields the token does not carry, like email_confirmed_at.
The three methods, and what each one costs
| Method | What it checks | Network call | Returns | Safe to authorize on |
|---|---|---|---|---|
getSession() | Nothing — reads the stored session | No | Session + user, unverified | No (server); fine in the browser, where the user owns the cookie anyway |
getClaims() | JWT signature and expiry | No, with an asymmetric signing key | Decoded JWT claims (sub, email, exp, …) | Yes |
getUser() | Asks Supabase Auth to validate and look up | Yes | The full user row | Yes |
The trap is that all three "work" in development. A signed-in user gets a truthy answer from every one of them, so a getSession() check passes review, passes manual testing, and is only wrong against an attacker who edits a cookie. That is why this repo's security rules make it a named prohibition rather than a preference, and why the ban is repeated in a comment at every client factory.
What the codebase actually does
Three numbers, counted across src/ excluding tests:
- 14 calls to
supabase.auth.getUser() - 3 calls to
supabase.auth.getClaims() - 0 calls to
supabase.auth.getSession()
The zero is the interesting one. There is a getSession identifier in src/app/api/download/route.ts, but it is a method on the route's own dependency interface — the handler's injected session provider — and its implementation calls getUser(). The Supabase method itself is never called in application code.
The cheap check: getClaims() in the proxy
src/proxy.ts runs on essentially every request, refreshing the session cookie so Server Components see a valid one. It is the hottest auth path on the site, so it uses the method that does not make a network call:
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
await supabase.auth.getClaims();
return response;
}
The return value is discarded. The call is there for its side effect: touching the auth client is what rotates an expiring token and writes the refreshed cookie onto the response. Using getUser() here would add an HTTP round trip to Supabase on every page view for a result nobody reads.
The route guard: getClaims() again
src/app/dashboard/layout.tsx gates every /dashboard/* route, and only needs to know whether there is a valid identity:
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
redirect("/login?next=/dashboard");
}
// …
}
data.claims is the decoded, signature-verified JWT. claims.sub is the user id and claims.email the email — enough to build a checkout URL, which is exactly what src/lib/actions/checkout.ts does with the third getClaims() call.
The expensive check: getUser() when the token isn't enough
src/app/api/download/route.ts issues signed download URLs, and its comment states the reason it pays for the round trip:
async getSession() {
// `getUser()` (not `getClaims()`) is what exposes `email_confirmed_at`
// — the same call redemption.ts/dashboard use for this same check.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
return {
userId: user.id,
emailVerified: Boolean(user.email_confirmed_at),
};
}
email_confirmed_at is not a JWT claim. A download here requires a verified email, so the claims are insufficient and the user record has to be fetched. That is the whole rule: getClaims() when identity is enough, getUser() when you need a field the token does not carry.
The browser is a different question
Everything above is server-side. In the browser the user already controls their own cookies, so "verified" means something weaker — the question is not "can I trust this" but "should I even load the auth SDK".
src/lib/supabase/lazyClient.ts answers it with a cookie probe:
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
@supabase/ssr stores the session in cookies named sb-<project-ref>-auth-token, chunked into .0/.1 suffixes when the token is large, and deliberately not HttpOnly — createBrowserClient reads them from document.cookie itself. So this regex sees exactly what the SDK would.
Why bother: @supabase/ssr plus auth-js is roughly 68 KiB over the wire, 255 KiB parsed, and it was reaching every page on this site. The site-wide Header wanted to know whether to show "Dashboard" or "Sign in", and useOwnedProducts wanted to know whether the viewer owns each template on a grid — both imported the client at module scope, which put the entire auth stack in the initial bundle of /, /blog, /docs and /pricing, pages with no account UI at all. Both call sites only touch the client inside an effect, so the import waits for the effect too:
useEffect(() => {
if (!hasAuthCookie()) return;
// …loadSupabaseClient() dynamically imports the SDK
}, []);
For a signed-out visitor or a crawler, the auth stack is now never fetched. The comment in the file is explicit that this is a rendering shortcut and nothing more: it can never grant anything, every real gate stays server-side, and a false negative costs at most a stale "Sign in" link until the next document load — which is also when signing in or out lands, since both round-trip the page.
The same file memoizes ownership into one lookup per page load, shared by every card on the grid. A templates page renders a dozen cards that each want the same answer; asking per card would be a dozen getUser() round trips returning identical data.
Reacting to login and logout
A one-time check answers "is the user logged in right now". For a header that has to update when they sign out in another tab, subscribe instead:
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setIsSignedIn(!!session?.user);
});
unsubscribe = () => subscription.unsubscribe();
Two things that are easy to get wrong here: always unsubscribe on unmount, and never do async Supabase work inside the callback — it runs on the auth lock and awaiting another auth call from within it can deadlock. Set state, then do the work in an effect keyed on that state.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
getSession() returns a user who was deleted | It reads the stored session and verifies nothing | Use getUser() or getClaims() for any real decision |
getUser() returns null in a Server Component | No session cookie reached the client, usually a missing cookie adapter | Build the server client with cookies() and a getAll/setAll adapter |
| Session vanishes between requests | Nothing is refreshing the rotated token | Touch the auth client in middleware/proxy on every request |
getClaims() is slower than expected | With a legacy HS256 secret it falls back to a network verification | Enable asymmetric JWT signing keys so verification is local |
email_confirmed_at is undefined | It isn't a JWT claim | Fetch the user record with getUser() |
| Auth state doesn't update after logout in another tab | You checked once, never subscribed | Add onAuthStateChange and unsubscribe on unmount |
| Signing in appears to do nothing until reload | Server Components rendered before the cookie was written | Refresh the route after the auth action, or let a full navigation carry it |
| Cookie exists but SDK reports signed out | Token expired and could not be refreshed | Treat signed-out as the correct answer and re-authenticate |
Frequently asked questions
What's the difference between getUser() and getSession() in Supabase?
getSession() returns the session as stored, with no validation — on a server that means trusting a cookie an attacker can write. getUser() sends the token to Supabase Auth, which validates it and returns the authoritative user record. Anything that grants access should use getUser() or getClaims().
Is getClaims() safe to authorize with?
Yes. It verifies the JWT's signature and expiry, which is exactly what getUser() asks the server to do — it just does it locally when the project uses asymmetric signing keys, so there is no network hop. Its limit is scope, not trust: it returns only what is in the token.
How do I check login status in a Next.js Server Component?
Create a server client bound to the request cookies, then await supabase.auth.getClaims() and branch on data?.claims. That is the exact shape of the /dashboard guard above, which redirects to /login?next=/dashboard when the claims are absent.
Can I just check whether the auth cookie exists? Only to decide what to render or whether to load the SDK, never to decide what someone may do. A cookie is user-writable; the check tells you a session may exist, not that it is valid. This codebase uses exactly that probe in the browser and still re-runs full authorization on every server request.
Where to take this next
The client factories those calls sit behind are laid out in Supabase auth in Next.js, and protected routes in React covers the redirect side of the same guard. If you are migrating off the deprecated helpers, Supabase auth-helpers to @supabase/ssr is where the getSession() habit usually survives the rewrite.
Templates in this post
ASoc Zenith, ASoc Aegis and ASoc Ally are static marketing pages with no auth stack at all — worth knowing if you want a landing page that never ships 68 KiB of session code to a visitor who has no session.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
