Supabase Security
In this guide, we look at security practices used in Supabase.
Following these resources,
We analyze the following concepts in the Supabase source code:
- Authentication
- Authorization
- Data Handling Model
[L: Authentication] Supabase makes authentication easy to implement but have you ever wondered how Supabase implemented its own authentication on their application?
In this guide, we will analyze the Authentication mechanism in Supabase.
Supabase uses pages router, at the time of writing this guide. Let's first analyze the sign in with email and password
[Insert screenshot of email and password screen here]
There is pages/sign-in.tsx, it has a lot of imports, but in this guide, out focus is on SigninForm
[Insert screenshot of Line - https://github.com/supabase/supabase/blob/master/apps/studio/pages/sign-in.tsx#L51]
SigninForm
sign-in page has a component named SigninForm
Let's analyze sign-in form.
<Form
validateOnBlur
id="signIn-form"
initialValues={{ email: '', password: '' }}
validationSchema={signInSchema}
onSubmit={onSignIn}
>
{({ isSubmitting }: { isSubmitting: boolean }) => {
return (
<div className="flex flex-col gap-4">
<Input
id="email"
name="email"
type="email"
label="Email"
placeholder="you@example.com"
disabled={isSubmitting}
autoComplete="email"
/>
<div className="relative">
<Input
id="password"
name="password"
type="password"
label="Password"
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="current-password"
/>
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
<Link
href="/forgot-password"
className="absolute top-0 right-0 text-sm text-foreground-lighter"
>
Forgot Password?
</Link>
</div>
<div className="self-center">
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
size="invisible"
onVerify={(token) => {
setCaptchaToken(token)
}}
onExpire={() => {
setCaptchaToken(null)
}}
/>
</div>
<LastSignInWrapper type="email">
<Button
block
form="signIn-form"
htmlType="submit"
size="large"
disabled={isSubmitting}
loading={isSubmitting}
>
Sign In
</Button>
</LastSignInWrapper>
</div>
)
}}
</Form>
Form, Input, Button are imported from internal package ui.
Let's focus on the following functionalities:
- validationSchema
- onSubmit
- HCaptcha
validationSchema
const signInSchema = object({
email: string().email('Must be a valid email').required('Email is required'),
password: string().required('Password is required'),
})
Supabase uses yup, a schema builder for runtime value parsing and validation
[Insert screenshot of this line - https://github.com/supabase/supabase/blob/master/apps/studio/components/interfaces/SignIn/SignInForm.tsx#L87C13-L87C38]
onSubmit
onSubmit calls a function named onSignIn
.
[Insert screenshot of line - https://github.com/supabase/supabase/blob/master/apps/studio/components/interfaces/SignIn/SignInForm.tsx#L88]
There's a lot happening inside onSignIn function.
const toastId = toast.loading('Signing in...')
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
}
toast is from sonner package
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
}
captchaRef is reference to HCaptcha element
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
size="invisible"
onVerify={(token) => {
setCaptchaToken(token)
}}
onExpire={() => {
setCaptchaToken(null)
}}
/>
HCaptcha is imported at the top of the file.
import HCaptcha from '@hcaptcha/react-hcaptcha'
Token is updated based on captchaResponse.
With HCaptcha, you can send upto 1 million requests per month on free plan.
const { error } = await auth.signInWithPassword({
email,
password,
options: { captchaToken: token ?? undefined },
})
auth
is imported from lib/gotrue
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
Note: Our focus is on the way the authentication is implemented. This means, we look at the sequence of operations performed, at a high level.
When the auth succeeds
When the auth succeeds, the following are operations are performed based on error flag.
if (!error) {
setLastSignIn('email')
try {
const data = await getMfaAuthenticatorAssuranceLevel()
if (data) {
if (data.currentLevel !== data.nextLevel) {
toast.success(`You need to provide your second factor authentication`, { id: toastId })
const url = buildPathWithParams('/sign-in-mfa')
router.replace(url)
return
}
}
toast.success(`Signed in successfully!`, { id: toastId })
await queryClient.resetQueries()
const returnTo = getReturnToPath()
// since we're already on the /sign-in page, prevent redirect loops
router.push(returnTo === '/sign-in' ? '/projects' : returnTo)
} catch (error: any) {
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
Sentry.captureMessage('[CRITICAL] Failed to sign in via EP: ' + error.message)
}
}
Let's analyze the operations in the above snippet.
setLastSignIn('email')
try {
const data = await getMfaAuthenticatorAssuranceLevel()
if (data) {
if (data.currentLevel !== data.nextLevel) {
toast.success(`You need to provide your second factor authentication`, { id: toastId })
const url = buildPathWithParams('/sign-in-mfa')
router.replace(url)
return
}
}
This code handles the second factor authentication, notice how it redirects to a different url - /sign-in-mfa
based on the data returned by getMfaAuthenticatorAssuranceLevel
toast.success(`Signed in successfully!`, { id: toastId })
await queryClient.resetQueries()
const returnTo = getReturnToPath()
// since we're already on the /sign-in page, prevent redirect loops
router.push(returnTo === '/sign-in' ? '/projects' : returnTo)
toast is shown, queryClient resets queries, supabase uses a query param - returnTo
to redirect a user
to the page they came from to "signin".
The comment there explains the mechanism to prevent redirect loops.
catch (error: any) {
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
Sentry.captureMessage('[CRITICAL] Failed to sign in via EP: ' + error.message)
}
When the sign in fails, it is reported to Sentry vai captureMessage
.
else {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
if (error.message.toLowerCase() === 'email not confirmed') {
return toast.error(
'Account has not been verified, please check the link sent to your email',
{ id: toastId }
)
}
toast.error(error.message, { id: toastId })
}
}
setCaptchaToken
is set to null, captchaRef is reset.
if (error.message.toLowerCase() === 'email not confirmed') {
- this checks if the error message contains 'email not confirmed'
and shows a toast.error with a message.
Summary of onSubmit operations:
- Show a toaster
- Get token from captchaResponse
- Call the signinWithPassword function
- If there's no error, check if the 2FA is enabled and redirect to get the 2FA code.
- If there's no 2FA,
router.push
based on redirectTo but with ternary operation to prevent redirect loop - If for any reason steps 4, 5 fail, error is reported to Sentry via
captureMessage
- If there's an error in signin, captcha is reset. In case the account is not verified, a toaster is shown
References:
- https://github.com/supabase/supabase/blob/master/apps/studio/pages/sign-in.tsx
- https://github.com/supabase/supabase/blob/master/apps/studio/components/interfaces/SignIn/SignInForm.tsx#L22
- https://www.hcaptcha.com/
- https://github.com/supabase/supabase/blob/master/apps/studio/lib/gotrue.ts
- https://github.com/supabase/supabase/blob/master/apps/studio/data/profile/mfa-authenticator-assurance-level-query.ts#L9