docs: warn that default signOut scope revokes all sessions (#50119)

Warn that default signOut scope revokes all sessions. Motivation:
https://github.com/supabase/ssr/issues/68

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

## Summary by CodeRabbit

* **Documentation**
* Clarified that signing out without a specified scope ends all sessions
by default.
* Added guidance for using a local sign-out scope to preserve sessions
on other devices and browsers.
* Documented the invalid refresh token errors that may occur when other
sessions are revoked.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Katerina Skroumpelou authored and GitHub committed 2026-09-10 12:37:06 +00:00
1 parent 045f29ecb6
commit 028e05205b
9 files changed
+411 -85

No files matched your search

+5
View File
@@ -129,6 +129,10 @@ jobs:
# Vercel skips the docs preview when a PR only changes the harness
# (e2e/docs, workflow), so wait for a preview only when apps/docs changed.
# When apps/docs changed earlier in the PR but not in the head commit,
# Vercel skips the head build as not affected and its URL serves a
# placeholder page; the script then uses the newest READY preview from
# an earlier commit of the PR, which serves the same docs content.
#
# Vercel's GitHub App stopped writing GitHub Deployment objects on
# 2026-02-17 (broken app auth), so vercel/wait-for-deployment-action
@@ -146,6 +150,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_STATUS_CONTEXT: 'Vercel – docs'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
+5 -1
View File
@@ -120,7 +120,10 @@ jobs:
cache: 'pnpm'
# Vercel skips the preview when only the harness changed, so wait for one
# only when apps/www changed. See scripts/waitForVercelPreview.js.
# only when apps/www changed. A head commit that leaves apps/www untouched
# gets a skipped build whose URL serves a placeholder page, so the script
# falls back to the newest READY preview from an earlier commit of the PR.
# See scripts/waitForVercelPreview.js.
- name: Wait for Vercel www preview
if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.www_app == 'true'
id: deployment
@@ -129,6 +132,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_STATUS_CONTEXT: 'Vercel – zone-www-dot-com'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
@@ -46,6 +46,8 @@ It is likely that the refresh token sent from the browser to your server is stal
When you receive this error on the server-side, try to defer rendering to the browser where the client library can access an up-to-date refresh token and present the user with a better experience.
A common cause is calling `supabase.auth.signOut()` without a `scope`. It defaults to `scope: 'global'`, which revokes the refresh token for every session that user has. That includes other tabs, devices, and any SSR context still holding the old token. See [Sign out and scopes](/docs/guides/auth/signout#sign-out-and-scopes). Pass `{ scope: 'local' }` if you only want to end the current session.
### Should I set a shorter `Max-Age` parameter on the cookies?
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.
+10
View File
@@ -89,6 +89,16 @@ Supabase Auth allows you to specify three different scopes for when a user invok
You can invoke these by providing the `scope` option:
<Admonition type="caution">
JavaScript, Swift, Python, and C# default to the `global` scope. Dart and Kotlin default to `local`.
With the `global` scope, calling `signOut()` on one device revokes the refresh tokens for every session that user has, including other devices and browsers. Those other sessions fail with `AuthApiError: Invalid Refresh Token: Refresh Token Not Found` the next time they try to refresh, even though no one signed out on that device.
If your app expects independent sessions per device, pass the `local` scope explicitly, as shown in the examples below.
</Admonition>
<Tabs
scrollable
size="small"
+4 -2
View File
@@ -211,8 +211,10 @@ owned docs content, partials, or `e2e/docs`.
1. Diff the pull request against its base branch and resolve in-scope page paths.
2. Skip Playwright when nothing in scope changed.
3. When `apps/docs` changed, wait for the Vercel docs preview and set
`PLAYWRIGHT_BASE_URL` to that preview. When no preview resolves, skip rather than
test against production.
`PLAYWRIGHT_BASE_URL` to that preview. If Vercel skipped the head commit's
build as not affected, the newest ready preview from an earlier commit of the
pull request is used instead. When no preview resolves, skip rather than test
against production.
4. Run the suite with `DOCS_E2E_PAGE_PATHS` set to the resolved list.
Draft pull requests stay skipped until you mark them ready for review. Manual
+10 -15
View File
@@ -16,7 +16,7 @@ import {
} from '../utils/axe-helpers.js'
import {
articleSelectorForPagePath,
browserLikeUserAgent,
checkLinkFromBrowser,
collectDocsOwnedLinks,
} from '../utils/docs-links.js'
@@ -56,22 +56,17 @@ test.describe('Docs owned pages', () => {
await expect(article, 'Page article should be present').toBeVisible()
const links = await collectDocsOwnedLinks(page, baseURL!, articleSelector)
const userAgent = await browserLikeUserAgent(page)
for (const url of links) {
try {
const linkResponse = await page.request.get(url, { headers: { 'user-agent': userAgent } })
expect
.soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`)
.toBeTruthy()
} catch (error) {
expect
.soft(
null,
`${url} should be reachable (${error instanceof Error ? error.message : error})`
)
.toBeTruthy()
}
const result = await checkLinkFromBrowser(page, url)
expect
.soft(
result.ok,
result.error
? `${url} should be reachable (${result.error})`
: `${url} should resolve (status ${result.status})`
)
.toBeTruthy()
}
})
}
+19 -4
View File
@@ -55,8 +55,23 @@ export async function collectDocsOwnedLinks(
return [...links].sort()
}
// Vercel bot protection blocks the HeadlessChrome UA on some routes; strip it.
export async function browserLikeUserAgent(page: Page): Promise<string> {
const userAgent = await page.evaluate(() => navigator.userAgent)
return userAgent.replace('HeadlessChrome', 'Chrome')
export type LinkCheckResult = {
ok: boolean
status: number
error?: string
}
// The docs middleware rewrites /reference/* requests whose user agent looks like a bot
// (isbot, which matches HeadlessChrome) to /api/crawlers. Fetching from inside the page
// keeps the browser's own user agent and network stack, so a link resolves the same way
// page.goto resolves it in this suite.
export async function checkLinkFromBrowser(page: Page, url: string): Promise<LinkCheckResult> {
return page.evaluate(async (linkUrl) => {
try {
const response = await fetch(linkUrl)
return { ok: response.ok, status: response.status }
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) }
}
}, url)
}
+160 -63
View File
@@ -4,56 +4,162 @@
// builds fine. Poll the project's Vercel commit status instead, then resolve
// the actual preview URL via Vercel's own deployments API.
//
// A build that Vercel's Ignored Build Step skips still gets a successful
// commit status, but the deployment behind it is CANCELED and its URL serves
// a placeholder page with a 200 status. Such a preview is not testable, so
// the URL is resolved from the newest earlier commit of the pull request
// whose deployment is READY. Vercel skips a build only when the commit does
// not affect the project, so that earlier preview serves the same content.
//
// Set VERCEL_STATUS_CONTEXT to the project's commit status name, e.g.
// "Vercel – docs" or "Vercel – zone-www-dot-com".
// "Vercel – docs" or "Vercel – zone-www-dot-com". PR_NUMBER enables the
// fallback to earlier commits of the pull request.
const { appendFileSync } = require('fs')
const TIMEOUT_MS = 900_000
const POLL_INTERVAL_MS = 15_000
const GITHUB_PAGE_SIZE = 100
async function fetchLatestStatus(repository, sha, githubToken, statusContext) {
const url = `https://api.github.com/repos/${repository}/commits/${sha}/statuses`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github+json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch commit statuses: ${response.status} ${response.statusText}`)
function deploymentIdFromTargetUrl(targetUrl) {
let rawId
try {
rawId = new URL(targetUrl).pathname.split('/').filter(Boolean).pop()
} catch {
rawId = undefined
}
const statuses = await response.json()
return statuses
.filter((status) => status.context === statusContext)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
}
async function resolveDeploymentUrl(targetUrl, vercelToken, teamId) {
const rawId = targetUrl.split('/').filter(Boolean).pop()
if (!rawId) {
throw new Error(`Could not parse a deployment ID from target_url: ${targetUrl}`)
}
const deploymentId = rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}`
return rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}`
}
const url = teamId
? `https://api.vercel.com/v13/deployments/${deploymentId}?teamId=${teamId}`
: `https://api.vercel.com/v13/deployments/${deploymentId}`
function shortSha(sha) {
return sha.slice(0, 10)
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${vercelToken}` },
})
function createClients({ fetchImpl = fetch, repository, githubToken, vercelToken, teamId }) {
const githubHeaders = {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github+json',
}
const vercelHeaders = { Authorization: `Bearer ${vercelToken}` }
if (!response.ok) {
async function getJson(url, headers, what) {
const response = await fetchImpl(url, { headers })
if (!response.ok) {
throw new Error(`Failed to ${what}: ${response.status} ${response.statusText}`)
}
return response.json()
}
return {
// Newest status for the context, or undefined when Vercel has not posted one.
async latestStatus(sha, statusContext) {
const statuses = await getJson(
`https://api.github.com/repos/${repository}/commits/${sha}/statuses`,
githubHeaders,
`fetch commit statuses for ${shortSha(sha)}`
)
return statuses
.filter((status) => status.context === statusContext)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
},
// Commit SHAs of the pull request, newest first.
async pullRequestShas(prNumber) {
const shas = []
for (let page = 1; ; page += 1) {
const commits = await getJson(
`https://api.github.com/repos/${repository}/pulls/${prNumber}/commits?per_page=${GITHUB_PAGE_SIZE}&page=${page}`,
githubHeaders,
`fetch commits for pull request #${prNumber}`
)
shas.push(...commits.map((commit) => commit.sha))
if (commits.length < GITHUB_PAGE_SIZE) break
}
return shas.reverse()
},
async deployment(targetUrl) {
const deploymentId = deploymentIdFromTargetUrl(targetUrl)
const query = teamId ? `?teamId=${teamId}` : ''
const deployment = await getJson(
`https://api.vercel.com/v13/deployments/${deploymentId}${query}`,
vercelHeaders,
`resolve Vercel deployment ${deploymentId}`
)
return { url: `https://${deployment.url}`, state: deployment.readyState ?? deployment.status }
},
}
}
async function resolveFromEarlierCommits(clients, { headSha, prNumber, statusContext, log }) {
if (!prNumber) {
throw new Error(
`Failed to resolve Vercel deployment ${deploymentId}: ${response.status} ${response.statusText}`
`The "${statusContext}" build for ${shortSha(headSha)} was skipped, and PR_NUMBER is required to fall back to an earlier commit's preview`
)
}
const deployment = await response.json()
return `https://${deployment.url}`
const shas = await clients.pullRequestShas(prNumber)
for (const sha of shas) {
if (sha === headSha) continue
const status = await clients.latestStatus(sha, statusContext)
if (status?.state !== 'success' || !status.target_url) continue
const deployment = await clients.deployment(status.target_url)
if (deployment.state === 'READY') {
log(`Using the "${statusContext}" preview built for ${shortSha(sha)}: ${deployment.url}`)
return deployment.url
}
}
throw new Error(
`No READY "${statusContext}" preview among the ${shas.length} commits of pull request #${prNumber}`
)
}
async function resolvePreviewUrl(clients, options) {
const { headSha, statusContext, timeoutMs, pollIntervalMs, sleep, now, log } = options
const start = now()
for (;;) {
const latest = await clients.latestStatus(headSha, statusContext)
if (latest?.state === 'success') {
if (!latest.target_url) {
throw new Error(
`"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
)
}
const deployment = await clients.deployment(latest.target_url)
if (deployment.state === 'READY') {
return deployment.url
}
if (deployment.state === 'CANCELED') {
log(
`"${statusContext}" for ${shortSha(headSha)} reports "${latest.description}" and its deployment is CANCELED; looking for the newest READY preview among earlier commits`
)
return resolveFromEarlierCommits(clients, options)
}
throw new Error(
`"${statusContext}" deployment for ${shortSha(headSha)} is ${deployment.state}, not READY`
)
}
if (latest?.state === 'failure' || latest?.state === 'error') {
throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
}
if (now() - start > timeoutMs) {
throw new Error(
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the "${statusContext}" preview deployment`
)
}
await sleep(pollIntervalMs)
}
}
function writeOutput(name, value) {
@@ -71,6 +177,7 @@ async function main() {
const vercelToken = process.env.VERCEL_TOKEN
const teamId = process.env.VERCEL_TEAM_ID
const statusContext = process.env.VERCEL_STATUS_CONTEXT
const prNumber = process.env.PR_NUMBER
if (!repository) throw new Error('GITHUB_REPOSITORY environment variable is required')
if (!sha) throw new Error('HEAD_SHA environment variable is required')
@@ -78,35 +185,25 @@ async function main() {
if (!vercelToken) throw new Error('VERCEL_TOKEN environment variable is required')
if (!statusContext) throw new Error('VERCEL_STATUS_CONTEXT environment variable is required')
const start = Date.now()
for (;;) {
const latest = await fetchLatestStatus(repository, sha, githubToken, statusContext)
if (latest?.state === 'success') {
if (!latest.target_url) {
throw new Error(
`"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
)
}
const deploymentUrl = await resolveDeploymentUrl(latest.target_url, vercelToken, teamId)
writeOutput('deployment-url', deploymentUrl)
return
}
if (latest?.state === 'failure' || latest?.state === 'error') {
throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
}
if (Date.now() - start > TIMEOUT_MS) {
throw new Error(`Timed out after 900s waiting for the "${statusContext}" preview deployment`)
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}
const clients = createClients({ repository, githubToken, vercelToken, teamId })
const url = await resolvePreviewUrl(clients, {
headSha: sha,
prNumber,
statusContext,
timeoutMs: TIMEOUT_MS,
pollIntervalMs: POLL_INTERVAL_MS,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now: Date.now,
log: console.log,
})
writeOutput('deployment-url', url)
}
main().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})
module.exports = { createClients, deploymentIdFromTargetUrl, resolvePreviewUrl }
if (require.main === module) {
main().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})
}
+196
View File
@@ -0,0 +1,196 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const { deploymentIdFromTargetUrl, resolvePreviewUrl } = require('./waitForVercelPreview.js')
const CONTEXT = 'Vercel – docs'
const HEAD = 'head000000000000000000000000000000000000'
const OLDER = 'older00000000000000000000000000000000000'
const OLDEST = 'oldest0000000000000000000000000000000000'
function success(id, description = 'Deployment has completed') {
return { state: 'success', description, target_url: `https://vercel.com/supabase/docs/${id}` }
}
function fakeClients({ statuses = {}, deployments = {}, prShas = [] }) {
const calls = { latestStatus: [], deployment: [], pullRequestShas: 0 }
return {
calls,
async latestStatus(sha) {
calls.latestStatus.push(sha)
const list = statuses[sha]
return Array.isArray(list) ? list.shift() : list
},
async pullRequestShas() {
calls.pullRequestShas += 1
return prShas
},
async deployment(targetUrl) {
calls.deployment.push(targetUrl)
return deployments[deploymentIdFromTargetUrl(targetUrl)]
},
}
}
function options(overrides = {}) {
const log = []
return {
headSha: HEAD,
prNumber: 50119,
statusContext: CONTEXT,
timeoutMs: 1000,
pollIntervalMs: 10,
sleep: async () => {},
now: () => 0,
log: (line) => log.push(line),
logLines: log,
...overrides,
}
}
test('deploymentIdFromTargetUrl prefixes bare inspector ids and keeps dpl_ ids', () => {
assert.equal(
deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/Avoo1vcp'),
'dpl_Avoo1vcp'
)
assert.equal(
deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/dpl_Avoo1vcp'),
'dpl_Avoo1vcp'
)
assert.throws(() => deploymentIdFromTargetUrl('https://vercel.com'), /deployment ID/)
})
test('returns the head deployment when it is READY', async () => {
const clients = fakeClients({
statuses: { [HEAD]: success('dpl_head') },
deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } },
})
const url = await resolvePreviewUrl(clients, options())
assert.equal(url, 'https://docs-head.vercel.app')
assert.equal(clients.calls.pullRequestShas, 0)
})
test('polls while the head status is pending', async () => {
let clock = 0
const sleeps = []
const clients = fakeClients({
statuses: { [HEAD]: [undefined, { state: 'pending' }, success('dpl_head')] },
deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } },
})
const url = await resolvePreviewUrl(
clients,
options({
now: () => clock,
sleep: async (ms) => {
sleeps.push(ms)
clock += ms
},
})
)
assert.equal(url, 'https://docs-head.vercel.app')
assert.deepEqual(sleeps, [10, 10])
})
test('falls back to the newest earlier commit with a READY deployment when the head build was skipped', async () => {
const clients = fakeClients({
prShas: [HEAD, OLDER, OLDEST],
statuses: {
[HEAD]: success('dpl_head', 'Skipped - Not affected'),
[OLDER]: undefined,
[OLDEST]: success('dpl_oldest'),
},
deployments: {
dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' },
dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' },
},
})
const opts = options()
const url = await resolvePreviewUrl(clients, opts)
assert.equal(url, 'https://docs-oldest.vercel.app')
assert.deepEqual(clients.calls.latestStatus, [HEAD, OLDER, OLDEST])
assert.ok(opts.logLines.some((line) => line.includes('Skipped - Not affected')))
assert.ok(opts.logLines.some((line) => line.includes(OLDEST.slice(0, 10))))
})
test('skips earlier commits whose deployment is not READY', async () => {
const clients = fakeClients({
prShas: [HEAD, OLDER, OLDEST],
statuses: {
[HEAD]: success('dpl_head', 'Skipped - Not affected'),
[OLDER]: success('dpl_older', 'Skipped - Not affected'),
[OLDEST]: success('dpl_oldest'),
},
deployments: {
dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' },
dpl_older: { url: 'https://docs-older.vercel.app', state: 'CANCELED' },
dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' },
},
})
const url = await resolvePreviewUrl(clients, options())
assert.equal(url, 'https://docs-oldest.vercel.app')
})
test('throws when the head build was skipped and no earlier commit has a READY deployment', async () => {
const clients = fakeClients({
prShas: [HEAD, OLDER],
statuses: {
[HEAD]: success('dpl_head', 'Skipped - Not affected'),
[OLDER]: { state: 'failure', target_url: 'https://vercel.com/supabase/docs/dpl_older' },
},
deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } },
})
await assert.rejects(resolvePreviewUrl(clients, options()), /No READY "Vercel – docs" preview/)
})
test('throws when the head build was skipped and no pull request number is available', async () => {
const clients = fakeClients({
statuses: { [HEAD]: success('dpl_head', 'Skipped - Not affected') },
deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } },
})
await assert.rejects(resolvePreviewUrl(clients, options({ prNumber: undefined })), /PR_NUMBER/)
assert.equal(clients.calls.pullRequestShas, 0)
})
test('throws when the head deployment is in any state other than READY or CANCELED', async () => {
const clients = fakeClients({
statuses: { [HEAD]: success('dpl_head') },
deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'ERROR' } },
})
await assert.rejects(resolvePreviewUrl(clients, options()), /ERROR/)
})
test('throws when the head status reports a failed deployment', async () => {
const clients = fakeClients({ statuses: { [HEAD]: { state: 'failure' } } })
await assert.rejects(resolvePreviewUrl(clients, options()), /deployment failed/)
})
test('throws when the head status never resolves before the timeout', async () => {
let clock = 0
const clients = fakeClients({ statuses: { [HEAD]: { state: 'pending' } } })
await assert.rejects(
resolvePreviewUrl(
clients,
options({
timeoutMs: 25,
now: () => clock,
sleep: async (ms) => {
clock += ms
},
})
),
/Timed out/
)
})