Merge branch 'vercel-marketplace' of github.com:supabase/supabase into vercel-marketplace

This commit is contained in:
Alaister Young committed 2024-08-21 18:09:03 +08:00
commit 5f68b4485b
557 files changed
+24320 -10210

No files matched your search

+4 -2
View File
@@ -2,7 +2,9 @@
/packages/shared-data/plans.ts @roryw10 @kevcodez
/apps/studio/ @supabase/Dashboard
/apps/www/ @supabase/Website
/apps/www/_blog @supabase/marketing
/apps/www/ @supabase/marketing
/apps/www/public/images/blog @supabase/marketing
/apps/www/lib/redirects.js
/docker/ @supabase/cli
+35
View File
@@ -0,0 +1,35 @@
# Smoke tests against the docs production site, run daily
name: Docs Production Smoke Tests
on:
workflow_dispatch:
schedule:
- cron: '0 4 * * *'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
apps/docs
packages
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Run tests
run: npm --prefix="apps/docs" run test:smoke
@@ -0,0 +1,72 @@
import { load } from 'cheerio'
import { describe, expect, it } from 'vitest'
const REFERENCE_DOCS_URL = 'https://supabase.com/docs/reference'
// For dev testing: comment out above and uncomment below
// const REFERENCE_DOCS_URL = 'http://localhost:3001/docs/reference'
describe('prod smoke test: crawler pages return correct data', () => {
/**
* No special tricks required to spoof the user agent. Tests are correctly
* detected as coming from bots. If they ever aren't, the `h1` test will fail
* as a different `h1` is served to non-bots.
*/
it('metadata: title, description, canonical, image', async () => {
const result = await fetch(REFERENCE_DOCS_URL + '/javascript/rangelte')
const text = await result.text()
const $ = load(text)
const title = $('title').text()
expect(title).toBe('JavaScript: Less than or equal to a range | Supabase Docs')
const metaDescription = $('meta[name="description"]')
expect(metaDescription.attr('content')).toBe(
'Supabase API reference for JavaScript: Less than or equal to a range'
)
const canonical = $('link[rel="canonical"]')
expect(canonical.attr('href')).toBe('https://supabase.com/docs/reference/javascript/rangelte')
const ogImage = $('meta[name="og:image"]')
expect(ogImage.attr('content')).toBe('https://supabase.com/docs/img/supabase-og-image.png')
const twitterImage = $('meta[name="twitter:image"]')
expect(twitterImage.attr('content')).toBe('https://supabase.com/docs/img/supabase-og-image.png')
})
it('markdown pages', async () => {
const result = await fetch(REFERENCE_DOCS_URL + '/javascript/introduction')
const text = await result.text()
const $ = load(text)
const h1 = $('h1').text()
expect(h1).toBe('JavaScript: Introduction')
const firstPara = $('h1').next().text()
expect(/JavaScript library/.test(firstPara)).toBe(true)
expect(/supabase-js/.test(firstPara)).toBe(true)
})
it('function pages', async () => {
const result = await fetch(REFERENCE_DOCS_URL + '/javascript/rangelte')
const text = await result.text()
const $ = load(text)
const h1 = $('h1').text()
expect(h1).toBe('JavaScript: Less than or equal to a range')
const description = $('h1').next().text()
expect(description).toBe(
'Only relevant for range columns. Match only rows where every element in column is either contained in range or less than any element in range.'
)
const headings = [] as Array<string | undefined>
$('h2').map(function () {
headings.push($(this).attr('id'))
})
expect(headings.includes('parameters')).toBe(true)
expect(headings.includes('examples')).toBe(true)
})
})
+240
View File
@@ -0,0 +1,240 @@
import { toHtml } from 'hast-util-to-html'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toHast } from 'mdast-util-to-hast'
import { mdxjs } from 'micromark-extension-mdxjs'
import { redirect } from 'next/navigation'
import { visit } from 'unist-util-visit'
import { REFERENCES } from '~/content/navigation.references'
import {
getFlattenedSections,
getFunctionsList,
getTypeSpec,
} from '~/features/docs/Reference.generated.singleton'
import { getRefMarkdown } from '~/features/docs/Reference.mdx'
import type { MethodTypes } from '~/features/docs/Reference.typeSpec'
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
import { notFoundLink } from '~/features/recommendations/NotFound.utils'
import { BASE_PATH } from '~/lib/constants'
export async function GET(request: Request) {
const url = new URL(request.url)
let [, , lib, maybeVersion, slug] = url.pathname.split('/')
const libraryMeta = REFERENCES[lib]
const isVersion = /^v\d+$/.test(maybeVersion)
const version = isVersion ? maybeVersion : libraryMeta.versions[0]
if (!isVersion) {
slug = maybeVersion
}
let section: AbbrevCommonClientLibSection
let sectionsWithUrl: Array<AbbrevCommonClientLibSection & { url: URL }>
try {
const flattenedSections = (await getFlattenedSections(lib, version)) ?? []
sectionsWithUrl = flattenedSections.map((section) => {
const url = new URL(request.url)
url.pathname = [BASE_PATH, 'reference', lib, isVersion ? version : null, section.slug]
.filter(Boolean)
.join('/')
return {
...section,
url,
}
})
section = flattenedSections.find(
(section) =>
(section.type === 'markdown' || section.type === 'function') && section.slug === slug
)
} catch {}
if (!section) {
redirect(notFoundLink(`${lib}/${slug}`))
}
const html = htmlShell(
lib,
isVersion ? version : null,
slug,
section,
libraryNav(sectionsWithUrl) + (await sectionDetails(lib, isVersion ? version : null, section))
)
const response = new Response(html)
response.headers.set('Content-Type', 'text/html; charset=utf-8')
return response
}
function htmlShell(
lib: string,
version: string | null,
slug: string,
section: AbbrevCommonClientLibSection,
body: string
) {
const libraryName = REFERENCES[lib].name
let title = libraryName + ': ' + section.title ?? ''
return (
'<!doctype html><html>' +
'<head>' +
`<title>${title} | Supabase Docs</title>` +
`<meta name="description" content="Supabase API reference for ${libraryName}${section.title ? ': ' + section.title : ''}">` +
`<meta name="og:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
`<meta name="twitter:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
`<link rel="canonical" href="https://supabase.com/docs/reference/${lib}` +
(version ? '/' + version : '') +
(slug ? '/' + slug : '') +
`">` +
'</head>' +
'<body>' +
body +
'</body></html>'
)
}
function libraryNav(sections: Array<AbbrevCommonClientLibSection & { url: URL }>) {
return (
'<nav><ul>' +
sections
.map((section) => `<li><a href="${section.url}">${section.title ?? ''}</a></li>`)
.join('') +
'</ul></nav>'
)
}
async function sectionDetails(lib: string, version: string, section: AbbrevCommonClientLibSection) {
const libraryName = REFERENCES[lib].name
let result = '<h1>' + (libraryName + ': ' + section.title ?? '') + '</h1>'
if (section.type === 'markdown') {
result += await markdown(lib, version, section)
} else {
result += await functionDetails(lib, version, section)
}
return result
}
async function markdown(
lib: string,
version: string | null,
section: AbbrevCommonClientLibSection
) {
const dir = !!section.meta?.shared ? 'shared' : lib + (version ? '/' + version : '')
let content = await getRefMarkdown(dir + '/' + section.slug)
content = mdxToHtml(content)
return content
}
async function functionDetails(
lib: string,
version: string | null,
section: AbbrevCommonClientLibSection
) {
const libraryMeta = REFERENCES[lib]
const fns = await getFunctionsList(lib, version ?? libraryMeta.versions[0])
const fn = fns!.find((fn) => fn.id === section.id)
if (!fn) return ''
let types: MethodTypes | undefined
if (libraryMeta.typeSpec && '$ref' in fn) {
types = await getTypeSpec(fn['$ref'] as string)
}
const fullDescription = [
types?.comment?.shortText,
'description' in fn && (fn.description as string),
'notes' in fn && (fn.notes as string),
]
.filter((x) => typeof x === 'string')
.map(mdxToHtml)
.join('')
const parameters = parametersToHtml(fn, types)
const examples = examplesToHtml(fn)
return fullDescription + parameters + examples
}
function mdxToHtml(markdownUnescaped: string): string {
const markdown = markdownUnescaped.replace(/(?<!\\)\{/g, '\\{').replace(/(?<!\\)\}/g, '\\}')
const mdast = fromMarkdown(markdown, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
visit(mdast, 'text', (node) => {
node.value = node.value.replace(/\n/g, ' ')
})
if (!mdast) return ''
const hast = toHast(mdast)
if (!hast) return ''
// @ts-ignore
const html = toHtml(hast)
return html
}
function parametersToHtml(fn: any, types: MethodTypes | undefined) {
let result = '<h2 id="parameters">Parameters</h2>'
if ('overwriteParams' in fn || 'params' in fn) {
const params = fn.overwriteParams ?? fn.params
if (params.length === 0) return ''
result +=
'<ul>' +
params
.map(
(param) =>
'<li>' +
`<h3>${param.name}</h3>` +
`<span>${param.isOptional ? '(Optional)' : '(Required)'}</span>` +
`<p>${param.description}</p>` +
'</li>'
)
.join('') +
'</ul>'
return result
}
if (!types?.params || types.params.length === 0) return ''
result +=
'<ul>' +
types.params
.map(
(param) =>
'<li>' +
`<h3>${String(param.name)}</h3>` +
`<span>${param.isOptional ? '(Optional)' : '(Required)'}</span>` +
`<p>${param.comment?.shortText ?? ''}</p>` +
'</li>'
)
.join('') +
'</ul>'
return result
}
function examplesToHtml(fn: any) {
if (!fn.examples || fn.examples.length === 0) return ''
let result = '<h2 id="examples">Examples</h2>'
result += fn.examples
.map((example) => `<h3>${example.name ?? ''}</h3>` + mdxToHtml(example.code ?? ''))
.join('')
return result
}
@@ -1,4 +1,7 @@
import matter from 'gray-matter'
import { type Heading } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toMarkdown } from 'mdast-util-to-markdown'
import { type SerializeOptions } from 'next-mdx-remote/dist/types'
import { readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
@@ -13,6 +16,7 @@ import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLin
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import remarkGfm from 'remark-gfm'
// We fetch these docs at build time from an external repo
const org = 'supabase'
@@ -175,8 +179,27 @@ const getContent = async (params: Params) => {
;({ remoteFile, meta } = federatedPage)
const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`
const response = await fetchRevalidatePerDay(`https://raw.githubusercontent.com/${repoPath}`)
content = await response.text()
const rawContent = await response.text()
const { content: contentWithoutFrontmatter } = matter(rawContent)
// This is the more robust way of doing it, but problems with the rewritten
// Markdown and handling of tables this way, so saving it for later.
//
// const mdxTree = fromMarkdown(contentWithoutFrontmatter)
// const maybeH1 = mdxTree.children[0]
// if (maybeH1 && maybeH1.type === 'heading' && (maybeH1 as Heading).depth === 1) {
// mdxTree.children.shift()
// }
// content = toMarkdown(mdxTree)
content = contentWithoutFrontmatter
if (meta.title) {
const h1Regex = new RegExp(`(?:^|\n)# ${meta.title}\n+`)
content = content.replace(h1Regex, '')
}
}
return {
@@ -0,0 +1,42 @@
import { redirect } from 'next/navigation'
import { REFERENCES } from '~/content/navigation.references'
import { ClientSdkReferencePage } from '~/features/docs/Reference.sdkPage'
import {
generateReferenceMetadata,
generateReferenceStaticParams,
parseReferencePath,
redirectNonexistentReferenceSection,
} from '~/features/docs/Reference.utils'
import { notFoundLink } from '~/features/recommendations/NotFound.utils'
export default async function ReferencePage({
params: { slug },
}: {
params: { slug: Array<string> }
}) {
if (!Object.keys(REFERENCES).includes(slug[0])) {
redirect(notFoundLink(slug.join('/')))
}
const parsedPath = parseReferencePath(slug)
const isClientSdkReference = parsedPath.__type === 'clientSdk'
if (isClientSdkReference) {
const { sdkId, maybeVersion, path } = parsedPath
const sdkData = REFERENCES[sdkId]
const latestVersion = sdkData.versions[0]
const version = maybeVersion ?? latestVersion
await redirectNonexistentReferenceSection(sdkId, version, path, version === latestVersion)
return <ClientSdkReferencePage sdkId={sdkId} libVersion={version} />
} else {
// Unimplemented -- eventually API and CLI
redirect(notFoundLink(slug.join('/')))
}
}
export const generateStaticParams = generateReferenceStaticParams
export const generateMetadata = generateReferenceMetadata
+62
View File
@@ -0,0 +1,62 @@
import Link from 'next/link'
import { REFERENCES, clientSdkIds } from '~/content/navigation.references'
import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
export default function ReferenceIndexPage() {
return (
<SidebarSkeleton>
<LayoutMainContent>
<article className="prose">
<h1>API References</h1>
<p>
The Supabase client libraries help you interact with Supabase products, such as the
Postgres Database, Auth, and Realtime. They are available in several popular programming
languages.
</p>
<p>
Supabase also has a Management API to help with managing your Supabase Platform, and a
CLI for local development and CI workflows.
</p>
<h2 className="mb-8">Client Libraries</h2>
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
{clientSdkIds.map((sdkId) => {
return (
<Link
key={REFERENCES[sdkId].name}
href={`/reference/${REFERENCES[sdkId].libPath}`}
passHref
className="col-span-6 md:col-span-4"
>
<IconPanelWithIconPicker
title={REFERENCES[sdkId].name}
icon={REFERENCES[sdkId].icon}
/>
</Link>
)
})}
</div>
<h2 className="mb-8">Management API and CLI</h2>
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
<Link
href={`/reference/api/introduction`}
passHref
className="col-span-6 md:col-span-4"
>
<IconPanelWithIconPicker title="Management API" icon={REFERENCES['api'].icon} />
</Link>
<Link
href={`/reference/cli/introduction`}
passHref
className="col-span-6 md:col-span-4"
>
<IconPanelWithIconPicker title="CLI" icon={REFERENCES['cli'].icon} />
</Link>
</div>
</article>
</LayoutMainContent>
</SidebarSkeleton>
)
}
+5 -3
View File
@@ -1,8 +1,10 @@
'use client'
import React, { Fragment } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import React, { Fragment } from 'react'
import { useBreakpoint } from 'common'
import {
Breadcrumb_Shadcn_ as Breadcrumb,
BreadcrumbList_Shadcn_ as BreadcrumbList,
@@ -23,9 +25,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { getMenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu.utils'
import { useBreakpoint } from 'common'
import * as NavItems from './Navigation/NavigationMenu/NavigationMenu.constants'
const Breadcrumbs = ({ className }: { className?: string }) => {
const pathname = usePathname()
+2
View File
@@ -1,5 +1,6 @@
'use client'
import AuthErrorCodesTable from './auth_error_codes_table.mdx'
import AuthRateLimits from './auth_rate_limits.mdx'
import CreateClientSnippet from './create_client_snippet.mdx'
import DatabaseSetup from './database_setup.mdx'
@@ -15,6 +16,7 @@ import SocialProviderSettingsSupabase from './social_provider_settings_supabase.
import SocialProviderSetup from './social_provider_setup.mdx'
export {
AuthErrorCodesTable,
AuthRateLimits,
CreateClientSnippet,
DatabaseSetup,
@@ -1,5 +1,5 @@
This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:
- [Supabase Database](/docs/guides/database) - a Postgres database for storing your user data and [Row Level Security](/docs/guides/auth#row-level-security) so data is protected and users can only access their own information.
- [Supabase Auth](/docs/guides/auth) - users log in through magic links sent to their email (without having to set up passwords).
- [Supabase Auth](/docs/guides/auth) - allow users to sign up and log in.
- [Supabase Storage](/docs/guides/storage) - users can upload a profile photo.
@@ -13,16 +13,6 @@ export interface NavMenuSection {
items: Partial<NavMenuSection>[]
}
export interface References {
[key: string]: {
name: string
library?: string
versions: string[]
icon: string
currentVersion?: string
}
}
type MenuItem = {
label: string
icon?: string
@@ -1,7 +1,6 @@
'use client'
import React from 'react'
import {
IconBranching,
IconGitHub,
IconMenuApi,
IconMenuAuth,
IconMenuCli,
@@ -26,10 +25,8 @@ import {
IconMenuKotlin,
IconMenuAI,
IconMenuDevCli,
IconGitHub,
IconSupport,
IconTroubleshooting,
IconBranching,
} from './MenuIcons'
function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, className?: string) {
@@ -1,5 +1,4 @@
import { IS_DEV } from '~/lib/constants'
import type { GlobalMenuItems, NavMenuConstant, References } from '../Navigation.types'
import type { GlobalMenuItems, NavMenuConstant } from '../Navigation.types'
export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
[
@@ -100,38 +99,38 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
{
label: 'JavaScript',
icon: 'reference-javascript',
href: '/reference/javascript/introduction',
href: '/reference/javascript',
level: 'reference_javascript',
},
{
label: 'Flutter',
icon: 'reference-dart',
href: '/reference/dart/introduction',
href: '/reference/dart',
level: 'reference_dart',
},
{
label: 'Swift',
icon: 'reference-swift',
href: '/reference/swift/introduction',
href: '/reference/swift',
level: 'reference_swift',
},
{
label: 'Python',
icon: 'reference-python',
href: '/reference/python/introduction',
href: '/reference/python',
level: 'reference_python',
},
{
label: 'C#',
icon: 'reference-csharp',
href: '/reference/csharp/introduction',
href: '/reference/csharp',
level: 'reference_csharp',
community: true,
},
{
label: 'Kotlin',
icon: 'reference-kotlin',
href: '/reference/kotlin/introduction',
href: '/reference/kotlin',
level: 'reference_kotlin',
community: true,
},
@@ -202,51 +201,6 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
],
]
export const REFERENCES: References = {
javascript: {
name: 'supabase-js',
library: 'supabase-js',
versions: ['v2', 'v1'],
icon: '/img/libraries/javascript-icon',
},
dart: {
name: 'Flutter',
library: 'supabase-dart',
versions: ['v2', 'v1'],
icon: '/docs/img/libraries/flutter-icon.svg',
},
csharp: {
name: 'C#',
library: 'supabase-csharp',
versions: ['v1', 'v0'],
icon: '/docs/img/libraries/c-sharp-icon.svg',
},
swift: {
name: 'Swift',
library: 'supabase-swift',
versions: ['v2', 'v1'],
icon: '/docs/img/libraries/swift-icon.svg',
},
kotlin: {
name: 'Kotlin',
library: 'supabase-kt',
versions: ['v2', 'v1'],
icon: '/docs/img/libraries/kotlin-icon.svg',
},
cli: {
name: 'CLI',
library: undefined,
versions: [],
icon: '/docs/img/icons/cli-icon.svg',
},
api: {
name: 'API',
library: undefined,
versions: [],
icon: '/docs/img/icons/api-icon.svg',
},
}
export const gettingstarted: NavMenuConstant = {
icon: 'getting-started',
title: 'Start with Supabase',
@@ -615,13 +569,29 @@ export const auth = {
name: 'Identity Linking',
url: '/guides/auth/auth-identity-linking',
},
{ name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa' },
{
name: 'Multi-Factor Authentication',
url: '/guides/auth/auth-mfa',
items: [
{ name: 'App Authenticator (TOTP)', url: '/guides/auth/auth-mfa/totp' },
{ name: 'Phone', url: '/guides/auth/auth-mfa/phone' },
],
},
{
name: 'Signout',
url: '/guides/auth/signout',
},
],
},
{
name: 'Third-party auth',
items: [
{ name: 'Overview', url: '/guides/auth/third-party/overview' },
{ name: 'Firebase Auth', url: '/guides/auth/third-party/firebase-auth' },
{ name: 'Auth0', url: '/guides/auth/third-party/auth0' },
{ name: 'AWS Cognito (Amplify)', url: '/guides/auth/third-party/aws-cognito' },
],
},
{
name: 'Configuration',
items: [
@@ -634,7 +604,32 @@ export const auth = {
name: 'Redirect URLs',
url: '/guides/auth/redirect-urls',
},
{ name: 'Auth Hooks', url: '/guides/auth/auth-hooks' },
{
name: 'Auth Hooks',
url: '/guides/auth/auth-hooks',
items: [
{
name: 'Custom access token hook',
url: '/guides/auth/auth-hooks/custom-access-token-hook',
},
{
name: 'Send SMS hook',
url: '/guides/auth/auth-hooks/send-sms-hook',
},
{
name: 'Send email hook',
url: '/guides/auth/auth-hooks/send-email-hook',
},
{
name: 'MFA verification hook',
url: '/guides/auth/auth-hooks/mfa-verification-hook',
},
{
name: 'Password verification hook',
url: '/guides/auth/auth-hooks/password-verification-hook',
},
],
},
{ name: 'Custom SMTP', url: '/guides/auth/auth-smtp' },
{ name: 'User Management', url: '/guides/auth/managing-user-data' },
],
@@ -770,6 +765,10 @@ export const database: NavMenuConstant = {
name: 'Managing Postgres Roles',
url: '/guides/database/postgres/roles',
},
{
name: 'Using Custom Postgres Roles',
url: '/guides/storage/schema/custom-roles',
},
{ name: 'Managing secrets with Vault', url: '/guides/database/vault' },
{
name: 'Superuser Access and Unsupported Operations',
@@ -1170,6 +1169,10 @@ export const functions: NavMenuConstant = {
name: 'Deploying with CI / CD pipelines',
url: '/guides/functions/cicd-workflow',
},
{
name: 'Integrating with Log Drains',
url: '/guides/functions/log-drains',
},
],
},
{
@@ -1743,6 +1746,7 @@ export const platform: NavMenuConstant = {
url: undefined,
items: [
{ name: 'Logging', url: '/guides/platform/logs' },
{ name: 'Advanced Log Filtering', url: '/guides/platform/advanced-log-filtering' },
{ name: 'Log Drains', url: '/guides/platform/log-drains' },
{ name: 'Metrics', url: '/guides/platform/metrics' },
{ name: 'Monitoring with Sentry', url: '/guides/platform/sentry-monitoring' },
@@ -1924,6 +1928,11 @@ export const resources: NavMenuConstant = {
name: 'Migrate to Supabase',
url: '/guides/resources/migrating-to-supabase',
items: [
{
name: 'Amazon RDS',
url: '/guides/resources/migrating-to-supabase/amazon-rds',
items: [],
},
{
name: 'Auth0',
url: '/guides/resources/migrating-to-supabase/auth0',
@@ -1933,7 +1942,7 @@ export const resources: NavMenuConstant = {
url: '/guides/resources/migrating-to-supabase/firebase-auth',
},
{
name: 'Firestore Data',
name: 'Firebase Firestore',
url: '/guides/resources/migrating-to-supabase/firestore-data',
},
{
@@ -1941,23 +1950,9 @@ export const resources: NavMenuConstant = {
url: '/guides/resources/migrating-to-supabase/firebase-storage',
},
{
name: 'Heroku',
name: 'Heroku Postgres',
url: '/guides/resources/migrating-to-supabase/heroku',
},
{
name: 'Render',
url: '/guides/resources/migrating-to-supabase/render',
},
{
name: 'Amazon RDS',
url: '/guides/resources/migrating-to-supabase/amazon-rds',
items: [],
},
{
name: 'Postgres',
url: '/guides/resources/migrating-to-supabase/postgres',
items: [],
},
{
name: 'MySQL',
url: '/guides/resources/migrating-to-supabase/mysql',
@@ -1968,6 +1963,15 @@ export const resources: NavMenuConstant = {
url: '/guides/resources/migrating-to-supabase/mssql',
items: [],
},
{
name: 'Postgres',
url: '/guides/resources/migrating-to-supabase/postgres',
items: [],
},
{
name: 'Render',
url: '/guides/resources/migrating-to-supabase/render',
},
],
},
],
@@ -2093,13 +2097,6 @@ export const reference = {
items: [],
icon: '/img/icons/menu/reference-kotlin',
},
// {
// name: 'supabase-python',
// url: '/reference/python/start',
// level: 'reference_python',
//
// icon: '/img/icons/menu/reference-javascript',
// },
],
},
{
@@ -2125,6 +2122,10 @@ export const reference_javascript_v1 = {
title: 'JavaScript',
url: '/guides/reference/javascript',
parent: '/reference',
pkg: {
name: '@supabase/supabase-js',
repo: 'https://github.com/supabase/supabase-js',
},
}
export const reference_javascript_v2 = {
@@ -2132,6 +2133,10 @@ export const reference_javascript_v2 = {
title: 'JavaScript',
url: '/guides/reference/javascript',
parent: '/reference',
pkg: {
name: '@supabase/supabase-js',
repo: 'https://github.com/supabase/supabase-js',
},
}
export const reference_dart_v1 = {
@@ -2139,6 +2144,10 @@ export const reference_dart_v1 = {
title: 'Flutter',
url: '/guides/reference/dart',
parent: '/reference',
pkg: {
name: 'supabase_flutter',
repo: 'https://github.com/supabase/supabase-flutter',
},
}
export const reference_dart_v2 = {
@@ -2146,6 +2155,10 @@ export const reference_dart_v2 = {
title: 'Flutter',
url: '/guides/reference/dart',
parent: '/reference',
pkg: {
name: 'supabase_flutter',
repo: 'https://github.com/supabase/supabase-flutter',
},
}
export const reference_csharp_v0 = {
@@ -2153,6 +2166,10 @@ export const reference_csharp_v0 = {
title: 'C#',
url: 'guides/reference/csharp',
parent: '/reference',
pkg: {
name: 'supabase',
repo: 'https://github.com/supabase-community/supabase-csharp',
},
}
export const reference_csharp_v1 = {
@@ -2160,6 +2177,10 @@ export const reference_csharp_v1 = {
title: 'C#',
url: 'guides/reference/csharp',
parent: '/reference',
pkg: {
name: 'supabase',
repo: 'https://github.com/supabase-community/supabase-csharp',
},
}
export const reference_python_v2 = {
@@ -2167,6 +2188,10 @@ export const reference_python_v2 = {
title: 'Python',
url: '/guides/reference/python',
parent: '/reference',
pkg: {
name: 'supabase-py',
repo: 'https://github.com/supabase/supabase-py',
},
}
export const reference_swift_v1 = {
@@ -2174,6 +2199,10 @@ export const reference_swift_v1 = {
title: 'swift',
url: 'guides/reference/swift',
parent: '/reference',
pkg: {
name: 'supabase-swift',
repo: 'https://github.com/supabase/supabase-swift',
},
}
export const reference_swift_v2 = {
@@ -2181,6 +2210,10 @@ export const reference_swift_v2 = {
title: 'swift',
url: 'guides/reference/swift',
parent: '/reference',
pkg: {
name: 'supabase-swift',
repo: 'https://github.com/supabase/supabase-swift',
},
}
export const reference_kotlin_v1 = {
@@ -2188,6 +2221,10 @@ export const reference_kotlin_v1 = {
title: 'kotlin',
url: 'guides/reference/kotlin',
parent: '/reference',
pkg: {
name: '@supabase-community/supabase-kt',
repo: 'https://github.com/supabase-community/supabase-kt',
},
}
export const reference_kotlin_v2 = {
@@ -2195,6 +2232,10 @@ export const reference_kotlin_v2 = {
title: 'kotlin',
url: 'guides/reference/kotlin',
parent: '/reference',
pkg: {
name: '@supabase-community/supabase-kt',
repo: 'https://github.com/supabase-community/supabase-kt',
},
}
export const reference_cli = {
@@ -1,4 +1,5 @@
import { memo } from 'react'
import NavigationMenuGuideList from './NavigationMenuGuideList'
import NavigationMenuRefList from './NavigationMenuRefList'
import { useCloseMenuOnRouteChange } from './NavigationMenu.utils'
@@ -50,8 +51,7 @@ interface GuideMenu extends BaseMenu {
interface ReferenceMenu extends BaseMenu {
type: 'reference'
path: string
commonSectionsFile: string
specFile?: string
commonSectionsFile?: string
}
type Menu = GuideMenu | ReferenceMenu
@@ -115,133 +115,109 @@ const menus: Menu[] = [
},
{
id: MenuId.RefJavaScriptV1,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_js_v1.yml',
type: 'reference',
path: '/reference/javascript/v1',
},
{
id: MenuId.RefJavaScriptV2,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_js_v2.yml',
type: 'reference',
path: '/reference/javascript',
},
{
id: MenuId.RefDartV1,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_dart_v1.yml',
type: 'reference',
path: '/reference/dart/v1',
},
{
id: MenuId.RefDartV2,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_dart_v2.yml',
type: 'reference',
path: '/reference/dart',
},
{
id: MenuId.RefCSharpV0,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_csharp_v0.yml',
type: 'reference',
path: '/reference/csharp/v0',
},
{
id: MenuId.RefCSharpV1,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_csharp_v1.yml',
type: 'reference',
path: '/reference/csharp',
},
{
id: MenuId.RefPythonV2,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_py_v2.yml',
type: 'reference',
path: '/reference/python',
},
{
id: MenuId.RefSwiftV1,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_swift_v1.yml',
type: 'reference',
path: '/reference/swift',
},
{
id: MenuId.RefSwiftV2,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_swift_v2.yml',
type: 'reference',
path: '/reference/swift',
},
{
id: MenuId.RefKotlinV1,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_kt_v1.yml',
type: 'reference',
path: '/reference/kotlin/v1',
},
{
id: MenuId.RefKotlinV2,
commonSectionsFile: 'common-client-libs-sections.json',
specFile: 'supabase_kt_v2.yml',
type: 'reference',
path: '/reference/kotlin',
},
{
id: MenuId.RefCli,
commonSectionsFile: 'common-cli-sections.json',
type: 'reference',
path: '/reference/cli',
commonSectionsFile: 'common-cli-sections.json',
},
{
id: MenuId.RefApi,
commonSectionsFile: 'common-api-sections.json',
type: 'reference',
path: '/reference/api',
commonSectionsFile: 'common-api-sections.json',
},
{
id: MenuId.SelfHostingAuth,
commonSectionsFile: 'common-self-hosting-auth-sections.json',
type: 'reference',
path: '/reference/self-hosting-auth',
commonSectionsFile: 'common-self-hosting-auth-sections.json',
},
{
id: MenuId.SelfHostingStorage,
commonSectionsFile: 'common-self-hosting-storage-sections.json',
type: 'reference',
path: '/reference/self-hosting-storage',
commonSectionsFile: 'common-self-hosting-storage-sections.json',
},
{
id: MenuId.SelfHostingRealtime,
commonSectionsFile: 'common-self-hosting-realtime-sections.json',
type: 'reference',
path: '/reference/self-hosting-realtime',
commonSectionsFile: 'common-self-hosting-realtime-sections.json',
},
{
id: MenuId.SelfHostingAnalytics,
commonSectionsFile: 'common-self-hosting-analytics-sections.json',
type: 'reference',
path: '/reference/self-hosting-analytics',
commonSectionsFile: 'common-self-hosting-analytics-sections.json',
},
{
id: MenuId.SelfHostingFunctions,
commonSectionsFile: 'common-self-hosting-functions-sections.json',
type: 'reference',
path: '/reference/self-hosting-functions',
commonSectionsFile: 'common-self-hosting-functions-sections.json',
},
]
export function getMenuById(id: MenuId) {
function getMenuById(id: MenuId) {
return menus.find((menu) => menu.id === id)
}
function getMenuElement(menu: Menu | undefined) {
if (!menu) throw Error('No menu found for this menuId')
const menuType = menu.type
const menuType = menu?.type
switch (menuType) {
case 'guide':
return <NavigationMenuGuideList id={menu.id} />
@@ -251,7 +227,6 @@ function getMenuElement(menu: Menu | undefined) {
id={menu.id}
basePath={menu.path}
commonSectionsFile={menu.commonSectionsFile}
specFile={menu.specFile}
/>
)
default:
@@ -268,5 +243,5 @@ const NavigationMenu = ({ menuId }: { menuId: MenuId }) => {
return getMenuElement(menu)
}
export { MenuId }
export { MenuId, getMenuById }
export default memo(NavigationMenu)
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import type { ICommonItem } from '~/components/reference/Reference.types'
import type { Json } from '~/types'
import type { Json } from '~/features/helpers.types'
import { menuState } from '../../../hooks/useMenuState'
export function getPathWithoutHash(relativePath: string) {
@@ -99,14 +99,6 @@ export function useSpec(specFile?: string) {
return spec
}
export const useCloseMenuOnRouteChange = () => {
const pathname = usePathname()
useEffect(() => {
menuState.setMenuMobileOpen(false)
}, [pathname])
}
export const getMenuId = (pathname: string | null) => {
pathname = (pathname ??= '').replace(/^\/guides\//, '')
@@ -141,3 +133,11 @@ export const getMenuId = (pathname: string | null) => {
return MenuId.GettingStarted
}
}
export const useCloseMenuOnRouteChange = () => {
const pathname = usePathname()
useEffect(() => {
menuState.setMenuMobileOpen(false)
}, [pathname])
}
@@ -28,12 +28,6 @@ const NavigationMenuRefList = ({
}
const filteredSections = commonSections.filter((section) => {
if (section.type === 'category') {
section.items = section.items.filter((item) => {
return !item.excludes?.includes(id)
})
}
return !section.excludes?.includes(id)
})
@@ -185,7 +185,6 @@ const NavigationMenuRefListItems = ({
<div className="flex items-center gap-3 my-3">
<MenuIconPicker icon={menu.icon} width={21} height={21} />
<HeaderLink title={menu.title} url={menu.url} id={id} />
<RevVersionDropdown />
</div>
<ul className="function-link-list flex flex-col gap-2 pb-5">
{filteredSections.map((section) => {
+1 -1
View File
@@ -2,7 +2,7 @@ import Link from 'next/link'
import Image from 'next/legacy/image'
import { usePathname } from 'next/navigation'
import { IconChevronRight, IconArrowLeft } from '~/../../packages/ui'
import { REFERENCES } from './NavigationMenu/NavigationMenu.constants'
import { REFERENCES } from '~/content/navigation.references'
import { NavMenuGroup, NavMenuSection } from './Navigation.types'
import * as Accordion from '@radix-ui/react-accordion'
+23 -22
View File
@@ -1,4 +1,8 @@
import { usePathname, useRouter } from 'next/navigation'
'use client'
import { ChevronDown } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Badge,
DropdownMenu,
@@ -6,34 +10,32 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
IconChevronDown,
} from 'ui'
import { REFERENCES } from './Navigation/NavigationMenu/NavigationMenu.constants'
const RevVersionDropdown = () => {
const pathname = usePathname()
import { REFERENCES } from '~/content/navigation.references'
const RevVersionDropdown = ({
library,
currentVersion,
}: {
library: string
currentVersion: string
}) => {
const { push } = useRouter()
const pathSegments = pathname.split('/')
const library = pathSegments.length >= 3 ? pathSegments[2] : undefined
const libraryMeta = REFERENCES?.[library] ?? undefined
const versions = libraryMeta?.versions ?? []
const currentVersion = versions.includes(pathSegments[pathSegments.indexOf(library) + 1])
? pathSegments[pathSegments.indexOf(library) + 1]
: versions[0]
const onSelectVersion = (version: string) => {
if (!library) return
if (version === versions[0]) {
push(`/reference/${library}/start`)
} else {
push(`/reference/${library}/${version}/start`)
}
if (!versions || versions.length <= 1) {
return null
}
if (!versions || versions.length === 0) {
return <></>
const onSelectVersion = (version: string) => {
if (version === versions[0]) {
push(`/reference/${library}`)
} else {
push(`/reference/${library}/${version}`)
}
}
return (
@@ -52,11 +54,10 @@ const RevVersionDropdown = () => {
flex items-center gap-1 text-foreground-muted text-xs group-hover:text-foreground transition
"
>
{/* <span>version</span> */}
<span className="text-foreground text-sm group-hover:text-foreground transition">
{currentVersion}.0
</span>
<IconChevronDown size={14} strokeWidth={2} />
<ChevronDown size={14} strokeWidth={2} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom" className="w-48">
+287 -641
View File
@@ -1,10 +1,12 @@
---
id: 'auth-hooks'
title: 'Auth Hooks'
description: 'Use Supabase Postgres Functions to customize your authentication flow'
subtitle: 'Use HTTP or Postgres Functions to customize your authentication flow'
---
Supabase allows you to use Postgres functions to alter the default Supabase Auth flow. Developers can use hooks to add custom behavior that's not supported natively.
## What is a hook
A hook is an endpoint that allows you to alter the default Supabase Auth flow at specific execution points. Developers can use hooks to add custom behavior that's not supported natively.
Hooks help you:
@@ -12,33 +14,195 @@ Hooks help you:
- Improve security by adding additional checks to password and multi-factor authentication
- Support legacy systems by integrating with identity credentials from external authentication systems
- Add additional custom claims to your JWT
- Send authentication emails or SMS messages through a custom provider
You can use hooks at specific points along an Auth flow to perform custom behavior via Postgres Functions. The following hooks are available:
The following hooks are available:
| Hook | Available on Plan |
| ----------------------------- | ------------------- |
| Custom Access Token | Free, Pro |
| MFA Verification Attempt | Team and Enterprise |
| Password Verification Attempt | Team and Enterprise |
| Hook | Available on Plan |
| ---------------------------------------------------------------------------------------- | -------------------- |
| [Custom Access Token](/docs/guides/auth/auth-hooks/custom-access-token-hook) | Free, Pro |
| [Send SMS](/docs/guides/auth/auth-hooks/send-sms-hook) | Free, Pro |
| [Send Email](/docs/guides/auth/auth-hooks/send-email-hook) | Free, Pro |
| [MFA Verification Attempt](/docs/guides/auth/auth-hooks/mfa-verification-hook) | Teams and Enterprise |
| [Password Verification Attempt](/docs/guides/auth/auth-hooks/password-verification-hook) | Teams and Enterprise |
You can connect a hook to Supabase Auth to signal to Supabase Auth that it should make use of the hook.
Supabase supports 2 ways to [configure a hook](/dashboard/project/_/auth/hooks) in your project:
## Create a hook
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="postgres-function"
>
<TabPanel id="postgres-function" label="Postgres Function">
### What is a hook
A [postgres function](/docs/guides/database/functions) can be configured as a hook. The function should take in a single argument -- the event of type JSONB -- and return a JSONB object. Since the postgres function runs on your database, the request does not leave your project's instance.
A hook is a [Postgres Function](https://www.postgresql.org/docs/current/sql-createfunction.html) with a single argument -- the `event` of type [JSONB]() -- and which returns a JSONB object.
</TabPanel>
<TabPanel id="http" label="HTTP Endpoint">
This function is invoked by Supabase Auth at its point in the flow by providing an event object. The object returned by the function gives instructions on how Supabase Auth should continue processing.
A HTTP Hook is an endpoint which takes in a JSON event payload and returns a JSON response. You can use any HTTP endpoint as a Hook, including an endpoint in your application. The easiest way to create a HTTP hook is to create a [Supabase Edge Function](/docs/guides/functions/quickstart).
To access properties of the `event` argument, you can use the [JSON operators and functions](https://www.postgresql.org/docs/current/functions-json.html).
</TabPanel>
</Tabs>
There are no restrictions as to what language can be used to write Auth Hooks. If [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html) is too difficult consider using the [plv8](/docs/guides/database/extensions/plv8) extension which lets you use JavaScript to define functions.
## Security Model
Sign the payload and grant permissions selectively in order to guard the integrity of the payload.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
When you configure a postgres function as a hook, Supabase will automatically apply the following grants to the function for these reasons:
- Allow the `supabase_auth_admin` role to execute the function. The `supabase_auth_admin` role is the postgres role that is used by Supabase Auth to make requests to your database.
- Revoke permissions from other roles (e.g. `anon`, `authenticated`, `public`) to ensure the function is not accessible by Supabase Data APIs.
```sql
-- Grant access to function to supabase_auth_admin
grant execute
on function public.custom_access_token_hook
to supabase_auth_admin;
-- Grant access to schema to supabase_auth_admin
grant usage on schema public to supabase_auth_admin;
-- Revoke function permissions from authenticated, anon and public
revoke execute
on function public.custom_access_token_hook
from authenticated, anon, public;
```
<Admonition type="caution" label="Create postgres functions with security invoker">
We do not recommend creating postgres functions with the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker).
</Admonition>
</TabPanel>
<TabPanel id="http" label="HTTP">
HTTP Hooks in Supabase follow the [Standard Webhooks Specification](https://www.standardwebhooks.com/), which is a set of guidelines aligning how hooks are implemented. The specification attaches three security headers to guarantee the integrity of the payload:
- `webhook-id`: the unique webhook identifier described in the preceding sections.
- `webhook-timestamp`: integer UNIX timestamp (seconds since epoch).
- `webhook-signature`: the signatures of this webhook. This is generated from body of the hook.
When the request is made to the HTTP hook, you should use the [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) to verify these headers.
When a HTTP hook is created, the secret generated should be of the following format: `v1,whsec_<standard-base64-secret>`:
- `v1` denotes the version of the hook
- `whsec_` signifies that the secret is symmetric
- `<standard-base64-secret>` implies a Standard Base64 encoded secret which can contain the characters `+`, `/` and `=`
The secret is used to verify the payload received in your hook. Create an entry in your `.env.local` file to store the secret for each hook that you have. For example:
```bash
HOOK_SECRET=<standard-base64-secret>
```
Use the secret in conjunction with the Standard Webhooks package to verify the payload before processing it:
```jsx
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
Deno.serve(async (req) => {
const payload = await req.text()
const hookSecret = Deno.env.get('HOOK_SECRET')
// Extract headers and security specific fields
const headers = Object.fromEntries(req.headers)
const wh = new Webhook(hookSecret)
const data = wh.verify(payload, headers)
// Payload data is verified, continue with business logic here
// ...
})
```
</TabPanel>
</Tabs>
## Using Hooks
### Developing
Let us develop a Hook locally and then deploy it to the cloud. As a recap, here’s a list of available Hooks
| Hook | Suggested Function Name | When it is called | What it Does |
| ----------------------------- | ------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Send SMS | `send_sms` | Each time an SMS is sent | Allows you to customize message content and SMS Provider |
| Send Email | `send_email` | Each time an Email is sent | Allows you to customize message content and Email Provider |
| Custom Access Token | `custom_access_token` | Each time a new JWT is created | Returns the claims you wish to be present in the JWT. |
| MFA Verification Attempt | `mfa_verification_attempt` | Each time a user tries to verify an MFA factor. | Returns a decision on whether to reject the attempt and future ones, or to allow the user to keep trying. |
| Password Verification Attempt | `password_verification_attempt` | Each time a user tries to sign in with a password. | Return a decision whether to allow the user to reject the attempt, or to allow the user to keep trying. |
Edit `config.toml` to set up the Auth Hook locally.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
Modify the `auth.hook.<hook_name>` field and set `uri` to a value of `pg-functions://postgres/<schema>/<function_name>`
```
[auth.hook.<hook_name>]
enabled = true
uri = "pg-functions://...."
```
You need to assign additional permissions so that Supabase Auth can access the hook as well as the tables it interacts with.
The `supabase_auth_admin` role does not have permissions to the `public` schema. You need to grant the role permission to execute your hook:
```sql
grant execute
on function public.custom_access_token_hook
to supabase_auth_admin;
```
You also need to grant usage to `supabase_auth_admin`:
```sql
grant usage on schema public to supabase_auth_admin;
```
Also revoke permissions from the `authenticated` and `anon` roles to ensure the function is not accessible by Supabase Serverless APIs.
```sql
revoke execute
on function public.custom_access_token_hook
from authenticated, anon;
```
For security, we recommend against the use the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. When a function is created via the Supabase dashboard with the tag, it will have the extensive permissions of the `postgres` role which make it easier for undesirable actions to occur.
We recommend that you do not use any tag and explicitly grant permissions to `supabase_auth_admin` as described above.
Read more about `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker).
There are no restrictions as to what language can be used to write a Postgres Hook. If [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html) is too difficult consider using the [plv8](/docs/guides/database/extensions/plv8) extension which lets you use JavaScript to define functions.
Once done, save your Auth Hook as a migration in order to version the Auth Hook and share it with other team members. Run [`supabase migration new`](/docs/reference/cli/supabase-migration-new) to create a migration.
<Admonition type="caution">
If you're using the Supabase SQL Editor, there's an issue when using the `?` (*Does the string
exist as a top-level key within the JSON value?*) operator. Use a direct connection to the
database if you need to use it when defining a function.
If you're using the Supabase SQL Editor, there's an issue when using the `?` (_Does the string exist as a top-level key within the JSON value?_) operator. Use a direct connection to the database if you need to use it when defining a function.
</Admonition>
Here is an example hook signature:
@@ -55,43 +219,47 @@ begin
return event;
end;
$$;
```
You can visit `SQL Editor > Templates` for hook templates.
### Hook grants and roles
You need to assign additional permissions so that Supabase Auth can access the hook as well as the tables it interacts with.
The `supabase_auth_admin` role does not have permissions to the `public` schema. You need to grant the role permission to execute your hook function:
```sql
grant execute
on function public.custom_access_token_hook
to supabase_auth_admin;
</TabPanel>
<TabPanel id="http" label="HTTP">
Modify the `auth.hook.<hook_name>` field and set `uri` to a valid HTTP URI. For example, the `send_sms` hook would take the following fields:
```
[auth.hook.send_sms]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/send_sms"
secret = "env(SEND_SMS_HOOK_SECRET)"
```
You also need to grant usage to `supabase_auth_admin`:
`host.docker.internal` is a special DNS name used in Docker to allow a container to access the host machine's network. This allows the Auth container to reach the Functions container
```sql
grant usage on schema public to supabase_auth_admin;
Fill in the Hook Secret in `supabase/functions/.env`
```bash
SEND_SMS_HOOK_SECRET='v1,whsec_<base64_standard_secret>'
```
Also revoke permissions from the `authenticated`, `public`, and `anon` roles to ensure the function is not accessible by Supabase Serverless APIs. The `public` role has access to functions created on `public.*` by default and `anon` and `authenticated` inherit permissions from the `public` role. Permission is revoked from the `public` role in order to prevent the `anon` and `authenticated` roles from inheriting permissions to invoke the Postgres Hook.
Start the function locally:
```sql
revoke execute
on function public.custom_access_token_hook
from authenticated, anon, public;
```bash
supabase functions serve send-sms --no-verify-jwt
```
For security, we recommend against the use of the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. When a function is created via the Supabase dashboard with the tag, it will have the extensive permissions of the `postgres` role which make it easier for undesirable actions to occur.
Disable JWT verification via the `--no-verify-jwt` to accommodate hooks which are run before a JWT is issued. Payload authenticity is instead protected via the appended security headers associated with the Standard Webhooks Standard.
We recommend that you do not use any tag and explicitly grant permissions to `supabase_auth_admin` as described above.
Note that payloads are sent uncompressed in order to accurately track Content Length. In addition, there is a 20KB payload limit to guard against payload stuffing attacks.
Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker).
</TabPanel>
</Tabs>
### Hook errors
### Deploying
In the dashboard, navigate to [`Authentication > Hooks`](/dashboard/project/_/auth/hooks) and select the appropriate function type (SQL or HTTP) from the dropdown menu.
### Error Handling
You should return an error when facing a runtime error. Runtime errors are specific to your application and arise from specific business rules rather than programmer errors.
@@ -100,6 +268,16 @@ Runtime errors could happen when:
- The user does not have appropriate permissions
- The event payload received does not have required claims.
- The user has performed an action which violates a business rule.
- The email or phone provider used in the webhook returned an error.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
The error is a JSON object and has the following properties:
@@ -118,618 +296,86 @@ Here's an example:
}
```
When an error is returned, the error is propagated from the hook to Supabase Auth and translated into a HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload.
Errors returned from a Postgres Hook are not retry-able. When an error is returned, the error is propagated from the hook to Supabase Auth and translated into a HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload.
### Timeouts
</TabPanel>
Ensure that your hooks complete within **2 seconds** to avoid any errors.
<TabPanel id="http" label="HTTP">
Hooks return status codes based on the nature of the response. These status codes help determine the next steps in the processing flow:
### Connect a hook
| HTTP Status Code | Description | Example Usage |
| ---------------- | ------------------------------------------------------------- | ---------------------------------------------- |
| 200, 202, 204 | Valid response, proceed | Successful processing of the request |
| 429, 503 | Retry-able errors with Retry-after header supported | Temporary server overload or maintenance |
| 403, 400 | Treated as Internal Server Errors and return a 500 Error Code | Malformed requests or insufficient permissions |
In the dashboard, navigate to [`Authentication > Hooks (Beta)`](/dashboard/project/_/auth/hooks) and select the appropriate PostgreSQL function from the dropdown menu.
Errors are responses which contain status codes 400 and above. On a retry-able error, such as an error with a `429` or `503` status code, HTTP Hooks will attempt up to three retries with a back-off of two seconds.
## Hook: MFA verification attempt
Here's a sample HTTP retry schedule:
You can add additional checks to the [Supabase MFA implementation](/docs/guides/auth/auth-mfa) with hooks. For example, you can:
| Time Since Start (HH:MM:SS) | Event | Notes |
| --------------------------- | ----------------------- | ------------------------------------------------- |
| 00:00:00 | Initial Attempt | Initial invocation begins. |
| 00:00:05 | Initial Attempt Timeout | Initial invocation must complete. |
| 00:00:07 | Retry Start #1 | After 2 sec delay, first retry begins. |
| 00:00:12 | Retry Timeout #1 | First retry timeout. |
| 00:00:14 | Retry Start #2 | After 2 sec delay, second retry begins. |
| 00:00:19 | Retry Timeout #2 | Second retry timeout. Returns an error on failure |
- Limit the number of verification attempts performed over a period of time.
- Sign out users who have too many invalid verification attempts.
- Count, rate limit, or ban sign-ins.
Return a retry-able error by attaching a appropriate status code (`429`, `503` ) and a non-empty `retry-after` header
**Inputs**
Supabase Auth will supply the following fields to your hook:
- `factor_id` Unique identifier for the MFA factor being verified.
- `user_id` Unique identifier for the user.
- `valid` Whether the verification attempt was valid. For TOTP, this means that the six digit code was correct (true) or incorrect (false).
Example payload:
```json
{
"factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db",
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
"valid": true
}
```jsx
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error}`,
}),
{ status: 429, headers: { 'Content-Type': 'application/json', 'retry-after': 'true' } }
)
```
**Outputs**
Return this if your hook processed the input without errors.
- `decision` A string containing the decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior.
- `message` The message to show the user if the decision was `reject`.
Example output:
```json
{
"decision": "reject",
"message": "You have exceeded maximum number of MFA attempts."
}
```
**Examples**
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="limit-failed-mfa-verification-attempts"
queryGroup="language"
>
<TabPanel id="limit-failed-mfa-verification-attempts" label="Limit failed MFA verification attempts">
Your company requires that a user can input an incorrect MFA Verification code no more than once every 2 seconds.
Create a table to record the last time a user had an incorrect MFA verification attempt for a factor.
```sql
create table public.mfa_failed_verification_attempts (
user_id uuid not null,
factor_id uuid not null,
last_failed_at timestamp not null default now(),
primary key (user_id, factor_id)
);
```
Create a hook to read and write information to this table. For example:
```sql
create function public.hook_mfa_verification_attempt(event jsonb)
returns jsonb
language plpgsql
as $$
declare
last_failed_at timestamp;
begin
if event->'valid' is true then
-- code is valid, accept it
return jsonb_build_object('decision', 'continue');
end if;
select last_failed_at into last_failed_at
from public.mfa_failed_verification_attempts
where
user_id = event->'user_id'
and
factor_id = event->'factor_id';
if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then
-- last attempt was done too quickly
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 429,
'message', 'Please wait a moment before trying again.'
)
);
end if;
-- record this failed attempt
insert into public.mfa_failed_verification_attempts
(
user_id,
factor_id,
last_refreshed_at
)
values
(
event->'user_id',
event->'factor_id',
now()
)
on conflict do update
set last_refreshed_at = now();
-- finally let Supabase Auth do the default behavior for a failed attempt
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions and revoke access
grant execute
on function public.hook_mfa_verification_attempt
to supabase_auth_admin;
grant all
on table public.mfa_failed_verification_attempts
to supabase_auth_admin;
revoke execute
on function public.hook_mfa_verification_attempt
from authenticated, anon, public;
revoke all
on table public.mfa_failed_verification_attempts
from authenticated, anon, public;
```
Note that all responses, including error responses, need a `Content-Type` of `application/json` - not specifying the appropriate `Content-Type` will result in the function returning an error response. Supabase Auth will in turn return an Internal Server Error.
</TabPanel>
</Tabs>
## Hook: Password verification attempt
Your company wishes to increase security beyond the requirements of the default password implementation in order to fulfill security or compliance requirements. You plan to track the status of a password sign-in attempt and take action via an email or a restriction on logins where necessary.
As this hook runs on unauthenticated requests, malicious users can abuse the hook by calling it multiple times. Pay extra care when using the hook as you can unintentionally block legitimate users from accessing your application.
Check if a password is valid prior to taking any additional action to ensure the user is legitimate. Where possible, send an email or notification instead of blocking the user.
**Inputs**
- `user_id` Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table.
- `valid` Whether the password verification attempt was valid.
```json
{
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
"valid": true
}
```
**Outputs**
Return these only if your hook processed the input without errors.
- `decision` A string containing the decision whether to allow authentication to move forward. Use `reject` to completely reject the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior.
- `message` The message to show the user if the decision was `reject`.
- `should_logout_user` Whether to logout a user if a `reject` decision is issued. Has no effect when a `continue` decision is issued.
Example output:
```json
{
"decision": "reject",
"message": "You have exceeded maximum number of password sign-in attempts.",
"should_logout_user": "false"
}
```
**Examples**
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="limit-failed-password-verification-attempts"
queryGroup="language"
>
<TabPanel id="limit-failed-password-verification-attempts" label="Limit failed password verification attempts">
As part of new security measures within the company, users can only input an incorrect password every 10 seconds and not more than that. You want to write a hook to enforce this.
Create a table to record each user's last incorrect password verification attempt.
```sql
create table public.password_failed_verification_attempts (
user_id uuid not null,
last_failed_at timestamp not null default now(),
primary key (user_id)
);
```
Create a hook to read and write information to this table. For example:
```sql
create function public.hook_password_verification_attempt(event jsonb)
returns jsonb
language plpgsql
as $$
declare
last_failed_at timestamp;
begin
if event->'valid' is true then
-- password is valid, accept it
return jsonb_build_object('decision', 'continue');
end if;
select last_failed_at into last_failed_at
from public.password_failed_verification_attempts
where
user_id = event->'user_id';
if last_failed_at is not null and now() - last_failed_at < interval '10 seconds' then
-- last attempt was done too quickly
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 429,
'message', 'Please wait a moment before trying again.'
)
);
end if;
-- record this failed attempt
insert into public.password_failed_verification_attempts
(
user_id,
last_failed_at
)
values
(
event->'user_id',
now()
)
on conflict do update
set last_failed_at = now();
-- finally let Supabase Auth do the default behavior for a failed attempt
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions
grant execute
on function public.hook_password_verification_attempt
to supabase_auth_admin;
grant all
on table public.password_failed_verification_attempts
to supabase_auth_admin;
revoke execute
on function public.hook_password_verification_attempt
from authenticated, anon, public;
revoke all
on table public.password_failed_verification_attempts
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="send-email-on-failed-password-attempt" label="Send email notification on failed password attempts">
You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send a HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from.
First, create a table to track sign in attempts.
```sql
create table public.password_sign_in_attempts (
user_id uuid not null,
attempt_id uuid not null,
last_attempt_at timestamp not null default now(),
attempt_successful boolean not null,
primary key (user_id, attempt_id)
);
```
Next, store the API key of our email API provider:
```sql
select vault.create_secret('my_api_key', 'my_api_key_name', 'description_of_my_api_key');
```
Create the hook:
```sql
create or replace function public.hook_notify_user_on_failed_attempts(event jsonb)
returns jsonb
language plpgsql
as $$
declare
user_id uuid;
server_token text;
user_email_address text;
email_body jsonb;
response_id int; -- Variable to store the response ID
http_code int;
error_message jsonb;
attempt_count int;
max_attempts int := 5; -- Set the threshold for failed attempts
begin
user_id := (event->>'user_id')::uuid;
-- Record the attempt
insert into public.password_sign_in_attempts (user_id, attempt_id, last_attempt_at, attempt_successful)
values (user_id, (event->>'attempt_id')::uuid, now(), (event->>'valid')::boolean)
on conflict (user_id, attempt_id)
do update set last_attempt_at = now(), attempt_successful = (event->>'valid')::boolean;
-- Check failed attempts and fetch user email
select count(*), u.email into attempt_count, user_email_address
from public.password_sign_in_attempts a
join auth.users u on a.user_id = u.id
where a.user_id = user_id and attempt_successful = false and last_attempt_at > (now() - interval '1 day');
-- Notify user if the number of failed attempts exceeds the threshold
if attempt_count >= max_attempts then
-- Fetch the server token
select decrypted_secret into server_token from vault.decrypted_secrets where name = 'my_api_key_name';
-- Prepare the email body
email_body := format('{
"from": "yoursenderemail@example.com",
"to": "%s",
"subject": "Security Alert: Repeated Login Attempts Detected",
"textbody": "We have detected repeated login attempts for your account. If this was not you, please secure your account.",
"htmlbody": "<html><body><strong>Security Alert:</strong> We have detected repeated login attempts for your account. If this was not you, please secure your account.</body></html>",
"messagestream": "outbound"
}', user_email_address)::jsonb;
-- Perform the HTTP POST request using Postmark
select id into response_id from net.http_post(
'https://api.youremailprovider.com/email',
email_body,
'application/json',
array['Accept: application/json', 'X-Postmark-Server-Token: ' || server_token]
);
-- Fetch the response from net._http_response using the obtained id
select status_code, content into http_code, error_message from net._http_response where id = response_id;
-- Handle email sending errors
if http_code is null or (http_code < 200 or http_code >= 300) then
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', coalesce(http_code, 0),
'message', coalesce(error_message ->> 'message', 'error sending email')
)
);
end if;
end if;
-- Continue with default behavior
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions
grant execute
on function public.hook_notify_user_on_failed_attempts
to supabase_auth_admin;
revoke execute
on function public.hook_notify_user_on_failed_attempts
from authenticated, anon, public;
grant all
on table public.password_sign_in_attempts
to supabase_auth_admin;
revoke all
on table public.password_sign_in_attempts
from authenticated, anon, public;
```
</TabPanel>
</Tabs>
## Hook: Custom access token
The custom access token hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
Claims returned must conform to our specification. Supabase Auth will check for these claims after the hook is run and return an error if they are not present.
These are the fields currently available on an access token:
Required Claims: `aud`, `exp`, `iat`, `sub`, `email`, `phone`, `role`, `aal`, `session_id`
Optional Claims: `jti`, `iss`, `nbf`, `app_metadata`, `user_metadata`, `amr`
**Inputs**
- `user_id` Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table.
- `claims` Claims which are attached to the access token.
- `authentication_method` the authentication method used to request the access token. Possible values include:
`oauth`, `password`, `otp`, `totp`, `recovery`, `invite`, `sso/saml`, `magiclink`, `email/signup`, `email_change`, `token_refresh`.
**Outputs**
Return these only if your hook processed the input without errors.
- `claims` A json containing the updated claims after the hook has been run.
**Examples**
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="add-admin-role"
queryGroup="language"
>
<TabPanel id="add-admin-role" label="Add admin role">
You can allow registered admin users to perform restricted actions by granting an `admin` claim to their token.
Create a profiles table with an `is_admin` flag:
```sql
create table profiles (
user_id uuid not null primary key references auth.users (id),
is_admin boolean not null default false
);
```
Create a hook:
```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
as $$
declare
claims jsonb;
is_admin boolean;
begin
-- Check if the user is marked as admin in the profiles table
select is_admin into is_admin from profiles where user_id = (event->>'user_id')::uuid;
-- Proceed only if the user is an admin
if is_admin then
claims := event->'claims';
-- Check if 'app_metadata' exists in claims
if jsonb_typeof(claims->'app_metadata') is null then
-- If 'app_metadata' does not exist, create an empty object
claims := jsonb_set(claims, '{app_metadata}', '{}');
end if;
-- Set a claim of 'admin'
claims := jsonb_set(claims, '{app_metadata, admin}', 'true');
-- Update the 'claims' object in the original event
event := jsonb_set(event, '{claims}', claims);
end if;
-- Return the modified or original event
return event;
end;
$$;
grant execute
on function public.custom_access_token_hook
to supabase_auth_admin;
revoke execute
on function public.custom_access_token_hook
from authenticated, anon, public;
grant all
on table public.profiles
to supabase_auth_admin;
revoke all
on table public.profiles
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="add-metadata-claim-plv8" label="Add claim via plv8">
You can add custom claims to the access token. In this example, we fetch the level of an existing user from the `profiles` table and assign new users a default level of zero.
For convenience, we enable the `plv8` extension under [`Database > Extensions`](/dashboard/project/_/database/extensions) to run JavaScript within our query.
Create a `profiles` table to hold information:
```sql
create table profiles (
user_id uuid not null primary key references auth.users (id),
level integer,
is_admin boolean not null default false
);
```
```javascript
create or replace function custom_access_token_hook(event jsonb)
returns jsonb
language plv8
as $$
var user_level;
// Fetch the current user's level from the profiles table
var result = plv8.execute("select level from public.profiles where user_id = $1", [event.user_id]);
if (result.length > 0) {
user_level = result[0].level;
} else {
// Assign a default level of 0 if no level is found
user_level = 0;
}
// Check if 'claims' exists in the event object; if not, initialize it
if (!event.claims) {
event.claims = {};
}
// Update the level in the claims
event.claims.level = user_level;
return event;
$$;
grant all
on table public.profiles
to supabase_auth_admin;
revoke all
on table public.profiles
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="restrict-access-to-sso-users" label="Restrict access to SSO users">
You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist.
```sql
create or replace function public.restrict_application_access(event jsonb)
returns jsonb
language plpgsql
as $function$
declare
authentication_method text;
email_claim text;
allowed_emails text[] := array['myemail@company.com', 'example@company.com'];
begin
-- Extract email claim and authentication method
email_claim = event->'claims'->>'email';
authentication_method = event->'authentication_method';
-- Authentication methods come double quoted (e.g. "otp")
authentication_method = replace(authentication_method, '"', '');
if email_claim ilike '%@supabase.io' or authentication_method = 'sso/saml' or email_claim = any(allowed_emails) then
return event;
end if;
-- If none of the conditions are met, return an error
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 403,
'message', 'Staging access is only allowed to team members. Please use your @company.com account instead'
)
);
end;
$function$
;
-- manually added
grant execute
on function public.restrict_application_access
to supabase_auth_admin;
revoke execute
on function public.restrict_application_access
from authenticated, anon, public;
```
</TabPanel>
</Tabs>
## Local Development
Unlike the hosted platform, the local development dashboard does not have a user interface for linking a Hook. Instead, edit `config.toml` to link an Auth Hook and run it with your local setup. Modify the `auth.hook.<hook_name>` field and set `uri` to a value of `pg-functions://postgres/<schema>/<function_name>`
For instance, the `config.toml` for a Custom Access Token Hook which uses the function `public.custom_access_token_hook`:
```
...
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"
...
```
Save your Auth Hook as a migration in order to version the Auth Hook and share it with other team members. Run [`supabase migration new`](https://supabase.com/docs/reference/cli/supabase-migration-new) to create a migration.
### Debugging Your Hook
Test the Postgres Function in isolation before enabling the hook by connecting to your database with `psql` and running `select <your_function_name>`. Where necessary, use [`RAISE` statements](https://www.postgresql.org/docs/current/plpgsql-errors-and-messages.html) to print information to the terminal.
Outside of runtime errors, both HTTP Hooks and Postgres Hooks return timeout errors. Postgres Hooks have <SharedData data="config">auth.hook_timeouts.postgres_hooks</SharedData> seconds to complete processing while HTTP Hooks should complete in <SharedData data="config">auth.hook_timeouts.http_hooks</SharedData> seconds. Both HTTP Hooks and Postgres Hooks are run in a transaction do limit the duration of execution to avoid delays in authentication process.
## Available Hooks
Each Hook description contains an example JSONSchema which you can use in conjunction with [JSONSchema Faker](https://json-schema-faker.js.org/) in order to generate a mock payload. For HTTP Hooks, you can also use [the Standard Webhooks Testing Tool](https://www.standardwebhooks.com/simulate) to simulate a request.
<div className="grid md:grid-cols-12 gap-4 not-prose">
{[
{
name: 'Custom Access Token',
description: 'Customize the access token issued by Supabase Auth',
href: '/guides/auth/auth-hooks/custom-access-token-hook',
},
{
name: 'Send SMS',
description: 'Use a custom SMS provider to send authentication messages',
href: '/guides/auth/auth-hooks/send-sms-hook',
},
{
name: 'Send Email',
description: 'Use a custom email provider to send authentication messages',
href: '/guides/auth/auth-hooks/send-email-hook',
},
{
name: 'MFA Verification',
description: 'Add additional checks to the MFA verification flow',
href: '/guides/auth/auth-hooks/mfa-verification-hook',
},
{
name: 'Password verification',
description: 'Add additional checks to the password verification flow',
href: '/guides/auth/auth-hooks/password-verification-hook',
},
].map((x) => (
<div className="col-span-4" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
</div>
@@ -0,0 +1,483 @@
---
id: 'custom-access-token-hook'
title: 'Custom Access Token Hook'
subtitle: 'Customize the access token issued by Supabase Auth'
---
The custom access token hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
Claims returned must conform to our specification. Supabase Auth will check for these claims after the hook is run and return an error if they are not present.
These are the fields currently available on an access token:
Required Claims: `aud`, `exp`, `iat`, `sub`, `email`, `phone`, `role`, `aal`, `session_id`
Optional Claims: `jti`, `iss`, `nbf`, `app_metadata`, `user_metadata`, `amr`
**Inputs**
| Field | Type | Description |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | `string` | Unique identifier for the user attempting to sign in. |
| `claims` | `object` | Claims which are included in the access token. |
| `authentication_method` | `string` | The authentication method used to request the access token. Possible values include: `oauth`, `password`, `otp`, `totp`, `recovery`, `invite`, `sso/saml`, `magiclink`, `email/signup`, `email_change`, `token_refresh`, `anonymous`. |
<Tabs
scrollable
size="small"
type="underlined"
>
<TabPanel id="custom-access-token-json" label="JSON">
```json
{
"user_id": "8ccaa7af-909f-44e7-84cb-67cdccb56be6",
"claims": {
"aud": "authenticated",
"exp": 1715690221,
"iat": 1715686621,
"sub": "8ccaa7af-909f-44e7-84cb-67cdccb56be6",
"email": "",
"phone": "",
"app_metadata": {},
"user_metadata": {},
"role": "authenticated",
"aal": "aal1",
"amr": [ { "method": "anonymous", "timestamp": 1715686621 } ],
"session_id": "4b938a09-5372-4177-a314-cfa292099ea2",
"is_anonymous": true
},
"authentication_method": "anonymous"
}
```
</TabPanel>
<TabPanel id="custom-access-token-json-schema" label="JSON Schema">
```json
{
"type": "object",
"properties": {
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"claims": {
"type": "object",
"properties": {
"aud": {
"type": "string",
"x-faker": "random.word"
},
"exp": {
"type": "integer",
"x-faker": "date.future"
},
"iat": {
"type": "integer",
"x-faker": "date.past"
},
"sub": {
"type": "string",
"x-faker": "random.uuid"
},
"email": {
"type": "string",
"x-faker": "internet.email"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"app_metadata": {
"type": "object",
"x-faker": "random.objectElement"
},
"user_metadata": {
"type": "object",
"x-faker": "random.objectElement"
},
"role": {
"type": "string",
"enum": ["anon", "authenticated"]
},
"aal": {
"type": "string",
"enum": ["aal1", "aal2", "aal3"]
},
"amr": {
"type": "array",
"items": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": [
"oauth",
"password",
"otp",
"totp",
"recovery",
"invite",
"sso/saml",
"magiclink",
"email/signup",
"email_change",
"token_refresh",
"anonymous"
]
},
"timestamp": {
"type": "integer",
"x-faker": "date.past"
}
},
"required": ["method", "timestamp"]
}
},
"session_id": {
"type": "string",
"x-faker": "random.uuid"
},
"is_anonymous": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": [
"aud",
"exp",
"iat",
"sub",
"email",
"phone",
"app_metadata",
"user_metadata",
"role",
"aal",
"amr",
"session_id",
"is_anonymous"
]
},
"authentication_method": {
"type": "string",
"enum": [
"oauth",
"password",
"otp",
"totp",
"recovery",
"invite",
"sso/saml",
"magiclink",
"email/signup",
"email_change",
"token_refresh",
"anonymous"
]
}
},
"required": ["user_id", "claims", "authentication_method"]
}
```
</TabPanel>
</Tabs>
**Outputs**
Return these only if your hook processed the input without errors.
| Field | Type | Description |
| -------- | -------- | ----------------------------------------------- |
| `claims` | `object` | The updated claims after the hook has been run. |
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="add-admin-role"
>
<TabPanel id="add-admin-role" label="Add admin role">
You can allow registered admin users to perform restricted actions by granting an `admin` claim to their token.
Create a profiles table with an `is_admin` flag:
```sql
create table profiles (
user_id uuid not null primary key references auth.users (id),
is_admin boolean not null default false
);
```
Create a hook:
```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
as $$
declare
claims jsonb;
is_admin boolean;
begin
-- Check if the user is marked as admin in the profiles table
select is_admin into is_admin from profiles where user_id = (event->>'user_id')::uuid;
-- Proceed only if the user is an admin
if is_admin then
claims := event->'claims';
-- Check if 'app_metadata' exists in claims
if jsonb_typeof(claims->'app_metadata') is null then
-- If 'app_metadata' does not exist, create an empty object
claims := jsonb_set(claims, '{app_metadata}', '{}');
end if;
-- Set a claim of 'admin'
claims := jsonb_set(claims, '{app_metadata, admin}', 'true');
-- Update the 'claims' object in the original event
event := jsonb_set(event, '{claims}', claims);
end if;
-- Return the modified or original event
return event;
end;
$$;
grant all
on table public.profiles
to supabase_auth_admin;
revoke all
on table public.profiles
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="add-metadata-claim-plv8" label="Add claim via plv8">
You can add custom claims to the access token. In this example, we fetch the level of an existing user from the `profiles` table and assign new users a default level of zero.
For convenience, we enable the `plv8` extension under [`Database > Extensions`](/dashboard/project/_/database/extensions) to run JavaScript within our query.
Create a `profiles` table to hold information:
```sql
create table profiles (
user_id uuid not null primary key references auth.users (id),
level integer,
is_admin boolean not null default false
);
```
```sql
create or replace function custom_access_token_hook(event jsonb)
returns jsonb
language plv8
as $$
var user_level;
-- Fetch the current user's level from the profiles table
var result = plv8.execute("select level from public.profiles where user_id = $1", [event.user_id]);
if (result.length > 0) {
user_level = result[0].level;
} else {
-- Assign a default level of 0 if no level is found
user_level = 0;
}
-- Check if 'claims' exists in the event object; if not, initialize it
if (!event.claims) {
event.claims = {};
}
-- Update the level in the claims
event.claims.level = user_level;
return event;
$$;
grant all
on table public.profiles
to supabase_auth_admin;
revoke all
on table public.profiles
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="restrict-access-to-sso-users" label="Restrict access to SSO users">
You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist.
```sql
create or replace function public.restrict_application_access(event jsonb)
returns jsonb
language plpgsql
as $function$
declare
authentication_method text;
email_claim text;
allowed_emails text[] := array['myemail@company.com', 'example@company.com'];
begin
-- Extract email claim and authentication method
email_claim = event->'claims'->>'email';
authentication_method = event->'authentication_method';
-- Authentication methods come double quoted (e.g. "otp")
authentication_method = replace(authentication_method, '"', '');
if email_claim ilike '%@supabase.io' or authentication_method = 'sso/saml' or email_claim = any(allowed_emails) then
return event;
end if;
-- If none of the conditions are met, return an error
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 403,
'message', 'Staging access is only allowed to team members. Please use your @company.com account instead'
)
);
end;
$function$
;
-- manually added
grant execute
on function public.restrict_application_access
to supabase_auth_admin;
revoke execute
on function public.restrict_application_access
from authenticated, anon, public;
```
</TabPanel>
</Tabs>
</TabPanel>
<TabPanel id="http" label="HTTP">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="http-add-metadata-claim"
>
<TabPanel id="http-add-metadata-claim" label="Add claim">
Your company wishes to add assign permissions via the role claim on the `app_metadata` field. Add the role claim to the token via a Hook.
```javascript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts'
Deno.serve(async (req) => {
const payload = await req.text()
const base64_secret = Deno.env.get('AUTH_CUSTOM_ACCESS_TOKEN_SECRET')
const headers = Object.fromEntries(req.headers)
const wh = new Webhook(base64_secret)
try {
const { user_id, claims, authentication_method } = wh.verify(payload, headers)
if (claims.app_metadata && claims.app_metadata.role) {
claims['role'] = claims.app_metadata.role
}
return new Response(
JSON.stringify({
claims,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
} catch (error) {
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
})
```
</TabPanel>
<TabPanel id="http-restrict-access-to-sso-users" label="Restrict access to SSO users">
You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist.
```javascript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts'
Deno.serve(async (req) => {
const payload = await req.text()
const base64_secret = Deno.env.get('AUTH_CUSTOM_ACCESS_TOKEN_SECRET')
const headers = Object.fromEntries(req.headers)
const wh = new Webhook(base64_secret)
try {
const { user_id, claims, authentication_method } = wh.verify(payload, headers)
// Check the condition
const allowedEmails = ['myemail@company.com', 'example@company.com']
if (authentication_method === 'sso/saml' || allowedEmails.includes(claims.email)) {
return new Response(
JSON.stringify({
claims,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
} else {
return new Response(
JSON.stringify({
error: 'Unauthorized',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
} catch (error) {
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
})
```
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
@@ -0,0 +1,174 @@
---
id: 'mfa-verification-hook'
title: 'MFA Verification Hook'
---
You can add additional checks to the [Supabase MFA implementation](/docs/guides/auth/auth-mfa) with hooks. For example, you can:
- Limit the number of verification attempts performed over a period of time.
- Sign out users who have too many invalid verification attempts.
- Count, rate limit, or ban sign-ins.
**Inputs**
Supabase Auth will send a payload containing these fields to your hook:
| Field | Type | Description |
| ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `factor_id` | `string` | Unique identifier for the MFA factor being verified |
| `factor_type` | `string` | `totp` or `phone` |
| `user_id` | `string` | Unique identifier for the user |
| `valid` | `boolean` | Whether the verification attempt was valid. For TOTP, this means that the six digit code was correct (true) or incorrect (false). |
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="mfa-verification-attempt-json"
>
<TabPanel id="mfa-verification-attempt-json" label="JSON">
```json
{
"factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db",
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
"valid": true
}
```
</TabPanel>
<TabPanel id="mfa-verification-attempt-json-schema" label="JSON Schema">
```json
{
"type": "object",
"properties": {
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"valid": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": ["user_id", "valid"]
}
```
</TabPanel>
</Tabs>
**Outputs**
Return this if your hook processed the input without errors.
| Field | Type | Description |
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decision` | `string` | The decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. |
| `message` | `string` | The message to show the user if the decision was `reject`. |
```json
{
"decision": "reject",
"message": "You have exceeded maximum number of MFA attempts."
}
```
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql-limit-failed-mfa-verification-attempts"
>
<TabPanel id="sql-limit-failed-mfa-verification-attempts" label="Limit failed MFA verification attempts">
Your company requires that a user can input an incorrect MFA Verification code no more than once every 2 seconds.
Create a table to record the last time a user had an incorrect MFA verification attempt for a factor.
```sql
create table public.mfa_failed_verification_attempts (
user_id uuid not null,
factor_id uuid not null,
last_failed_at timestamp not null default now(),
primary key (user_id, factor_id)
);
```
Create a hook to read and write information to this table. For example:
```sql
create function public.hook_mfa_verification_attempt(event jsonb)
returns jsonb
language plpgsql
as $$
declare
last_failed_at timestamp;
begin
if event->'valid' is true then
-- code is valid, accept it
return jsonb_build_object('decision', 'continue');
end if;
select last_failed_at into last_failed_at
from public.mfa_failed_verification_attempts
where
user_id = event->'user_id'
and
factor_id = event->'factor_id';
if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then
-- last attempt was done too quickly
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 429,
'message', 'Please wait a moment before trying again.'
)
);
end if;
-- record this failed attempt
insert into public.mfa_failed_verification_attempts
(
user_id,
factor_id,
last_refreshed_at
)
values
(
event->'user_id',
event->'factor_id',
now()
)
on conflict do update
set last_refreshed_at = now();
-- finally let Supabase Auth do the default behavior for a failed attempt
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions and revoke access
grant all
on table public.mfa_failed_verification_attempts
to supabase_auth_admin;
revoke all
on table public.mfa_failed_verification_attempts
from authenticated, anon, public;
```
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
@@ -0,0 +1,279 @@
---
id: 'password-verification-hook'
title: 'Password Verification Hook'
---
Your company wishes to increase security beyond the requirements of the default password implementation in order to fulfill security or compliance requirements. You plan to track the status of a password sign-in attempt and take action via an email or a restriction on logins where necessary.
As this hook runs on unauthenticated requests, malicious users can abuse the hook by calling it multiple times. Pay extra care when using the hook as you can unintentionally block legitimate users from accessing your application.
Check if a password is valid prior to taking any additional action to ensure the user is legitimate. Where possible, send an email or notification instead of blocking the user.
**Inputs**
| Field | Type | Description |
| --------- | --------- | ----------------------------------------------------------------------------------------------- |
| `user_id` | `string` | Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table. |
| `valid` | `boolean` | Whether the password verification attempt was valid. |
<Tabs
scrollable
size="small"
type="underlined"
>
<TabPanel id="password-verification-attempt-json" label="JSON">
```json
{
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
"valid": true
}
```
</TabPanel>
<TabPanel id="password-verification-attempt-json-schema" label="JSON Schema">
```json
{
"type": "object",
"properties": {
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"valid": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": ["user_id", "valid"]
}
```
</TabPanel>
</Tabs>
**Outputs**
Return these only if your hook processed the input without errors.
| Field | Type | Description |
| -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decision` | `string` | The decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. |
| `message` | `string` | The message to show the user if the decision was `reject`. |
| `should_logout_user` | `boolean` | Whether to log out the user if a `reject` decision is issued. Has no effect when a `continue` decision is issued. |
```json
{
"decision": "reject",
"message": "You have exceeded maximum number of password sign-in attempts.",
"should_logout_user": "false"
}
```
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql-limit-failed-password-verification-attempts"
>
<TabPanel id="sql-limit-failed-password-verification-attempts" label="Limit failed password verification attempts">
As part of new security measures within the company, users can only input an incorrect password every 10 seconds and not more than that. You want to write a hook to enforce this.
Create a table to record each user's last incorrect password verification attempt.
```sql
create table public.password_failed_verification_attempts (
user_id uuid not null,
last_failed_at timestamp not null default now(),
primary key (user_id)
);
```
Create a hook to read and write information to this table. For example:
```sql
create function public.hook_password_verification_attempt(event jsonb)
returns jsonb
language plpgsql
as $$
declare
last_failed_at timestamp;
begin
if event->'valid' is true then
-- password is valid, accept it
return jsonb_build_object('decision', 'continue');
end if;
select last_failed_at into last_failed_at
from public.password_failed_verification_attempts
where
user_id = event->'user_id';
if last_failed_at is not null and now() - last_failed_at < interval '10 seconds' then
-- last attempt was done too quickly
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 429,
'message', 'Please wait a moment before trying again.'
)
);
end if;
-- record this failed attempt
insert into public.password_failed_verification_attempts
(
user_id,
last_failed_at
)
values
(
event->'user_id',
now()
)
on conflict do update
set last_failed_at = now();
-- finally let Supabase Auth do the default behavior for a failed attempt
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions
grant all
on table public.password_failed_verification_attempts
to supabase_auth_admin;
revoke all
on table public.password_failed_verification_attempts
from authenticated, anon, public;
```
</TabPanel>
<TabPanel id="sql-send-email-on-failed-password-attempt" label="Send email notification on failed password attempts">
You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send a HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from.
First, create a table to track sign in attempts.
```sql
create table public.password_sign_in_attempts (
user_id uuid not null,
attempt_id uuid not null,
last_attempt_at timestamp not null default now(),
attempt_successful boolean not null,
primary key (user_id, attempt_id)
);
```
Next, store the API key of our email API provider:
```sql
select vault.create_secret('my_api_key', 'my_api_key_name', 'description_of_my_api_key');
```
Create the hook:
```sql
create or replace function public.hook_notify_user_on_failed_attempts(event jsonb)
returns jsonb
language plpgsql
as $$
declare
user_id uuid;
server_token text;
user_email_address text;
email_body jsonb;
response_id int; -- Variable to store the response ID
http_code int;
error_message jsonb;
attempt_count int;
max_attempts int := 5; -- Set the threshold for failed attempts
begin
user_id := (event->>'user_id')::uuid;
-- Record the attempt
insert into public.password_sign_in_attempts (user_id, attempt_id, last_attempt_at, attempt_successful)
values (user_id, (event->>'attempt_id')::uuid, now(), (event->>'valid')::boolean)
on conflict (user_id, attempt_id)
do update set last_attempt_at = now(), attempt_successful = (event->>'valid')::boolean;
-- Check failed attempts and fetch user email
select count(*), u.email into attempt_count, user_email_address
from public.password_sign_in_attempts a
join auth.users u on a.user_id = u.id
where a.user_id = user_id and attempt_successful = false and last_attempt_at > (now() - interval '1 day');
-- Notify user if the number of failed attempts exceeds the threshold
if attempt_count >= max_attempts then
-- Fetch the server token
select decrypted_secret into server_token from vault.decrypted_secrets where name = 'my_api_key_name';
-- Prepare the email body
email_body := format('{
"from": "yoursenderemail@example.com",
"to": "%s",
"subject": "Security Alert: Repeated Login Attempts Detected",
"textbody": "We have detected repeated login attempts for your account. If this was not you, please secure your account.",
"htmlbody": "<html><body><strong>Security Alert:</strong> We have detected repeated login attempts for your account. If this was not you, please secure your account.</body></html>",
"messagestream": "outbound"
}', user_email_address)::jsonb;
-- Perform the HTTP POST request using Postmark
select id into response_id from net.http_post(
'https://api.youremailprovider.com/email',
email_body,
'application/json',
array['Accept: application/json', 'X-Postmark-Server-Token: ' || server_token]
);
-- Fetch the response from net._http_response using the obtained id
select status_code, content into http_code, error_message from net._http_response where id = response_id;
-- Handle email sending errors
if http_code is null or (http_code < 200 or http_code >= 300) then
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', coalesce(http_code, 0),
'message', coalesce(error_message ->> 'message', 'error sending email')
)
);
end if;
end if;
-- Continue with default behavior
return jsonb_build_object('decision', 'continue');
end;
$$;
-- Assign appropriate permissions
grant execute
on function public.hook_notify_user_on_failed_attempts
to supabase_auth_admin;
revoke execute
on function public.hook_notify_user_on_failed_attempts
from authenticated, anon, public;
grant all
on table public.password_sign_in_attempts
to supabase_auth_admin;
revoke all
on table public.password_sign_in_attempts
from authenticated, anon, public;
```
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
@@ -0,0 +1,839 @@
---
id: 'send-email-hook'
title: 'Send Email Hook'
subtitle: 'Use a custom email provider to send authentication messages'
---
The Send Email Hook runs before an email is sent and allows for flexibility around email sending. You can use this hook to configure a back-up email provider or add internationalization to your emails.
**Inputs**
| Field | Type | Description |
| ------- | ------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `user` | [`User`](/docs/guides/auth/users#the-user-object) | The user attempting to sign in. |
| `email` | `object` | Metadata specific to the email sending process. Includes the OTP and `token_hash`. |
<Tabs
scrollable
size="small"
type="underlined"
>
<TabPanel id="send-email-json" label="JSON">
```json
{
"user": {
"id": "8484b834-f29e-4af2-bf42-80644d154f76",
"aud": "authenticated",
"role": "authenticated",
"email": "john@soupbase.io",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {
"email": "john@soupbase.io",
"email_verified": false,
"phone_verified": false,
"sub": "8484b834-f29e-4af2-bf42-80644d154f76"
},
"identities": [
{
"identity_id": "bc26d70b-517d-4826-bce4-413a5ff257e7",
"id": "8484b834-f29e-4af2-bf42-80644d154f76",
"user_id": "8484b834-f29e-4af2-bf42-80644d154f76",
"identity_data": {
"email": "john@soupbase.io",
"email_verified": false,
"phone_verified": false,
"sub": "8484b834-f29e-4af2-bf42-80644d154f76"
},
"provider": "email",
"last_sign_in_at": "2024-05-14T12:56:33.824231484Z",
"created_at": "2024-05-14T12:56:33.824261Z",
"updated_at": "2024-05-14T12:56:33.824261Z",
"email": "john@soupbase.io"
}
],
"created_at": "2024-05-14T12:56:33.821567Z",
"updated_at": "2024-05-14T12:56:33.825595Z",
"is_anonymous": false
},
"email_data": {
"token": "305805",
"token_hash": "7d5b7b1964cf5d388340a7f04f1dbb5eeb6c7b52ef8270e1737a58d0",
"redirect_to": "http://localhost:3000/",
"email_action_type": "signup",
"site_url": "http://localhost:9999",
"token_new": "",
"token_hash_new": ""
}
}
```
</TabPanel>
<TabPanel id="send-email-json-schema" label="JSON Schema">
```json
{
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"aud": {
"type": "string",
"enum": ["authenticated"]
},
"role": {
"type": "string",
"enum": ["anon", "authenticated"]
},
"email": {
"type": "string",
"x-faker": "internet.email"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"app_metadata": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["email"]
},
"providers": {
"type": "array",
"items": {
"type": "string",
"enum": ["email"]
},
"minItems": 1,
"maxItems": 1
}
}
},
"user_metadata": {
"type": "object",
"properties": {
"email": {
"type": "string",
"x-faker": "internet.email"
},
"email_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"phone_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"sub": {
"type": "string",
"x-faker": "random.uuid"
}
}
},
"identities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"identity_id": {
"type": "string",
"x-faker": "random.uuid"
},
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"identity_data": {
"type": "object",
"properties": {
"email": {
"type": "string",
"x-faker": "internet.email"
},
"email_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"phone_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"sub": {
"type": "string",
"x-faker": "random.uuid"
}
}
},
"provider": {
"type": "string",
"enum": ["email"]
},
"last_sign_in_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"email": {
"type": "string",
"x-faker": "internet.email"
}
},
"required": [
"identity_id",
"id",
"user_id",
"identity_data",
"provider",
"last_sign_in_at",
"created_at",
"updated_at",
"email"
]
}
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"is_anonymous": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": [
"id",
"aud",
"role",
"email",
"phone",
"app_metadata",
"user_metadata",
"identities",
"created_at",
"updated_at",
"is_anonymous"
]
},
"email_data": {
"type": "object",
"properties": {
"token": {
"type": "string",
"pattern": "^[0-9]{6}$",
"x-faker": {
"fake": "{{helpers.replaceSymbols('######')}}"
}
},
"token_hash": {
"type": "string",
"minLength": 16,
"maxLength": 30,
"x-faker": {
"fake": "{{random.alphaNumeric(30)}}"
}
},
"redirect_to": {
"type": "string",
"x-faker": "internet.url"
},
"email_action_type": {
"type": "string",
"enum": ["signup", "login", "reset_password"]
},
"site_url": {
"type": "string",
"x-faker": "internet.url"
},
"token_new": {
"type": "string",
"minLength": 16,
"maxLength": 30,
"x-faker": {
"fake": "{{random.alphaNumeric(30)}}"
}
},
"token_hash_new": {
"type": "string",
"minLength": 16,
"maxLength": 30,
"x-faker": {
"fake": "{{random.alphaNumeric(30)}}"
}
}
},
"required": [
"token",
"token_hash",
"redirect_to",
"email_action_type",
"site_url",
"token_new",
"token_hash_new"
]
}
},
"required": ["user", "email_data"]
}
```
</TabPanel>
</Tabs>
**Outputs**
- No outputs are required. An empty response with a status code of 200 is taken as a successful response.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="http"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql-queue-email-messages"
>
<TabPanel id="sql-queue-email-messages" label="Queue Email Messages">
Your company uses a worker to manage all emails related jobs. For performance reasons, the messaging system sends emails in batches via a job queue. Instead of sending a message immediately, messages are queued and sent in periodic intervals via `pg_cron`.
Create a table to store jobs
```sql
create table job_queue (
job_id uuid primary key default gen_random_uuid(),
job_data jsonb not null,
created_at timestamp default now(),
status text default 'pending',
priority int default 0,
retry_count int default 0,
max_retries int default 2,
scheduled_at timestamp default now()
);
```
Create the hook
```sql
create or replace function send_email(event jsonb) returns void as $$
declare
job_data jsonb;
scheduled_time timestamp;
priority int;
begin
-- Extract email details from the event JSON
job_data := jsonb_build_object(
'email_action_type', event->'email_data'->>'email_action_type',
'token_hash', event->'email_data'->>'token_hash',
'token', event->'email_data'->>'token',
'email', event->'user'->>'email'
);
-- Calculate the nearest 5-minute window for scheduled_time
scheduled_time := date_trunc('minute', now()) + interval '5 minute' * floor(extract('epoch' from (now() - date_trunc('minute', now())) / 60) / 5);
-- Assign priority dynamically (example logic: higher priority for earlier scheduled time)
priority := extract('epoch' from (scheduled_time - now()))::int;
insert into job_queue (job_data, priority, scheduled_at, max_retries)
values (job_data, priority, scheduled_time, 2);
end;
$$ language plpgsql;
grant all
on table public.job_queue
to supabase_auth_admin;
revoke all
on table public.job_queue
from authenticated, anon;
```
Create a function to periodically run and dequeue all jobs
```sql
create or replace function dequeue_and_run_jobs() returns void as $$
declare
job record;
begin
for job in
select * from job_queue
where status = 'pending'
and scheduled_at <= now()
order by priority desc, created_at
for update skip locked
loop
begin
-- add job processing logic here.
-- for demonstration, we'll just update the job status to 'completed'.
update job_queue
set status = 'completed'
where job_id = job.job_id;
exception when others then
-- handle job failure and retry logic
if job.retry_count < job.max_retries then
update job_queue
set retry_count = retry_count + 1,
scheduled_at = now() + interval '1 minute' -- delay retry by 1 minute
where job_id = job.job_id;
else
update job_queue
set status = 'failed'
where job_id = job.job_id;
end if;
end;
end loop;
end;
$$ language plpgsql;
grant execute
on function public.dequeue_and_run_jobs
to supabase_auth_admin;
revoke execute
on function public.dequeue_and_run_jobs
from authenticated, anon;
```
Configure `pg_cron` to run the job on an interval. You can use a tool like [crontab.guru](https://crontab.guru/) to check that your job is running on an appropriate schedule. Ensure that `pg_cron` is enabled under `Database > Extensions`
```sql
select
cron.schedule(
'* * * * *', -- this cron expression means every minute.
'select dequeue_and_run_jobs();'
);
```
</TabPanel>
</Tabs>
</TabPanel>
<TabPanel id="http" label="HTTP">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="http-send-email-with-resend"
>
<TabPanel id="http-send-email-with-resend" label="Use Resend as an email provider">
You can configure [Resend](https://resend.com/) as the custom email provider through the "Send Email" hook. This allows you to take advantage of Resend's developer-friendly APIs to send emails and leverage [React Email](https://react.email/) for managing your email templates. If you want to send emails through the Supabase Resend integration, which uses Resend's SMTP server, check out [this integration](/partners/integrations/resend) instead.
Create a `.env` file with the following environment variables:
```bash
RESEND_API_KEY=your_resend_api_key
SEND_EMAIL_HOOK_SECRET=<base64_secret>
```
Set the secrets in your Supabase project:
```bash
$ supabase secret set --env-file .env
```
Create a new edge function:
```bash
$ supabase functions new send-email
```
Add the following code to your edge function:
```javascript
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
import { Resend } from "npm:resend";
const resend = new Resend(Deno.env.get("RESEND_API_KEY") as string);
const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET") as string;
Deno.serve(async (req) => {
const payload = await req.text();
const headers = Object.fromEntries(req.headers);
const wh = new Webhook(hookSecret);
try {
const { user, email_data } = wh.verify(payload, headers) as {
user: {
email: string;
};
email_data: {
token: string;
token_hash: string;
redirect_to: string;
email_action_type: string;
site_url: string;
token_new: string;
token_hash_new: string;
};
};
const { error } = await resend.emails.send({
from: "welcome <onboarding@example.com>",
to: [user.email],
subject: "Welcome to my site!",
text: `Confirm you signup with this code: ${email_data.token}`,
});
if (error) {
throw error;
}
} catch (error) {
return new Response(
JSON.stringify({
error: {
http_code: error.code,
message: error.message,
},
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}
const responseHeaders = new Headers();
responseHeaders.set("Content-Type", "application/json");
return new Response(JSON.stringify({}), {
status: 200,
headers: responseHeaders,
});
});
```
Deploy your edge function and [configure it as a hook](/dashboard/project/_/auth/hooks):
```bash
supabase functions deploy send-email --no-verify-jwt
```
</TabPanel>
<TabPanel id="http-internationalization-for-emails" label="Add Internationalization for Email Templates">
Your company is expanding to France and Spain. As part of expansion efforts, the company would like to deliver internationalized email templates to best support local users in their native language. Ensure that you have configured `POSTMARK_SERVER_TOKEN` and `AUTH_SEND_EMAIL_HOOK_SECRET` in your `.env` file.
```javascript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
const postmarkEndpoint = 'https://api.postmarkapp.com/email'
// Replace this with your email
const FROM_EMAIL = 'myemail@gmail.com'
// Email Subjects
const subjects = {
en: {
signup: 'Confirm Your Email',
recovery: 'Reset Your Password',
invite: 'You have been invited',
magic_link: 'Your Magic Link',
email_change: 'Confirm Email Change',
email_change_new: 'Confirm New Email Address',
reauthentication: 'Confirm Reauthentication',
},
es: {
signup: 'Confirma tu correo electrónico',
recovery: 'Restablece tu contraseña',
invite: 'Has sido invitado',
magic_link: 'Tu enlace mágico',
email_change: 'Confirma el cambio de correo electrónico',
email_change_new: 'Confirma la Nueva Dirección de Correo',
reauthentication: 'Confirma la reautenticación',
},
fr: {
signup: 'Confirmez votre adresse e-mail',
recovery: 'Réinitialisez votre mot de passe',
invite: 'Vous avez été invité',
magic_link: 'Votre Lien Magique',
email_change: 'Confirmez le changement d’adresse e-mail',
email_change_new: 'Confirmez la nouvelle adresse e-mail',
reauthentication: 'Confirmez la réauthentification',
},
}
// HTML Body
const templates = {
en: {
signup: `<h2>Confirm your email</h2><p>Follow this link to confirm your email:</p><p><a href="{{confirmation_url}}">Confirm your email address</a></p><p>Alternatively, enter the code: {{token}}</p>`,
recovery: `<h2>Reset password</h2><p>Follow this link to reset the password for your user:</p><p><a href="{{confirmation_url}}">Reset password</a></p><p>Alternatively, enter the code: {{token}}</p>`,
invite: `<h2>You have been invited</h2><p>You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:</p><p><a href="{{confirmation_url}}">Accept the invite</a></p><p>Alternatively, enter the code: {{token}}</p>`,
magic_link: `<h2>Magic Link</h2><p>Follow this link to login:</p><p><a href="{{confirmation_url}}">Log In</a></p><p>Alternatively, enter the code: {{token}}</p>`,
email_change: `<h2>Confirm email address change</h2><p>Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:</p><p><a href="{{confirmation_url}}">Change email address</a></p><p>Alternatively, enter the codes: {{token}} and {{new_token}}</p>`,
email_change_new: `<h2>Confirm New Email Address</h2><p>Follow this link to confirm your new email address:</p><p><a href="{{confirmation_url}}">Confirm new email address</a></p><p>Alternatively, enter the code: {{new_token}}</p>`,
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`,
},
es: {
signup: `<h2>Confirma tu correo electrónico</h2><p>Sigue este enlace para confirmar tu correo electrónico:</p><p><a href="{{confirmation_url}}">Confirma tu correo electrónico</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
recovery: `<h2>Restablece tu contraseña</h2><p>Sigue este enlace para restablecer la contraseña de tu usuario:</p><p><a href="{{confirmation_url}}">Restablece tu contraseña</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
invite: `<h2>Has sido invitado</h2><p>Has sido invitado para crear un usuario en {{site_url}}. Sigue este enlace para aceptar la invitación:</p><p><a href="{{confirmation_url}}">Aceptar la invitación</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
magic_link: `<h2>Tu enlace mágico</h2><p>Sigue este enlace para iniciar sesión:</p><p><a href="{{confirmation_url}}">Iniciar sesión</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
email_change: `<h2>Confirma el cambio de correo electrónico</h2><p>Sigue este enlace para confirmar la actualización de tu correo electrónico de {{old_email}} a {{new_email}}:</p><p><a href="{{confirmation_url}}">Cambiar correo electrónico</a></p><p>Alternativamente, ingresa los códigos: {{token}} y {{new_token}}</p>`,
email_change_new: `<h2>Confirma la Nueva Dirección de Correo</h2><p>Sigue este enlace para confirmar tu nueva dirección de correo electrónico:</p><p><a href="{{confirmation_url}}">Confirma la nueva dirección de correo</a></p><p>Alternativamente, ingresa el código: {{new_token}}</p>`,
reauthentication: `<h2>Confirma la reautenticación</h2><p>Ingresa el código: {{token}}</p>`,
},
fr: {
signup: `<h2>Confirmez votre adresse e-mail</h2><p>Suivez ce lien pour confirmer votre adresse e-mail :</p><p><a href="{{confirmation_url}}">Confirmez votre adresse e-mail</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
recovery: `<h2>Réinitialisez votre mot de passe</h2><p>Suivez ce lien pour réinitialiser votre mot de passe :</p><p><a href="{{confirmation_url}}">Réinitialisez votre mot de passe</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
invite: `<h2>Vous avez été invité</h2><p>Vous avez été invité à créer un utilisateur sur {{site_url}}. Suivez ce lien pour accepter l'invitation :</p><p><a href="{{confirmation_url}}">Acceptez l'invitation</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
magic_link: `<h2>Votre Lien Magique</h2><p>Suivez ce lien pour vous connecter :</p><p><a href="{{confirmation_url}}">Connectez-vous</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
email_change: `<h2>Confirmez le changement d’adresse e-mail</h2><p>Suivez ce lien pour confirmer la mise à jour de votre adresse e-mail de {{old_email}} à {{new_email}} :</p><p><a href="{{confirmation_url}}">Changez d’adresse e-mail</a></p><p>Vous pouvez aussi saisir les codes : {{token}} et {{new_token}}</p>`,
email_change_new: `<h2>Confirmez la nouvelle adresse e-mail</h2><p>Suivez ce lien pour confirmer votre nouvelle adresse e-mail :</p><p><a href="{{confirmation_url}}">Confirmez la nouvelle adresse e-mail</a></p><p>Vous pouvez aussi saisir le code : {{new_token}}</p>`,
reauthentication: `<h2>Confirmez la réauthentification</h2><p>Saisissez le code : {{token}}</p>`,
},
}
Deno.serve(async (req) => {
const payload = await req.text()
const serverToken = Deno.env.get('POSTMARK_SERVER_TOKEN')
const headers = Object.fromEntries(req.headers)
const base64_secret = Deno.env.get('AUTH_SEND_EMAIL_HOOK_SECRET')
const wh = new Webhook(base64_secret)
const { user, email_data } = wh.verify(payload, headers)
const language = (user.user_metadata && user.user_metadata.i18n) || 'en'
const subject = subjects[language][email_data.email_action_type] || 'Notification'
let template = templates[language][email_data.email_action_type]
let htmlBody = template
.replace('{{confirmation_url}}', email_data.confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '')
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Postmark-Server-Token': serverToken,
},
body: JSON.stringify({
From: FROM_EMAIL,
To: user.email,
Subject: subject,
HtmlBody: htmlBody,
}),
}
try {
const response = await fetch(postmarkEndpoint, requestOptions)
if (!response.ok) {
const errorData = await response.json()
throw new Error(`Failed to send email: ${errorData.Message}`)
}
return new Response(
JSON.stringify({
message: 'Email sent successfully.',
}),
{
headers: {
'Content-Type': 'application/json',
},
}
)
} catch (error) {
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error.message}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
})
```
</TabPanel>
{/* <TabPanel id="http-backup-email-provider" label="Add Backup Email Provider">
Your company is rapidly growing and depends heavily on email signups. You'd like to configure a backup email provider in case the email provider runs out of credits during your new product launch. Postmark and Sengrid are used as examples but in practice you can use any email provider.
Ensure that you have configured `POSTMARK_SERVER_TOKEN`, `SENDGRID_API_KEY` and `AUTH_SEND_EMAIL_HOOK_SECRET` in your `.env` file.
```javascript
import {
Webhook
} from "https://esm.sh/standardwebhooks@1.0.0";
import {
readAll
} from "https://deno.land/std/io/read_all.ts";
const postmarkEndpoint = 'https://api.postmarkapp.com/email';
const sendGridEndpoint = 'https://api.sendgrid.com/v3/mail/send';
const FROM_EMAIL = 'myemail@gmail.com'
// Email Subjects
const subjects = {
signup: 'Confirm Your Email',
recovery: 'Reset Your Password',
invite: 'You have been invited',
magic_link: 'Your Magic Link',
email_change: 'Confirm Email Change',
email_change_new: 'Confirm New Email Address',
reauthentication: 'Confirm Reauthentication'
};
// HTML Body
const templates = {
signup: `<h2>Confirm your email</h2><p>Follow this link to confirm your email:</p><p><a href="{{confirmation_url}}">Confirm your email address</a></p><p>Alternatively, enter the code: {{token}}</p>`,
recovery: `<h2>Reset password</h2><p>Follow this link to reset the password for your user:</p><p><a href="{{confirmation_url}}">Reset password</a></p><p>Alternatively, enter the code: {{token}}</p>`,
invite: `<h2>You have been invited</h2><p>You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:</p><p><a href="{{confirmation_url}}">Accept the invite</a></p><p>Alternatively, enter the code: {{token}}</p>`,
magic_link: `<h2>Magic Link</h2><p>Follow this link to login:</p><p><a href="{{confirmation_url}}">Log In</a></p><p>Alternatively, enter the code: {{token}}</p>`,
email_change: `<h2>Confirm email address change</h2><p>Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:</p><p><a href="{{confirmation_url}}">Change email address</a></p><p>Alternatively, enter the codes: {{token}} and {{new_token}}</p>`,
email_change_new: `<h2>Confirm New Email Address</h2><p>Follow this link to confirm your new email address:</p><p><a href="{{confirmation_url}}">Confirm new email address</a></p><p>Alternatively, enter the code: {{new_token}}</p>`,
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`
};
async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise<Response> {
const subject = subjects[email_data.email_action_type] || 'Notification';
let template = templates[email_data.email_action_type];
let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '');
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Postmark-Server-Token': serverToken
},
body: JSON.stringify({
From: FROM_EMAIL,
To: user.email,
Subject: subject,
HtmlBody: htmlBody
})
};
return await fetch(postmarkEndpoint, requestOptions);
}
async function sendEmailWithSendGrid(user: any, email_data: any, apiKey: string): Promise<Response> {
const subject = subjects[email_data.email_action_type] || 'Notification';
let template = templates[email_data.email_action_type];
let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '');
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
personalizations: [{
to: [{
email: user.email
}],
subject: subject
}],
from: {
email: FROM_EMAIL
},
content: [{
type: "text/html",
value: htmlBody
}]
})
};
return await fetch(sendGridEndpoint, requestOptions);
}
Deno.serve(async (req) => {
const payload = await req.text();
const postmarkServerToken = Deno.env.get("POSTMARK_SERVER_TOKEN");
const sendGridApiKey = Deno.env.get("SENDGRID_API_KEY");
const headers = Object.fromEntries(req.headers);
const base64_secret = Deno.env.get('AUTH_SEND_EMAIL_HOOK_SECRET');
const wh = new Webhook(base64_secret);
const {
user,
email_data
} = wh.verify(payload, headers);
try {
// Try sending email using Postmark
let response = await sendEmailWithPostmark(user, email_data, postmarkServerToken!);
if (!response.ok) {
// If Postmark fails, try SendGrid
console.error(`Primary email send failed: ${await response.text()}`);
response = await sendEmailWithSendGrid(user, email_data, sendGridApiKey!);
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to send email via backup: ${errorData.errors[0].message}`);
}
}
return new Response(JSON.stringify({
message: "Email sent successfully."
}), {
headers: {
"Content-Type": "application/json"
}
});
} catch (error) {
return new Response(JSON.stringify({
error: `Failed to process the request: ${error.message}`
}), {
status: 500,
headers: {
"Content-Type": "application/json"
}
});
}
});
```
</TabPanel> */}
</Tabs>
</TabPanel>
</Tabs>
@@ -0,0 +1,645 @@
---
id: 'send-sms-hook'
title: 'Send SMS Hook'
subtitle: 'Use a custom SMS provider to send authentication messages'
---
Runs before a message is sent. Use the hook to:
- Use a regional SMS Provider
- Use alternate messaging channels such as WhatsApp
- Adjust the message body to include platform specific fields such as the [AppHash](https://developers.google.com/identity/sms-retriever/overview)
**Inputs**
| Field | Type | Description |
| ------ | ------------------------------------------------- | --------------------------------------------------------------- |
| `user` | [`User`](/docs/guides/auth/users#the-user-object) | The user attempting to sign in. |
| `sms` | `object` | Metadata specific to the SMS sending process. Includes the OTP. |
<Tabs
scrollable
size="small"
type="underlined"
>
<TabPanel id="send-sms-json" label="JSON">
```json
{
"user": {
"id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"aud": "authenticated",
"role": "authenticated",
"email": "",
"phone": "+1333363128",
"phone_confirmed_at": "2024-05-13T11:52:48.157306Z",
"confirmation_sent_at": "2024-05-14T12:31:52.824573Z",
"confirmed_at": "2024-05-13T11:52:48.157306Z",
"phone_change_sent_at": "2024-05-13T11:47:02.183064Z",
"last_sign_in_at": "2024-05-13T11:52:48.162518Z",
"app_metadata": {
"provider": "phone",
"providers": ["phone"]
},
"user_metadata": {},
"identities": [
{
"identity_id": "3be5e552-65aa-41d9-9db9-2a502f845459",
"id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"user_id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"identity_data": {
"email_verified": false,
"phone": "+1612341244428",
"phone_verified": true,
"sub": "6481a5c1-3d37-4a56-9f6a-bee08c554965"
},
"provider": "phone",
"last_sign_in_at": "2024-05-13T11:52:48.155562Z",
"created_at": "2024-05-13T11:52:48.155599Z",
"updated_at": "2024-05-13T11:52:48.159391Z"
}
],
"created_at": "2024-05-13T11:45:33.7738Z",
"updated_at": "2024-05-14T12:31:52.82475Z",
"is_anonymous": false
},
"sms": {
"otp": "561166"
}
}
```
</TabPanel>
<TabPanel id="send-sms-json-schema" label="JSON Schema">
```json
{
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"aud": {
"type": "string",
"enum": ["authenticated"]
},
"role": {
"type": "string",
"enum": ["anon", "authenticated"]
},
"email": {
"type": "string",
"x-faker": "internet.email"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"phone_confirmed_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"confirmation_sent_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"confirmed_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"phone_change_sent_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"last_sign_in_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"app_metadata": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["phone"]
},
"providers": {
"type": "array",
"items": {
"type": "string",
"enum": ["phone"]
}
}
}
},
"user_metadata": {
"type": "object",
"x-faker": "random.objectElement"
},
"identities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"identity_id": {
"type": "string",
"x-faker": "random.uuid"
},
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"identity_data": {
"type": "object",
"properties": {
"email_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"phone_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"sub": {
"type": "string",
"x-faker": "random.uuid"
}
}
},
"provider": {
"type": "string",
"enum": ["phone", "email", "google"]
},
"last_sign_in_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
}
},
"required": [
"identity_id",
"id",
"user_id",
"identity_data",
"provider",
"last_sign_in_at",
"created_at",
"updated_at"
]
}
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"is_anonymous": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": [
"id",
"aud",
"role",
"email",
"phone",
"phone_confirmed_at",
"confirmation_sent_at",
"confirmed_at",
"phone_change_sent_at",
"last_sign_in_at",
"app_metadata",
"user_metadata",
"identities",
"created_at",
"updated_at",
"is_anonymous"
]
},
"sms": {
"type": "object",
"properties": {
"otp": {
"type": "string",
"pattern": "^[0-9]{6}$",
"x-faker": {
"fake": "{{helpers.replaceSymbols(######)}}"
}
}
},
"required": ["otp"]
}
},
"required": ["user", "sms"]
}
```
</TabPanel>
</Tabs>
**Outputs**
- No outputs are required. An empty response with a status code of 200 is taken as a successful response.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="language"
>
<TabPanel id="sql" label="SQL">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql-send-message-via-job-queue"
>
<TabPanel id="sql-send-message-via-job-queue" label="Queue SMS Messages">
Your company uses a worker to manage all messaging related jobs. For performance reasons, the messaging system sends messages in intervals via a job queue. Instead of sending a message immediately, messages are queued and sent in periodic intervals via `pg_cron`.
Create a table to store jobs
```sql
create table job_queue (
job_id uuid primary key default gen_random_uuid(),
job_data jsonb not null,
created_at timestamp default now(),
status text default 'pending',
priority int default 0,
retry_count int default 0,
max_retries int default 2,
scheduled_at timestamp default now()
);
```
Create the hook:
```sql
create or replace function send_sms(event jsonb) returns void as $$
declare
job_data jsonb;
scheduled_time timestamp;
priority int;
begin
-- extract phone and otp from the event json
job_data := jsonb_build_object(
'phone', event->'user'->>'phone',
'otp', event->'sms'->>'otp'
);
-- calculate the nearest 5-minute window for scheduled_time
scheduled_time := date_trunc('minute', now()) + interval '5 minute' * floor(extract('epoch' from (now() - date_trunc('minute', now())) / 60) / 5);
-- assign priority dynamically (example logic: higher priority for earlier scheduled time)
priority := extract('epoch' from (scheduled_time - now()))::int;
-- insert the job into the job_queue table
insert into job_queue (job_data, priority, scheduled_at, max_retries)
values (job_data, priority, scheduled_time, 2);
end;
$$ language plpgsql;
grant all
on table public.job_queue
to supabase_auth_admin;
revoke all
on table public.job_queue
from authenticated, anon;
```
Create a function to periodically run and dequeue all jobs
```sql
create or replace function dequeue_and_run_jobs() returns void as $$
declare
job record;
begin
for job in
select * from job_queue
where status = 'pending'
and scheduled_at <= now()
order by priority desc, created_at
for update skip locked
loop
begin
-- add job processing logic here.
-- for demonstration, we'll just update the job status to 'completed'.
update job_queue
set status = 'completed'
where job_id = job.job_id;
exception when others then
-- handle job failure and retry logic
if job.retry_count < job.max_retries then
update job_queue
set retry_count = retry_count + 1,
scheduled_at = now() + interval '1 minute' -- delay retry by 1 minute
where job_id = job.job_id;
else
update job_queue
set status = 'failed'
where job_id = job.job_id;
end if;
end;
end loop;
end;
$$ language plpgsql;
grant execute
on function public.dequeue_and_run_jobs
to supabase_auth_admin;
revoke execute
on function public.dequeue_and_run_jobs
from authenticated, anon;
```
Configure `pg_cron` to run the job on an interval. You can use a tool like [crontab.guru](https://crontab.guru/) to check that your job is running on an appropriate schedule. Ensure that `pg_cron` is enabled under `Database > Extensions`
```sql
select
cron.schedule(
'* * * * *', -- this cron expression means every minute.
'select dequeue_and_run_jobs();'
);
```
</TabPanel>
</Tabs>
</TabPanel>
<TabPanel id="http" label="HTTP">
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="http-alternate-message-provider"
>
<TabPanel id="http-alternate-message-provider" label="Alternate message provider">
Your company would like to use an alternate message provider. Some examples of alternate message providers include [Msg91](https://msg91.com/) for India and [Africa's Talking](https://africastalking.com/). The example uses Twilio as it is widely available and does not require a regional number.
```javascript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
import { Twilio } from 'https://cdn.skypack.dev/twilio'
import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts'
const accountSid: string | undefined = Deno.env.get('TWILIO_ACCOUNT_SID')
const authToken: string | undefined = Deno.env.get('TWILIO_AUTH_TOKEN')
const fromNumber: string = Deno.env.get('TWILIO_PHONE_NUMBER')
const sendTextMessage = async (
messageBody: string,
accountSid: string | undefined,
authToken: string | undefined,
fromNumber: string,
toNumber: string
): Promise<any> => {
if (!accountSid || !authToken) {
console.log('Your Twilio account credentials are missing. Please add them.')
return
}
const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
const encodedCredentials: string = base64.fromUint8Array(
new TextEncoder().encode(`${accountSid}:${authToken}`)
)
const body: URLSearchParams = new URLSearchParams({
To: `+${toNumber}`,
From: fromNumber,
// Uncomment when testing with a fixed number
Body: messageBody,
})
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${encodedCredentials}`,
},
body,
})
return response.json()
}
Deno.serve(async (req) => {
const payload = await req.text()
const base64_secret = Deno.env.get('SEND_SMS_HOOK_SECRET')
const headers = Object.fromEntries(req.headers)
const wh = new Webhook(base64_secret)
try {
const { user, sms } = wh.verify(payload, headers)
const messageBody = `Your OTP is: ${sms.otp}`
const response = await sendTextMessage(
messageBody,
accountSid,
authToken,
fromNumber,
user.phone
)
if (response.status !== 'queued') {
return new Response(
JSON.stringify({
error: {
http_code: response.code,
message: `Failed to send SMS: ${response.message}. More info: ${response.more_info}`,
},
}),
{
status: response.status,
headers: {
'Content-Type': 'application/json',
},
}
)
}
return new Response(
{},
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
} catch (error) {
return new Response(
JSON.stringify({
error: {
http_code: 500,
message: `Failed to send sms: ${JSON.stringify(error)}`,
}
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
})
```
</TabPanel>
<TabPanel id="http-whatsapp-and-sms-messages" label="Use WhatsApp with SMS">
Your company is expanding into Latin America and would like to use WhatsApp for higher deliverability. Write a hook to send WhatsApp messages to requests from the continent and SMS messages to all other numbers.
```javascript
import {
Webhook
} from "https://esm.sh/standardwebhooks@1.0.0";
import {
readAll
} from "https://deno.land/std/io/read_all.ts";
import * as base64 from "https://denopkg.com/chiefbiiko/base64/mod.ts";
const accountSid: string | undefined = Deno.env.get("TWILIO_ACCOUNT_SID");
const authToken: string | undefined = Deno.env.get("TWILIO_AUTH_TOKEN");
const fromNumber: string = Deno.env.get("TWILIO_WHATSAPP_NUMBER");
const smsFromNumber: string = Deno.env.get("TWILIO_SMS_NUMBER");
const latinAmericanCountryCodes = ['54', '55', '56', '57', '58', '501', '502', '503', '504', '505', '506', '507', '508', '509', '51', '52', '53', '591', '592', '593', '594', '595', '596', '597', '598', '599'];
const sendMessage = async (
messageBody: string,
accountSid: string | undefined,
authToken: string | undefined,
fromNumber: string,
toNumber: string,
useWhatsApp: boolean,
): Promise < any > => {
if (!accountSid || !authToken) {
console.log("Your Twilio account credentials are missing. Please add them.");
return;
}
const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const encodedCredentials: string = base64.fromUint8Array(
new TextEncoder().encode(`${accountSid}:${authToken}`),
);
const body: URLSearchParams = new URLSearchParams({
To: useWhatsApp ? `whatsapp:${toNumber}` : toNumber,
From: useWhatsApp ? `whatsapp:${fromNumber}` : smsFromNumber,
Body: messageBody,
});
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${encodedCredentials}`,
},
body,
});
return response.json();
};
Deno.serve(async (req) => {
const payload = await req.text();
const base64_secret = Deno.env.get("SEND_SMS_HOOK_SECRET");
const headers = Object.fromEntries(req.headers);
const wh = new Webhook(base64_secret);
try {
const {
user,
sms
} = wh.verify(payload, headers);
const messageBody = `Your OTP is: ${sms.otp}`;
const userPhoneNumber = user.phone;
const countryCode = userPhoneNumber.substring(1, userPhoneNumber.indexOf(userPhoneNumber.match(/\d/)!));
const useWhatsApp = latinAmericanCountryCodes.includes(countryCode);
const response = await sendMessage(
messageBody,
accountSid,
authToken,
fromNumber,
userPhoneNumber,
useWhatsApp,
);
if (response.status !== "queued") {
return new Response(
JSON.stringify({
error: `Failed to send message, Error Code: ${response.code} ${response.message} ${response.more_info}`,
}), {
status: response.status,
headers: {
"Content-Type": "application/json",
},
},
);
}
return new Response(
JSON.stringify({
message: "Message sent successfully."
}), {
headers: {
"Content-Type": "application/json",
},
},
);
} catch (error) {
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error}`
}), {
status: 500,
headers: {
"Content-Type": "application/json",
},
},
);
}
});
```
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
+32 -325
View File
@@ -13,11 +13,11 @@ Users with weak passwords or compromised social login accounts are prone to mali
- Something they know.
Password, or access to a social-login account.
- Something they have.
Access to an authenticator app (a.k.a. TOTP), mobile phone or recovery code.
Access to an authenticator app (a.k.a. TOTP) or a mobile phone.
## Overview
Supabase Auth implements only Time-based One Time Password(TOTP) multi-factor authentication. This type of multi-factor authentication uses a timed one-time password generated from an authenticator app in the control of users. The [MFA API](/docs/reference/javascript/auth-mfa-api) is enabled on all Supabase projects by default.
Supabase Auth implements MFA via two methods: App Authenticator, which makes use of a Time based-one Time Password, and phone messaging, which makes use of a code generated by Supabase Auth.
Applications using MFA require two important flows:
@@ -32,16 +32,7 @@ Supabase Auth provides:
- **Challenge and Verify APIs** - securely verify that the user has access to a factor.
- **List Factors API** - build rich user interfaces for signing in with additional factors.
Below is a flow chart illustrating how these APIs work together to enable MFA features in your app.
<Image
alt="Diagram showing the flow of Multi-Factor authentication"
src={{
light: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
dark: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
}}
containerClassName="max-w-[700px]"
/>
You can control access to the Enrollment API as well as the Challenge and Verify APIs via the Supabase Dashboard. A setting of `Verification Disabled` will disable both the challenge API and the verification API.
These sets of APIs let you control the MFA experience that works for you. You can create flows where MFA is optional, mandatory for all, or only specific groups of users.
@@ -55,7 +46,7 @@ This information is represented by an [Authenticator Assurance Level](https://pa
login.
2. **Assurance Level 2: `aal2`**
Means that the user's identity was additionally verified using at least one
second factor, such as a TOTP code.
second factor, such as a TOTP code or One-Time Password code.
This assurance level is encoded in the `aal` claim in the JWT associated with the user. By decoding this value you can create custom authorization rules in your frontend, backend, and database that will enforce the MFA policy that works for your application. JWTs without an `aal` claim are at the `aal1` level.
@@ -67,7 +58,7 @@ Adding MFA to your app involves these four steps:
You need to provide a UI within your app that your users will be able to set-up
MFA in. You can add this right after sign-up, or as part of a separate flow in
the settings portion of your app.
2. **Add unenrollment flow.**
2. **Add unenroll flow.**
You need to support a UI through which users can see existing devices and unenroll
devices which are no longer relevant.
3. **Add challenge step to login.**
@@ -79,140 +70,39 @@ Adding MFA to your app involves these four steps:
authorization rules across your app: on the frontend, backend, API servers or
Row-Level Security policies.
### Add enrollment flow
The enrollment flow and the challenge steps differ by factor and are covered on a separate page. Visit the [Phone](/docs/guides/auth/auth-mfa/phone) or [App Authenticator](/docs/guides/auth/auth-mfa/totp) pages to see how to add the flows for the respective factors. You can combine both flows and allow for use of both Phone and App Authenticator Factors.
An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app:
### Add unenroll flow
1. Right after login or sign up.
This lets users quickly set up MFA immediately after they log in or create an
account. We recommend encouraging all users to set up MFA if that makes sense
for your application. Many applications offer this as an opt-in step in an
effort to reduce onboarding friction.
2. From within a settings page.
Allows users to set up, disable or modify their MFA settings.
The unenroll process is the same for both Phone and TOTP factors.
We recommend building one generic flow that you can reuse in both cases with minor modifications.
An unenroll flow provides a UI for users to manage and unenroll factors linked to their accounts. Most applications do so via a factor management page where users can view and unlink selected factors.
Enrolling a factor for use with MFA takes three steps:
When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call:
1. Call `supabase.auth.mfa.enroll()`.
This method returns a QR code and a secret. Display the QR
code to the user and ask them to scan it with their authenticator application.
If they are unable to scan the QR code, show the secret in plain text which
they can type or paste into their authenticator app.
2. Calling the `supabase.auth.mfa.challenge()` API.
This prepares Supabase Auth to accept a verification code from the user
and returns a challenge ID.
3. Calling the `supabase.auth.mfa.verify()` API.
This verifies that the user has indeed added the secret from step (1) into
their app and is working correctly. If the verification succeeds, the factor
immediately becomes active for the user account. If not, you should repeat
steps 2 and 3.
#### Example: React
Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow.
- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is
called once to start the process of enrolling a new factor for the current
user.
- This API returns a QR code in the SVG format, which is shown on screen using
a normal `<img>` tag by encoding the SVG as a data URL.
- Once the user has scanned the QR code with their authenticator app, they
should enter the verification code within the `verifyCode` input field and
click on `Enable`.
- A challenge is created using the `supabase.auth.mfa.challenge()` API and the
code from the user is submitted for verification using the
`supabase.auth.mfa.verify()` challenge.
- `onEnabled` is a callback that notifies the other components that enrollment
has completed.
- `onCancelled` is a callback that notifies the other components that the user
has clicked the `Cancel` button.
```tsx
/**
* EnrollMFA shows a simple enrollment dialog. When shown on screen it calls
* the `enroll` API. Each time a user clicks the Enable button it calls the
* `challenge` and `verify` APIs to check if the code provided by the user is
* valid.
* When enrollment is successful, it calls `onEnrolled`. When the user clicks
* Cancel the `onCancelled` callback is called.
*/
export function EnrollMFA({
onEnrolled,
onCancelled,
}: {
onEnrolled: () => void
onCancelled: () => void
}) {
const [factorId, setFactorId] = useState('')
const [qr, setQR] = useState('') // holds the QR code image SVG
const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user
const [error, setError] = useState('') // holds an error message
const onEnableClicked = () => {
setError('')
;(async () => {
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
onEnrolled()
})()
}
useEffect(() => {
;(async () => {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
})
if (error) {
throw error
}
setFactorId(data.id)
// Supabase Auth returns an SVG QR code which you can convert into a data
// URL that you can place in an <img> tag.
setQR(data.totp.qr_code)
})()
}, [])
return (
<>
{error && <div className="error">{error}</div>}
<img src={qr} />
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Enable" onClick={onEnableClicked} />
<input type="button" value="Cancel" onClick={onCancelled} />
</>
)
}
```
supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})
```
### Add unenrollment flow
to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`.
An unenrollment flow provides a UI for users to manage and unenroll factors linked to their accounts. Most applications do so via a factor management page where users can view and unlink selected factors.
### Enforce rules for MFA logins
When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call `supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})` to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`.
Adding MFA to your app's UI does not in-and-of-itself offer a higher level of security to your users. You also need to enforce the MFA rules in your application's database, APIs, and server-side rendering.
Depending on your application's needs, there are three ways you can choose to enforce MFA.
1. **Enforce for all users (new and existing).**
Any user account will have to enroll MFA to continue using your app.
The application will not allow access without going through MFA first.
2. **Enforce for new users only.**
Only new users will be forced to enroll MFA, while old users will be encouraged
to do so.
The application will not allow access for new users without going through MFA
first.
3. **Enforce only for users that have opted-in.**
Users that want MFA can enroll in it and the application will not allow access
without going through MFA first.
#### Example: React
@@ -248,7 +138,7 @@ export function UnenrollMFA() {
throw error
}
setFactors(data.totp)
setFactors([...data.totp, ...data.phone])
})()
}, [])
@@ -260,6 +150,7 @@ export function UnenrollMFA() {
<td>Factor ID</td>
<td>Friendly Name</td>
<td>Factor Status</td>
<td>Phone Number</td>
</tr>
{factors.map((factor) => (
<tr>
@@ -267,6 +158,7 @@ export function UnenrollMFA() {
<td>{factor.friendly_name}</td>
<td>{factor.factor_type}</td>
<td>{factor.status}</td>
<td>{factor.phone}</td>
</tr>
))}
</tbody>
@@ -277,164 +169,6 @@ export function UnenrollMFA() {
}
```
### Add challenge step to login
Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified.
This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL).
Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA.
Below is a table that explains the combined meaning.
| Current Level | Next Level | Meaning |
| ------------: | :--------- | :------------------------------------------------------- |
| `aal1` | `aal1` | User does not have MFA enrolled. |
| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. |
| `aal2` | `aal2` | User has verified their MFA factor. |
| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) |
#### Example: React
Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic.
This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below.
```tsx
function AppWithMFA() {
const [readyToShow, setReadyToShow] = useState(false)
const [showMFAScreen, setShowMFAScreen] = useState(false)
useEffect(() => {
;(async () => {
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (error) {
throw error
}
console.log(data)
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
setShowMFAScreen(true)
}
} finally {
setReadyToShow(true)
}
})()
}, [])
if (readyToShow) {
if (showMFAScreen) {
return <AuthMFA />
}
return <App />
}
return <></>
}
```
- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise.
Don't worry, this is a very fast method (microseconds) as it rarely uses the
network.
- `readyToShow` only makes sure the AAL check completes before showing any
application UI to the user.
- If the current level can be upgraded to the next one, the MFA screen is
shown.
- Once the challenge is successful, the `App` component is finally rendered on
screen.
Below is the component that implements the challenge and verify logic.
```tsx
function AuthMFA() {
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const onSubmitClicked = () => {
setError('')
;(async () => {
const factors = await supabase.auth.mfa.listFactors()
if (factors.error) {
throw factors.error
}
const totpFactor = factors.data.totp[0]
if (!totpFactor) {
throw new Error('No TOTP factors found!')
}
const factorId = totpFactor.id
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
})()
}
return (
<>
<div>Please enter the code from your authenticator app.</div>
{error && <div className="error">{error}</div>}
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Submit" onClick={onSubmitClicked} />
</>
)
}
```
- You can extract the available MFA factors for the user by calling
`supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick
and rarely uses the network.
- If `listFactors()` returns more than one factor (or of a different type) you
should present the user with a choice. For simplicity this is not shown in
the example.
- Each time the user presses the "Submit" button a new challenge is created for
the chosen factor (in this case the first one) and it is immediately
verified. Any errors are displayed to the user.
- On successful verification, the client library will refresh the session in
the background automatically and finally call the `onSuccess` callback, which
will show the authenticated `App` component on screen.
### Enforce rules for MFA logins
Adding MFA to your app's UI does not in-and-of-itself offer a higher level of security to your users. You also need to enforce the MFA rules in your application's database, APIs, and server-side rendering.
Depending on your application's needs, there are three ways you can choose to enforce MFA.
1. **Enforce for all users (new and existing).**
Any user account will have to enroll MFA to continue using your app.
The application will not allow access without going through MFA first.
2. **Enforce for new users only.**
Only new users will be forced to enroll MFA, while old users will be encouraged
to do so.
The application will not allow access for new users without going through MFA
first.
3. **Enforce only for users that have opted-in.**
Users that want MFA can enroll in it and the application will not allow access
without going through MFA first.
#### Database
Your app should sufficiently deny or allow access to tables or rows based on the user's current and possible authenticator levels.
@@ -568,33 +302,6 @@ If your application uses the Supabase Database, Storage or Edge Functions, just
className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4"
>
<AccordionItem
header={<span className="text-foreground">Why is there a challenge and verify API when challenge does not do much?</span>}
id="why-is-there-a-challenge-and-verify-api"
>
TOTP is not going to be the only MFA factor Supabase Auth is going to support in the future. By separating out the challenge and verify steps, we're making the library forward compatible with new factors we may add in the future -- such as SMS or WebAuthn. For example, for SMS the `challenge` endpoint would actually send out the SMS with the authentication code. For convenience, you may use `challengeAndVerify` to create and verify a challenge in a single step.
</AccordionItem>
<AccordionItem
header={<span className="text-foreground">What's inside the QR code?</span>}
id="what-is-inside-the-qr-code"
>
The TOTP QR code encodes a URI with the `otpauth` scheme. It was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps.
</AccordionItem>
<AccordionItem
header={<span className="text-foreground">How long is the TOTP code valid for?</span>}
id="how-long-is-the-totp-code-valid-for"
>
In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks.
</AccordionItem>
<AccordionItem
header={<span className="text-foreground">How do I check when a user went through MFA?</span>}
id="how-do-i-check-when-a-user-went-through-mfa"
@@ -0,0 +1,313 @@
---
id: 'auth-mfa-phone'
title: 'Multi-Factor Authentication (Phone)'
description: 'Add an additional layer of security with phone (SMS or WhatsApp) multi-factor-authentication.'
---
## How does phone multi-factor-authentication?
Phone multi-factor authentication involves a shared code generated by Supabase Auth and the end user. The code is delivered via a messaging channel, such as SMS or WhatsApp, and the user uses the code to authenticate to Supabase Auth.
The phone messaging configuration for MFA is shared with [phone auth login](/docs/guides/auth/phone-login). The same provider configuration that is used for phone login is used for MFA. You can also use the [Send SMS Hook](/docs/guides/auth/auth-hooks/send-sms-hook) if you need to use an MFA (Phone) messaging provider different from what is supported natively.
Below is a flow chart illustrating how the Enrollment and Verify APIs work in the context of MFA (Phone).
<Image
alt="Diagram showing the flow of Multi-Factor authentication"
src={{
light: '/docs/img/guides/auth-mfa/auth-mfa-phone-flow.svg',
dark: '/docs/img/guides/auth-mfa/auth-mfa-phone-flow.svg',
}}
containerClassName="max-w-[700px]"
/>
Phone MFA is part of the Auth Advanced MFA Add-on and costs an additional $75 per month for the first project in the organization and an additional $10 per month for additional projects.
### Add enrollment flow
An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app:
1. Right after login or sign up.
This allows users quickly set up Multi Factor Authentication (MFA) post login or account creation. Where possible, encourage all users to set up MFA. Many applications offer this as an opt-in step in an
effort to reduce onboarding friction.
2. From within a settings page.
Allows users to set up, disable or modify their MFA settings.
As far as possible, maintain a generic flow that you can reuse in both cases with minor modifications.
Enrolling a factor for use with MFA takes three steps for phone MFA:
1. Call `supabase.auth.mfa.enroll()`.
2. Calling the `supabase.auth.mfa.challenge()` API. This sends a code via SMS or WhatsApp and prepares Supabase Auth to accept a verification code from the user.
3. Calling the `supabase.auth.mfa.verify()` API. `supabase.auth.mfa.challenge()` returns a challenge ID.
This verifies that the code issued by Supabase Auth matches the code input by the user. If the verification succeeds, the factor
immediately becomes active for the user account. If not, you should repeat
steps 2 and 3.
#### Example: React
Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow.
- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is
called once to start the process of enrolling a new factor for the current
user.
- A challenge is created using the `supabase.auth.mfa.challenge()` API and the
code from the user is submitted for verification using the
`supabase.auth.mfa.verify()` challenge.
- `onEnabled` is a callback that notifies the other components that enrollment
has completed.
- `onCancelled` is a callback that notifies the other components that the user
has clicked the `Cancel` button.
```tsx
export function EnrollMFA({
onEnrolled,
onCancelled,
}: {
onEnrolled: () => void
onCancelled: () => void
}) {
const [phoneNumber, setPhoneNumber] = useState('')
const [factorId, setFactorId] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const [challengeId, setChallengeId] = useState('')
const onEnableClicked = () => {
setError('')
;(async () => {
const verify = await auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
onEnrolled()
})()
}
const onEnrollClicked = async () => {
setError('')
try {
const factor = await auth.mfa.enroll({
phone: phoneNumber,
factorType: 'phone',
})
if (factor.error) {
setError(factor.error.message)
throw factor.error
}
setFactorId(factor.data.id)
} catch (error) {
setError('Failed to Enroll the Factor.')
}
}
const onSendOTPClicked = async () => {
setError('')
try {
const challenge = await auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
setChallengeId(challenge.data.id)
} catch (error) {
setError('Failed to resend the code.')
}
}
return (
<>
{error && <div className="error">{error}</div>}
<input
type="text"
placeholder="Phone Number"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.trim())}
/>
<input
type="text"
placeholder="Verification Code"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Enroll" onClick={onEnrollClicked} />
<input type="button" value="Submit Code" onClick={onEnableClicked} />
<input type="button" value="Send OTP Code" onClick={onSendOTPClicked} />
<input type="button" value="Cancel" onClick={onCancelled} />
</>
)
}
```
### Add a challenge step to login
Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified.
This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL).
Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA.
Below is a table that explains the combined meaning.
| Current Level | Next Level | Meaning |
| ------------: | :--------- | :------------------------------------------------------- |
| `aal1` | `aal1` | User does not have MFA enrolled. |
| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. |
| `aal2` | `aal2` | User has verified their MFA factor. |
| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) |
#### Example: React
Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic.
This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below.
```tsx
function AppWithMFA() {
const [readyToShow, setReadyToShow] = useState(false)
const [showMFAScreen, setShowMFAScreen] = useState(false)
useEffect(() => {
;(async () => {
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (error) {
throw error
}
console.log(data)
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
setShowMFAScreen(true)
}
} finally {
setReadyToShow(true)
}
})()
}, [])
if (readyToShow) {
if (showMFAScreen) {
return <AuthMFA />
}
return <App />
}
return <></>
}
```
- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise.
Don't worry, this is a very fast method (microseconds) as it rarely uses the
network.
- `readyToShow` only makes sure the AAL check completes before showing any
application UI to the user.
- If the current level can be upgraded to the next one, the MFA screen is
shown.
- Once the challenge is successful, the `App` component is finally rendered on
screen.
Below is the component that implements the challenge and verify logic.
```tsx
function AuthMFA() {
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const [factorId, setFactorId] = useState('')
const [challengeId, setChallengeId] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const startChallenge = async () => {
setError('')
try {
const factors = await supabase.auth.mfa.listFactors()
if (factors.error) {
throw factors.error
}
const phoneFactor = factors.data.phone[0]
if (!phoneFactor) {
throw new Error('No phone factors found!')
}
const factorId = phoneFactor.id
setFactorId(factorId)
setPhoneNumber(phoneFactor.phone)
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
setChallengeId(challenge.data.id)
} catch (error) {
setError(error.message)
}
}
const verifyCode = async () => {
setError('')
try {
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
} catch (error) {
setError(error.message)
}
}
return (
<>
<div>Please enter the code sent to your phone.</div>
{phoneNumber && <div>Phone number: {phoneNumber}</div>}
{error && <div className="error">{error}</div>}
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
{!challengeId ? (
<input type="button" value="Start Challenge" onClick={startChallenge} />
) : (
<input type="button" value="Verify Code" onClick={verifyCode} />
)}
</>
)
}
```
- You can extract the available MFA factors for the user by calling
`supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick
and rarely uses the network.
- If `listFactors()` returns more than one factor (or of a different type) you
should present the user with a choice. For simplicity this is not shown in
the example.
- Phone numbers are unique per user. Users can only have one verified phone factor with a given phone number.
Attempting to enroll a new phone factor alongside an existing verified factor with the same number will result in an error.
- Each time the user presses the "Submit" button a new challenge is created for
the chosen factor (in this case the first one)
- On successful verification, the client library will refresh the session in
the background automatically and finally call the `onSuccess` callback, which
will show the authenticated `App` component on screen.
### Security Configuration
Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the [Authentication Settings](/dashboard/project/_/settings/auth).
Please be aware that Phone MFA is vulnerable to SIM swap attacks where an attacker will call a mobile provider and ask to port the target's phone number to a new SIM card and then use the said SIM card to intercept an MFA code. Please evaluate the your application's tolerance for such an attack. You can read more about SIM swapping attacks [here](https://en.wikipedia.org/wiki/SIM_swap_scam)
@@ -0,0 +1,320 @@
---
id: 'auth-mfa-totp'
title: 'Multi-Factor Authentication (TOTP)'
description: 'Add an additional layer of security to your apps with TOTP multi-factor authentication.'
---
## How does app authenticator multi-factor authentication work?
App Authenticator (TOTP) multi-factor authentication involves a timed one-time password generated from an authenticator app in the control of users. It uses a QR Code which to transmit a shared secret used to generate a One Time Password. A user can scan a QR code with their phone to capture a shared secret required for subsequent authentication.
The use of a QR code was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps. The QR code has an alternate representation in URI form following the `otpauth` scheme such as: `otpauth://totp/supabase:alice@supabase.com?secret=<secret>&issuer=supabase` which a user can manually input in cases where there is difficulty rendering a QR Code.
Below is a flow chart illustrating how the Enrollment, Challenge, and Verify APIs work in the context of MFA (TOTP).
<Image
alt="Diagram showing the flow of Multi-Factor authentication"
src={{
light: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
dark: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
}}
containerClassName="max-w-[700px]"
/>
[TOTP MFA API](/docs/reference/javascript/auth-mfa-api) is free to use and is enabled on all Supabase projects by default.
### Add enrollment flow
An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app:
1. Right after login or sign up.
This lets users quickly set up MFA immediately after they log in or create an
account. We recommend encouraging all users to set up MFA if that makes sense
for your application. Many applications offer this as an opt-in step in an
effort to reduce onboarding friction.
2. From within a settings page.
Allows users to set up, disable or modify their MFA settings.
Enrolling a factor for use with MFA takes three steps:
1. Call `supabase.auth.mfa.enroll()`.
This method returns a QR code and a secret. Display the QR
code to the user and ask them to scan it with their authenticator application.
If they are unable to scan the QR code, show the secret in plain text which
they can type or paste into their authenticator app.
2. Calling the `supabase.auth.mfa.challenge()` API.
This prepares Supabase Auth to accept a verification code from the user
and returns a challenge ID. In the case of Phone MFA this step also sends the verification code to the user.
3. Calling the `supabase.auth.mfa.verify()` API.
This verifies that the user has indeed added the secret from step (1) into
their app and is working correctly. If the verification succeeds, the factor
immediately becomes active for the user account. If not, you should repeat
steps 2 and 3.
#### Example: React
Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow.
- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is
called once to start the process of enrolling a new factor for the current
user.
- This API returns a QR code in the SVG format, which is shown on screen using
a normal `<img>` tag by encoding the SVG as a data URL.
- Once the user has scanned the QR code with their authenticator app, they
should enter the verification code within the `verifyCode` input field and
click on `Enable`.
- A challenge is created using the `supabase.auth.mfa.challenge()` API and the
code from the user is submitted for verification using the
`supabase.auth.mfa.verify()` challenge.
- `onEnabled` is a callback that notifies the other components that enrollment
has completed.
- `onCancelled` is a callback that notifies the other components that the user
has clicked the `Cancel` button.
```tsx
/**
* EnrollMFA shows a simple enrollment dialog. When shown on screen it calls
* the `enroll` API. Each time a user clicks the Enable button it calls the
* `challenge` and `verify` APIs to check if the code provided by the user is
* valid.
* When enrollment is successful, it calls `onEnrolled`. When the user clicks
* Cancel the `onCancelled` callback is called.
*/
export function EnrollMFA({
onEnrolled,
onCancelled,
}: {
onEnrolled: () => void
onCancelled: () => void
}) {
const [factorId, setFactorId] = useState('')
const [qr, setQR] = useState('') // holds the QR code image SVG
const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user
const [error, setError] = useState('') // holds an error message
const onEnableClicked = () => {
setError('')
;(async () => {
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
onEnrolled()
})()
}
useEffect(() => {
;(async () => {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
})
if (error) {
throw error
}
setFactorId(data.id)
// Supabase Auth returns an SVG QR code which you can convert into a data
// URL that you can place in an <img> tag.
setQR(data.totp.qr_code)
})()
}, [])
return (
<>
{error && <div className="error">{error}</div>}
<img src={qr} />
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Enable" onClick={onEnableClicked} />
<input type="button" value="Cancel" onClick={onCancelled} />
</>
)
}
```
### Add a challenge step to login
Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified.
This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL).
Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA.
Below is a table that explains the combined meaning.
| Current Level | Next Level | Meaning |
| ------------: | :--------- | :------------------------------------------------------- |
| `aal1` | `aal1` | User does not have MFA enrolled. |
| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. |
| `aal2` | `aal2` | User has verified their MFA factor. |
| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) |
#### Example: React
Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic.
This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below.
```tsx
function AppWithMFA() {
const [readyToShow, setReadyToShow] = useState(false)
const [showMFAScreen, setShowMFAScreen] = useState(false)
useEffect(() => {
;(async () => {
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (error) {
throw error
}
console.log(data)
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
setShowMFAScreen(true)
}
} finally {
setReadyToShow(true)
}
})()
}, [])
if (readyToShow) {
if (showMFAScreen) {
return <AuthMFA />
}
return <App />
}
return <></>
}
```
- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise.
Don't worry, this is a very fast method (microseconds) as it rarely uses the
network.
- `readyToShow` only makes sure the AAL check completes before showing any
application UI to the user.
- If the current level can be upgraded to the next one, the MFA screen is
shown.
- Once the challenge is successful, the `App` component is finally rendered on
screen.
Below is the component that implements the challenge and verify logic.
```tsx
function AuthMFA() {
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const onSubmitClicked = () => {
setError('')
;(async () => {
const factors = await supabase.auth.mfa.listFactors()
if (factors.error) {
throw factors.error
}
const totpFactor = factors.data.totp[0]
if (!totpFactor) {
throw new Error('No TOTP factors found!')
}
const factorId = totpFactor.id
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
})()
}
return (
<>
<div>Please enter the code from your authenticator app.</div>
{error && <div className="error">{error}</div>}
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Submit" onClick={onSubmitClicked} />
</>
)
}
```
- You can extract the available MFA factors for the user by calling
`supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick
and rarely uses the network.
- If `listFactors()` returns more than one factor (or of a different type) you
should present the user with a choice. For simplicity this is not shown in
the example.
- Each time the user presses the "Submit" button a new challenge is created for
the chosen factor (in this case the first one) and it is immediately
verified. Any errors are displayed to the user.
- On successful verification, the client library will refresh the session in
the background automatically and finally call the `onSuccess` callback, which
will show the authenticated `App` component on screen.
## Frequently asked questions
<Accordion
type="default"
openBehaviour="multiple"
chevronAlign="right"
justified
size="medium"
className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4"
>
<AccordionItem
header={<span className="text-foreground">What's inside the QR code?</span>}
id="what-is-inside-the-qr-code"
>
</AccordionItem>
<AccordionItem
header={<span className="text-foreground">How long is the TOTP code valid for?</span>}
id="how-long-is-the-totp-code-valid-for"
>
In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks.
</AccordionItem>
</Accordion>
@@ -35,6 +35,8 @@ hideToc: true
- [TypeScript](https://www.typescriptlang.org/)
- [Tailwind CSS](https://tailwindcss.com/)
[See GitHub repo](https://github.com/vercel/next.js/tree/canary/examples/with-supabase)
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -672,7 +672,7 @@ export function createClient() {
```
```ts utils/supabase/api.ts
import { createServerClient, type CookieOptions, serialize } from '@supabase/ssr'
import { createServerClient, serializeCookieHeader } from '@supabase/ssr'
import { type NextApiRequest, type NextApiResponse } from 'next'
export default function createClient(req: NextApiRequest, res: NextApiResponse) {
@@ -687,7 +687,9 @@ export default function createClient(req: NextApiRequest, res: NextApiResponse)
setAll(cookiesToSet) {
res.setHeader(
'Set-Cookie',
cookiesToSet.map(({ name, value, options }) => serialize(name, value, options))
cookiesToSet.map(({ name, value, options }) =>
serializeCookieHeader(name, value, options)
)
)
},
},
@@ -68,7 +68,7 @@ To use Google's pre-built signin buttons:
1. Go to the [API Credentials page](https://console.cloud.google.com/apis/credentials).
1. Click `Create credentials` and choose `OAuth Client ID`.
1. For application type, choose `Web application`.
1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain.
1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. If you're testing in localhost, ensure that you have `http://localhost` set in the **Authorized JavaScript origins** section as well. This is important when integrating with Google One-Tap to ensure you can use it locally.
1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Authorized Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
</TabPanel>
@@ -329,22 +329,108 @@ Most web apps and websites can utilize Google's [personalized sign-in buttons](h
```js
// Adapted from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
const nonce = btoa(
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))),
);
const encoder = new TextEncoder();
const encodedNonce = encoder.encode(nonce);
crypto.subtle.digest("SHA-256", encodedNonce).then((hashBuffer) => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashedNonce = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))
const encoder = new TextEncoder()
const encodedNonce = encoder.encode(nonce)
crypto.subtle.digest('SHA-256', encodedNonce).then((hashBuffer) => {
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashedNonce = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
})
// Use 'hashedNonce' when making the authentication request to Google
// Use 'nonce' when invoking the supabase.auth.signInWithIdToken() method
```
### One-tap with NextJS
If you're integrating Google One-Tap with your NextJS application, you can refer to the example below to get started:
```tsx
'use client'
import Script from 'next/script'
import { createClient } from '@/utils/supabase/client'
import { CredentialResponse } from 'google-one-tap'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
const OneTapComponent = () => {
const supabase = createClient()
const router = useRouter()
// generate nonce to use for google id token sign-in
const generateNonce = async (): Promise<string[]> => {
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))
const encoder = new TextEncoder()
const encodedNonce = encoder.encode(nonce)
const hashBuffer = await crypto.subtle.digest('SHA-256', encodedNonce)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashedNonce = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
return [nonce, hashedNonce]
}
useEffect(() => {
const initializeGoogleOneTap = () => {
console.log('Initializing Google One Tap')
window.addEventListener('load', async () => {
const [nonce, hashedNonce] = await generateNonce()
console.log('Nonce: ', nonce, hashedNonce)
// check if there's already an existing session before initializing the one-tap UI
const { data, error } = await supabase.auth.getSession()
if (error) {
console.error('Error getting session', error)
}
if (data.session) {
router.push('/')
return
}
/* global google */
google.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
callback: async (response: CredentialResponse) => {
try {
// send id token returned in response.credential to supabase
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: response.credential,
nonce,
})
if (error) throw error
console.log('Session data: ', data)
console.log('Successfully logged in with Google One Tap')
// redirect to protected page
router.push('/')
} catch (error) {
console.error('Error logging in with Google One Tap', error)
}
},
nonce: hashedNonce,
// with chrome's removal of third-party cookiesm, we need to use FedCM instead (https://developers.google.com/identity/gsi/web/guides/fedcm-migration)
use_fedcm_for_prompt: true,
})
google.accounts.id.prompt() // Display the One Tap UI
})
}
initializeGoogleOneTap()
return () => window.removeEventListener('load', initializeGoogleOneTap)
}, [])
return (
<>
<Script src="https://accounts.google.com/gsi/client" />
<div id="oneTap" className="fixed top-0 right-0 z-[100]" />
</>
)
}
export default OneTapComponent
```
</TabPanel>
<TabPanel id="react-native" label="Expo React Native">
+139
View File
@@ -0,0 +1,139 @@
---
id: 'auth-third-party-auth0'
title: 'Auth0'
subtitle: 'Use Auth0 with your Supabase project'
---
Auth0 can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
## Getting started
1. First you need to add an integration to connect your Supabase project with your Auth0 tenant. You will need your tenant ID (and in some cases region ID).
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth).
3. Assign the `role: 'authenticated'` custom claim to all JWTs by using an Auth0 Action.
4. Finally setup the Supabase client in your application.
## Setup the Supabase client library
<Tabs type="underlined" queryGroup="auth0-create-client">
<TabPanel id="ts" label="TypeScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
accessToken: async () => {
const accessToken = await auth0.getTokenSilently()
// Alternatively you can use (await auth0.getIdTokenClaims()).__raw to
// use an ID token instead.
return accessToken
},
})
```
</TabPanel>
<TabPanel id="swift" label="Swift (iOS)">
```swift
import Auth0
import Supabase
extension CredentialsManager {
static let shared = Auth0.CredentialsManager(authentication: Auth0.authentication())
}
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
supabaseKey: "SUPABASE_ANON_KEY",
options: SupabaseClientOptions(
auth: SupabaseClientOptions.AuthOptions(
accessToken: {
try await CredentialsManager.shared.credentials().idToken
}
)
)
)
```
</TabPanel>
<TabPanel id="dart" label="Flutter">
```dart
import 'package:auth0_flutter/auth0_flutter.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
final auth0 = Auth0('AUTH0_DOMAIN', 'AUTH0_CLIENT_ID');
await Supabase.initialize(
url: 'https://<supabase-project>.supabase.co',
anonKey: 'SUPABASE_ANON_KEY',
accessToken: () async {
final credentials = await auth0.credentialsManager.credentials();
return credentials.accessToken;
},
);
runApp(const MyApp());
}
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
```kotlin
import com.auth0.android.result.Credentials
val supabase = createSupabaseClient(
"https://<supabase-project>.supabase.co",
"SUPABASE_ANON_KEY"
) {
accessToken = {
val credentials: Credentials = ...; // Get credentials from Auth0
credentials.accessToken
}
}
```
</TabPanel>
</Tabs>
## Add a new Third-Party Auth integration to your project
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
In the CLI add the following config to your `supabase/config.toml` file:
```toml
[auth.third_party.auth0]
enabled = true
tenant_id = "<id>"
tenant_region = "<region>" # if your tenant has a region
```
## Use an Auth0 Action to assign the authenticated role
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
By default, Auth0 JWTs (both access token and ID token) do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
A recommended approach to do this is to configure the [`onExecutePostLogin` Auth0 Action](https://auth0.com/docs/secure/tokens/json-web-tokens/create-custom-claims#create-custom-claims) which will add the custom claim:
```javascript
exports.onExecutePostLogin = async (event, api) => {
api.accessToken.setCustomClaim('role', 'authenticated')
}
```
## Limitations
At this time, Auth0 tenants with the following [signing algorithms](https://auth0.com/docs/get-started/applications/signing-algorithms) are not supported:
- HS256 (HMAC with SHA-256) -- also known as symmetric JWTs
- PS256 (RSA-PSS with SHA-256)
@@ -0,0 +1,171 @@
---
id: 'auth-third-party-aws-cognito'
title: 'Amazon Cognito (Amplify)'
subtitle: 'Use Amazon Cognito via Amplify or standalone with your Supabase project'
---
Amazon Cognito User Pools (via AWS Amplify or on its own) can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
## Getting started
1. First you need to add an integration to connect your Supabase project with your Amazon Cognito User Pool. You will need the pool's ID and region.
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth) or configure it in the CLI.
3. Assign the `role: 'authenticated'` custom claim to all JWTs by using a Pre-Token Generation Trigger.
4. Finally setup the Supabase client in your application.
## Setup the Supabase client library
<Tabs type="underlined" queryGroup="cognito-create-client">
<TabPanel id="ts" label="TypeScript (Amplify)">
```typescript
import { fetchAuthSession, Hub } from 'aws-amplify/auth'
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
accessToken: async () => {
const tokens = await fetchAuthSession()
// Alternatively you can use tokens?.idToken instead.
return tokens?.accessToken
},
})
// if you're using Realtime you also need to set up a listener for Cognito auth changes
Hub.listen('auth', () => {
fetchAuthSession().then((tokens) => supabase.realtime.setAuth(tokens?.accessToken))
})
```
</TabPanel>
<TabPanel id="swift" label="Swift (iOS)">
```swift
import Supabase
import AWSPluginsCore
struct UnexpectedAuthSessionError: Error {}
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
supabaseKey: "SUPABASE_ANON_KEY",
options: SupabaseClientOptions(
auth: SupabaseClientOptions.AuthOptions(
accessToken: {
let session = try await Amplify.Auth.fetchAuthSession()
guard let cognitoTokenProvider = session as? AuthCognitoTokensProvider else {
throw UnexpectedAuthSessionError()
}
let tokens = try cognitoTokenProvider.getCognitoTokens().get()
return tokens.idToken
}
)
)
)
```
</TabPanel>
<TabPanel id="dart" label="Flutter">
```dart
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
await Supabase.initialize(
url: 'https://<supabase-project>.supabase.co',
anonKey: 'SUPABASE_ANON_KEY',
accessToken: () async {
final session = await Amplify.Auth.fetchAuthSession();
final cognitoSession = session as CognitoAuthSession;
return cognitoSession.userPoolTokensResult.value.accessToken.raw;
},
);
runApp(const MyApp());
}
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
```kotlin
import com.amplifyframework.auth.AuthSession
import com.amplifyframework.auth.cognito.AWSCognitoAuthSession
import com.amplifyframework.core.Amplify
val supabase = createSupabaseClient(
"https://<supabase-project>.supabase.co",
"SUPABASE_ANON_KEY"
) {
accessToken = {
getAccessToken()
}
}
suspend fun getAccessToken(): String? {
return suspendCoroutine {
Amplify.Auth.fetchAuthSession(
{ result: AuthSession ->
val cognitoAuthSession = result as AWSCognitoAuthSession
it.resume(cognitoAuthSession.userPoolTokensResult.value?.accessToken)
},
{ _ ->
// Handle error
})
}
}
```
</TabPanel>
</Tabs>
## Add a new Third-Party Auth integration to your project
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
In the CLI add the following config to your `supabase/config.toml` file:
```toml
[auth.third_party.aws_cognito]
enabled = true
user_pool_id = "<id>"
user_pool_region = "<region>"
```
## Use a Pre-Token Generation Trigger to assign the authenticated role
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
By default, Amazon Cognito JWTs (both ID token and access tokens) do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
A recommended approach to do this is to configure a [Pre-Token Generation Trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html) either `V1_0` (ID token only) or `V2_0` (both access and ID token). To do this you will need to create a new Lambda function (in any language and runtime) and assign it to the [Amazon Cognito User Pool's Lambda Triggers configuration](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html). For example, the Lambda function should look similar to this:
<Tabs type="underlined" queryGroup="cognito-lambda">
<TabPanel id="blocking-nodejs" label="Node.js">
```javascript
export const handler = async (event) => {
event.response = {
claimsOverrideDetails: {
claimsToAddOrOverride: {
role: 'authenticated',
},
},
}
return event
}
```
</TabPanel>
</Tabs>
@@ -0,0 +1,332 @@
---
id: 'auth-third-party-firebase-auth'
title: 'Firebase Auth'
subtitle: 'Use Firebase Auth with your Supabase project'
---
<Admonition type="note">
Creating a Firebase Auth third-party integration with your Supabase project is currently limited to a private alpha release. We are still trying to improve the developer experience around securing your project. [Register your interest in Third-Party Auth with Firebase](https://forms.supabase.com/third-party-auth-with-firebase) and the team will reach out to you.
</Admonition>
Firebase Auth can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
## Getting started
1. First you need to add an integration to connect your Supabase project with your Firebase project. You will need to get the Project ID in the [Firebase Console](https://console.firebase.google.com/u/0/project/_/settings/general).
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth).
3. If you are using Third Party Auth in local development, create and attach restrictive RLS policies to all tables in your public schema, Storage and Realtime to prevent unauthorized access from unrelated Firebase projects.
4. Assign the `role: 'authenticated'` [custom user claim](https://firebase.google.com/docs/auth/admin/custom-claims) to all your users.
5. Finally set up the Supabase client in your application.
## Setup the Supabase client library
<Tabs type="underlined" queryGroup="firebase-create-client">
<TabPanel id="ts" label="TypeScript">
Creating a client for the Web is as easy as passing the `accessToken` async function. This function should [return the Firebase Auth JWT of the current user](https://firebase.google.com/docs/auth/admin/verify-id-tokens#web) (or null if no such user) is found.
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
accessToken: async () => {
;(await firebase.auth().currentUser?.getIdToken(/* forceRefresh */ false)) ?? null
},
})
```
Make sure the all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
</TabPanel>
<TabPanel id="swift" label="Swift (iOS)">
```swift
import Supabase
import FirebaseAuth
struct MissingFirebaseTokenError: Error {}
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
supabaseKey: "SUPABASE_ANON_KEY",
options: SupabaseClientOptions(
auth: SupabaseClientOptions.AuthOptions(
accessToken: {
guard let token = await Auth.auth().currentUser?.getIDToken() else {
throw MissingFirebaseTokenError()
}
return token
}
)
)
)
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin (Android)">
Create a Supabase client with the `accessToken` function that returns the Firebase Auth JWT of the current user. This code uses the [official Firebase SDK](https://firebase.google.com/docs/auth/android/start) for Android.
```kotlin
import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
val supabase = createSupabaseClient(
"https://<supabase-project>.supabase.co",
"SUPABASE_ANON_KEY"
) {
accessToken = {
Firebase.auth.currentUser?.getIdToken(false)?.await()?.token
}
}
```
</TabPanel>
<TabPanel id="kotlinmp" label="Kotlin (Multiplatform)">
Create a Supabase client with the `accessToken` function that returns the Firebase Auth JWT of the current user. This code uses a [community Firebase SDK](https://github.com/GitLiveApp/firebase-kotlin-sdk) which supports Kotlin Multiplatform.
```kotlin
import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.auth.auth
val supabase = createSupabaseClient(
"https://<supabase-project>.supabase.co",
"SUPABASE_ANON_KEY"
) {
accessToken = {
Firebase.auth.currentUser?.getIdToken(false)
}
}
```
</TabPanel>
</Tabs>
## Add a new Third-Party Auth integration to your project
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
In the CLI add the following config to your `supabase/config.toml` file:
```toml
[auth.third_party.firebase]
enabled = true
project_id = "<id>"
```
## Adding an extra layer of security to your project's RLS policies
<Admonition type="note">
While Third-Party Auth with Firebase is in a private alpha, it is strongly recommended to follow this guide closely. Once it becomes generally available this method would not be necessary to secure your project against unauthorized access from other Firebase projects.
You should also follow this best practice if self-hosting.
</Admonition>
Firebase Auth uses a single set of JWT signing keys for all projects. This means that JWTs issued from an unrelated Firebase project to yours could access data in your Supabase project. To guard against this, creating and maintaining the following RLS policies for **all of your tables in the `public` schema** is very important. You should also attach this policy to [Storage](/docs/guides/storage/security/access-control) buckets or [Realtime](/docs/guides/realtime/authorization) channels.
To achieve this we recommend using a [restrictive Postgres Row-Level Security policy](https://www.postgresql.org/docs/current/sql-createpolicy.html).
Restrictive RLS policies differ from regular (or permissive) policies in that they use the `as restrictive` clause when being defined. They do not grant permissions, but rather restrict any existing or future permissions. They're great for cases like this where the technical limitations of Firebase Auth remain separate from your app's logic.
<Admonition type="danger">
Postgres has two types of policies: permissive and restrictive. This example uses restrictive policies so make sure you don't omit the `as restrictive` clause.
</Admonition>
This is an example of such an RLS policy that will restrict access to only your project's (denoted with `<firebase-project-id>`) users, and not any other Firebase project.
```sql
create policy "Restrict access to Supabase Auth and Firebase Auth for project ID <firebase-project-id>"
on table_name
as restrictive
to authenticated
using (
(auth.jwt()->>'iss' = 'https://<project-ref>.supabase.co/auth/v1')
or
(
auth.jwt()->>'iss' = 'https://securetoken.google.com/<firebase-project-id>'
and
auth.jwt()->>'aud' = '<firebase-project-id>'
)
);
```
If you have a lot of tables in your app, or need to manage complex RLS policies for [Storage](/docs/guides/storage) or [Realtime](/docs/guides/realtime) it can be useful to define a [stable Postgres function](https://www.postgresql.org/docs/current/xfunc-volatility.html) that performs the check to cut down on duplicate code. For example:
```sql
create function public.is_supabase_or_firebase_project_jwt()
returns bool
language sql
stable
returns null on null input
return (
(auth.jwt()->>'iss' = 'https://<project-ref>.supabase.co/auth/v1')
or
(
auth.jwt()->>'iss' = concat('https://securetoken.google.com/<firebase-project-id>')
and
auth.jwt()->>'aud' = '<firebase-project-id>'
)
);
```
Make sure you substitute `<project-ref>` with your Supabase project's ID and the `<firebase-project-id>` to your Firebase Project ID. Then the restrictive policies on all your tables, buckets and channels can be simplified to be:
```sql
create policy "Restrict access to correct Supabase and Firebase projects"
on table_name
as restrictive
to authenticated
using ((select public.is_supabase_or_firebase_project_jwt()) is true);
```
## Assign the "role" custom claim
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
By default, Firebase JWTs do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
### Use Firebase Authentication functions to assign the authenticated role
You have two choices to set up a Firebase Authentication function depending on your Firebase project's configuration:
1. Easiest: Use a [blocking Firebase Authentication function](https://firebase.google.com/docs/auth/extend-with-blocking-functions) but this is only available if your project uses [Firebase Authentication with Identity Platform](https://cloud.google.com/security/products/identity-platform).
2. Manually assign the custom claims to all users with the [admin SDK](https://firebase.google.com/docs/auth/admin/custom-claims#set_and_validate_custom_user_claims_via_the_admin_sdk) and define an [`onCreate` Firebase Authentication Cloud Function](https://firebase.google.com/docs/auth/extend-with-functions) to persist the role to all newly created users.
<Tabs type="underlined" queryGroup="firebase-functions">
<TabPanel id="blocking-nodejs" label="Node.js (Blocking Functions Gen 2)">
```typescript
import { beforeUserCreated, beforeUserSignedIn } from 'firebase-functions/v2/identity'
export const beforecreated = beforeUserCreated((event) => {
return {
customClaims: {
// The Supabase project will use this role to assign the `authenticated`
// Postgres role.
role: 'authenticated',
},
}
})
export const beforesignedin = beforeUserSignedIn((event) => {
return {
customClaims: {
// The Supabase project will use this role to assign the `authenticated`
// Postgres role.
role: 'authenticated',
},
}
})
```
Note that instead of using `customClaims` you can instead use `sessionClaims`. The difference is that `session_claims` are not saved in the Firebase user profile, but remain valid for as long as the user is signed in.
</TabPanel>
<TabPanel id="blocking-python" label="Python (Blocking Functions Gen 2)">
```python
@identity_fn.before_user_created()
def set_supabase_role_sign_up(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeCreateResponse | None:
return identity_fn.BeforeCreateResponse(
# The Supabase project will use this role to assign the `authenticated`
# Postgres role.
custom_claims={'role': 'authenticated'})
@identity_fn.before_user_signed_in()
def set_supabase_role_sign_in(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSignInResponse | None:
return identity_fn.BeforeSignInResponse(
# The Supabase project will use this role to assign the `authenticated`
# Postgres role.
custom_claims={'role': 'authenticated'})
```
Note that instead of using `custom_claims` you can instead use `session_claims`. The difference is that `session_claims` are not saved in the Firebase user profile, but remain valid for as long as the user is signed in.
</TabPanel>
<TabPanel id="oncreate-nodejs" label="onCreate Cloud Function in Node.js">
```javascript
const functions = require('firebase-functions')
const { initializeApp } = require('firebase-admin/app')
const { getAuth } = require('firebase-admin/auth')
const { getDatabase } = require('firebase-admin/database')
initializeApp()
// On sign up.
exports.processSignUp = functions.auth.user().onCreate(async (user) => {
try {
// Set custom user claims on this newly created user.
await getAuth().setCustomUserClaims(user.uid, {
role: 'authenticated',
})
} catch (error) {
console.log(error)
}
})
```
Note that the `onCreate` Firebase Cloud Function is not _synchronous_ (unlike the Blocking Functions), so the very first ID token received by the Firebase client library in your app _will not contain_ the `role: 'authenticated'` claim. Force-refresh the ID token immediately after sign-up to fetch an ID token with the applied role.
</TabPanel>
</Tabs>
Finally deploy your functions for the changes to take effect:
```
firebase deploy --only functions
```
Note that these functions are only called on new sign-ups and sign-ins. Existing users will not have these claims in their ID tokens. You will need to use the admin SDK to assign the role custom claim to all users. Make sure you do this after the blocking Firebase Authentication functions as described above are deployed.
### Use the admin SDK to assign the role custom claim to all users
You need to run a script that will assign the `role: 'authenticated'` custom claim to all of your existing Firebase Authentication users. You can do this by combining the [list users](https://firebase.google.com/docs/auth/admin/manage-users#list_all_users) and [set custom user claims](https://firebase.google.com/docs/auth/admin/create-custom-tokens) admin APIs. An example script is provided below:
```javascript
'use strict';
const { initializeApp } = require('firebase-admin/app');
const { getAuth } = require('firebase-admin/auth');
initializeApp();
async function setRoleCustomClaim() => {
let nextPageToken = undefined
do {
const listUsersResult = await getAuth().listUsers(1000, nextPageToken)
nextPageToken = listUsersResult.pageToken
await Promise.all(listUsersResult.users.map(async (userRecord) => {
try {
await getAuth().setCustomUserClaims(userRecord.id, {
role: 'authenticated'
})
} catch (error) {
console.error('Failed to set custom role for user', userRecord.id)
}
})
} while (nextPageToken);
};
setRoleCustomClaim().then(() => process.exit(0))
```
After all users have received the `role: 'authenticated'` claim, it will appear in all newly issued ID tokens for the user.
+29
View File
@@ -0,0 +1,29 @@
---
id: 'auth-third-party-overview'
title: 'Third-party auth'
subtitle: 'First-class support for authentication providers'
---
Supabase has first-class support for these third-party authentication providers:
- [Firebase Auth](/docs/guides/auth/third-party/firebase-auth)
- [Auth0](/docs/guides/auth/third-party/auth0)
- [AWS Cognito (with or without AWS Amplify)](/docs/guides/auth/third-party/aws-cognito)
You can use these providers alongside Supabase Auth, or on their own, to access the [Data API (REST and GraphQL)](/docs/guides/database), [Storage](/docs/guides/storage), [Realtime](/docs/guides/storage) and [Functions](/docs/guides/functions) from your existing apps.
If you already have production apps using one of these authentication providers, and would like to use a Supabase feature, you no longer need to migrate your users to Supabase Auth or use work-arounds like translating JWTs into the Supabase Auth format and using your project's signing secret.
## How does it work?
To use Supabase products like Data APIs for your Postgres database, Storage or Realtime, you often need to send access tokens or JWTs via the Supabase client libraries or via the REST API. Third-party auth support means that when you add a new integration with one of these providers, the API will trust JWTs issued by the provider similar to how it trusts JWTs issued by Supabase Auth.
This is made possible if the providers are using JWTs signed with asymmetric keys, which means that the Supabase APIs will be able to only verify but not create JWTs.
## Limitations
There are some limitations you should be aware of when using third-party authentication providers with Supabase.
1. The third-party provider must use asymmetrically signed JWTs (exposed as an OIDC Issuer Discovery URL by the third-party authentication provider). Using symmetrically signed JWTs is not possible at this time.
2. The JWT signing keys from the third-party provider are stored in the configuration of your project, and are checked for changes periodically. If you are rotating your keys (when supported) allow up to 30 minutes for the change to be picked up.
3. It is not possible to disable Supabase Auth at this time.
@@ -167,4 +167,4 @@ npx @snaplet/seed sync
npx tsx seed.ts > supabase/seed.sql
```
For more information, check out Snaplet's [seed documentation](https://docs.snaplet.dev/seed/integrations/supabase)
For more information, check out Snaplet's [seed documentation](https://snaplet-seed.netlify.app/seed/integrations/supabase)
@@ -10,7 +10,7 @@ Postgres provides a set of sensible defaults for you database size. In some case
## Timeouts
See the [Timeouts](/docs/guides/database/timeouts) section.
See the [Timeouts](/docs/guides/database/postgres/timeouts) section.
## Statement optimization
@@ -44,7 +44,9 @@ If you use special symbols in your postgres password, you must remember to [perc
When you created your project you were also asked to enter a password. This is actually the password for the `postgres` role in your database. You can update this from the Dashboard under the [database settings](https://supabase.com/dashboard/project/_/settings/database) page. You should _never_ give this to third-party service unless you absolutely trust them. Instead, we recommend that you create a new user for every service that you want to give access too. This will also help you with debugging - you can see every query that each role is executing in your database within `pg_stat_statements`.
## Granting permissions
Changing the password does not result in any downtime. All connected services, such as postgrest, pgbouncer, and other Supabase managed services, are automatically updated to use the latest password to ensure availability. However, if you have any external services connecting to the Supabase database using hardcoded username/password credentials, a manual update will be required.
## Granting Permissions
Roles can be granted various permissions on database objects using the `GRANT` command. Permissions include `SELECT`, `INSERT`, `UPDATE`, and `DELETE`. You can configure access to almost any object inside your database - including tables, views, functions, and triggers.
@@ -220,6 +220,12 @@ Returns the ID of the user making the request.
### `auth.jwt()`
<Admonition type="caution">
Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the `user_metadata` claim can create security issues in your application as this information can be modified by authenticated end users.
</Admonition>
Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two:
- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data.
@@ -484,7 +490,9 @@ This prevents the policy `( (select auth.uid()) = user_id )` from running for an
| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| [test6-To-role](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test6-To-role) | 170 | < 0.1 | 99.78% | <details className="cursor-pointer">Before:<br/>No `TO` policy<br/><br/>After:<br/>`TO authenticated` (anon accessing)</details> |
## Resources
## More resources
- [Testing your database](/docs/guides/database/testing)
- [Row Level Security and Supabase Auth](/docs/guides/database/postgres/row-level-security)
- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576)
- Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main)
@@ -18,7 +18,7 @@ You can change the Postgres timeout at the:
### Session level
Session level settings persist for the duration of the connection. You must be connecting to the database in session mode for this to take effect.
Session level settings persist only for the duration of the connection.
Set the session timeout by running:
@@ -26,7 +26,7 @@ Set the session timeout by running:
set statement_timeout = '10min';
```
Because this applies to sessions only, it can only be used with connections through Supavisor in session mode (port 5432) or a direct connection. It cannot be used in the Dashboard, with the Supabase Client API, nor with Supavisor in Transaction mode (port 6543).
Because it applies to sessions only, it can only be used with connections through Supavisor in session mode (port 5432) or a direct connection. It cannot be used in the Dashboard, with the Supabase Client API, nor with Supavisor in Transaction mode (port 6543).
This is most often used for single, long running, administrative tasks, such as creating an HSNW index. Once the setting is implemented, you can view it by executing:
@@ -51,25 +51,9 @@ set statement_timeout TO '4s'; -- set custom timeout
This is mostly for recurring functions that need a special exemption for runtimes.
### Global level
This changes the statement timeout for all queries.
It is only available on Supabase Postgres version 15.1.1.78 and higher. If you are unsure what version you have, check your [Infrastructure Settings](/dashboard/project/_/settings/infrastructure)
```sql
alter database postgres set statement_timeout TO '4s';
```
Check if your changes took effect:
```sql
show statement_timeout;
```
### Role level
This sets the timeout for a specific role. It has a higher precedence than the global timeout setting.
This sets the timeout for a specific role.
The default role timeouts are:
@@ -101,6 +85,20 @@ where
);
```
### Global level
This changes the statement timeout for all roles and sessions without an explicit timeout already set.
```sql
alter database postgres set statement_timeout TO '4s';
```
Check if your changes took effect:
```sql
show statement_timeout;
```
Although not necessary, if you are uncertain if a timeout has been applied, you can run a quick test:
```sql
@@ -117,7 +115,7 @@ The Supabase Dashboard contains tools to help you identify timed-out and long-ru
### Using the Log Explorer
Go to the [Log Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run in greater than 10 seconds (`duration`).
Go to the [Log Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`).
```sql
select
@@ -170,9 +168,3 @@ where
-- find events from the relevant role
parsed.user_name = '<ROLE>'
```
## Resources
- [How to interpret and explore the Postgres logs](https://github.com/orgs/supabase/discussions/26224)
- [How to interpret and explore the Database API logs](https://github.com/orgs/supabase/discussions/22849)
- [How to filter the Log Explorer](https://github.com/orgs/supabase/discussions/22640)
@@ -0,0 +1,50 @@
---
id: 'functions-compression'
title: 'Handling Compressed Requests'
description: 'Handling Gzip compressed requests.'
subtitle: 'Handling Gzip compressed requests.'
---
To decompress Gzip bodies, you can use `gunzipSync` from the `node:zlib` API to decompress and then read the body.
```ts
import { gunzipSync } from 'node:zlib'
Deno.serve(async (req) => {
try {
// Check if the request body is gzip compressed
const contentEncoding = req.headers.get('content-encoding')
if (contentEncoding !== 'gzip') {
return new Response('Request body is not gzip compressed', {
status: 400,
})
}
// Read the compressed body
const compressedBody = await req.arrayBuffer()
// Decompress the body
const decompressedBody = gunzipSync(new Uint8Array(compressedBody))
// Convert the decompressed body to a string
const decompressedString = new TextDecoder().decode(decompressedBody)
const data = JSON.parse(decompressedString)
// Process the decompressed body as needed
console.log(`Received: ${JSON.stringify(data)}`)
return new Response('ok', {
headers: { 'Content-Type': 'text/plain' },
})
} catch (error) {
console.error('Error:', error)
return new Response('Error processing request', { status: 500 })
}
})
```
<Admonition type="warning">
Edge functions have a runtime memory limit of 150MB. Overly large compressed payloads may result in an out-of-memory error.
</Admonition>
@@ -13,9 +13,9 @@ Developing with Edge Functions is similar to developing with Node.js, but with a
Supabase Edge Functions support:
- The Deno [standard library](https://deno.land/std)
- Third party [modules](https://deno.land/x)
- JavaScript modules from npm (https://docs.deno.com/examples/npm/)
- Built-in [Node APIs](https://docs.deno.com/runtime/manual/node/compatibility)
- Node.js modules published to npm
- Third party modules published to [JSR](https://jsr.io/) or [deno.land/x](https://deno.land/x)
You can import npm modules using the `npm:` specifier or via CDNs like [esm.sh](https://esm.sh). For example, to import the `supabase-js` package:
@@ -37,6 +37,9 @@ The following script is a good example to get started with testing your Edge Fun
import { assert, assertEquals } from 'https://deno.land/std@0.192.0/testing/asserts.ts'
import { createClient, SupabaseClient } from 'jsr:@supabase/supabase-js@2'
// Will load the .env file to Deno.env
import 'https://deno.land/x/dotenv@v3.2.2/load.ts'
// Set up the configuration for the Supabase client
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
@@ -130,19 +133,19 @@ To locally test and debug Edge Functions, you can utilize the Supabase CLI. Let'
```bash
# creates the file
touch .env.local
touch .env
# adds the SUPABASE_URL secret
echo "SUPABASE_URL=http://localhost:54321" >> .env.local
echo "SUPABASE_URL=http://localhost:54321" >> .env
# adds the SUPABASE_ANON_KEY secret
echo "SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" >> .env.local
echo "SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" >> .env
# Alternatively, you can open it in your editor:
open .env.local
open .env
```
4. To run the tests, use the following command in your terminal:
```bash
deno test --allow-all supabase/functions/tests/function-one-test.ts --env=.env.local
deno test --allow-all supabase/functions/tests/function-one-test.ts
```
## Resources
@@ -66,6 +66,10 @@ Deploy read-only databases across multiple regions, for lower latency and better
Launch the Supabase stack on Fly.io edge network. [Docs](/docs/guides/platform/fly-postgres).
### Log Drains
Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains).
## Studio
### Studio Single Sign On
@@ -144,7 +148,7 @@ Upload large files using resumable uploads. [Docs](/docs/guides/storage/uploads/
### S3 compatibility
Interact with Storage from tool which supports with the S3 protocol. [Docs](/docs/guides/storage/s3/compatibility).
Interact with Storage from tool which supports the S3 protocol. [Docs](/docs/guides/storage/s3/compatibility).
## Edge Functions
@@ -212,11 +216,14 @@ In addition to the Beta requirements, features in GA are covered by the [uptime
| Platform | Terraform Provider | `public alpha` | N/A |
| Platform | Read Replicas | `private alpha` | N/A |
| Platform | Fly Postgres | `private alpha` | N/A |
| Platform | Log Drains | `public alpha` | ✅ |
| Studio | | `GA` | ✅ |
| Studio | SSO | `GA` | ✅ |
| Realtime | Postgres Changes | `GA` | ✅ |
| Realtime | Broadcast | `GA` | ✅ |
| Realtime | Presence | `GA` | ✅ |
| Realtime | Broadcast Authorization | `public beta` | ✅ |
| Realtime | Presence Authorization | `public beta` | ✅ |
| Storage | | `GA` | ✅ |
| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) |
| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) |
@@ -119,7 +119,18 @@ Add an intent-filter to enable deep linking:
</TabPanel>
<TabPanel id="web" label="Web">
There are no additional configurations.
Supabase redirects do not work with Flutter's [default URL strategy](https://docs.flutter.dev/ui/navigation/url-strategies).
We can switch to the path URL strategy as follows:
```dart
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
usePathUrlStrategy();
runApp(ExampleApp());
}
```
</TabPanel>
</Tabs>
@@ -303,14 +303,18 @@ class ProductRepositoryImpl @Inject constructor(
set("price", price)
set("image", buildImageUrl(imageFileName = imageUrl))
}) {
eq("id", id)
filter {
eq("id", id)
}
}
} else {
postgrest.from("products").update({
set("name", name)
set("price", price)
}) {
eq("id", id)
filter {
eq("id", id)
}
}
}
}
@@ -221,7 +221,7 @@ Now that we have all the components in place, let's update `App.svelte`:
import Account from './lib/Account.svelte'
import Auth from './lib/Auth.svelte'
let session: AuthSession
let session: AuthSession | null
onMount(() => {
supabase.auth.getSession().then(({ data }) => {
@@ -269,9 +269,9 @@ Let's create an avatar for the user so that they can upload a profile photo. We
import { supabase } from '../supabaseClient'
export let size: number
export let url: string
export let url: string | null = null
let avatarUrl: string = null
let avatarUrl: string | null = null
let uploading = false
let files: FileList
@@ -341,8 +341,8 @@ Let's create an avatar for the user so that they can upload a profile photo. We
id="single"
accept="image/*"
bind:files
on:change="{uploadAvatar}"
disabled="{uploading}"
on:change={uploadAvatar}
disabled={uploading}
/>
</span>
</div>
@@ -128,6 +128,8 @@ After a user is signed in, you can allow them to edit their profile details and
Create a new view for that called `ProfileView.swift`.
```swift ProfileView.swift
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@@ -180,7 +182,7 @@ struct ProfileView: View {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile = try await supabase.database
let profile: Profile = try await supabase
.from("profiles")
.select()
.eq("id", value: currentUser.id)
@@ -204,7 +206,7 @@ struct ProfileView: View {
do {
let currentUser = try await supabase.auth.session.user
try await supabase.database
try await supabase
.from("profiles")
.update(
UpdateProfileParams(
@@ -260,6 +262,8 @@ Now that you've created all the views, add an entry point for the application. T
Add a new `AppView.swift` file.
```swift AppView.swift
import SwiftUI
struct AppView: View {
@State var isAuthenticated = false
@@ -0,0 +1,193 @@
---
title: 'Advanced Log Filtering'
description: 'Filter logs with regular expressions'
---
# Querying the logs
## Understanding field references
The log tables are queried with subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`.
| column | description |
| ------------- | --------------------------- |
| timestamp | time event was recorded |
| event_message | the log's message |
| metadata | information about the event |
The `metadata` column is an array of JSON objects that stores important details about each recorded event. For example, in the postgres table, the `metadata.parsed.error_severity` field indicates the error level of an event. To work with its values, you need to `unnest` them using a `cross join`.
This approach is commonly used with JSON and array columns, so it might look a bit unfamiliar if you're not used to working with these data types.
```sql
select
event_message,
parsed.error_severity,
parsed.user_name
from
postgres_logs
-- extract first layer
cross join unnest(postgres_logs.metadata) as metadata
-- extract second layer
cross join unnest(metadata.parsed) as parsed;
```
## Expanding results
Logs returned by queries may be difficult to read in table format. A row can be double-clicked to expand the results into more readable JSON:
![Expanding log results](/docs/img/guides/platform/expanded-log-results.png)
## Filtering with [regular expressions](https://en.wikipedia.org/wiki/Regular_expression)
The Logs use BigQuery Style regular expressions with the [regexp_contains function](https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#regexp_contains). In its most basic form, it will check if a string is present in a specified column.
```sql
select
cast(timestamp as datetime) as timestamp,
event_message,
metadata
from postgres_logs
where regexp_contains(event_message, 'is present');
```
There are multiple operators that you should consider using:
### Find messages that start with a phrase
`^` only looks for values at the start of a string
```sql
-- find only messages that start with connection
regexp_contains(event_message, '^connection')
```
### Find messages that end with a phrase:
`$` only looks for values at the end of the string
```sql
-- find only messages that ends with port=12345
regexp_contains(event_message, '$port=12345')
```
### Ignore case sensitivity:
`(?i)` ignores capitalization for all proceeding characters
```sql
-- find all event_messages with the word "connection"
regexp_contains(event_message, '(?i)COnnecTion')
```
### Wildcards:
`.` can represent any string of characters
```sql
-- find event_messages like "hello<anything>world"
regexp_contains(event_message, 'hello.world')
```
### Alphanumeric ranges:
`[1-9a-zA-Z]` finds any strings with only numbers and letters
```sql
-- find event_messages that contain a number between 1 and 5 (inclusive)
regexp_contains(event_message, '[1-5]')
```
### Repeated values:
`x*` zero or more x
`x+` one or more x
`x?` zero or one x
`x{4,}` four or more x
`x{3}` exactly 3 x
```sql
-- find event_messages that contains any sequence of 3 digits
regexp_contains(event_message, '[0-9]{3}')
```
### Escaping reserved characters:
`\.` interpreted as period `.` instead of as a wildcard
```sql
-- escapes .
regexp_contains(event_message, 'hello world\.')
```
### `or` statements:
`x|y` any string with `x` or `y` present
```sql
-- find event_messages that have the word 'started' followed by either the word "host" or "authenticated"
regexp_contains(event_message, 'started host|authenticated')
```
### and/or/not statements in SQL:
`and`, `or`, and `not` are all native terms in SQL and can be used in conjunction with regular expressions to filter results
```sql
select
cast(timestamp as datetime) as timestamp,
event_message,
metadata
from postgres_logs
where
(regexp_contains(event_message, 'connection') and regexp_contains(event_message, 'host'))
or not regexp_contains(event_message, 'received');
```
### Filtering and unnesting example
**Filter for Postgres**
```sql
select
cast(postgres_logs.timestamp as datetime) as timestamp,
parsed.error_severity,
parsed.user_name,
event_message
from
postgres_logs
cross join unnest(metadata) as metadata
cross join unnest(metadata.parsed) as parsed
where regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC')
order by timestamp desc
limit 100;
```
## Limitations
### Log tables cannot be joined together
Each product table operates independently without the ability to join with other log tables. This may change in the future.
### The `with` keyword and subqueries are not supported
The parser does not yet support `with` and subquery statements.
### The `ilike` and `similar to` keywords are not supported
Although `like` and other comparison operators can be used, `ilike` and `similar to` are incompatible with BigQuery's variant of SQL. `regexp_contains` can be used as an alternative.
### The wildcard operator `*` to select columns is not supported
The log parser is not able to parse the `*` operator for column selection. Instead, you can access all fields from the `metadata` column:
```sql
select
cast(postgres_logs.timestamp as datetime) as timestamp,
event_message,
metadata
from
<log_table_name>
order by timestamp desc
limit 100;
```
@@ -134,3 +134,27 @@ A date and time picker will be provided upon pressing the `Start a restore` butt
![PITR: Confirmation modal](/docs/img/backups-pitr-confirmation-modal.png)
After locking in the desired point in time to recover to, The Dashboard will then prompt for a review and confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot for downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the latest physical backup available is downloaded to the project and the database is partially restored. WAL files generated after this physical backup up to the specified point-in-time are then downloaded. The underlying records of transactions in these files are replayed against the database to complete the restoration. The Dashboard will display a notification once the restoration completes.
## Troubleshooting
### Logical backups
#### search_path issues
During the `pg_restore` process, the `search_path` is set to an empty string for predictability, and security. Using unqualified references to functions or relations can cause restorations using logical backups to fail, as the database will not be able to locate the function or relation being referenced. This can happen even if the database functions without issues during normal operations, as the `search_path` is usually set to include several schemas during normal operations. Therefore, you should always use schema-qualified names within your SQL code.
You can refer to [an example PR](https://github.com/supabase/supabase/pull/28393/files) on how to update SQL code to use schema-qualified names.
#### Invalid check constraints
Postgres requires that [check constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-CHECK-CONSTRAINTS) be:
1. immutable
1. not reference table data other than the new or updated row being checked
Violating these requirements can result in numerous failure scenarios, including during logical restorations.
Common examples of check constraints that can result in such failures are:
- validating against the current time, e.g. that the row being inserted references a future event
- validating the contents of a row against the contents of another table
@@ -64,7 +64,7 @@ Read more on [how billing for compute works](https://supabase.com/docs/guides/pl
#### What is unified egress and how is it billed?
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor, Log Drains and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
To learn more about unified egress and its details, please refer to the information provided [here](https://supabase.com/docs/guides/platform/org-based-billing#:~:text=running%20any%20projects.-,Unified%20egress,-%23).
@@ -6,6 +6,8 @@ description: "Learn about your project's instance and additional add-ons."
Every project on the Supabase Platform comes with its own dedicated Postgres instance running inside a virtual machine (VM). The following table describes the base instance with additional compute add-ons available if you need extra performance when scaling up Supabase.
You can upgrade your compute from the [Supabase Dashboard](/dashboard/project/_/settings/addons).
<Admonition type="note">
Free Plan resources subject to changes.
@@ -101,3 +103,7 @@ The maximum number of replication slots and WAL senders depends on your compute
As mentioned in the Postgres [documentation](https://postgresqlco.nf/doc/en/param/max_replication_slots/), setting `max_replication_slots` to a lower value than the current number of replication slots will prevent the server from starting. If you are downgrading your compute add-on, please ensure that you are using fewer slots than the maximum number of replication slots available for the new compute add-on.
</Admonition>
## Upgrade downtime
Compute add-on changes are usually applied with less than 2 minutes of downtime, but can take longer depending on the Cloud Provider.
@@ -32,7 +32,6 @@ The following parameters are available for overrides:
1. [max_worker_processes](https://postgresqlco.nf/doc/en/param/max_worker_processes/)
1. [session_replication_role](https://postgresqlco.nf/doc/en/param/session_replication_role/)
1. [shared_buffers](https://postgresqlco.nf/doc/en/param/shared_buffers/)
1. [statement_timeout](https://postgresqlco.nf/doc/en/param/statement_timeout/)
1. [work_mem](https://postgresqlco.nf/doc/en/param/work_mem/)
### Setting config using the CLI
@@ -64,17 +63,6 @@ max_parallel_workers |3 |
- End of Custom Postgres Config -
```
When configuring parameters that require time units, such as `statement_timeout`, the CLI expects a string value with a time unit. Supported units include `ms`, `s`, and `min`.
```bash
$ supabase --experimental --project-ref <project-ref> postgres-config update --config statement_timeout=1000ms
- Custom Postgres Config -
Config |Value |
statement_timeout |1000ms |
- End of Custom Postgres Config -
```
## Considerations
1. The Postgres cluster will be restarted in order to change the configuration being used. This will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds.
+107 -15
View File
@@ -4,17 +4,11 @@ title: 'Log Drains'
description: 'Getting started with Supabase Log Drains'
---
Log drains will send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Team and Enterprise Plans.
Log drains will send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Team and Enterprise Plans. Log drains is available in the dashboard under [Project Settings > Log Drains](https://supabase.com/dashboard/project/_/settings/log-drains).
<Admonition type="note">
You can read about the intiial announcement [here](https://supabase.com/blog/log-drains) and vote for your preferred drains in [this discussion](https://github.com/orgs/supabase/discussions/28324?sort=top).
Log drains is in **Private Alpha**, and is not available in the dashboard yet. [Reach out to us](https://forms.supabase.com/logdrains) to get log drains enabled for your project.
You can read about the initial announcement [here](https://github.com/orgs/supabase/discussions/26650).
</Admonition>
## Supported Destinations
# Supported Destinations
The following table lists the supported destinations and the required setup configuration:
@@ -25,7 +19,7 @@ The following table lists the supported destinations and the required setup conf
HTTP requests are batched with a max of 250 logs or 1 second intervals, whichever happens first. Logs are compressed via Gzip if the destination supports it.
### Generic HTTP endpoint
## Generic HTTP Endpoint
Logs are sent as a POST request with a JSON body. Both HTTP/1 and HTTP/2 protocols are supported.
Custom headers can optionally be configured for all requests.
@@ -36,7 +30,109 @@ Note that requests are **unsigned**.
Unsigned requests to HTTP endpoints are temporary and all requests will signed in the near future.
</Admonition>
### Datadog
<Accordion
type="default"
openBehaviour="multiple"
>
<AccordionItem
header="Edge Function Walkthrough (Uncompressed)"
id="uncompressed"
>
1. Create and deploy the edge function
Generate a new edge function template and update it to log out the received JSON payload. For simplicity, we will accept any request with an Anon Key.
```bash
supabase functions new hello-world
```
You can use this example snippet as an illustration of how the received request will be like.
```ts
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
Deno.serve(async (req) => {
const data = await req.json()
console.log(`Received ${data.length} logs, first log:\n ${JSON.stringify(data[0])}`)
return new Response(JSON.stringify({ message: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
})
})
```
And then deploy it with:
```bash
supabase functions deploy hello-world --project-ref [PROJECT REF]
```
<Admonition type="warning">
This will create an infinite loop, as we are generating an additional log event that will eventually trigger a new request to this edge function. However, due to the batching nature of how Log Drain events are dispatched, the rate of edge function triggers will not increase greatly and will have an upper bound.
</Admonition>
2. Configure the HTTP Drain
Create a HTTP drain under the [Project Settings > Log Drains](https://supabase.com/dashboard/project/_/settings/log-drains).
- Disable the Gzip, as we want to receive the payload without compression.
- Under URL, set it to your edge function URL `https://[PROJECT REF].supabase.co/functions/v1/hello-world`
- Under Headers, set the `Authorization: Bearer [ANON KEY]`
<ProjectConfigVariables variable="anonKey" />
</AccordionItem>
<AccordionItem
header="Edge Function Gzip Example"
id="gzip"
>
Gzip payloads can be decompressed using native in-built APIs. Refer to the Edge Function [compression guide](https://supabase.com/docs/guides/functions/compression)
```ts
import { gunzipSync } from 'node:zlib'
Deno.serve(async (req) => {
try {
// Check if the request body is gzip compressed
const contentEncoding = req.headers.get('content-encoding')
if (contentEncoding !== 'gzip') {
return new Response('Request body is not gzip compressed', {
status: 400,
})
}
// Read the compressed body
const compressedBody = await req.arrayBuffer()
// Decompress the body
const decompressedBody = gunzipSync(new Uint8Array(compressedBody))
// Convert the decompressed body to a string
const decompressedString = new TextDecoder().decode(decompressedBody)
const data = JSON.parse(decompressedString)
// Process the decompressed body as needed
console.log(`Received: ${data.length} logs.`)
return new Response('ok', {
headers: { 'Content-Type': 'text/plain' },
})
} catch (error) {
console.error('Error:', error)
return new Response('Error processing request', { status: 500 })
}
})
```
</AccordionItem>
</Accordion>
## Datadog Logs
Logs sent to Datadog have the name of the log source set on the `service` field of the event and the source set to `Supabase`. Logs are gzipped before they are sent to Datadog.
@@ -79,10 +175,6 @@ To setup Datadog log drain, generate a Datadog API key [here](https://app.datado
metadata.parsed.error_severity, metadata.level
```
[Remapper](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/remapper) for setting the log event message.
```
event_message
```
</AccordionItem>
</Accordion>
@@ -174,10 +174,9 @@ Sentry.init({
breadcrumbs: true,
errors: true,
}),
Sentry.nativeNodeFetchIntegration({
ignoreOutgoingRequests: (url) => {
console.log('server', `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`, url)
return url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
Sentry.browserTracingIntegration({
shouldCreateSpanForRequest: (url) => {
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
},
}),
],
@@ -207,10 +206,10 @@ Sentry.init({
breadcrumbs: true,
errors: true,
}),
Sentry.winterCGFetchIntegration({
Sentry.nativeNodeFetchIntegration({
breadcrumbs: true,
shouldCreateSpanForRequest: (url) => {
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
ignoreOutgoingRequests: (url) => {
return url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
},
}),
],
@@ -229,17 +228,17 @@ Sentry.init({
```js sentry.edge.config.ts
import * as Sentry from '@sentry/nextjs'
import { SupabaseClient } from '@supabase/supabase-js'
import { SupabaseIntegration } from '@supabase/sentry-js-integration'
import { supabaseIntegration } from '@supabase/sentry-js-integration'
Sentry.init({
dsn: SENTRY_DSN,
integrations: [
new SupabaseIntegration(SupabaseClient, {
supabaseIntegration(SupabaseClient, Sentry, {
tracing: true,
breadcrumbs: true,
errors: true,
}),
new Sentry.Integrations.WinterCGFetch({
Sentry.winterCGFetchIntegration({
breadcrumbs: true,
shouldCreateSpanForRequest: (url) => {
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
@@ -73,6 +73,17 @@ Go to your Supabase project's [API Settings](https://supabase.com/dashboard/proj
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
from supabase import create_client
URL = "https://<project>.supabase.co"
KEY = "<your-anon-key>"
supabase = create_client(URL, KEY)
```
</TabPanel>
</Tabs>
### Listening to broadcast messages
@@ -159,6 +170,25 @@ You can provide a callback for the `broadcast` channel to receive message. In th
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
# Join a room/topic. Can be anything except for 'realtime'.
channel_a = supabase.channel('room-1')
# Simple function to log any messages we receive
def message_received(payload):
print(f"Broadcast received: {payload}")
# Subscribe to the Channel
channel_a
.on_broadcast(
'test',
message_received
)
.subscribe()
```
</TabPanel>
</Tabs>
### Sending broadcast messages
@@ -250,6 +280,28 @@ You can provide a callback for the `broadcast` channel to receive message. In th
```
</TabPanel>
<TabPanel id="python" label="Python">
We can send Broadcast messages using `channel_b.send_broadcast()`. Let's set up another client to send messages.
```python
# Join a room/topic. Can be anything except for 'realtime'.
channel_b = supabase.channel('room-1')
def on_subscribe(status, err):
if status != RealtimeSubscribeStates.SUBSCRIBED:
return
# Send a message once the client is subscribed
channel_b.send_broadcast(
'test',
{ "message": 'hello, world' },
)
channel_b.subscribe(on_subscribe)
```
</TabPanel>
</Tabs>
Before sending messages we need to ensure the client is connected, which we have done within the `subscribe()` callback.
@@ -374,6 +426,33 @@ You can pass configuration options while initializing the Supabase Client.
```
</TabPanel>
<TabPanel id="python" label="Python">
By default, broadcast messages are only sent to other clients. You can broadcast messages back to the sender by setting Broadcast's `self` parameter to `True`.
```python
# Join a room/topic. Can be anything except for 'realtime'.
my_channel = supabase.channel('room-2', {"config": {"broadcast": {"self": True}}})
my_channel.on_broadcast(
'test-my-messages',
lambda payload: print(payload)
)
def on_subscribe(status, err):
if status != RealtimeSubscribeStates.SUBSCRIBED:
return
# Send a message once the client is subscribed
channel_b.send_broadcast(
'test-my-messages',
{ "message": 'talking to myself' },
)
my_channel.subscribe(on_subscribe)
```
</TabPanel>
</Tabs>
### Acknowledge messages
@@ -466,6 +545,9 @@ You can pass configuration options while initializing the Supabase Client.
```
</TabPanel>
<TabPanel id="python" label="Python">
Unsupported in Python yet.
</TabPanel>
</Tabs>
Use this to guarantee that the server has received the message before resolving `channelD.send`'s promise. If the `ack` config is not set to `true` when creating the channel, the promise returned by `channelD.send` will resolve immediately.
@@ -551,4 +633,7 @@ You can also send a Broadcast message by making an HTTP request to Realtime serv
```
</TabPanel>
<TabPanel id="python" label="Python">
Unsupported in Python yet.
</TabPanel>
</Tabs>
@@ -277,6 +277,18 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('schema-db-changes').on_postgres_changes(
"*",
schema="public",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -358,6 +370,18 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('schema-db-changes').on_postgres_changes(
"INSERT", # Listen only to INSERTs
schema="public",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -439,6 +463,18 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('schema-db-changes').on_postgres_changes(
"UPDATE", # Listen only to UPDATEs
schema="public",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -520,6 +556,18 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('schema-db-changes').on_postgres_changes(
"DELETE", # Listen only to DELETEs
schema="public",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -606,6 +654,19 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="todos",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -693,6 +754,23 @@ val userChanges = myChannel.postgresChangeFlow<PostgresAction.Insert>(schema = "
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"*",
schema="public",
table="messages"
callback=lambda payload: print(payload)
).on_postgres_changes(
"INSERT",
schema="public",
table="users",
callback=lambda payload: print(payload)
).subscribe()
```
</TabPanel>
</Tabs>
@@ -784,6 +862,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"INSERT",
schema="public",
table="todos",
filter="id=eq.1",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -879,6 +971,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="messages",
filter="body=eq.hey",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -973,6 +1079,20 @@ supabase.realtime.connect()
myChannel.join()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"INSERT",
schema="public",
table="messages",
filter="body=neq.bye",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1066,6 +1186,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"INSERT",
schema="public",
table="profiles",
filter="age=lt.65",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1159,6 +1293,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="profiles",
filter="age=lte.65",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1252,6 +1400,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="products",
filter="quantity=gt.10",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1345,6 +1507,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="products",
filter="quantity=gte.10",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1438,6 +1614,20 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="products",
filter="name=in.(red, blue, yellow)",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1587,6 +1777,22 @@ changes
myChannel.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
supabase.realtime.set_auth('your-custom-jwt')
changes = supabase.channel('db-changes').on_postgres_changes(
"UPDATE",
schema="public",
table="products",
filter="name=in.(red, blue, yellow)",
callback=lambda payload: print(payload)
)
.subscribe()
```
</TabPanel>
</Tabs>
@@ -1634,6 +1840,13 @@ In Kotlin, you have to update the token manually per channel:
myChannel.updateAuth("fresh-token")
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
supabase.realtime.set_auth('fresh-token')
```
</TabPanel>
</Tabs>
@@ -70,6 +70,34 @@ val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
}
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
from supabase import create_client
SUPABASE_URL = 'https://<project>.supabase.co'
SUPABASE_KEY = '<your-anon-key>'
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
```
</TabPanel>
<TabPanel id="dart" label="Dart">
```dart
void main() {
Supabase.initialize(
url: 'https://<project>.supabase.co',
anonKey: '<your-anon-key>',
);
runApp(MyApp());
}
final supabase = Supabase.instance.client;
```
</TabPanel>
</Tabs>
@@ -156,6 +184,21 @@ presenceFlow
roomOne.subscribe()
```
</TabPanel>
<TabPanel id="python" label="Python">
Listen to the `sync`, `join`, and `leave` events triggered whenever any client joins or leaves the channel or changes their slice of state:
```python
room_one = supabase.channel('room_01')
room_one
.on_presence_sync(lambda: print('sync', room_one.presenceState()))
.on_presence_join(lambda key, curr_presences, joined_presences: print('join', key, curr_presences, joined_presences))
.on_presence_leave(lambda key, curr_presences, left_presences: print('leave', key, curr_presences, left_presences))
.subscribe()
```
</TabPanel>
</Tabs>
@@ -249,6 +292,26 @@ roomOne.subscribe(blockUntilSubscribed = true) //You can also use the roomOne.st
roomOne.track(userStatus)
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
room_one = supabase.channel('room_01')
user_status = {
"user": 'user-1',
"online_at": datetime.datetime.now().isoformat(),
}
def on_subscribe(status, err):
if status != RealtimeSubscribeStates.SUBSCRIBED:
return
room_one.track(user_status)
room_one.subscribe(on_subscribe)
```
</TabPanel>
</Tabs>
@@ -308,6 +371,13 @@ suspend fun untrackPresence() {
untrackPresence()
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
room_one.untrack()
```
</TabPanel>
</Tabs>
@@ -370,5 +440,18 @@ val channelC = supabase.channel("test") {
}
```
</TabPanel>
<TabPanel id="python" label="Python">
```python
channel_c = supabase.channel('test', {
"config": {
"presence": {
"key": 'userId-123',
},
},
})
```
</TabPanel>
</Tabs>
@@ -1,6 +1,7 @@
---
title: 'Migrate from Amazon RDS to Supabase'
description: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase Postgres database.'
description: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase.'
subtitle: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase.'
---
This guide aims to exhibit the process of transferring your Amazon RDS database from any of these engines Postgres, MySQL or MS SQL to Supabase's Postgres database. Although Amazon RDS is a favored managed database service provided by AWS, it may not suffice for all use cases. Supabase, on the other hand, provides an excellent free and open source option that encompasses all the necessary backend features to develop a product: a Postgres database, authentication, instant APIs, edge functions, real-time subscriptions, and storage.
@@ -21,9 +22,7 @@ Supabase's core is Postgres, enabling the use of row-level security and providin
## Retrieve your Supabase host [#retrieve-supabase-host]
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
1. If you're new to Supabase, [create a project](https://database.new). Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`).
@@ -254,4 +254,4 @@ This isn't supported by Supabase Auth yet.
## Useful references
- [Migrating 125k users from Auth0 to Supabase](https://kevcodez.medium.com/migrating-125-000-users-from-auth0-to-supabase-81c0568de307)
- [Loper to Supabase migraton](https://eigen.sh/posts/auth-migration)
- [Loper to Supabase migration](https://eigen.sh/posts/auth-migration)
@@ -1,14 +1,14 @@
---
id: 'firebase-auth'
title: 'Firebase Auth Migration'
description: 'Migrate Firebase auth users to a Supabase project.'
sidebar_label: 'Firebase Auth'
title: 'Migrate from Firebase Auth to Supabase'
description: 'Migrate Firebase auth users to Supabase Auth.'
subtitle: 'Migrate Firebase auth users to Supabase Auth.'
---
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/auth) to help migrate auth users from a Firebase project to a Supabase project. There are two parts to the migration process:
- `firestoreusers2json` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/firestoreusers2json.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/firestoreusers2json.js)) exports users from an existing Firebase project to a `.json` file on your local system.
- `import_users` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.js)) imports users from a saved `.json` file into your Supabase project (inserting those users into the `auth.users` table of your `PostgreSQL` database instance).
- `import_users` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.js)) imports users from a saved `.json` file into your Supabase project (inserting those users into the `auth.users` table of your `Postgres` database instance).
## Set up the migration tool [#set-up-migration-tool]
@@ -69,7 +69,7 @@ hash_config {
- `filename.json`: (optional) output filename (defaults to `./users.json`)
- `batchSize`: (optional) number of users to fetch in each batch (defaults to 100)
### Import JSON users file to Supabase Auth (PostgreSQL: auth.users) [#import-json-users-file]
### Import JSON users file to Supabase Auth (Postgres: auth.users) [#import-json-users-file]
`node import_users.js <path_to_json_file> [<batch_size>]`
@@ -1,8 +1,8 @@
---
id: 'firebase-storage'
title: 'Firebase Storage Migration'
title: 'Migrated from Firebase Storage to Supabase'
description: 'Migrate Firebase Storage files to Supabase Storage.'
sidebar_label: 'Firebase Storage'
subtitle: 'Migrate Firebase Storage files to Supabase Storage.'
---
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/storage) to convert storage files from Firebase Storage to Supabase Storage. Conversion is a two-step process:
@@ -1,8 +1,8 @@
---
id: 'firestore-data'
title: 'Firestore Data Migration'
title: 'Migrated from Firebase Firestore to Supabase'
description: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
sidebar_label: 'Firestore Data'
subtitle: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
---
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/firestore) to convert data from a Firebase Firestore database to a Supabase PostgreSQL database. The process copies the entire contents of a single Firestore `collection` to a single PostgreSQL `table`.
@@ -2,8 +2,8 @@
id: 'heroku'
title: 'Migrate from Heroku to Supabase'
description: 'Migrate your Heroku Postgres database to Supabase.'
sidebar_label: 'Heroku'
video: 'https://www.youtube.com/v/xsRhPMphtZ4'
subtitle: 'Migrate your Heroku Postgres database to Supabase.'
tocVideo: 'xsRhPMphtZ4'
---
Supabase is one of the best [free alternatives to Heroku Postgres](https://supabase.com/alternatives/supabase-vs-heroku-postgres). This guide shows how to migrate your Heroku Postgres database to Supabase. This migration requires the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) CLI tools, which are installed automatically as part of the complete PostgreSQL installation package.
@@ -1,6 +1,7 @@
---
title: 'Migrate Your MSSQL Database to Supabase'
description: 'Migrate your Microsoft SQL Server database to Supabase Postgres database.'
title: 'Migrate from MSSQL to Supabase'
description: 'Migrate your Microsoft SQL Server database to Supabase.'
subtitle: 'Migrate your Microsoft SQL Server database to Supabase.'
---
This guide aims to demonstrate the process of transferring your Microsoft SQL Server database to Supabase's PostgreSQL database. Supabase is a powerful and open-source platform offering a wide range of backend features, including a PostgreSQL database, authentication, instant APIs, edge functions, real-time subscriptions, and storage. Migrating your MSSQL database to Supabase's Postgres enables you to leverage Postgres's capabilities and access all the features you need for your project.
@@ -1,6 +1,7 @@
---
title: 'Migrate Your MySQL Database to Supabase'
title: 'Migrate from MySQL to Supabase'
description: 'Migrate your MySQL database to Supabase Postgres database.'
subtitle: 'Migrate your MySQL database to Supabase Postgres database.'
---
This guide aims to exhibit the process of transferring your MySQL database to Supabase's Postgres database. Supabase is a robust and open-source platform offering a wide range of backend features, including a PostgreSQL database, authentication, instant APIs, edge functions, real-time subscriptions, and storage. Migrating your MySQL database to Supabase's PostgreSQL enables you to leverage PostgreSQL's capabilities and access all the features you need for your project.
@@ -1,6 +1,7 @@
---
title: 'Migrate from Postgres to Supabase'
description: 'Migrate your Postgres database to Supabase.'
description: 'Migrate your existing Postgres database to Supabase.'
subtitle: 'Migrate your existing Postgres database to Supabase.'
---
This is a guide for migrating your Postgres database to [Supabase](https://supabase.com).
@@ -1,6 +1,7 @@
---
title: 'Migrate from Render to Supabase'
description: 'Migrate your Render Postgres database to Supabase.'
subtitle: 'Migrate your Render Postgres database to Supabase.'
---
Render is a popular Web Hosting service in the online services category that also has a managed Postgres service. Render has a great developer experience, allowing users to deploy straight from GitHub or GitLab. This is the core of their product and they do it really well. However, when it comes to Postgres databases, it may not be the best option.
@@ -6,7 +6,7 @@ subtitle: 'Learn about using custom roles with storage schema'
sidebar_label: 'Schema'
---
In this guide, you will learn how to create and use custom roles with Storage to manage role-based access to objects and buckets.
In this guide, you will learn how to create and use custom roles with Storage to manage role-based access to objects and buckets. The same approach can be used to use custom roles with any other Supabase service.
Supabase Storage uses the same role-based access control system as any other Supabase service using RLS (Row Level Security).
+123
View File
@@ -0,0 +1,123 @@
export const REFERENCES = {
javascript: {
type: 'sdk',
name: 'JavaScript',
library: 'supabase-js',
libPath: 'javascript',
versions: ['v2', 'v1'],
typeSpec: true,
icon: 'reference-javascript',
meta: {
v2: {
libId: 'reference_javascript_v2',
specFile: 'supabase_js_v2',
},
v1: {
libId: 'reference_javascript_v1',
specFile: 'supabase_js_v1',
},
},
},
dart: {
type: 'sdk',
name: 'Flutter',
library: 'supabase-dart',
libPath: 'dart',
versions: ['v2', 'v1'],
icon: 'reference-dart',
meta: {
v2: {
libId: 'reference_dart_v2',
specFile: 'supabase_dart_v2',
},
v1: {
libId: 'reference_dart_v1',
specFile: 'supabase_dart_v1',
},
},
},
csharp: {
type: 'sdk',
name: 'C#',
library: 'supabase-csharp',
libPath: 'csharp',
versions: ['v1', 'v0'],
icon: 'reference-csharp',
meta: {
v1: {
libId: 'reference_csharp_v1',
specFile: 'supabase_csharp_v1',
},
v0: {
libId: 'reference_csharp_v0',
specFile: 'supabase_csharp_v0',
},
},
},
swift: {
type: 'sdk',
name: 'Swift',
library: 'supabase-swift',
libPath: 'swift',
versions: ['v2', 'v1'],
icon: 'reference-swift',
meta: {
v2: {
libId: 'reference_swift_v2',
specFile: 'supabase_swift_v2',
},
v1: {
libId: 'reference_swift_v1',
specFile: 'supabase_swift_v1',
},
},
},
kotlin: {
type: 'sdk',
name: 'Kotlin',
library: 'supabase-kt',
libPath: 'kotlin',
versions: ['v2', 'v1'],
icon: 'reference-kotlin',
meta: {
v2: {
libId: 'reference_kotlin_v2',
specFile: 'supabase_kt_v2',
},
v1: {
libId: 'reference_kotlin_v1',
specFile: 'supabase_kt_v1',
},
},
},
python: {
type: 'sdk',
name: 'Python',
library: 'supabase-py',
libPath: 'python',
versions: ['v2'],
icon: 'reference-python',
meta: {
v2: {
libId: 'reference_python_v2',
specFile: 'supabase_py_v2',
},
},
},
cli: {
type: 'cli',
name: 'CLI',
versions: [],
icon: 'reference-cli',
},
api: {
type: 'api',
name: 'API',
versions: [],
icon: 'reference-api',
},
} as const
export const clientSdkIds = Object.keys(REFERENCES).filter(
(reference) => REFERENCES[reference].type === 'sdk'
)
+5 -25
View File
@@ -1,29 +1,9 @@
---
id: introduction
title: Introduction
hideTitle: true
---
This reference documents every object and method available in Supabase's C# library, [supabase](https://www.nuget.org/packages/supabase). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuCsharp width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">C# Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">
@supabase-community/supabase-csharp
</h2>
</div>
</div>
<Admonition type="note">
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's C# library, [supabase](https://www.nuget.org/packages/supabase). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
</div>
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
{/* prettier-ignore */}
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
</div>
</Admonition>
+5 -25
View File
@@ -1,29 +1,9 @@
---
id: introduction
title: Introduction
hideTitle: true
---
This reference documents every object and method available in Supabase's C# library, [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuCsharp width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">C# Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">
@supabase-community/supabase-csharp
</h2>
</div>
</div>
<Admonition type="note">
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's C# library, [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
</div>
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
{/* prettier-ignore */}
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
</div>
</Admonition>
@@ -0,0 +1,12 @@
Supabase Auth can throw or return various errors when using the API. All errors originating from the `supabase.auth` namespace of the client library will be wrapped by the `AuthException` class.
Error objects are split in a few classes. `AuthApiException` is an exception which originates from the Supabase Auth API.
Errors originating from the server API classed as `AuthApiException` always have a `code` property that can be used to identify the error returned by the server. The `statusCode` property is also present, encoding the HTTP status code received in the response.
<AuthErrorCodesTable />
### Tips for better error handling
- Do not use string matching on error messages! Always use the error classes and `code` properties of error objects to identify the situation.
- Although HTTP status codes generally don't change, they can suddenly change due to bugs, so avoid relying on them unless absolutely necessary.
+2 -19
View File
@@ -1,20 +1,3 @@
---
id: introduction
title: Introduction
hideTitle: true
---
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuFlutter width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
</div>
</div>
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
</div>
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
+2 -23
View File
@@ -1,24 +1,3 @@
---
id: introduction
title: Introduction
hideTitle: true
---
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuFlutter width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
</div>
</div>
<div className="max-w-xl">
<Admonition type="caution">
You're viewing the docs for an older version of the `supabase-flutter` library. Learn how to [upgrade to the latest version](/docs/reference/dart/v0/upgrade-guide).
</Admonition>
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
</div>
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
+2 -19
View File
@@ -1,20 +1,3 @@
---
id: introduction
title: Introduction
hideTitle: true
---
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuFlutter width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
</div>
</div>
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
</div>
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
+1 -12
View File
@@ -4,15 +4,4 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuJavascript width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">JavaScript Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-js</h2>
</div>
</div>
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
</div>
This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
@@ -1,25 +1 @@
---
id: introduction
title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuJavascript width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">JavaScript Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-js</h2>
</div>
</div>
<div className="max-w-xl">
<Admonition type="caution">
You're viewing the docs for an older version of the `supabase-js` library. Learn how to [upgrade to the latest version](/docs/reference/javascript/v1/upgrade-guide).
</Admonition>
<p>
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
</p>
</div>
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
@@ -1,6 +0,0 @@
---
id: release-notes
title: Release Notes
---
## js v1 this is the release notes file.
+2 -19
View File
@@ -1,30 +1,13 @@
---
id: introduction
title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuKotlin width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Kotlin Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">@supabase-community/supabase-kt</h2>
</div>
</div>
<div className="max-w-xl">
This reference documents every object and method available in Supabase's Kotlin Multiplatform library, [supabase-kt](https://github.com/supabase-community/supabase-kt). You can use supabase-kt to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
To see supported Kotlin targets, check the corresponding module README on [GitHub](https://github.com/supabase-community/supabase-kt).
To migrate from version 1.4.X to 2.0.0, see the [migration guide](https://github.com/supabase-community/supabase-kt/blob/master/MIGRATION.md)
</div>
<Admonition type="note">
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
Huge thanks to official maintainer, [jan-tennert](https://github.com/jan-tennert).
</div>
</Admonition>
+393
View File
@@ -0,0 +1,393 @@
---
id: installing
title: 'Installing & Initialization'
slug: installing
custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supabase.yml
---
### Add one or more modules to your project
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<a href="https://github.com/supabase-community/supabase-kt/releases" style={{ marginRight: '8px' }}>
<img src="https://img.shields.io/github/release/supabase-community/supabase-kt?label=stable" alt="latest stable supabase-kt version"/>
</a>
<a href="https://github.com/supabase-community/supabase-kt/releases">
<img src="https://img.shields.io/maven-central/v/io.github.jan-tennert.supabase/supabase-kt?label=experimental" alt="latest supabase-kt version"/>
</a>
</div>
Add dependency to your build file using the BOM.
The available modules are:
- [**gotrue-kt**](https://github.com/supabase-community/supabase-kt/tree/master/GoTrue)
- [**realtime-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Realtime)
- [**storage-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Storage)
- [**functions-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Functions)
- [**postgrest-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Postgrest)
- Other plugins also available [here](https://github.com/supabase-community/supabase-kt/tree/master/plugins)
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
implementation(platform("io.github.jan-tennert.supabase:bom:VERSION"))
implementation("io.github.jan-tennert.supabase:postgrest-kt")
implementation("io.github.jan-tennert.supabase:gotrue-kt")
implementation("io.github.jan-tennert.supabase:realtime-kt")
```
</TabPanel>
<TabPanel id="groovy" label="build.gradle">
```groovy
implementation platform("io.github.jan-tennert.supabase:bom:VERSION")
implementation 'io.github.jan-tennert.supabase:postgrest-kt'
implementation 'io.github.jan-tennert.supabase:gotrue-kt'
implementation 'io.github.jan-tennert.supabase:realtime-kt'
```
</TabPanel>
<TabPanel id="xml" label="pom.xml">
```xml
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>bom</artifactId>
<version>VERSION</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>postgrest-kt</artifactId>
</dependency>
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>gotrue-kt</artifactId>
</dependency>
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>realtime-kt</artifactId>
</dependency>
```
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
### Add Ktor Client Engine to each of your Kotlin targets (required)
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
You can find a list of engines [here](https://ktor.io/docs/http-client-engines.html)
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
implementation("io.ktor:ktor-client-[engine]:KTOR_VERSION")
```
</TabPanel>
<TabPanel id="groovy" label="build.gradle">
```groovy
implementation 'io.ktor:ktor-client-[engine]:KTOR_VERSION'
```
</TabPanel>
<TabPanel id="xml" label="pom.xml">
```xml
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-[engine]</artifactId>
<version>KTOR_VERSION</version>
</dependency>
```
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
<RefSubLayout.Details>
Multiplatform example:
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
val commonMain by getting {
dependencies {
//supabase modules
}
}
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-client-cio:KTOR_VERSION")
}
}
val androidMain by getting {
dependsOn(jvmMain)
}
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:KTOR_VERSION")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:KTOR_VERSION")
}
}
```
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
### Serialization
supabase-kt provides several different ways to encode and decode your custom objects.
By default, [KotlinX Serialization](https://github.com/Kotlin/kotlinx.serialization) is used.
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
Use [KotlinX Serialization](https://github.com/Kotlin/kotlinx.serialization).
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
plugins {
kotlin("plugin.serialization") version "KOTLIN_VERSION"
}
```
</TabPanel>
<TabPanel id="groovy" label="build.gradle">
```groovy
plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version 'KOTLIN_VERSION'
}
```
</TabPanel>
<TabPanel id="xml" label="pom.xml">
```xml
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
```
</TabPanel>
</Tabs>
```kotlin
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
//Already the default serializer, but you can provide a custom Json instance (optional):
defaultSerializer = KotlinXSerializer(Json {
//apply your custom config
})
}
```
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
Use [Moshi](https://github.com/square/moshi).
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
implementation("io.github.jan-tennert.supabase:serializer-moshi:VERSION")
```
</TabPanel>
<TabPanel id="groovy" label="build.gradle">
```groovy
implementation 'io.github.jan-tennert.supabase:serializer-moshi:VERSION'
```
</TabPanel>
<TabPanel id="xml" label="pom.xml">
```xml
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>serializer-moshi</artifactId>
<version>VERSION</version>
</dependency>
```
</TabPanel>
</Tabs>
```kotlin
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
defaultSerializer = MoshiSerializer()
}
```
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
Use [Jackson](https://github.com/FasterXML/jackson-module-kotlin).
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="kotlin"
queryGroup="build-file"
>
<TabPanel id="kotlin" label="build.gradle.kts">
```kotlin
implementation("io.github.jan-tennert.supabase:serializer-jackson:VERSION")
```
</TabPanel>
<TabPanel id="groovy" label="build.gradle">
```groovy
implementation 'io.github.jan-tennert.supabase:serializer-jackson:VERSION'
```
</TabPanel>
<TabPanel id="xml" label="pom.xml">
```xml
<dependency>
<groupId>io.github.jan-tennert.supabase</groupId>
<artifactId>serializer-jackson</artifactId>
<version>VERSION</version>
</dependency>
```
</TabPanel>
</Tabs>
```kotlin
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
defaultSerializer = JacksonSerializer()
}
```
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
Use custom serializer.
</RefSubLayout.Details>
<RefSubLayout.Examples>
```kotlin
class CustomSerializer: SupabaseSerializer {
override fun <T : Any> encode(type: KType, value: T): String {
//encode value to string
}
override fun <T : Any> decode(type: KType, value: String): T {
//decode value
}
}
```
```kotlin
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
defaultSerializer = CustomSerializer()
}
```
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
@@ -0,0 +1,13 @@
This reference documents every object and method available in Supabase's Kotlin Multiplatform library, [supabase-kt](https://github.com/supabase-community/supabase-kt). You can use supabase-kt to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
To see supported Kotlin targets, check the corresponding module README on [GitHub](https://github.com/supabase-community/supabase-kt).
To migrate from version 1.4.X to 2.0.0, see the [migration guide](https://github.com/supabase-community/supabase-kt/blob/master/MIGRATION.md)
<Admonition type="note">
The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
Huge thanks to official maintainer, [jan-tennert](https://github.com/jan-tennert).
</Admonition>
+1 -18
View File
@@ -1,18 +1 @@
---
id: introduction
title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuPython width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Python Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-py</h2>
</div>
</div>
<div className="max-w-xl">
{/* prettier-ignore */}
<p>This reference documents every object and method available in Supabase's Python library, [supabase-py](https://github.com/supabase/supabase-py). You can use supabase-py to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.</p>
</div>
This reference documents every object and method available in Supabase's Python library, [supabase-py](https://github.com/supabase/supabase-py). You can use supabase-py to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
+1 -20
View File
@@ -1,20 +1 @@
---
id: introduction
title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<IconMenuSwift width={35} height={35} />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-foreground m-0">Swift Client Library</h1>
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-swift</h2>
</div>
</div>
{/* prettier-ignore */}
<div className="max-w-xl">
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
</div>
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
@@ -0,0 +1,63 @@
---
id: installing
title: 'Installing'
slug: installing
custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supabase.yml
---
### Install using Swift Package Manager
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
You can install Supabase package using Swift Package Manager.
The package exposes multiple libraries, you can choose between adding all of them using Supabase, or some of:
- `Auth`
- `Realtime`
- `Postgrest`
- `Functions`
- `Storage`
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
size="small"
type="underlined"
defaultActiveId="swift"
queryGroup="framework"
>
<TabPanel id="swift" label="Package.swift">
```swift
let package = Package(
...
dependencies: [
...
.package(
url: "https://github.com/supabase/supabase-swift.git",
from: "2.0.0"
),
],
targets: [
.target(
name: "YourTargetName",
dependencies: [
.product(
name: "Supabase", // Auth, Realtime, Postgrest, Functions, or Storage
package: "supabase-swift"
),
]
)
]
)
```
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
@@ -0,0 +1 @@
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
+19 -22
View File
@@ -8,7 +8,6 @@ import SiteLayout from '~/layouts/SiteLayout'
import { AuthContainer } from './auth/auth.client'
import { DocsCommandMenu } from './command'
import { QueryClientProvider } from './data/queryClient.client'
import { ShortcutPreviewBuild } from './envs/staging.client'
import { PageTelemetry } from './telemetry/telemetry.client'
import { ScrollRestoration } from './ui/helpers.scroll.client'
import { ThemeSandbox } from './ui/theme.client'
@@ -18,27 +17,25 @@ import { ThemeSandbox } from './ui/theme.client'
*/
function GlobalProviders({ children }: PropsWithChildren) {
return (
<ShortcutPreviewBuild>
<QueryClientProvider>
<AuthContainer>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
<PortalToast />
<PromoToast />
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</CommandProvider>
</ThemeProvider>
</AuthContainer>
</QueryClientProvider>
</ShortcutPreviewBuild>
<QueryClientProvider>
<AuthContainer>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
<PortalToast />
<PromoToast />
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</CommandProvider>
</ThemeProvider>
</AuthContainer>
</QueryClientProvider>
)
}
+8 -10
View File
@@ -4,10 +4,12 @@ import { redirect } from 'next/navigation'
import { readFile, readdir } from 'node:fs/promises'
import { extname, join, sep } from 'node:path'
import { pluckPromise } from '~/features/helpers.fn'
import { cache_fullProcess_withDevCacheBust, existsFile } from '~/features/helpers.fs'
import type { OrPromise } from '~/features/helpers.types'
import { notFoundLink } from '~/features/recommendations/NotFound.utils'
import { BASE_PATH, MISC_URL } from '~/lib/constants'
import { generateOpenGraphImageMeta } from '~/features/seo/openGraph'
import { BASE_PATH } from '~/lib/constants'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter, type GuideFrontmatter } from '~/lib/docs'
import { newEditLink } from './GuidesMdx.template'
@@ -108,9 +110,6 @@ const genGuidesStaticParams = (directory?: string) => async () => {
return result
}
const pluckPromise = <T, K extends keyof T>(promise: Promise<T>, key: K) =>
promise.then((data) => data[key])
const genGuideMeta =
<Params,>(
generate: (params: Params) => OrPromise<{ meta: GuideFrontmatter; pathname: `/${string}` }>
@@ -136,12 +135,11 @@ const genGuideMeta =
openGraph: {
...parentOg,
url: `${BASE_PATH}${pathname}`,
images: {
url: `${MISC_URL}/functions/v1/og-images?site=docs&type=${encodeURIComponent(ogType)}&title=${encodeURIComponent(meta.title)}&description=${encodeURIComponent(meta.description ?? 'undefined')}`,
width: 800,
height: 600,
alt: meta.title,
},
images: generateOpenGraphImageMeta({
type: ogType,
title: meta.title,
description: meta.description,
}),
},
}
}
@@ -13,6 +13,7 @@ import ButtonCard from '~/components/ButtonCard'
import { Extensions } from '~/components/Extensions'
import { JwtGenerator } from '~/components/JwtGenerator'
import {
AuthErrorCodesTable,
AuthRateLimits,
CreateClientSnippet,
DatabaseSetup,
@@ -41,6 +42,7 @@ const components = {
Accordion,
AccordionItem,
Admonition,
AuthErrorCodesTable,
AuthRateLimits,
AuthSmsProviderConfig,
AppleSecretGenerator,
@@ -0,0 +1,136 @@
import { keyBy, isPlainObject } from 'lodash'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from 'yaml'
import { REFERENCES, clientSdkIds } from '~/content/navigation.references'
import { parseTypeSpec } from '~/features/docs/Reference.typeSpec'
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
import { deepFilterRec } from '~/features/helpers.fn'
import type { Json } from '~/features/helpers.types'
import commonClientLibSections from '~/spec/common-client-libs-sections.json' assert { type: 'json' }
const DOCS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../..')
const SPEC_DIRECTORY = join(DOCS_DIRECTORY, 'spec')
const GENERATED_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), 'generated')
async function getSpec(specFile: string, { ext = 'yml' }: { ext?: string } = {}) {
const specFullPath = join(SPEC_DIRECTORY, `${specFile}.${ext}`)
const rawSpec = await readFile(specFullPath, 'utf-8')
return ext === 'yml' ? parse(rawSpec) : rawSpec
}
async function parseFnsList(rawSpec: Json): Promise<Array<{ id: unknown }>> {
if (isPlainObject(rawSpec) && 'functions' in (rawSpec as object)) {
const _rawSpec = rawSpec as { functions: unknown }
if (Array.isArray(_rawSpec.functions)) {
return _rawSpec.functions.filter(({ id }) => !!id)
}
}
return []
}
function genClientSdkSectionTree(fns: Array<{ id: unknown }>, excludeName: string) {
const validSections = deepFilterRec(
commonClientLibSections as Array<AbbrevCommonClientLibSection>,
'items',
(section) =>
section.type === 'markdown' || section.type === 'category'
? !('excludes' in section && section.excludes.includes(excludeName))
: section.type === 'function'
? fns.some(({ id }) => section.id === id)
: true
)
return validSections
}
export function flattenCommonClientLibSections(tree: Array<AbbrevCommonClientLibSection>) {
return tree.reduce((acc, elem) => {
if ('items' in elem) {
const prunedElem = { ...elem }
delete prunedElem.items
acc.push(prunedElem)
acc.push(...flattenCommonClientLibSections(elem.items))
} else {
acc.push(elem)
}
return acc
}, [] as Array<AbbrevCommonClientLibSection>)
}
async function writeTypes() {
const types = await parseTypeSpec()
await writeFile(
join(GENERATED_DIRECTORY, 'typeSpec.json'),
JSON.stringify(types, (key, value) => {
if (key === 'methods') {
return Object.fromEntries(value.entries())
} else {
return value
}
})
)
}
async function writeReferenceSections() {
return Promise.all(
clientSdkIds
.flatMap((sdkId) => {
const versions = REFERENCES[sdkId].versions
return versions.map((version) => ({
sdkId,
version,
}))
})
.flatMap(async ({ sdkId, version }) => {
const spec = await getSpec(REFERENCES[sdkId].meta[version].specFile)
const fnsList = await parseFnsList(spec)
const pendingFnListWrite = writeFile(
join(GENERATED_DIRECTORY, `${sdkId}.${version}.functions.json`),
JSON.stringify(fnsList)
)
const sectionTree = genClientSdkSectionTree(fnsList, REFERENCES[sdkId].meta[version].libId)
const pendingSectionTreeWrite = writeFile(
join(GENERATED_DIRECTORY, `${sdkId}.${version}.sections.json`),
JSON.stringify(sectionTree)
)
const flattened = flattenCommonClientLibSections(sectionTree)
const pendingFlattenedWrite = writeFile(
join(GENERATED_DIRECTORY, `${sdkId}.${version}.flat.json`),
JSON.stringify(flattened)
)
const sectionsBySlug = keyBy(flattened, (section) => section.slug)
const pendingSlugDictionaryWrite = writeFile(
join(GENERATED_DIRECTORY, `${sdkId}.${version}.bySlug.json`),
JSON.stringify(sectionsBySlug)
)
return [
pendingFnListWrite,
pendingSectionTreeWrite,
pendingFlattenedWrite,
pendingSlugDictionaryWrite,
]
})
)
}
async function run() {
try {
await mkdir(GENERATED_DIRECTORY, { recursive: true })
await Promise.all([writeTypes(), writeReferenceSections()])
} catch (err) {
console.error(err)
}
}
run()
@@ -0,0 +1,100 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ModuleTypes } from '~/features/docs/Reference.typeSpec'
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
let typeSpec: Array<ModuleTypes>
async function _typeSpecSingleton() {
if (!typeSpec) {
const rawJson = await readFile(
join(process.cwd(), 'features/docs', './generated/typeSpec.json'),
'utf-8'
)
typeSpec = JSON.parse(rawJson, (key, value) => {
if (key === 'methods') {
return new Map(Object.entries(value))
} else {
return value
}
})
}
return typeSpec
}
export async function getTypeSpec(ref: string) {
const modules = await _typeSpecSingleton()
const delimiter = ref.indexOf('.')
const refMod = ref.substring(0, delimiter)
const mod = modules.find((mod) => mod.name === refMod)
return mod?.methods.get(ref)
}
const functionsList = new Map<string, Array<{ id: unknown }>>()
export async function getFunctionsList(sdkId: string, version: string) {
const key = `${sdkId}.${version}`
if (!functionsList.has(key)) {
const data = await readFile(
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.functions.json`),
'utf-8'
)
functionsList.set(key, JSON.parse(data))
}
return functionsList.get(key)
}
const referenceSections = new Map<string, Array<AbbrevCommonClientLibSection>>()
export async function getReferenceSections(sdkId: string, version: string) {
const key = `${sdkId}.${version}`
if (!referenceSections.has(key)) {
const data = await readFile(
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.sections.json`),
'utf-8'
)
referenceSections.set(key, JSON.parse(data))
}
return referenceSections.get(key)
}
const flatSections = new Map<string, Array<AbbrevCommonClientLibSection>>()
export async function getFlattenedSections(sdkId: string, version: string) {
const key = `${sdkId}.${version}`
if (!flatSections.has(key)) {
const data = await readFile(
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.flat.json`),
'utf-8'
)
flatSections.set(key, JSON.parse(data))
}
return flatSections.get(key)
}
const sectionsBySlug = new Map<string, Map<string, AbbrevCommonClientLibSection>>()
export async function getSectionsBySlug(sdkId: string, version: string) {
const key = `${sdkId}.${version}`
if (!sectionsBySlug.has(key)) {
const data = await readFile(
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.bySlug.json`),
'utf-8'
)
const asObject = JSON.parse(data)
sectionsBySlug.set(key, new Map(Object.entries(asObject)))
}
return sectionsBySlug.get(key)
}
@@ -0,0 +1,54 @@
import { Github } from 'lucide-react'
import Link from 'next/link'
import { cn } from 'ui'
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
interface ClientLibHeaderProps {
menuData: {
title: string
icon?: string
pkg: {
name: string
repo: string
}
}
className?: string
}
function ClientLibHeader({ menuData, className }: ClientLibHeaderProps) {
return (
<div className={cn('flex items-start gap-6', className)}>
{'icon' in menuData && (
<MenuIconPicker
icon={menuData.icon}
width={35}
height={35}
className="text-foreground-light"
/>
)}
<div className="flex flex-col gap-2">
<h1 id="introduction" className="text-3xl text-foreground">
{menuData.title} Client Library
</h1>
<span
className={cn('text-base font-mono text-foreground-light', 'flex items-center gap-2')}
>
{menuData.pkg.name}
<Link
href={menuData.pkg.repo}
target="_blank"
rel="noreferrer noopener"
className="hover:text-brand focus-visible:text-brand transition-colors"
>
<span className="sr-only">View on GitHub</span>
<Github size={18} />
</Link>
</span>
</div>
</div>
)
}
export { ClientLibHeader }
@@ -0,0 +1,67 @@
import { AlertTriangle } from 'lucide-react'
import Link from 'next/link'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, cn } from 'ui'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { getRefMarkdown } from '~/features/docs/Reference.mdx'
import { ReferenceSectionWrapper } from '~/features/docs/Reference.ui.client'
import commonClientLibSections from '~/spec/common-client-libs-sections.json' assert { type: 'json' }
function hasIntro(sections: typeof commonClientLibSections, excludeName: string) {
return Boolean(
sections[0]?.type === 'markdown' &&
sections[0]?.slug === 'introduction' &&
!(
'excludes' in sections[0] &&
Array.isArray(sections[0].excludes) &&
sections[0].excludes?.includes(excludeName)
)
)
}
interface ClientLibIntroductionProps {
libPath: string
excludeName: string
version: string
isLatestVersion: boolean
}
export async function ClientLibIntroduction({
libPath,
excludeName,
version,
isLatestVersion,
}: ClientLibIntroductionProps) {
if (!hasIntro(commonClientLibSections, excludeName)) return null
const content = await getRefMarkdown(
`${libPath}/${isLatestVersion ? '' : `${version}/`}introduction`
)
return (
<ReferenceSectionWrapper
id="introduction"
link={`/docs/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}introduction`}
className="prose"
>
<MDXRemoteBase source={content} />
</ReferenceSectionWrapper>
)
}
export function OldVersionAlert({ libPath, className }: { libPath: string; className?: string }) {
return (
<Alert_Shadcn_ variant="warning" className={cn('not-prose', className)}>
<AlertTriangle />
<AlertTitle_Shadcn_ className="font-medium">Version out of date</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
There&apos;s a newer version of this library! Migrate to the{' '}
<Link href={`/reference/${libPath}`} className="underline underline-offset-2">
newest version
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
@@ -0,0 +1,20 @@
'use client'
/**
* The MDXProvider is necessary so that MDX partials will have access
* to components.
*
* Since the reference pages are so heavy, keep the weight down by only
* including the bare minimum.
*/
import { MDXProvider } from '@mdx-js/react'
import { type PropsWithChildren } from 'react'
import { Admonition } from 'ui'
const components = { Admonition }
export function MDXProviderReference({ children }: PropsWithChildren) {
return <MDXProvider components={components}>{children}</MDXProvider>
}
+49
View File
@@ -0,0 +1,49 @@
import matter from 'gray-matter'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { components } from '~/features/docs/MdxBase.shared'
import { RefSubLayout } from '~/features/docs/Reference.ui'
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
import { REF_DOCS_DIRECTORY } from '~/lib/docs'
async function getRefMarkdownInternal(relPath: string) {
const fullPath = join(REF_DOCS_DIRECTORY, relPath + '.mdx')
try {
if (!fullPath.startsWith(REF_DOCS_DIRECTORY)) {
throw Error(`Accessing forbidden path outside of REF_DOCS_DIRECTORY: ${fullPath}`)
}
const mdx = await readFile(fullPath, 'utf-8')
const { content } = matter(mdx)
return content
} catch (err) {
console.error(`Error fetching reference markdown from file: ${fullPath}:\n\n${err}`)
return ''
}
}
/**
* Caching this for the entire process is fine because the Markdown content is
* baked into each deployment and cannot change. There's also nothing sensitive
* here: this is just reading the public MDX files from the codebase.
*/
const getRefMarkdown = cache_fullProcess_withDevCacheBust(
getRefMarkdownInternal,
REF_DOCS_DIRECTORY,
(filename: string) => JSON.stringify([filename.replace(/\.mdx$/, '')])
)
interface MDXRemoteRefsProps {
source: string
}
function MDXRemoteRefs({ source }: MDXRemoteRefsProps) {
const refComponents = { ...components, RefSubLayout }
return <MDXRemoteBase source={source} components={refComponents} />
}
export { getRefMarkdown, MDXRemoteRefs }
@@ -0,0 +1,240 @@
'use client'
import * as Collapsible from '@radix-ui/react-collapsible'
import { debounce } from 'lodash'
import { ChevronUp } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from 'ui'
import { BASE_PATH } from '~/lib/constants'
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.navigation'
import { isElementInViewport } from '~/features/ui/helpers.dom'
export const ReferenceContentInitiallyScrolledContext = createContext<boolean>(false)
export function ReferenceContentScrollHandler({
libPath,
version,
isLatestVersion,
children,
}: PropsWithChildren<{
libPath: string
version: string
isLatestVersion: boolean
}>) {
const checkedPathnameOnLoad = useRef(false)
const [initiallyScrolled, setInitiallyScrolled] = useState(false)
const pathname = usePathname()
useEffect(() => {
if (!checkedPathnameOnLoad.current) {
const initialSelectedSection = pathname.replace(
`/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}`,
''
)
if (initialSelectedSection) {
const section = document.getElementById(initialSelectedSection)
section?.scrollIntoView()
section?.querySelector('h2')?.focus()
}
checkedPathnameOnLoad.current = true
setInitiallyScrolled(true)
}
}, [pathname, libPath, version, isLatestVersion])
return (
<ReferenceContentInitiallyScrolledContext.Provider value={initiallyScrolled}>
{children}
</ReferenceContentInitiallyScrolledContext.Provider>
)
}
export function ReferenceNavigationScrollHandler({
children,
...rest
}: PropsWithChildren & HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLDivElement>()
const initialScrollHappened = useContext(ReferenceContentInitiallyScrolledContext)
const scrollActiveIntoView = useCallback(() => {
const currentLink = ref.current?.querySelector('[aria-current=page]') as HTMLElement
if (currentLink && !isElementInViewport(currentLink)) {
currentLink.scrollIntoView({
block: 'center',
})
}
}, [])
useEffect(() => {
if (initialScrollHappened) {
scrollActiveIntoView()
}
}, [initialScrollHappened, scrollActiveIntoView])
useEffect(() => {
const debouncedScrollActiveIntoView = debounce(scrollActiveIntoView, 150)
window.addEventListener('scrollend', debouncedScrollActiveIntoView)
return () => window.removeEventListener('scrollend', debouncedScrollActiveIntoView)
}, [scrollActiveIntoView])
return (
<div ref={ref} {...rest}>
{children}
</div>
)
}
function deriveHref(basePath: string, section: AbbrevCommonClientLibSection) {
return 'slug' in section ? `${basePath}/${section.slug}` : ''
}
function getLinkStyles(isActive: boolean, className?: string) {
return cn(
'text-sm text-foreground-lighter',
!isActive && 'hover:text-foreground',
isActive && 'text-brand',
'transition-colors',
className
)
}
export function RefLink({
basePath,
section,
skipChildren = false,
className,
}: {
basePath: string
section: AbbrevCommonClientLibSection
skipChildren?: boolean
className?: string
}) {
const ref = useRef<HTMLAnchorElement>()
const pathname = usePathname()
const href = deriveHref(basePath, section)
const isActive =
pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction')
if (!('title' in section)) return null
const isCompoundSection = !skipChildren && 'items' in section && section.items.length > 0
return (
<>
{isCompoundSection ? (
<CompoundRefLink basePath={basePath} section={section} />
) : (
<Link
ref={ref}
href={href}
aria-current={isActive ? 'page' : false}
className={getLinkStyles(isActive, className)}
onClick={(evt: MouseEvent) => {
/*
* We don't actually want to navigate or rerender anything since
* links are all to sections on the same page.
*/
evt.preventDefault()
history.pushState({}, '', `${BASE_PATH}${href}`)
if ('slug' in section) {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const domElement = document.getElementById(section.slug)
domElement?.scrollIntoView({
behavior: reduceMotion ? 'auto' : 'smooth',
})
domElement?.querySelector('h2')?.focus()
}
}}
>
{section.title}
</Link>
)}
</>
)
}
function useCompoundRefLinkActive(basePath: string, section: AbbrevCommonClientLibSection) {
const [open, _setOpen] = useState(false)
const pathname = usePathname()
const parentHref = deriveHref(basePath, section)
const isParentActive = pathname === parentHref
const childHrefs = useMemo(
() => new Set(section.items.map((item) => deriveHref(basePath, item))),
[basePath, section]
)
const isChildActive = childHrefs.has(pathname)
const isActive = isParentActive || isChildActive
const setOpen = (open: boolean) => {
// Disable closing if the section is active, to prevent the currently active
// link disappearing
if (open || !isActive) _setOpen(open)
}
if (isActive && !open) {
setOpen(true)
}
return { open, setOpen, isActive }
}
function CompoundRefLink({
basePath,
section,
}: {
basePath: string
section: AbbrevCommonClientLibSection
}) {
const { open, setOpen, isActive } = useCompoundRefLinkActive(basePath, section)
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild disabled={isActive}>
<button
className={cn(
'group',
'cursor-pointer',
'w-full',
'flex items-center justify-between gap-2'
)}
>
<span className={getLinkStyles(false)}>{section.title}</span>
<ChevronUp
width={16}
className={cn(
'group-disabled:cursor-not-allowed group-disabled:opacity-10',
'data-open-parent:rotate-0 data-closed-parent:rotate-90',
'transition'
)}
/>
</button>
</Collapsible.Trigger>
<Collapsible.Content
className={cn('border-l border-control pl-3 ml-1 data-open:mt-2 grid gap-2.5')}
>
<ul className="space-y-2">
<RefLink basePath={basePath} section={section} skipChildren />
{section.items.map((item, idx) => {
return (
<li key={`${section.id}-${idx}`}>
<RefLink basePath={basePath} section={item} />
</li>
)
})}
</ul>
</Collapsible.Content>
</Collapsible.Root>
)
}
@@ -0,0 +1,105 @@
import { Fragment, type PropsWithChildren } from 'react'
import { cn } from 'ui'
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
import RefVersionDropdown from '~/components/RefVersionDropdown'
import { getReferenceSections } from '~/features/docs/Reference.generated.singleton'
import {
RefLink,
ReferenceNavigationScrollHandler,
} from '~/features/docs/Reference.navigation.client'
import { type AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
interface ClientSdkNavigationProps {
sdkId: string
name: string
menuData: { icon?: string }
libPath: string
version: string
isLatestVersion: boolean
}
async function ClientSdkNavigation({
sdkId,
name,
menuData,
libPath,
version,
isLatestVersion,
}: ClientSdkNavigationProps) {
const navSections = await getReferenceSections(sdkId, version)
const basePath = `/reference/${libPath}${isLatestVersion ? '' : `/${version}`}`
return (
<ReferenceNavigationScrollHandler className="w-full flex flex-col pt-3 pb-5 gap-3">
<div className="flex items-center gap-3">
{'icon' in menuData && <MenuIconPicker icon={menuData.icon} width={21} height={21} />}
<span className="text-base text-brand-600">{name}</span>
<RefVersionDropdown library={libPath} currentVersion={version} />
</div>
<ul className="flex flex-col gap-2">
{navSections.map((section) => (
<Fragment key={section.id}>
{section.type === 'category' ? (
<li>
<RefCategory basePath={basePath} section={section} />
</li>
) : (
<li className={topLvlRefNavItemStyles}>
<RefLink basePath={basePath} section={section} />
</li>
)}
</Fragment>
))}
</ul>
</ReferenceNavigationScrollHandler>
)
}
const topLvlRefNavItemStyles = 'leading-5'
function RefCategory({
basePath,
section,
}: {
basePath: string
section: AbbrevCommonClientLibSection
}) {
if (!('items' in section && section.items.length > 0)) return null
return (
<>
<Divider />
{'title' in section && <SideMenuTitle className="py-2">{section.title}</SideMenuTitle>}
<ul className="space-y-2">
{section.items.map((item) => (
<li key={item.id} className={topLvlRefNavItemStyles}>
<RefLink basePath={basePath} section={item} />
</li>
))}
</ul>
</>
)
}
function Divider() {
return <hr className="w-full h-px my-3 bg-control" />
}
function SideMenuTitle({ children, className }: PropsWithChildren<{ className?: string }>) {
return (
<div
className={cn(
'font-mono font-medium text-xs text-foreground tracking-wider uppercase',
className
)}
>
{children}
</div>
)
}
export { ClientSdkNavigation }
export type { AbbrevCommonClientLibSection }
@@ -0,0 +1,64 @@
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { REFERENCES } from '~/content/navigation.references'
import { ClientLibHeader } from '~/features/docs/Reference.header'
import { ClientLibIntroduction, OldVersionAlert } from '~/features/docs/Reference.introduction'
import { ClientSdkNavigation } from '~/features/docs/Reference.navigation'
import { ReferenceContentScrollHandler } from '~/features/docs/Reference.navigation.client'
import { ClientLibRefSections } from '~/features/docs/Reference.sections'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
interface ClientSdkReferenceProps {
sdkId: string
libVersion: string
}
export async function ClientSdkReferencePage({ sdkId, libVersion }: ClientSdkReferenceProps) {
const libraryMeta = REFERENCES[sdkId]
const versions = libraryMeta?.versions ?? []
const isLatestVersion = libVersion === versions[0]
const menuData = NavItems[libraryMeta.meta[libVersion].libId]
return (
<ReferenceContentScrollHandler
libPath={libraryMeta.libPath}
version={libVersion}
isLatestVersion={isLatestVersion}
>
<SidebarSkeleton
menuId={MenuId.RefJavaScriptV2}
NavigationMenu={
<ClientSdkNavigation
sdkId={sdkId}
name={menuData.title}
menuData={menuData}
libPath={libraryMeta.libPath}
version={libVersion}
isLatestVersion={isLatestVersion}
/>
}
>
<LayoutMainContent>
{!isLatestVersion && (
<OldVersionAlert
libPath={libraryMeta.libPath}
className="z-10 fixed top-[calc(var(--header-height)+1rem)] right-4 w-84 max-w-[calc(100vw-2rem)]"
/>
)}
<article className="@container/article">
<ClientLibHeader menuData={menuData} className="mt-4 mb-8" />
<ClientLibIntroduction
libPath={libraryMeta.libPath}
excludeName={libraryMeta.meta[libVersion].libId}
version={libVersion}
isLatestVersion={isLatestVersion}
/>
<ClientLibRefSections sdkId={sdkId} version={libVersion} />
</article>
</LayoutMainContent>
</SidebarSkeleton>
</ReferenceContentScrollHandler>
)
}
@@ -0,0 +1,236 @@
import { Fragment } from 'react'
import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, cn } from 'ui'
import { REFERENCES } from '~/content/navigation.references'
import { MDXRemoteRefs, getRefMarkdown } from '~/features/docs/Reference.mdx'
import { MDXProviderReference } from '~/features/docs/Reference.mdx.client'
import type { MethodTypes } from '~/features/docs/Reference.typeSpec'
import {
getFlattenedSections,
getFunctionsList,
getTypeSpec,
} from '~/features/docs/Reference.generated.singleton'
import {
CollapsibleDetails,
FnParameterDetails,
RefSubLayout,
ReturnTypeDetails,
StickyHeader,
} from '~/features/docs/Reference.ui'
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
import { normalizeMarkdown } from '~/features/docs/Reference.utils'
type ClientLibRefSectionsProps = {
sdkId: string
version: string
}
async function ClientLibRefSections({ sdkId, version }: ClientLibRefSectionsProps) {
let flattenedSections = await getFlattenedSections(sdkId, version)
flattenedSections = trimIntro(flattenedSections)
return (
<MDXProviderReference>
<div className="flex flex-col my-16 gap-16">
{flattenedSections
.filter((section) => section.type !== 'category')
.map((section, idx) => (
<Fragment key={`${section.id}-${idx}`}>
<SectionDivider />
<SectionSwitch sdkId={sdkId} version={version} section={section} />
</Fragment>
))}
</div>
</MDXProviderReference>
)
}
function trimIntro(sections: Array<AbbrevCommonClientLibSection>) {
const hasIntro = sections[0]?.type === 'markdown' && sections[0]?.slug === 'introduction'
if (hasIntro) {
return sections.slice(1)
}
return sections
}
function SectionDivider() {
return <hr />
}
type SectionSwitchProps = {
sdkId: string
version: string
section: AbbrevCommonClientLibSection
}
export function SectionSwitch({ sdkId, version, section }: SectionSwitchProps) {
const libPath = REFERENCES[sdkId].libPath
const isLatestVersion = version === REFERENCES[sdkId].versions[0]
const sectionLink = `/docs/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}${section.slug}`
switch (section.type) {
case 'markdown':
return (
<MarkdownSection
libPath={libPath}
version={version}
isLatestVersion={isLatestVersion}
link={sectionLink}
section={section}
/>
)
case 'function':
return (
<FunctionSection
sdkId={sdkId}
version={version}
link={sectionLink}
section={section}
useTypeSpec={REFERENCES[sdkId].typeSpec}
/>
)
default:
console.error(`Unhandled type in reference sections: ${section.type}`)
return null
}
}
interface MarkdownSectionProps {
libPath: string
version: string
isLatestVersion: boolean
link: string
section: AbbrevCommonClientLibSection
}
async function MarkdownSection({
libPath,
version,
isLatestVersion,
link,
section,
}: MarkdownSectionProps) {
const content = await getRefMarkdown(
section.meta?.shared
? `shared/${section.id}`
: `${libPath}/${isLatestVersion ? '' : `${version}/`}${section.id}`
)
return (
<RefSubLayout.EducationSection link={link} {...section}>
<StickyHeader {...section} />
<MDXRemoteRefs source={content} />
</RefSubLayout.EducationSection>
)
}
interface FunctionSectionProps {
sdkId: string
version: string
link: string
section: AbbrevCommonClientLibSection
useTypeSpec: boolean
}
async function FunctionSection({
sdkId,
version,
link,
section,
useTypeSpec,
}: FunctionSectionProps) {
const fns = await getFunctionsList(sdkId, version)
const fn = fns.find((fn) => fn.id === section.id)
if (!fn) return null
let types: MethodTypes | undefined
if (useTypeSpec && '$ref' in fn) {
types = await getTypeSpec(fn['$ref'] as string)
}
const fullDescription = [
types?.comment?.shortText,
'description' in fn && (fn.description as string),
'notes' in fn && (fn.notes as string),
]
.filter(Boolean)
.map(normalizeMarkdown)
.join('\n\n')
return (
<RefSubLayout.Section columns="double" link={link} {...section}>
<StickyHeader {...section} className="col-[1_/_-1]" />
<div className="overflow-hidden flex flex-col gap-8">
<div className="prose break-words text-sm">
<MDXRemoteRefs source={fullDescription} />
</div>
<FnParameterDetails
parameters={
'overwriteParams' in fn
? (fn.overwriteParams as Array<object>).map((overwrittenParams) => ({
...overwrittenParams,
__overwritten: true,
}))
: 'params' in fn
? (fn.params as Array<object>).map((param) => ({ ...param, __overwritten: true }))
: types?.params
}
altParameters={types?.altSignatures?.map(({ params }) => params)}
className="max-w-[80ch]"
/>
{!!types?.ret && <ReturnTypeDetails returnType={types.ret} />}
</div>
<div className="overflow-auto">
{'examples' in fn && Array.isArray(fn.examples) && fn.examples.length > 0 && (
<Tabs_Shadcn_ defaultValue={fn.examples[0].id}>
<TabsList_Shadcn_ className="flex-wrap gap-2 border-0">
{fn.examples.map((example) => (
<TabsTrigger_Shadcn_
key={example.id}
value={example.id}
className={cn(
'px-2.5 py-1 rounded-full',
'border-0 bg-surface-200 hover:bg-surface-300',
'text-xs text-foreground-lighter',
// Undoing styles from primitive component
'data-[state=active]:border-0 data-[state=active]:shadow-0',
'data-[state=active]:bg-foreground data-[state=active]:text-background',
'transition'
)}
>
{example.name}
</TabsTrigger_Shadcn_>
))}
</TabsList_Shadcn_>
{'examples' in fn &&
Array.isArray(fn.examples) &&
fn.examples.map((example) => (
<TabsContent_Shadcn_ key={example.id} value={example.id}>
<MDXRemoteRefs source={example.code} />
<div className="flex flex-col gap-2">
{!!example.data?.sql && (
<CollapsibleDetails title="Data source" content={example.data.sql} />
)}
{!!example.response && (
<CollapsibleDetails title="Response" content={example.response} />
)}
{!!example.description && (
<CollapsibleDetails
title="Notes"
content={normalizeMarkdown(example.description)}
/>
)}
</div>
</TabsContent_Shadcn_>
))}
</Tabs_Shadcn_>
)}
</div>
</RefSubLayout.Section>
)
}
export { ClientLibRefSections }
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { __parseTypeSpec } from './Reference.typeSpec'
import { parseTypeSpec } from './Reference.typeSpec'
describe('TS type spec parsing', () => {
it('matches snapshot', async () => {
const parsed = await __parseTypeSpec()
const parsed = await parseTypeSpec()
const json = JSON.stringify(
parsed,
(key, value) => {
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff. Show more