Guide
Supabase Security

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:

  1. Authentication
  2. Authorization
  3. 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="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
                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:

  1. validationSchema
  2. onSubmit
  3. 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:

  1. Show a toaster
  2. Get token from captchaResponse
  3. Call the signinWithPassword function
  4. If there's no error, check if the 2FA is enabled and redirect to get the 2FA code.
  5. If there's no 2FA, router.push based on redirectTo but with ternary operation to prevent redirect loop
  6. If for any reason steps 4, 5 fail, error is reported to Sentry via captureMessage
  7. If there's an error in signin, captcha is reset. In case the account is not verified, a toaster is shown

References:

  1. https://github.com/supabase/supabase/blob/master/apps/studio/pages/sign-in.tsx
  2. https://github.com/supabase/supabase/blob/master/apps/studio/components/interfaces/SignIn/SignInForm.tsx#L22
  3. https://www.hcaptcha.com/
  4. https://github.com/supabase/supabase/blob/master/apps/studio/lib/gotrue.ts
  5. https://github.com/supabase/supabase/blob/master/apps/studio/data/profile/mfa-authenticator-assurance-level-query.ts#L9