Skip to content
Search

Supabase:Auth

lastmod 2026-04-20created 2023-07-0429 edits

Managing User Data

Server-Side Rendering

When a user authenticates with Supabase Auth, the server issues two pieces of information.

  • The Access Token is in JWT format.
  • The Refresh Token is a randomly generated string.

Most Supabase projects have an auth server listening at <project-ref>.supabase.co/auth/v1.

So the access token and refresh token are set as the sb-access-token and sb-refresh-token cookies on the <project-ref>.supabase.co domain.

WARNING

These cookie names are for Supabase's internal use only and may change without warning. They should be used for illustration purposes only.

Web browsers restrict access to cookies across domains according to the SOP (Same-Origin Policy). Your web application cannot access these cookies, and these cookies are not sent to your application's server.

Understanding the auth flow

When you call one of the signIn methods, the client library running in the browser sends a request to the Supabase Auth server.

The auth server determines whether to use a phone number, an email and password combination, a Magic Link, or a social login (if configured in your project).

Once the user's identity is successfully verified, the Supabase Auth server redirects the user back to your SPA (Single-page app).

Tip

You can configure redirect URLs in the Supabase dashboard. You can also redirect to URLs of other forms using wildcard match patterns such as * and **.

Supabase Auth supports two authentication flows: Implicit and #PKCE Flow.

The #PKCE Flow flow is generally preferred when on the server. It introduces a few additional steps that protect against Replay attack and URL capture attacks.

Unlike the "Implicit flow", it also lets the user access the access_token and refresh_token on the server.

Implicit Flow

When using the implicit flow, a redirect URL with the following structure is returned.

https://yourapp.com/...#access_token=<...>&refresh_token=<...>&...

