Authentication Methods
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Supabase Auth สร้างขึ้นบน GoTrue server แบบ open-source และรองรับวิธีพิสูจน์ตัวตนของ user หลายแบบ — password, magic link, phone OTP, anonymous หรือ OAuth — และทุกวิธีจบลงที่จุดเดียวกันคือ row ที่ถูกสร้างหรือ match ใน table auth.users ที่มีมาให้
Email + password: sign-up จริง, sign-in จริง
หัวข้อที่มีชื่อว่า “Email + password: sign-up จริง, sign-in จริง”supabase-js เปิด method แต่ละอันไว้ที่ supabase.auth แต่ข้างใต้ทุกอันคือ HTTP request ธรรมดาไปที่ endpoint /auth/v1/* ของ GoTrue เอง — เห็นได้ตรง ๆ ด้วย curl กับ local stack ของคอร์สนี้:
curl -s -X POST "$API_URL/auth/v1/signup" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \{"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:
curl -s -X POST "$API_URL/auth/v1/token?grant_type=password" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \{"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", "..." : "..."}}user จริง ๆ อยู่ที่ไหน: auth.users กับ auth.identities
หัวข้อที่มีชื่อว่า “user จริง ๆ อยู่ที่ไหน: auth.users กับ auth.identities”ไม่ว่าจะ 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+00raw_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 / OTP: email จริง ครบทุกขั้นตอน
หัวข้อที่มีชื่อว่า “Magic link / OTP: email จริง ครบทุกขั้นตอน”magic link ไม่ต้องใช้ password เลย — GoTrue ส่ง sign-in link แบบใช้ครั้งเดียวทาง email และ (ต่างจาก password sign-up ที่ข้ามการยืนยันไปเลยบน config นี้) มัน ส่ง email จริงเสมอ ถูกจับไว้ที่ Mailpit inbox ของ local stack:
curl -s -X POST "$API_URL/auth/v1/otp" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \{}request เองคืน object ว่างเปล่า — ไม่มี token เลย ตั้งใจแบบนั้น เพราะประเด็นทั้งหมดคือมีแค่ inbox เท่านั้นที่มี token จริง ดึง message จริงจาก API ของ Mailpit เอง (http://127.0.0.1:55324 หาได้จาก INBUCKET_URL/MAILPIT_URL ของ supabase status):
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-ins
หัวข้อที่มีชื่อว่า “Anonymous sign-ins”anonymous sign-in สร้าง row จริงใน auth.users — is_anonymous = true ไม่มี email หรือ password เลย มีประโยชน์สำหรับให้คนใช้แอปได้ก่อนตัดสินใจสมัครสมาชิกจริง แล้วค่อยผูก identity จริงทีหลัง มันถูกคุมด้วย config flag ของตัวเอง enable_anonymous_sign_ins ซึ่งเป็น false บน local stack ของคอร์สนี้:
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 providers
หัวข้อที่มีชื่อว่า “OAuth providers”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 ที่มีอยู่แล้ว
Admin API: service_role ทำตัวแทน GoTrue เองได้
หัวข้อที่มีชื่อว่า “Admin API: service_role ทำตัวแทน GoTrue เองได้”service_role เปิด endpoint อีกชุดแยกต่างหากใต้ /auth/v1/admin/* — operation เดียวกับที่ supabase.auth.admin.* เรียกจาก server context ที่เชื่อถือได้เท่านั้น ไม่ใช่ browser:
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" \{"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 จะถูกปฏิเสธตรง ๆ:
curl -s -X POST "$API_URL/auth/v1/admin/users" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Content-Type: application/json" \{"code":403,"error_code":"not_admin","msg":"User not allowed"}supabase.auth.admin.createUser() ใน supabase-js เป็น wrapper บาง ๆ ทับ endpoint นี้เป๊ะ — ทำงานได้ก็ต่อเมื่อ client ถูกสร้างด้วย key service_role เท่านั้น นี่คือเหตุผลที่มันต้องไม่รันในโค้ดที่ส่งไปถึง browser เด็ดขาด
Rate limits (docs)
หัวข้อที่มีชื่อว่า “Rate limits (docs)”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/verify | IP address | 360/ชั่วโมง burst ได้ถึง 30 |
/auth/v1/token (refresh) | IP address | 1800/ชั่วโมง burst ได้ถึง 30 |
| Anonymous sign-ins | IP address | 30/ชั่วโมง 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")] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “ให้ admin panel สร้าง user account ได้โดยไม่ต้องส่ง email ยืนยัน” แล้วสร้างโค้ดนี้มา:
🐛 หา bug ในโค้ดนี้
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 เท่านั้น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกวิธี 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 เดียวกัน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- 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 ได้อย่างเดียว”
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ทุก 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 ที่ใช้ keyanonใครก็เรียกให้ตัวเองได้;admin.createUser()ต้องใช้service_roleและตั้งใจให้ server ที่เชื่อถือได้สร้าง account ให้คนอื่น — เอาสองอันมาสับสนกัน (เรียก admin call จาก browser ได้) คือทางตรงไปสู่ SpotTheBug ข้างบนเป๊ะ - “Anonymous sign-in เหมือนกับการไม่ signed-in เลย (แบบ role anon)” anonymous sign-in คือ session role
authenticatedจริง แยกต่างหาก มีauth.usersrow ของตัวเองและis_anonymous: true— RLS policy เห็นauth.uid()จริง ต่างจาก request ที่ roleanonแบบไม่ signed-in เลยจริง ๆ