ข้ามไปยังเนื้อหา

Authentication Methods

Supabase Auth สร้างขึ้นบน GoTrue server แบบ open-source และรองรับวิธีพิสูจน์ตัวตนของ user หลายแบบ — password, magic link, phone OTP, anonymous หรือ OAuth — และทุกวิธีจบลงที่จุดเดียวกันคือ row ที่ถูกสร้างหรือ match ใน table auth.users ที่มีมาให้

supabase-js เปิด method แต่ละอันไว้ที่ supabase.auth แต่ข้างใต้ทุกอันคือ HTTP request ธรรมดาไปที่ endpoint /auth/v1/* ของ GoTrue เอง — เห็นได้ตรง ๆ ด้วย curl กับ local stack ของคอร์สนี้:

scripts/rls-signup-proof.sh
curl -s -X POST "$API_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"rls-pw-alice-12345"}'
{"id":"64721d1e-dd1d-4672-bdb0-519dcde3c455","aud":"authenticated","role":"authenticated","email":"[email protected]","email_confirmed_at":"2026-09-29T04:21:53.558415Z","confirmed_at":"2026-09-29T04:21:53.558415Z","last_sign_in_at":"2026-09-29T04:21:53.560Z","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"email_verified":true},"identities":[{"identity_id":"e1c473ec-...","id":"64721d1e-...","user_id":"64721d1e-...","identity_data":{"email":"[email protected]","email_verified":true,"sub":"64721d1e-..."},"provider":"email"}],"created_at":"2026-09-29T04:21:53.548435Z","updated_at":"2026-09-29T04:21:53.560Z","is_anonymous":false}

sign-in ทีหลังเป็น endpoint คนละอัน /auth/v1/token ด้วย grant_type=password:

scripts/rls-signin-proof.sh
curl -s -X POST "$API_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"rls-pw-alice-12345"}'
{"access_token":"eyJhbGciOiJFUzI1NiIs...(trimmed)","token_type":"bearer","expires_in":3600,"expires_at":1790659357,"refresh_token":"...(trimmed)","user":{"id":"64721d1e-dd1d-4672-bdb0-519dcde3c455","email":"[email protected]", "role":"authenticated", "..." : "..."}}

ไม่ว่าจะ sign-up หรือ sign-in ด้วยวิธีไหนก็ตาม ผลลัพธ์คือ row ใน auth.users ถูกสร้างหรือใช้ยืนยันตัวตน ตรวจสอบ row จริงตรง ๆ:

=# select id, email, role, aud, email_confirmed_at, created_at, raw_app_meta_data, raw_user_meta_data
from auth.users where email = '[email protected]';
id | email | role | aud | email_confirmed_at | created_at
--------------------------------------+------------------------+---------------+---------------+--------------------------------+-------------------------------
64721d1e-dd1d-4672-bdb0-519dcde3c455 | [email protected] | authenticated | authenticated | 2026-09-29 04:21:53.558415+00 | 2026-09-29 04:21:53.548435+00

raw_app_meta_data คือ {"provider": "email", "providers": ["email"]} และ raw_user_meta_data คือ {"sub": "64721d1e-...", "email": "[email protected]", "email_verified": true, "phone_verified": false} — metadata พวกนี้คือสิ่งที่ auth.jwt() เอามาให้ RLS policy อ่านผ่าน claim app_metadata/user_metadata table แยกต่างหาก คือ auth.identities เก็บว่า provider ไหนยืนยันตัวตน user คนนี้ — user คนเดียวมีได้หลาย identity (email + GitHub + Google เป็นต้น) แต่ละอันเป็น row แยกกัน:

=# select id, email, provider from auth.identities where user_id = '64721d1e-dd1d-4672-bdb0-519dcde3c455';
id | email | provider
--------------------------------------+------------------------+----------
e1c473ec-4af4-44d5-8e94-51a1a17a40a0 | [email protected] | email
(1 row)

auth.users ไม่ได้ออกแบบมาให้เพิ่ม column ของแอปเข้าไปตรง ๆ — pattern ที่นิยมคือ table public.profiles ที่ primary key อ้างอิงกลับไปที่ auth.users(id) เก็บข้อมูลเฉพาะของแอปที่ Auth เองไม่เก็บให้

magic link ไม่ต้องใช้ password เลย — GoTrue ส่ง sign-in link แบบใช้ครั้งเดียวทาง email และ (ต่างจาก password sign-up ที่ข้ามการยืนยันไปเลยบน config นี้) มัน ส่ง email จริงเสมอ ถูกจับไว้ที่ Mailpit inbox ของ local stack:

scripts/rls-magiclink-request.sh
curl -s -X POST "$API_URL/auth/v1/otp" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
{}

request เองคืน object ว่างเปล่า — ไม่มี token เลย ตั้งใจแบบนั้น เพราะประเด็นทั้งหมดคือมีแค่ inbox เท่านั้นที่มี token จริง ดึง message จริงจาก API ของ Mailpit เอง (http://127.0.0.1:55324 หาได้จาก INBUCKET_URL/MAILPIT_URL ของ supabase status):

scripts/rls-magiclink-fetch.sh
curl -s "http://127.0.0.1:55324/api/v1/messages"
curl -s "http://127.0.0.1:55324/api/v1/message/<message-id-from-above>"
Your sign-in link
-----------------
Follow the link below to sign in. This link expires shortly and can only be used once.
Sign in ( http://127.0.0.1:55321/auth/v1/verify?token=8480714a...(trimmed)&type=magiclink&redirect_to=http://127.0.0.1:3000 )

ลิงก์ /auth/v1/verify?token=...&type=magiclink นั้นคือกลไกทั้งหมด: GoTrue สร้าง token แบบใช้ครั้งเดียว ส่ง URL ที่มี token ฝังอยู่ทาง email และการเปิด URL นั้น (เป็น GET เลยทำงานได้แม้เป็นลิงก์ email ธรรมดาไม่ต้องมี JavaScript) จะแลก token เป็น session จริง — หน้าตาเดียวกับ phone OTP flow เป๊ะ ต่างกันแค่รหัสแบบใช้ครั้งเดียวถูกส่งทาง SMS แทน email และแลกด้วย verifyOtp({ phone, token, type: 'sms' }) แทนการกดลิงก์

anonymous sign-in สร้าง row จริงใน auth.users — is_anonymous = true ไม่มี email หรือ password เลย มีประโยชน์สำหรับให้คนใช้แอปได้ก่อนตัดสินใจสมัครสมาชิกจริง แล้วค่อยผูก identity จริงทีหลัง มันถูกคุมด้วย config flag ของตัวเอง enable_anonymous_sign_ins ซึ่งเป็น false บน local stack ของคอร์สนี้:

scripts/rls-anon-signin-proof.sh
curl -s -X POST "$API_URL/auth/v1/signup" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '{}'
{"code":422,"error_code":"anonymous_provider_disabled","msg":"Anonymous sign-ins are disabled"}

error จริง เฉพาะเจาะจง — ไม่ใช่การเงียบไม่ทำอะไร ยืนยันว่า flag ถูกปิดไว้ ไม่ใช่ฟีเจอร์พัง ถ้า enable_anonymous_sign_ins = true request เดียวกันเป๊ะ (body ว่างไปที่ /auth/v1/signup) จะคืน session เต็มสำหรับ anonymous user คนใหม่ และ auth.jwt() ->> 'is_anonymous' จะอ่านได้ "true" ภายใน RLS policy ทำให้ policy กันไม่ให้ anonymous user เขียนข้อมูล production จริงได้อย่างชัดเจน (supabase.com/docs/guides/auth/auth-anonymous)

OAuth ส่ง user ไปให้ third-party provider (Google, GitHub และอื่น ๆ) จัดการ และ Supabase ดูแลขั้นตอน redirect กับการแลก token ให้ทั้งหมด:

const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
});

ไม่มี OAuth provider จริงให้เข้าถึงได้จาก local stack ที่ไม่มี credential นี้เลย — block [auth.external.<provider>] ใน config.toml ทั้งหมด enabled = false บน local และการต่อสายจริงต้องมี client ID/secret จริงที่ลงทะเบียนกับ provider นั้น ๆ (supabase.com/docs/guides/auth/social-login) ในทางกลไก ผลลัพธ์ลงเอยที่ table เดียวกันสองใบที่พิสูจน์แล้วข้างบนเป๊ะ: row ใหม่ใน auth.identities ที่ provider = 'github' (หรือ provider ไหนก็ตาม) ผูกกับ auth.users row ใหม่หรือที่มีอยู่แล้ว ถ้า email ที่ได้กลับมาตรงกับ account ที่มีอยู่แล้ว

service_role เปิด endpoint อีกชุดแยกต่างหากใต้ /auth/v1/admin/* — operation เดียวกับที่ supabase.auth.admin.* เรียกจาก server context ที่เชื่อถือได้เท่านั้น ไม่ใช่ browser:

scripts/rls-admin-create-user.sh
curl -s -X POST "$API_URL/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"rls-pw-dave-123456","email_confirm":true}'
{"id":"34d3e503-febc-4e37-9d5f-40c91efded40","email":"[email protected]","email_confirmed_at":"2026-09-29T04:40:50.086462384Z","role":"authenticated"}

email_confirm: true สร้าง Dave ให้ยืนยันแล้วเลย — ไม่ต้องผ่าน email เลยแม้แต่นิดเดียว มีประโยชน์สำหรับ seed script และ account ที่ admin สร้างเอง request เดียวกันเป๊ะแต่ใช้ key anon แทน service_role จะถูกปฏิเสธตรง ๆ:

Terminal window
curl -s -X POST "$API_URL/auth/v1/admin/users" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"rls-pw-eve-1234567"}'
{"code":403,"error_code":"not_admin","msg":"User not allowed"}

supabase.auth.admin.createUser() ใน supabase-js เป็น wrapper บาง ๆ ทับ endpoint นี้เป๊ะ — ทำงานได้ก็ต่อเมื่อ client ถูกสร้างด้วย key service_role เท่านั้น นี่คือเหตุผลที่มันต้องไม่รันในโค้ดที่ส่งไปถึง browser เด็ดขาด

GoTrue ตั้ง rate limit ให้ทุก auth endpoint โดย default พร้อมตัวเลขจริง (supabase.com/docs/guides/deployment/going-into-prod#rate-limits, supabase.com/docs/guides/auth/rate-limits):

Endpoint(s)จำกัดตามค่า default
/auth/v1/signup, /auth/v1/recover, /auth/v1/user (ส่ง email)รวม request ทั้งหมดโควต้าส่ง email ของ project (inbuilt SMTP) ปรับได้ด้วย custom SMTP
/auth/v1/otp (ส่ง OTP/magic link)รวม request ทั้งหมด360 ครั้งต่อชั่วโมง
/auth/v1/otp, /auth/v1/signup, /auth/v1/recover (ส่งซ้ำ)request ล่าสุดcooldown 60 วินาที
/auth/v1/verifyIP address360/ชั่วโมง burst ได้ถึง 30
/auth/v1/token (refresh)IP address1800/ชั่วโมง burst ได้ถึง 30
Anonymous sign-insIP address30/ชั่วโมง burst ได้ถึง 30

config.toml ของ local CLI stack เองก็สะท้อนรูปแบบเดียวกันนี้ใต้ [auth.rate_limit] (token_refresh = 150, anonymous_users = 30 บน probe นี้) — กลไกเป็นของจริงและเปิดใช้เสมอ แม้ใน local dev ก็ตาม แค่ default ต่างจากของ hosted project เพราะเป็นค่าที่เหมาะกับ CLI

flowchart LR
  user["User"] --> pw["Email + password"]
  user --> magic["Magic link / phone OTP"]
  user --> anon["Anonymous sign-in"]
  user --> oauth["OAuth provider"]
  pw --> gotrue["Supabase Auth (GoTrue)"]
  magic --> gotrue
  anon --> gotrue
  oauth --> gotrue
  gotrue --> table[("auth.users row created/matched")]
  gotrue --> ident[("auth.identities row per provider")]
ทุกวิธี sign-in ลงเอยที่ auth.users row เดียวกัน

AI assistant ถูกขอให้ “ให้ admin panel สร้าง user account ได้โดยไม่ต้องส่ง email ยืนยัน” แล้วสร้างโค้ดนี้มา:

🐛 หา bug ในโค้ดนี้

src/lib/admin-users.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
);
export async function createUserFromAdminPanel(email: string, password: string) {
const { data, error } = await supabase.auth.admin.createUser({
email,
password,
email_confirm: true,
});
if (error) throw error;
return data.user;
}
เฉลย

การเรียก admin.createUser() เองถูกต้อง — นี่คือ endpoint /auth/v1/admin/users จริงที่พิสูจน์แล้วข้างบนเป๊ะ และต้องใช้ service_role ถึงจะทำงานได้ บั๊กอยู่ที่ชื่อ env var: NEXT_PUBLIC_* (หรือ prefix ที่ expose ให้ client แบบไหนก็ตาม — VITE_*, PUBLIC_*) จะถูก bundle เข้าไปใน JavaScript ที่ส่งให้ผู้เข้าชมทุกคนตรง ๆ function นี้ปลอดภัยแค่ตอนรันใน server context เท่านั้น (Next.js Server Action, API route, Edge Function) — ตามที่เขียนไว้ พร้อม NEXT_PUBLIC_ service_role key ตัว browser bundle จะมี key ที่พิสูจน์แล้วข้างบนว่าข้าม RLS ได้ทุก table และเปิด admin API สร้าง/ลบ/แบน user ไหนก็ได้ ให้ทุกคนที่เปิด devtools เข้าถึงได้ ทางแก้ไม่ใช่การแก้โค้ดของ function นี้เลย — คือห้ามให้ service_role key อยู่ใต้ชื่อ env var ที่ expose ให้ client เด็ดขาด และเรียก function นี้จากไฟล์ที่ไม่ส่งไปถึง browser เท่านั้น

ทุกวิธี sign-in พวกนี้คือ GoTrue กำลังตอบคำถามเดียวกันจริง ๆ — “ฉันสร้างหรือ match row ใน auth.users ให้ request นี้ได้ไหม และถ้าได้ claims ของ session ที่ได้ควรเป็นอะไร” — ผ่านกลไกยืนยันตัวตนคนละแบบ: password ที่เช็คกับ hash, token แบบใช้ครั้งเดียวที่พิสูจน์ด้วยการควบคุม inbox, รหัสแบบใช้ครั้งเดียวที่พิสูจน์ด้วยการควบคุมเบอร์โทร, authorization code ที่พิสูจน์ด้วยการ redirect จาก third-party หรือ (anonymous) ไม่ต้องยืนยันอะไรเลย แค่สร้าง account ที่ยังไม่มีอะไรให้ยืนยัน auth.identities มีอยู่เพราะ “user คนนี้ถูกยืนยันตัวตนยังไง” กับ “user คนนี้คือใคร” เป็นคำถามคนละคำถาม — คนเดียวกันพิสูจน์ตัวตนได้หลายแบบต่างเวลากัน และการพิสูจน์แต่ละแบบเป็น row ของตัวเอง ทั้งหมดชี้ไปที่ auth.users row เดียวกัน

  • Password sign-in ไม่ต้องพึ่ง service ภายนอกและใช้ offline-first ได้ (เทียบกับ magic link) — แลกกับภาระ password hygiene แบบคลาสสิก: ข้อกำหนดความแข็งแรง, การเช็ค breach-list, flow reset ที่ต้องให้ client, GoTrue และนิสัยของ user เองร่วมมือกันทั้งหมด
  • Magic link/OTP ตัด password management ออกไปทั้งหมด — แลกกับการต้องพึ่ง inbox หรือเบอร์โทรที่ user เข้าถึงได้ ตอนที่พยายาม sign-in จริง ๆ ซึ่งเป็น dependency ด้าน availability ที่ password sign-in ไม่มี
  • Anonymous sign-in ทำให้โปรดักต์เลื่อนกำแพง signup ออกไปได้ — แลกกับ row และ session จริงใน auth.users ที่มีอยู่สำหรับคนที่อาจไม่เคย convert เลย บวกกับแอปต้องมี flow ผูก account ชัดเจน (updateUser/linkIdentity) เพื่อแปลง anonymous account เป็น account จริงโดยไม่เสียข้อมูล
  • Admin API ทำ auth operation ระดับ superuser ได้ทุกอย่างในเรียกเดียว — แลกกับความอันตรายพอ ๆ กับ key service_role ที่มันต้องใช้ ไม่มี role แบบละเอียดกว่านี้เช่น “admin แต่สร้าง user ได้อย่างเดียว”
  • “ทุก sign-up ต้องกดยืนยัน email เสมอ” พิสูจน์แล้วว่าไม่จริงบน local: พอ enable_confirmations = false, /auth/v1/signup คืน session เต็มทันที ไม่มี email เข้ามาเกี่ยวเลย การยืนยันเป็น gate ที่ตั้งค่าได้ ไม่ใช่ส่วนที่ติดมากับ flow เสมอ
  • “Magic link กับ OTP คือกลไกเดียวกัน ต่างกันแค่ email กับ SMS” ใกล้เคียงแต่กลไกพิสูจน์และแลกต่างกันจริง: magic link คือ URL แบบ GET ที่กดได้ มี token ฝังอยู่ในตัว; phone OTP คือรหัสสั้น ๆ ที่ user พิมพ์กลับมา แลกด้วย verifyOtp — ไม่มีลิงก์ให้กด เพราะ SMS ไม่มี browser context ให้กด
  • “admin.createUser() กับ signUp() ใช้แทนกันได้ แค่ชื่อต่างกัน” signUp() เป็น endpoint public ที่ใช้ key anon ใครก็เรียกให้ตัวเองได้; admin.createUser() ต้องใช้ service_role และตั้งใจให้ server ที่เชื่อถือได้สร้าง account ให้คนอื่น — เอาสองอันมาสับสนกัน (เรียก admin call จาก browser ได้) คือทางตรงไปสู่ SpotTheBug ข้างบนเป๊ะ
  • “Anonymous sign-in เหมือนกับการไม่ signed-in เลย (แบบ role anon)” anonymous sign-in คือ session role authenticated จริง แยกต่างหาก มี auth.users row ของตัวเองและ is_anonymous: true — RLS policy เห็น auth.uid() จริง ต่างจาก request ที่ role anon แบบไม่ signed-in เลยจริง ๆ
curl POST ไปที่ /auth/v1/signup จริงบน local stack คืน access_token กลับมาทันที ไม่มี email ยืนยันเลย ทำไม
ความสัมพันธ์ระหว่าง auth.users กับ auth.identities คืออะไร
ขอ magic link ผ่าน /auth/v1/otp คืน response {} ว่างเปล่า ทำไมไม่มีอะไรที่ใช้ได้เลย
Anonymous sign-in ถูกปิดบน local stack นี้ response จริงเฉพาะเจาะจงตอนพยายาม anonymous sign-in คืออะไร
ทำไมการเรียก endpoint /auth/v1/admin/users ด้วย key anon ถึง fail ทั้งที่ request เดียวกันสำเร็จด้วย service_role
อะไรทำให้ environment variable ชื่อ NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY เป็นบั๊กความปลอดภัยร้ายแรง ไม่ใช่แค่การตั้งชื่อที่ไม่ธรรมดา