Authentication
Signing in, resetting a password and recovering a lost authenticator all go
through one server-side function, auth-login. Rules:
ACC-060 … ACC-068.
Auth is built on Supabase Auth. There is no custom session layer: the JWT that
auth-login obtains from Supabase is the session, and the browser installs it with
supabase.auth.setSession(). Handlers read the user through supabase.auth.getUser().
What the browser does not do any more: resolve a username or phone number to an email, check its own captcha, or decide that a second factor has been passed. Each of those now happens on the server — see Signing in for why.
Endpoint catalog
| Route | Method | Who may call | Notes |
|---|---|---|---|
auth-login (edge function) |
POST | anyone | Sign in, reset a password, recover with a backup code, sign up with a WhatsApp code, set the first password from a WhatsApp sign-up link — see below |
whatsapp-webhook (edge function) |
POST | Meta only (signed) | Also the sign-up in the WhatsApp chat: "sign up" → the form; the filled form → the login and a set-password link (TRV-017). No browser calls it |
customer-signup (edge function) |
POST | anyone (captcha or app key) | Customer sign-up by email: the login (User + CUSTOMER role) and the confirmation email — no Customer row (TRV-007) |
/auth/register |
POST | public | Old browser route, called by no screen: supabase.auth.signUp, a User row and the CUSTOMER role — no Customer row (LC-006) |
/auth/partner-signup |
POST | public | B2B partner self-signup (creates Agent row, status=pending) |
/auth/me |
GET | authenticated | Returns { user } for the current JWT |
/auth/2fa/settings |
GET | authenticated (self) | { enabled, factorId, backupCodesRemaining } — the real state of your authenticator |
/auth/2fa/totp/enroll |
POST | authenticated (self) | Start adding an authenticator (Supabase MFA) |
/auth/2fa/totp/verify-enroll |
POST | authenticated (self) | Confirm it with a code; returns the first ten backup codes |
/auth/2fa/totp/disenroll |
POST | authenticated (self, aal2) |
Remove your authenticator ({ factorId } required) |
/auth/2fa/backup-codes/regenerate |
POST | authenticated (self, aal2) |
New backup codes; unused old ones stop working |
account-delete (edge function) |
POST | a traveller or partner (self); staff with customers.edit / partners.edit for the office's actions |
Request account deletion (always a request), and the office's complete (approve) / finish — see Deleting an account |
Retired on 2026-09-23 and gone: POST /auth/login, POST /auth/resolve-identifier,
POST /auth/2fa/status, POST /auth/otp/send, POST /auth/otp/verify,
POST /auth/2fa/recover, and PATCH /auth/2fa/settings.
auth-login
POST {SUPABASE_URL}/functions/v1/auth-login — called with
supabase.functions.invoke('auth-login', { body }). Deployed with verify_jwt = false,
because it is called before anybody is signed in.
Every call is throttled (ACC-063) and carries a Turnstile token, verified with Cloudflare. A token works once, so the sign-in forms issue a fresh challenge after each attempt.
Sign in
{ "action": "sign_in", "audience": "staff",
"identifier": "sameer", "password": "…", "captchaToken": "…" }
audience: "staff"—identifieris a username or either company address;@alhuda.co.inand@alhudatravels.inare the same person (ACC-061). Only active staff.audience: "portal"—identifieris an email or a phone number in any format. Only active customers and partners; a phone shared by two accounts matches neither.
200 — { "session": { "access_token", "refresh_token", "expires_in", "expires_at", "token_type" }, "mfaRequired": false }
If mfaRequired is true, the browser must now pass the TOTP challenge
(supabase.auth.mfa.challenge + verify). Until it does, the session is aal1 and the
database grants it nothing — no permission, not even "is staff" (ACC-064).
Reset a password
{ "action": "reset", "audience": "staff", "identifier": "sameer",
"captchaToken": "…", "redirectTo": "https://…/reset-password?mode=recovery&returnTo=/auth" }
200 — always { "ok": true, "message": "If an account matches, a link …" }, whether
or not an account matched (ACC-062). redirectTo is only honoured on an allowed origin:
https://alhudatravels.in, https://www.alhudatravels.in and https://travel.alhuda.co.in
always, plus SITE_URL and each origin in AUTH_ALLOWED_ORIGINS when set. Anything else,
or a path other than /reset-password, is replaced with
<SITE_URL>/reset-password?mode=recovery&returnTo=%2Fauth (%2Fcustomer%2Fauth for
audience: "portal"; SITE_URL defaults to https://alhudatravels.in), so a crafted
request cannot make the email hand the reset token to another site. An allowed redirect
always leaves with mode=recovery, which is what opens the new-password form
(ACC-072).
A reset code on WhatsApp (ACC-069)
200 — always { "ok": true, "available": true, "message": "If an account matches and has a
WhatsApp number, a 6-digit code …" }, whether or not an account or a number matched. When
WhatsApp codes are not configured (WHATSAPP_OTP_TEMPLATE_NAME unset, or no Meta
credentials) the answer is { "ok": true, "available": false, "message": "WhatsApp codes are
not switched on yet …" } — said before any account is looked up. Portals only; audience:
"staff" is 400 invalid_input. One code a minute per account; ten-minute life.
When identifier is a phone number that matched the account, the code goes to that
number; for an email address it goes to the login's number, else the customer's, else the
partner's (auth_reset_contact(p_user_id, p_typed_phone)). If WhatsApp refuses the send,
Meta's error code and words are kept on the attempt and listed on
GET /admin/whatsapp-delivery-failures; the answer
to the caller does not change.
{ "action": "reset_code_verify", "audience": "portal", "identifier": "9419975568",
"code": "246810", "password": "…", "captchaToken": "…" }
200 — { "ok": true, "message": "Your new password is active. Sign in with it now." }.
No session is returned; the person signs in normally. A wrong, expired, spent or locked
code — or no matching account — is 401 invalid_code with one message. Five wrong codes
lock that code for good. Both actions count as attempts under the throttle.
Sign up with a WhatsApp code (TRV-016)
Customers only; audience must be "portal", identifier is the mobile number (10–15
digits, any spelling; a bare 10 digits is India). Rule:
TRV-016.
200 — always { "ok": true, "available": true, "message": "If this number can be used for
a new account, a 6-digit code is on its way …" }: for a new number (a code is sent), for a
number that already has an account or is shared by two (no code), and within a minute of the
last code (no code). available: false when WhatsApp codes are not configured — said before
the number is looked up. The code goes through the WHATSAPP_OTP_TEMPLATE_NAME template;
a refusal by Meta is kept and listed on
GET /admin/whatsapp-delivery-failures.
{ "action": "signup_verify", "audience": "portal", "identifier": "9000020001", "code": "246810",
"password": "…", "fullName": "Zara Test", "email": "zara@example.com", "captchaToken": "…" }
email is optional (null or absent). 200 — { "ok": true, "session": { … }, "mfaRequired":
false, "message": "Your account is ready. …" }: the login is made — a User with the
CUSTOMER role, the number (E.164) and phoneVerifiedAt, no Customer row — and signed in.
session is null in the rare case the new login could not be signed in at once; the person
then signs in normally. A wrong, expired, spent or locked code is 401 invalid_code with one
message. 409 phone_taken only when the number got an account between the code and this call
(the code proved the caller owns the number). The auth user's email is a placeholder
(wa.<digits>.<random>@signup.alhudatravels.invalid, never mailed and never returned).
Both actions count as attempts under the throttle. Database functions (service role only):
auth_signup_code_issue, auth_signup_code_check, auth_signup_phone_taken,
auth_signup_create_login (channel 'whatsapp_code', the default); the table SignupCode.
The login itself is written by createVerifiedCustomerLogin()
(supabase/functions/_shared/customerLogin.ts), which the sign-up inside a WhatsApp chat
reuses with channel 'whatsapp_flow' (below).
A reset (link by email) for an account made this way sends nothing — the placeholder
receives no mail and the email it gave is unconfirmed — and answers as always; such an
account resets with reset_code.
Set the first password from a WhatsApp sign-up link (TRV-017)
A customer who signed up by chatting on WhatsApp gets a one-time link in the chat,
/customer/set-password?t=<token>. The page posts the token and the password. Rule:
TRV-017.
{ "action": "set_password_with_token", "audience": "portal", "token": "<43 characters>", "password": "…" }
No identifier and no captcha: the token (32 random bytes, base64url) is the proof. It is
also the throttle key, so tries are counted per link and per address (ACC-063). 200 —
{ "ok": true, "session": { … }, "mfaRequired": false, "message": "Your password is set. …" }:
auth_password_set_token_use() spent the link, the password was set through the admin API
and the login signed in (session is null in the rare case signing in failed; the password
is set anyway). A token that is wrong, expired, already used, superseded, or whose login is
paused, deleted or not a customer-only login is 401 invalid_link with one message. A
malformed token or a password under 8 characters is 400 invalid_input.
Database functions (service role only): auth_password_set_token_issue (by
whatsapp-webhook, 30 minutes, supersedes the older link), auth_password_set_token_use;
the table PasswordSetToken (hash only). The webhook side — the form token
(SignupFlowToken, auth_signup_flow_issue, auth_signup_flow_use) and the login with
auth_signup_create_login(..., p_channel => 'whatsapp_flow') — is described in
operations → WhatsApp.
Recover with a backup code
{ "action": "recover", "audience": "staff", "identifier": "sameer",
"password": "…", "backupCode": "ABCDE-23456", "captchaToken": "…" }
Proves the password again, spends the code exactly once (mfa_consume_backup_code),
removes the lost authenticator with the Supabase admin API and returns a session
(mfaRequired: false, recovered: true). The person then sets up a new authenticator.
Errors
Every refusal has the same shape — { "error": "<code>", "message": "<safe to show>" } —
and takes at least 700 ms, so timing cannot tell "no account" from "wrong password".
| Status | error |
Meaning |
|---|---|---|
| 400 | invalid_input |
Missing or malformed field |
| 400 | captcha |
The captcha did not verify — complete it again |
| 401 | invalid_credentials |
Wrong identifier, wrong password, inactive, or the wrong sign-in page. One message for all of them. |
| 401 | invalid_backup_code |
Code wrong or already used |
| 401 | invalid_code |
WhatsApp reset or sign-up code wrong, expired, spent or locked — or no matching account. One message for all of them. |
| 409 | phone_taken |
signup_verify only: the number got an account after the code was sent |
| 401 | invalid_link |
set_password_with_token only: the link is wrong, expired, used or its login is not an active customer login. One message for all of them. |
| 400 | no_authenticator |
Recovery requested for an account with nothing to recover |
| 429 | too_many_attempts |
Locked; retryAfterSeconds and a Retry-After header say for how long |
| 503 | unavailable |
A fault on our side — logged in full, never reported as a wrong password |
Two-step verification
The second factor is an authenticator app (ACC-065). Adding one:
POST /auth/2fa/totp/enroll→ a QR code for the app.POST /auth/2fa/totp/verify-enrollwith{ factorId, code }— this also upgrades the session toaal2and returns ten backup codes, shown once.
From then on every sign-in needs the code, and the database enforces it (ACC-064).
Backup codes come from mfa_regenerate_backup_codes(), which refuses anything but an
aal2 session; no browser session can read or delete them (ACC-066).
/auth/2fa/settings is read-only: "two-step verification is on" means an authenticator
exists, not a flag anybody can flip.
Registration flows
customer-signup (edge function) — customer sign-up by email
POST {SUPABASE_URL}/functions/v1/customer-signup, verify_jwt = false. Called by the
website's Sign Up → Email (with the Turnstile token) and by the app (with app:<MOBILE_APP_KEY>).
{ "email": "zara@example.com", "password": "…", "firstName": "Zara", "lastName": "Test",
"phone": "+919000040001", "captchaToken": "…" }
{ "action": "resend", "email": "…" } asks for the link again. 200 — always { "ok": true,
"message": "If this email is new to us, a confirmation link is on its way. …" }, whether or
not the address has an account (ACC-062). It creates the auth user unconfirmed, then
auth_customer_login_create() writes the User row (name, number in E.164, email) and the
CUSTOMER role — never a Customer row; the first booking request makes that
(LC-006).
If the database refuses, the auth user is deleted again. Errors: 400 email / password /
name / captcha, 429 throttled (auth_login_throttle, action sign_up), 502 mail (the
confirmation email could not be sent; the login exists and resend works), 500 unavailable.
customer-exchange (edge function, the app only) repairs a confirmed login that has no User
row through the same auth_customer_login_create() — keyed by the login's id, never by email;
it never makes a Customer row and never touches a staff login.
self_register_customer(p_first_name, p_last_name, p_phone) (RPC) is no longer called by any
screen. For an old cached page it still gives the CUSTOMER role, puts what was typed on a login
that has none, and makes the record through customer_record_for_login() — with a
SIGNUP-… placeholder passport, never a blank one.
POST /auth/register — old browser route (no screen calls it)
Cite: src/lib/api.ts:2342
Input — { email, password, fullName, phone?, passportNo?, nationality?,
passportExpiry?, address? }.
Work:
1. supabase.auth.signUp creates the auth user.
2. Upsert a User row mirroring the auth account (so role/permission queries work).
3. self_assign_portal_role('CUSTOMER').
4. No Customer row (LC-006): the first booking request makes it.
5. Send a welcome email via sendMailer('customer_welcome', ...).
Returns — { id, email }.
POST /auth/partner-signup
Cite: src/lib/api.ts:2462
Input — { email, password, company, name, phone?, panCard, gstNumber?, address?,
agreementAccepted, documents? }.
Work:
1. Validate password length (≥8), PAN card (exactly 10 chars), and email uniqueness across
Agent.
2. supabase.auth.signUp creates the auth user.
3. Upsert User, assign the AGENT role.
4. Insert an Agent row with status='pending' (back-office must approve before the
partner can act on gated flows).
5. Persist any uploaded partner documents into AgentDocument.
6. Strip auto-assigned EMPLOYEE / MANAGER / CUSTOMER roles that Supabase triggers
sometimes seed (defensive cleanup).
Returns — { ok: true, agentId, status: 'pending' }.
Partner approval
New partners are status='pending' until an admin approves via
PATCH /agents/:id (see Customers + Partners). The login still
succeeds for a pending partner, but UI flows gate certain actions until
status='active'.
From the app
The native app registers a partner through the partner-signup edge function instead
(email confirmed first; the same rows, the same checks; PTR-082),
and files the registration documents after sign-in through upload-partner-doc
(Bookings → Portals).
GET /auth/me
Cite: src/lib/api.ts:2437
Returns the current session's user profile, role, and fullName. Called on page load by the
auth provider to hydrate useUser() state.
Errors — 401 if no JWT.
Password management
- Forgotten password — the reset page calls
auth-loginwithaction: "reset"(above). Staff may type their username or either company address; customers and partners their email or phone. - Setting the new password — Supabase's recovery link brings the person back to
/reset-password?mode=recovery, which callssupabase.auth.updateUser({ password }). - Admin reset —
PATCH /users/:id/password(see Admin), which requiresadmin.users.reset_password.
Customer password via admin
Staff can reset a customer portal password through
PATCH /sales/customers/:id/password (see Customers).
Deleting an account
Rules: AUD-025 … AUD-027.
Migrations 20261004090000_a_person_deletes_their_account.sql and
20261005090000_an_account_is_deleted_only_on_request.sql (since 1 Oct 2026 nobody erases their
own account: delete always files a request the office approves or declines).
account-delete (edge function)
POST {SUPABASE_URL}/functions/v1/account-delete, called with
supabase.functions.invoke('account-delete', { body }). Deployed with verify_jwt = false
(supabase/config.toml); the function checks the bearer itself (getAuthContext — an
inactive login is refused with 403). Every answer is JSON with a message to show as it is.
action |
Body | Who | What happens |
|---|---|---|---|
delete |
password, confirm: "DELETE", via: "app" \| "web" |
the signed-in person; a CUSTOMER or AGENT login (staff: 403) | The password is checked by a sign-in with the anon key (the extra session is signed out; wrong: 401 password; throttled with the sign-in rules: 429 throttled with retryAfterSeconds). Then account_delete_self, which never erases. { status: "requested", requestId, blockers: [{ code, label, ref }], message } — always; nothing is erased. message is "Your request has been sent. The office will review your account and contact you.", and for a traveller with something to settle first it adds the list. blockers[0] is the review line (office_review, or partner_review for a partner), then what is in the way. { status: "already_deleted" } — the office has already approved. (The deleted answer and its follow-up remain for a database before 20261005090000.) |
complete |
requestId, note? |
staff | The office's approval: account_deletion_complete with the caller's id: customers.edit (a traveller) or partners.edit (a partner) is checked in the database; refused (400) while something is still in the way. Then the follow-up: the goodbye email to the old address, the Drive files to the trash, the auth login deleted (auth.admin.deleteUser; banned if that fails). Returns followUp: { emailSent, driveFilesLeft, authDeleted }. |
finish |
requestId |
staff, same permission | Retries the follow-up of a completed deletion (Drive files still to trash, the auth login). The email is not sent again. |
Body errors (400): confirm (the word is not DELETE), password (empty), request (no
requestId), action (unknown). The password is never logged or echoed.
Database functions
| Function | Who may run it | Returns / does |
|---|---|---|
account_deletion_status() |
authenticated (own login) |
{ eligible, kind, byRequestOnly: true, blockers, canDeleteNow: false, request: { id, status, requestedAt, decidedAt, decisionNote } }; blockers starts with the review line; a staff login gets eligible: false |
account_delete_self(p_user_id, p_confirm, p_via) |
service role only | Records one pending request (row-locked; a second call finds it) with account_deletion_reasons(); never erases; refuses staff. Returns { status, requestId, kind, blockers } |
account_deletion_requests(p_status) |
authenticated; customers.edit / partners.edit per kind |
The office's list: pending (with the blockers worked out again, and answerBy = asked + 30 days), completed (no name), declined, all; at most 200 |
account_deletion_complete(p_request_id, p_actor_id, p_note) |
service role only | The office's completion, permission checked on p_actor_id |
account_deletion_decline(p_request_id, p_note) |
authenticated; customers.edit / partners.edit per kind |
Declines a pending request; the note is required and shown to the person |
account_deletion_finish(p_request_id, p_trashed_drive_ids, p_auth_deleted, p_email_sent) |
service role only | Records the follow-up on the row and marks the Drive files in DriveFile as trashed |
account_deletion_blockers(p_user_id), account_deletion_reasons(p_user_id), account_deletion_money(p_currency, p_amount), account_anonymise(p_user_id, p_request_id), account_deletion_subject(p_user_id) |
service role only (internal) | What is in the way (codes upcoming_booking, balance_due, credit_held, refund_pending, invoice_credit, on_account_credit, payment_claim, open_request, invoice_due, leads_group, paused); the review line plus the blockers; money in words (₹ for INR); the erasure itself (AUD-026) |
The table AccountDeletion is read by the person (own rows) and the office (per kind); no
role may write it directly.
Partner + customer portal separation
The portals (/portals/agent/*, /portals/customer/*) share the same Supabase JWT as staff
logins but use row-scoped handlers instead of permission checks. Example —
resolveAgentForCurrentUser() returns the single Agent row tied to the current JWT, and
every partner-portal query pre-filters by agentId so one partner cannot see another's
bookings/payments. A staff account cannot use a portal sign-in page, and a portal account
cannot use the staff one (auth-login refuses both with the same message).