After successful verification, the first access and refresh tokens are contained in the URL Fragment (everything after the # sign) of the redirect location. This is intentional and cannot be changed.

The client library is designed to listen for this type of URL, extract the "access token", the "refresh token", and some extra information from it, and finally persist it in local storage for further use by the library and your app.

Details
Web browsers do not send the URL fragment to the server they make the request to.
Since you may not be hosting your "SPA (single-page app)" on a server you directly control (e.g. GitHub Pages or other freemium hosting providers), this by default prevents the hosting service from accessing the user's authentication credentials.
Even when the server is under your direct control, GET requests and their full URLs are often logged.
This approach also prevents credentials from leaking into request or access logs. If you want to obtain the access_token and refresh_token on the server, using the #PKCE Flow is recommended.

PKCE Flow

When using the PKCE flow, a redirect URL with the following structure is returned.

https://yourapp.com/...?code=<...>

This code parameter is commonly known as the "Auth Code" and can be exchanged for an access token by calling exchangeCodeForSession(code).

INFORMATION

For security, the code is valid for 5 minutes and can be exchanged for an access token only once. To get a new access token, you must restart the auth flow from the beginning.

Because the flow runs on the Server-Side, localStorage may not be available.

You can configure custom storage for the client library with the storage option.

const customStorageAdapter: SupportedStorage = {
  getItem: (key) => {
    if (!supportsLocalStorage()) {
      // Configure alternate storage
      return null
    }
    return globalThis.localStorage.getItem(key)
  },
  setItem: (key, value) => {
    if (!supportsLocalStorage()) {
      // Configure alternate storage here
      return
    }
    globalThis.localStorage.setItem(key, value)
  },
  removeItem: (key) => {
    if (!supportsLocalStorage()) {
      // Configure alternate storage here
      return
    }
    globalThis.localStorage.removeItem(key)
  },
}

You can also configure the client library to automatically exchange the client library for a session after a successful redirect. Just set the detectSessionInUrl option to true.

Putting it all together, the client library initialization looks like this:

const supabase = createClient(
     'https://xyzcompany.supabase.co',
     'public-anon-key',
     options: {
       ...
       auth: {
         ...
         detectSessionInUrl: true,
         flowType: 'pkce',
         storage: customStorageAdapter,
       }
       ...
     }
)

See PKCE for details.

SignUp

When you sign up by email, the email comes with the following link:

https://<project-ref>.supabase.co/auth/v1/verify?token=...&type=signup&redirect_to=http://localhost:5173/

Row Level Security

When you need granular authorization rules, nothing beats PostgreSQL's RLS (Row Level Security) .

Policies are PostgreSQL's rule engine. They are incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs.

RLS can be enabled as follows:

alter table public.profiles enable row level security;

Policies

Policies are easy to understand once you get the hang of them. Each policy is attached to a table, and the policy is executed every time the table is accessed.

You can think of them as adding a WHERE clause to every query.

For example, with a policy like this:

create policy "Individuals can view their own todos."
    on todos for select
    using ( auth.uid() = user_id );

Whenever a user tries to select from the todos table, it is translated into:

select *
from todos
where auth.uid() = todos.user_id; -- Policy is implicitly added.

What's nice about this pattern? You can now query this table through the API and don't need to include data filters in your API queries. The policy takes care of it.

// 사용자가 로그아웃했다면 아무 것도 반환하지 않습니다.
const { data } = await supabase.from('profiles').select('id, username, avatar_url, website')

// 사용자가 로그인한 후에만 정상 데이터가 반환됩니다.
// 로그인한 사용자의 데이터 - 이 경우 단일 행
const { error } = await supabase.auth.signIn({ email })
const { data: profile } = await supabase
  .from('profiles')
  .select('id, username, avatar_url, website')

Bypassing Row Level Security

If you need to fetch the full list of user profiles, a service_key is provided that you can use with the API and client libraries to bypass Row Level Security.

WARNING

Never expose this publicly. However, you can use it on the server side to fetch all profiles.

Accessing User Metadata

How to access user metadata.

const { data, error } = await supabase.auth.signUp({
  email: '[email protected]',
  password: 'example-password',
  options: {
    data: {
      first_name: 'John',
      age: 27,
    },
  },
})

User metadata is stored in the raw_user_meta_data column of the auth.users table. To view the metadata:

const {
  data: { user },
} = await supabase.auth.getUser()
let metadata = user.user_metadata

New user event trigger

If you want to add a row to the public.profiles table every time a user signs up, you can use a trigger.

However, if the trigger fails, it can block user sign-ups, so make sure the code is well tested.

For example:

-- inserts a row into public.profiles
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
  insert into public.profiles (id)
  values (new.id);
  return new;
end;
$$;

-- trigger the function every time a user is created
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Modifying/deleting Metadata

Stored as jsonb type in the raw_user_meta_data column of the auth.users table.

Add or modify:

const result = await supabase.auth.updateUser({data: {nickname: 'user-name'}});

Delete:

const result = await supabase.auth.updateUser({data: {profile: null}});

INFORMATION

JSON properties other than the ones explicitly added like that are not changed. So rest assured, fellow citizens

Password Recovery Flow

Sends a password reset request to an email address. This method supports the PKCE flow.

  1. The password reset flow consists of 2 broad steps.
    1. Allow the user to log in via the password reset link.
    2. Update the user's password.
  2. This function, resetPasswordForEmail(), only sends a password reset link to the user's email. To update the user's password: updateUser().
  3. A PASSWORD_RECOVERY event is emitted when the password recovery link is clicked. You can listen for these events and invoke a callback function with onAuthStateChange().
  4. When the user clicks the reset link in the email, they are redirected back to your application. You can configure the URL to redirect to with the redirectTo parameter. See the Redirect URLs and wildcards documentation for how to add additional redirect URLs to your project.
  5. Once the user has been redirected successfully, prompt them for a new password and call updateUser().

Password Recovery request (sending the email)

POST https://<project-ref>.supabase.co/auth/v1/recover

Body:

{
  "email": "[email protected]"
}

Headers:

apikey: <anon-key>
Content-Type: application/json

Format of the link Supabase includes in the email:

https://<project-ref>.supabase.co/auth/v1/verify?token=<hashed_token>&type=recovery&redirect_to=<your-redirect-url>

The user is then redirected to redirect_to, with the token information included in the URL fragment:

https://your-app.com/#access_token=<jwt>&refresh_token=<token>&type=recovery

3. When using PKCE Flow (SSR, etc.)

https://your-app.com/?code=<auth_code>&type=recovery

4. Updating the password (after receiving the token)

PUT https://<project-ref>.supabase.co/auth/v1/user

Headers:

Authorization: Bearer <access_token>
apikey: <anon-key>
Content-Type: application/json

Body:

{
  "password": "new-password"
}

Using the JS SDK

// 1. Recovery 이메일 발송
await supabase.auth.resetPasswordForEmail('[email protected]', {
  redirectTo: 'https://your-app.com/reset-password',
})

// 2. 리다이렉트 후 세션 감지
supabase.auth.onAuthStateChange((event, session) => {
  if (event ==== 'PASSWORD_RECOVERY') {
    // 비밀번호 변경 UI 표시
  }
})

// 3. 새 비밀번호 설정
await supabase.auth.updateUser({ password: 'new-password' })

redirectTo configuration caveats

  • You must register the allowed URLs under Redirect URLs in Supabase Dashboard > Authentication > URL Configuration.
  • Unregistered URLs are ignored and redirected to the default Site URL.
  • Wildcard patterns supported: https://your-app.com/**

OAuth : Identity linking strategies

onAuthStateChange

supabase.auth.onAuthStateChange is a listener that subscribes to Supabase auth state changes.

Signature

const { data } = supabase.auth.onAuthStateChange(
  (event: AuthChangeEvent, session: Session | null) => { ... }
);

// 구독 해제
data.subscription.unsubscribe();

Event Types

Event

When it fires

Notes

INITIAL_SESSION

Right after the Supabase client is created, when the initial session is loaded from storage

Can be used instead of getSession(). Helps prevent race conditions

SIGNED_IN

On successful login, session re-verification, or return of tab focus

Fires repeatedly in many situations, not just the first login. Must be distinguished using the user object

SIGNED_OUT

On signOut() call, session expiry, or logout from another device

TOKEN_REFRESHED

When the access/refresh tokens are refreshed while logged in

Caching the access token in memory is recommended

USER_UPDATED

On successful updateUser()

Password change, metadata update, etc.

PASSWORD_RECOVERY

On entering a page whose URL contains a password recovery link

Fires instead of SIGNED_IN

MFA_CHALLENGE_VERIFIED

When an MFA challenge is verified

Callback caveats

async deadlock risk

The callback runs synchronously during event processing. If you await another Supabase function in an async callback, a deadlock can occur.

// BAD: 데드락 위험
supabase.auth.onAuthStateChange(async (event, session) => {
  await supabase.auth.signOut(); // 데드락
});

// GOOD: setTimeout으로 지연 실행
supabase.auth.onAuthStateChange((event, session) => {
setTimeout(() => {
supabase.auth.signOut();
}, 0);
});

Duplicate SIGNED_IN events

SIGNED_IN fires repeatedly not only on the first login but also on return of tab focus, in multi-tab environments, and so on. Whether it is a new login must be determined by comparing the user object.

Replacing getSession()

Since the INITIAL_SESSION event provides the initial session, initialization is possible with this event alone, without a separate getSession() call. This approach prevents race conditions.

Applying to the project

AuthProvider event handling

Event

Handling

INITIAL_SESSION

Set session/user → transition to loaded state

PASSWORD_RECOVERY

Set user to null → transition to passwordRecovery state

USER_UPDATED

Refresh session/user only when not in the passwordRecovery state

Others

Currently unhandled (to be extended later)

Why USER_UPDATED is guarded

Calling updateUser({password}) during password recovery fires USER_UPDATED. If it transitions to loaded without a guard, the password change UI disappears and the state gets tangled before the follow-up logic of updatePassword (signOut, etc.) runs.

References

Helper

Supabase provides a few easy functions you can use with policies.

  • auth.uid() - Returns the ID of the user making the request.
  • auth.jwt() - Returns the JWT of the user making the request.

Supabase Auth with the Next.js App Router

Middleware

import {createMiddlewareClient} from '@supabase/auth-helpers-nextjs';
import type {NextRequest} from 'next/server';
import {NextResponse} from 'next/server';

export async function middleware(req: NextRequest) {
  const res = NextResponse.next();

  // Create a Supabase client configured to use cookies
  const supabase = createMiddlewareClient({req, res});

  // Refresh session if expired - required for Server Components
  // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-session-with-middleware
  await supabase.auth.getSession();

  return res;
}

The initial value of res is:

{
  cookies: ResponseCookies {},
  url: '',
  body: null,
  bodyUsed: false,
  headers: { x-middleware-next: '1' },
  ok: true,
  redirected: false,
  status: 200,
  statusText: '',
  type: 'default'
}

After supabase.auth.getSession, cookie and set-cookie are added to the res value as shown below. These keys correspond to the Set-Cookie header.

{
  cookies: ResponseCookies {},
  url: '',
  body: null,
  bodyUsed: false,
  headers: {
  cookie: 'sb-zmbedptdtgbeubeigtge-auth-token=%5B%22eyJhbGciOiJIUzI1NiIsImtpZCI6ImRmS0NVcHk4NkphdndIRFQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjkyNDA1NTU4LCJpYXQiOjE2OTI0MDE5NTgsImlzcyI6Imh0dHBzOi8vaHR0cHM6Ly96bWJlZHB0ZHRnYmV1YmVpZ3RnZS5zdXBhYmFzZS5jby9hdXRoL3YxIiwic3ViIjoiNzkyMWE4ZjQtMGUzYi00NzlkLWJlNDItY2YwNmNiMDI0ZDBkIiwiZW1haWwiOiJvc29tODk3OUBnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7fSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY5MjM2NTk0MX1dLCJzZXNzaW9uX2lkIjoiYzUwMWY0MzktOGZmNS00N2JmLTljZGQtNzBhNzZkZTE3ZjRjIn0.TBvVeBT-qKySQwLDQ3DM4ZVEfvShmuT56r081R7vaPk%22%2C%22LB8TC-eJUhFAOPvaH6OjsA%22%2Cnull%2Cnull%2Cnull%5D; Max-Age=31536000000; Path=/',
  set-cookie: 'sb-zmbedptdtgbeubeigtge-auth-token=%5B%22eyJhbGciOiJIUzI1NiIsImtpZCI6ImRmS0NVcHk4NkphdndIRFQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjkyNDA1NTU4LCJpYXQiOjE2OTI0MDE5NTgsImlzcyI6Imh0dHBzOi8vaHR0cHM6Ly96bWJlZHB0ZHRnYmV1YmVpZ3RnZS5zdXBhYmFzZS5jby9hdXRoL3YxIiwic3ViIjoiNzkyMWE4ZjQtMGUzYi00NzlkLWJlNDItY2YwNmNiMDI0ZDBkIiwiZW1haWwiOiJvc29tODk3OUBnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7fSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY5MjM2NTk0MX1dLCJzZXNzaW9uX2lkIjoiYzUwMWY0MzktOGZmNS00N2JmLTljZGQtNzBhNzZkZTE3ZjRjIn0.TBvVeBT-qKySQwLDQ3DM4ZVEfvShmuT56r081R7vaPk%22%2C%22LB8TC-eJUhFAOPvaH6OjsA%22%2Cnull%2Cnull%2Cnull%5D; Max-Age=31536000000; Path=/',
  x-middleware-next: '1'
},
  ok: true,
  redirected: false,
  status: 200,
  statusText: '',
  type: 'default'
}

Creating a Supabase Client

There are 5 ways to access the Supabase client using the Next.js Auth Helpers.:

  • Client Components - createClientComponentClient in Client Components
  • Server Components - createServerComponentClient in Server Components
  • Server Actions - createServerActionClient in Server Actions
  • Route Handlers - createRouteHandlerClient in Route Handlers
  • Middleware - createMiddlewareClient in Middleware

Client Components

'use client'

import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useEffect, useState } from 'react'

