Skip to content

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" — identifier is a username or either company address; @alhuda.co.in and @alhudatravels.in are the same person (ACC-061). Only active staff.
  • audience: "portal" — identifier is 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)

{ "action": "reset_code", "audience": "portal", "identifier": "9419975568", "captchaToken": "…" }

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.

{ "action": "signup_code", "audience": "portal", "identifier": "9000020001", "captchaToken": "…" }

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.

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:

  1. POST /auth/2fa/totp/enroll → a QR code for the app.
  2. POST /auth/2fa/totp/verify-enroll with { factorId, code } — this also upgrades the session to aal2 and 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-login with action: "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 calls supabase.auth.updateUser({ password }).
  • Admin reset — PATCH /users/:id/password (see Admin), which requires admin.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).

These routes are documented under Bookings and Admin.