docs(auth): correct what getClaims verifies, and fix the Express env setup (#50288)

## Problem

These findings came from a technical audit and verification of the
claims in the doc.

I found two accuracy problems:

- **The guide said `getClaims()` is safe to trust** because it
"validates the JWT signature against the project's published public keys
every time". That only describes projects on asymmetric signing keys.
With a symmetric secret it calls the Auth server instead, which the
page's own partial already said. The advanced guide then read as a flat
contradiction: `getUser()` was "the only way" to know a session is
valid. The real distinction is revocation, not verification.

- **Running the Express sample verbatim doesn't work.** In the docs
sandbox, it printed `SUPABASE_URL = undefined`, so `createServerClient`
received undefined for both the URL and the key. The env var tab
installed dotenv twice, once inline and once through the package manager
tabs, and its "And initialize it" lead-in was followed by the second
install rather than any initialization. The route sample then required
dotenv without calling `config()`.

## Solution

- Say what `getClaims()` verifies against in each signing key mode.
- Reframe the advanced guide's `getUser()` answer around session
revocation, so the two pages stop contradicting each other.
- Switch the advanced guide's two middleware snippets from `getUser()`
to `getClaims()`, matching the guide.
- Rename its `Next.js middleware` heading and CloudFront bullet, which
the proxy rename missed.
- Load dotenv on the first line of the Express entry point, and drop the
duplicate install.
- Tag both Express fences `js`. They are CommonJS, not TypeScript.
- Update the stale "middleware refreshing user sessions" comment in the
rendered Next.js `server.ts` sample.

## Manual testing

1. Open the [SSR client
guide](https://docs-git-docs-ssr-client-accuracy-supabase.vercel.app/docs/guides/auth/server-side/creating-a-client)
on the deploy preview, then the Express tab. dotenv is installed once,
followed by `require('dotenv').config()`.
2. Open the [advanced
guide](https://docs-git-docs-ssr-client-accuracy-supabase.vercel.app/docs/guides/auth/server-side/advanced-guide).
The Next.js heading reads `Next.js proxy` and both snippets call
`getClaims()`.

Part of DOCS-1313.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Documentation**
- Clarified the difference between token validation and detecting
revoked server-side sessions.
  - Updated Next.js guidance and examples to use “proxy” terminology.
  - Refined CloudFront caching guidance for authenticated routes.
- Improved Express setup instructions, including dotenv loading and
JavaScript examples.
  - Expanded explanations of signing-key verification.
  - Updated Astro and Nuxt examples to forward cache headers correctly.
- Updated session-refresh guidance in the Next.js example to reference
the proxy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Miranda Limonczenko authored and GitHub committed 2026-09-16 12:31:11 -07:00
1 parent 7bec687917
commit a4106b01f5
3 files changed
+39 -28

No files matched your search

@@ -52,7 +52,7 @@ A common cause is calling `supabase.auth.signOut()` without a `scope`. It defaul
The `Max-Age` or `Expires` cookie parameters only control whether the browser sends the value to the server. Since a refresh token represents the long-lived authentication session of the user on that browser, setting a short `Max-Age` or `Expires` parameter on the cookies only results in a degraded user experience.
The only way to ensure that a user has logged out or their session has ended is to get the user's details with `getUser()`. The `getClaims()` method only checks local JWT validation (signature and expiration), but it doesn't verify with the auth server whether the session is still valid or if the user has logged out server-side.
The only way to detect that a session ended server-side, for example because the user signed out on another device, is to fetch the user with `getUser()`. `getClaims()` verifies the token's signature and expiry, which is what authorizes a request, but an unexpired token stays valid even when the session behind it was revoked. Call `getUser()` where that gap matters.
### What should I use for the `SameSite` property?
@@ -78,11 +78,11 @@ As of `@supabase/ssr` v0.10.0, the library automatically passes the necessary ca
If you are on an older version or need to set headers manually, add `Cache-Control: private, no-store` to responses from any route that handles authentication:
#### Next.js middleware
#### Next.js proxy
```ts
const response = NextResponse.next()
// ... supabase client setup and getUser() call
// ... supabase client setup and getClaims() call
response.headers.set('Cache-Control', 'private, no-store')
return response
```
@@ -90,7 +90,7 @@ return response
#### Nuxt server middleware
```ts
// ... supabase client setup and getUser() call
// ... supabase client setup and getClaims() call
setHeader(event, 'Cache-Control', 'private, no-store')
```
@@ -102,7 +102,7 @@ To protect against session leakage on CloudFront, use one or more of the followi
- **Set Minimum TTL to 0** in your CloudFront cache policy. This allows `Cache-Control: no-store` to take effect as intended.
- **Use `Cache-Control: no-cache="Set-Cookie"`** to instruct CloudFront not to cache the `Set-Cookie` header specifically, while still allowing other parts of the response to be cached.
- **Disable caching entirely** for authenticated routes (e.g. your middleware path) by associating a cache policy with TTL set to 0, or by using the managed `CachingDisabled` policy for those behaviors.
- **Disable caching entirely** for authenticated routes such as your proxy path, by associating a cache policy with TTL set to 0, or by using the managed `CachingDisabled` policy for those behaviors.
<Admonition type="note">
@@ -130,12 +130,6 @@ SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key
Install [dotenv](https://www.npmjs.com/package/dotenv):
```bash
npm i dotenv
```
And initialize it:
<Tabs size="small" type="underlined" queryGroup="package-manager" defaultActiveId="npm">
<TabPanel id="npm" label="npm">
@@ -164,6 +158,12 @@ pnpm add dotenv
</Tabs>
Then load the file before you read any variable from it. Put this on the first line of your entry point, above every other import:
```js app.js
require('dotenv').config()
```
</TabPanel>
<TabPanel id="hono" label="Hono">
@@ -269,9 +269,9 @@ The code adds a [matcher](https://nextjs.org/docs/app/api-reference/file-convent
Anyone can forge the session cookie, so trusting it without verification lets an attacker render another user's page. Always use `supabase.auth.getClaims()` to protect pages and user data.
_Never_ trust `supabase.auth.getSession()` inside server code such as Proxy. It isn't guaranteed to revalidate the Auth token.
_Never_ trust `supabase.auth.getSession()` inside server code such as Proxy. It reads the session out of the cookie without revalidating it.
It's safe to trust `getClaims()` because it validates the JWT signature against the project's published public keys every time.
`getClaims()` verifies the token's signature on every call. On projects with asymmetric signing keys, the default for new projects, it verifies locally against a cached copy of the project's public keys. On projects still using a symmetric secret, it calls the Auth server instead. Either way the claims come from a token the server has verified rather than from whatever the cookie says.
</Admonition>
@@ -422,10 +422,12 @@ const supabase = createServerClient(
<TabPanel id="astro-server-endpoint" label="Server Endpoint">
```ts route.ts
import { createServerClient, parseCookieHeader } from "@supabase/ssr";
import type { APIContext } from "astro";
import { createServerClient, parseCookieHeader } from '@supabase/ssr'
import type { APIContext } from 'astro'
export async function GET(context: APIContext) {
const responseHeaders = new Headers()
const supabase = createServerClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY,
@@ -434,15 +436,17 @@ export async function GET(context: APIContext) {
getAll() {
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value }) =>
context.cookies.set(name, value))
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => context.cookies.set(name, value))
Object.entries(headers).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
}
);
)
return ...
// Build your response here, and pass `responseHeaders` to it. Without them a
// shared cache can store this response along with its Set-Cookie header.
return new Response(null, { headers: responseHeaders })
}
```
@@ -455,6 +459,8 @@ import { createServerClient, parseCookieHeader } from '@supabase/ssr'
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
const responseHeaders = new Headers()
const supabase = createServerClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY,
@@ -463,14 +469,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
getAll() {
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet, _headers) {
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => context.cookies.set(name, value))
Object.entries(headers).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
}
)
return next()
const response = await next()
responseHeaders.forEach((value, key) => response.headers.set(key, value))
return response
})
```
@@ -609,7 +618,7 @@ You can now use any Supabase feature from your client or server code.
```ts server/api/hello.ts
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
import { appendHeader, defineEventHandler, getHeader } from 'h3'
import { appendHeader, defineEventHandler, getHeader, setHeader } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
@@ -622,10 +631,11 @@ export default defineEventHandler(async (event) => {
getAll() {
return parseCookieHeader(getHeader(event, 'Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value, options }) => {
appendHeader(event, 'Set-Cookie', serializeCookieHeader(name, value, options))
})
Object.entries(cacheHeaders).forEach(([key, value]) => setHeader(event, key, value))
},
},
}
@@ -785,7 +795,7 @@ You can now use any Supabase feature from your client or server code.
>
<TabPanel id="server-client" label="Server Client">
```ts lib/supabase.js
```js lib/supabase.js
const { createServerClient, parseCookieHeader, serializeCookieHeader } = require('@supabase/ssr')
exports.createClient = (context) => {
@@ -808,9 +818,10 @@ exports.createClient = (context) => {
</TabPanel>
<TabPanel id="express-route" label="Route">
```ts app.js
```js app.js
require("dotenv").config()
const express = require("express")
const dotenv = require("dotenv")
const { createClient } = require("./lib/supabase")
+1 -1
View File
@@ -19,7 +19,7 @@ export async function createClient() {
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// This can be ignored if you have a proxy refreshing
// user sessions.
}
},