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 |
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.
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_tokenandrefresh_tokenon the server, using the #PKCE Flow is recommended.
PKCE Flow
When using the PKCE flow, a redirect URL with the following structure is returned.
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
- Row Level Security | Supabase Docs
- A look at Row Level Security in Supabase and PostGreSQL
- [Recommended] Managing User Data | Supabase Docs
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:
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:
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:
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:
Delete:
| INFORMATION |
| JSON properties other than the ones explicitly added like that are not changed. So |
Password Recovery Flow
- JavaScript API Reference | Supabase Docs
- Password-based Auth | Supabase Docs
- Implicit flow | Supabase Docs
Sends a password reset request to an email address. This method supports the PKCE flow.
- The password reset flow consists of 2 broad steps.
- Allow the user to log in via the password reset link.
- Update the user's password.
resetPasswordForEmail(), only sends a password reset link to the user's email. To update the user's password: updateUser(). 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(). redirectTo parameter. See the Redirect URLs and wildcards documentation for how to add additional redirect URLs to your project. updateUser(). Password Recovery request (sending the email)
Body:
{
"email": "[email protected]"
}
Headers:
Redirect URL when the user clicks the email link
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:
3. When using PKCE Flow (SSR, etc.)
4. Updating the password (after receiving the token)
Headers:
Body:
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
- Identity Linking | Supabase Docs # Identity linking strategies
- Supabase Auth: Identity Linking, Hooks, and HaveIBeenPwned integration
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 |
| | Right after the Supabase client is created, when the initial session is loaded from storage | Can be used instead of |
| | 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 |
| | On | |
| | When the access/refresh tokens are refreshed while logged in | Caching the access token in memory is recommended |
| | On successful | Password change, metadata update, etc. |
| | On entering a page whose URL contains a password recovery link | Fires instead of |
| | 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 |
| | Set session/user → transition to |
| | Set user to null → transition to |
| | Refresh session/user only when not in the |
| 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
- Supabase official documentation
- vi.hoisted constraints (why constant literals are used in tests)
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;