import type { Database } from '@/lib/database.types'

type Todo = Database['public']['Tables']['todos']['Row']

export default function Page() {
  const [todos, setTodos] = useState<Todo[] | null>(null)
  const supabase = createClientComponentClient<Database>()

  useEffect(() => {
    const getData = async () => {
      const { data } = await supabase.from('todos').select()
      setTodos(data)
    }

    getData()
  }, [])

  return todos ? <pre>{JSON.stringify(todos, null, 2)}</pre> : <p>Loading todos...</p>
}

Server Components

import { cookies } from 'next/headers'
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'

import type { Database } from '@/lib/database.types'

export const dynamic = 'force-dynamic'

export default async function ServerComponent() {
  const supabase = createServerComponentClient<Database>({ cookies })
  const { data } = await supabase.from('todos').select()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

Server Actions

WARNING

As of 2023-12-28, using createServerActionClient in a server action doesn't seem to refresh the cache...

Default auth.users table schema

create table
  auth.users (
    instance_id uuid null,
    id uuid not null,
    aud character varying(255) null,
    role character varying(255) null,
    email character varying(255) null,
    encrypted_password character varying(255) null,
    email_confirmed_at timestamp with time zone null,
    invited_at timestamp with time zone null,
    confirmation_token character varying(255) null,
    confirmation_sent_at timestamp with time zone null,
    recovery_token character varying(255) null,
    recovery_sent_at timestamp with time zone null,
    email_change_token_new character varying(255) null,
    email_change character varying(255) null,
    email_change_sent_at timestamp with time zone null,
    last_sign_in_at timestamp with time zone null,
    raw_app_meta_data jsonb null,
    raw_user_meta_data jsonb null,
    is_super_admin boolean null,
    created_at timestamp with time zone null,
    updated_at timestamp with time zone null,
    phone text null default null::character varying,
    phone_confirmed_at timestamp with time zone null,
    phone_change text null default ''::character varying,
    phone_change_token character varying(255) null default ''::character varying,
    phone_change_sent_at timestamp with time zone null,
    confirmed_at timestamp with time zone null,
    email_change_token_current character varying(255) null default ''::character varying,
    email_change_confirm_status smallint null default 0,
    banned_until timestamp with time zone null,
    reauthentication_token character varying(255) null default ''::character varying,
    reauthentication_sent_at timestamp with time zone null,
    is_sso_user boolean not null default false,
    deleted_at timestamp with time zone null,
    constraint users_pkey primary key (id),
    constraint users_phone_key unique (phone),
    constraint users_email_change_confirm_status_check check (
      (
        (email_change_confirm_status >= 0)
        and (email_change_confirm_status <= 2)
      )
    )
  ) tablespace pg_default;

create unique index confirmation_token_idx on auth.users using btree (confirmation_token)
where
  ((confirmation_token)::text !~ '^[0-9 ]*$'::text) tablespace pg_default;

create unique index email_change_token_current_idx on auth.users using btree (email_change_token_current)
where
  (
    (email_change_token_current)::text !~ '^[0-9 ]*$'::text
  ) tablespace pg_default;

create unique index email_change_token_new_idx on auth.users using btree (email_change_token_new)
where
  (
    (email_change_token_new)::text !~ '^[0-9 ]*$'::text
  ) tablespace pg_default;

create unique index reauthentication_token_idx on auth.users using btree (reauthentication_token)
where
  (
    (reauthentication_token)::text !~ '^[0-9 ]*$'::text
  ) tablespace pg_default;

create unique index recovery_token_idx on auth.users using btree (recovery_token)
where
  ((recovery_token)::text !~ '^[0-9 ]*$'::text) tablespace pg_default;

create unique index users_email_partial_key on auth.users using btree (email)
where
  (is_sso_user = false) tablespace pg_default;

create index if not exists users_instance_id_email_idx on auth.users using btree (instance_id, lower((email)::text)) tablespace pg_default;

create index if not exists users_instance_id_idx on auth.users using btree (instance_id) tablespace pg_default;

See also

Favorite site

author Zer0 sitemap