Authentication and Authorization
Navaid uses Supabase Auth email OTP, an invite-gated account-creation hook, multi-company memberships, Postgres RLS, and explicit Edge Function authorization. Supabase Auth can also act as an OAuth 2.1 authorization server for the remote MCP connector.
Authentication answers who the caller is. Membership, role, session relationship, invitation token, document ownership, and feature configuration answer what the caller may do.
Magic Code OTP Flow
Existing-user sign-in
Login email form
│
├─ signInWithOtp(email, shouldCreateUser:false)
│ └─ unknown email is rejected; no silent account creation
│
├─ Supabase Auth sends six-digit code
│
├─ user enters code
│
└─ verifyOtp(email, token)
├─ Supabase session stored
├─ public.users profile loaded
├─ active company memberships loaded
└─ safe redirect restored
Invite-gated signup
/login?mode=signup
│
├─ inviteCodePreflight(email, optional code)
│ ├─ rate-limited public Edge Function
│ ├─ deliberately uniform result to avoid account/invite enumeration
│ └─ optional 15-minute reservation
│
├─ signInWithOtp(email, shouldCreateUser:true, invite_code metadata)
│
└─ before_user_created database hook
├─ fail closed when email is absent
├─ permit an unexpired invitation for the exact normalized email, or
├─ atomically validate/consume a code
├─ use (code,email) ledger for retry idempotency
└─ reject every other account creation
The browser preflight is not the security boundary. Direct Auth API requests
still encounter hook_before_user_created.
Code settings
The checked-in local config uses:
| Setting | Value |
|---|---|
| OTP length | 6 |
| OTP expiry | 3600 seconds |
| Email sends | 2 per hour |
| Sign-in/signup requests | 30 per 5 minutes |
| Token verifications | 30 per 5 minutes |
| Refresh-token rotation | enabled |
| Refresh-token reuse interval | 10 seconds |
| Inactivity timeout | not configured |
The UI's 60-second counter is a resend cooldown, not code expiry. Hosted values must be verified in Supabase Auth settings.
Supabase Auth Configuration
Required configuration
- email signup/sign-in enabled
- six-digit OTP template compatible with the UI
- allowed site/redirect URLs for the application
- production SMTP provider for hosted OTP delivery
- Before User Created hook pointing to
pg-functions://postgres/public/hook_before_user_created
The migration creates the hook function, but the hosted Auth control-plane setting must also be enabled. Without it, the signup gate is not load-bearing.
OAuth server configuration
MCP requires the hosted Supabase OAuth server, authorization URL
/oauth/consent, and the chosen dynamic-client-registration policy. The
audited developer-local, Git-ignored supabase/config.toml has OAuth server and
dynamic registration disabled; that is neither a reproducible main-branch
default nor evidence of hosted configuration.
See MCP and OAuth.
SMTP versus Resend application mail
These are separate:
- Supabase Auth SMTP sends OTP/magic-code email.
RESEND_API_KEYEdge Function secret sends company invitation, feedback-board, and group-session notification mail.
Setting RESEND_API_KEY does not automatically configure Auth SMTP.
Frontend Implementation
Auth API
frontend/src/services/api/auth.ts provides:
signInWithOtp(email, options)verifyOtp(email, token)signOut()getCurrentUser()- profile/avatar update
- data export
- account deletion
getCurrentUser() selects the profile and nested memberships/companies. If the
profile trigger is momentarily absent it attempts a minimal own-row insert and
can return a minimal profile so the auth flow does not deadlock.
useAuth
The hook:
- subscribes to Supabase Auth state,
- loads the current application profile,
- loads active memberships,
- selects/restores an accessible company,
- clears identity state on sign-out,
- leaves device-local
sidebarHiddenintact.
Do not infer authorization from the selected company alone.
Auth guards
AuthGuard protects /app. It redirects unauthenticated users and routes
authenticated users without a company through onboarding.
SystemAdminGuard protects /app/admin from rendering for a non-system-admin.
It is a structural/privacy control; RLS and RPC checks remain authoritative.
Company-admin affordances use permission utilities. Backend policies/functions must independently enforce the same role.
Post-login redirect safety
The login flow can preserve an intended internal route, including OAuth consent. Redirects must:
- be same-origin relative paths,
- reject protocol-relative or absolute external URLs,
- avoid loops back to auth pages,
- preserve only expected query parameters.
This prevents an authentication link from becoming an open redirect.
Session Management
JWT validation
Direct PostgREST queries are evaluated by RLS against the JWT.
Edge Functions call:
supabase.auth.getUser(accessToken)
They do not trust a locally decoded payload as proof of current identity.
The project uses ES256 Auth tokens. The Edge Function gateway's legacy verification path rejects them, so every function is configured and deployed with gateway verification off and verifies auth in code:
npx supabase functions deploy <name> \
--project-ref bxmwvqnilignrxkitaae \
--no-verify-jwt
HTTP bearer tokens
_shared/http.ts accepts a case-insensitive RFC 6750 Bearer scheme, trims the
token, and returns a generic 401 for missing/invalid identity.
WebSocket tokens
Browser WebSocket APIs cannot set Authorization headers. chat-live and
aida-live URLs include the current access token as a query parameter and
validate it before upgrading.
Consequences:
- never log full connection URLs,
- keep tokens short-lived and refresh before reconnect,
- do not put tokens in analytics events,
- use a strict Referrer Policy,
- reject upgrades before provider connections on auth failure.
Refresh and cross-tab behaviour
Supabase refresh-token rotation is enabled. Auth events propagate across tabs. Clients should tolerate:
- a refresh in another tab,
- sign-out while a request is in flight,
- a stale WebSocket reconnect token,
- membership removal after initial page load.
Sensitive server operations re-check current membership instead of assuming the session's earlier state.
Role-Based Access Control
System scope
users.is_system_admin grants platform-wide administrative operations.
The authoritative database helper is:
public.is_system_admin()
Company scope
| Role | Capability summary |
|---|---|
company_admin | Company settings, users, invitations, usage and knowledge |
company_consultant | Same permissions as company admin |
company_manager | Company-wide sessions/tasks without full user/settings administration |
company_user | Own sessions and member-scoped data |
Only active memberships count. A user may hold roles in several companies.
Permission utilities
frontend/src/utils/permissions.ts defines:
ADMIN_ROLES = ['company_admin', 'company_consultant']
MANAGER_PLUS_ROLES = [...ADMIN_ROLES, 'company_manager']
Key helpers include:
getUserRoleisAdminRolecanManageUserscanViewCompanySessionscanViewSession
Keep the frontend role sets, _shared/authz.ts, and database
is_company_admin() synchronized.
Permission matrix
| Action | User | Manager | Admin/consultant | System admin |
|---|---|---|---|---|
| Create own session | Yes | Yes | Yes | Yes |
| View own session | Yes | Yes | Yes | Yes |
| View all company sessions | No | Yes | Yes | Yes |
| Update company tasks | Limited member policy | Yes | Yes | Yes |
| Invite/remove members | No | No | Yes | Yes |
| Edit company knowledge | No | No | Yes | Yes |
| Change company budget defaults | No | No | No | Yes |
| Manage prompts/config/invite codes | No | No | No | Yes |
Specific table/function policies are the final authority.
Registration Paths
Path A: Invite-code company creation
- Gated signup and OTP verification.
handle_new_usercreates the profile.- AuthGuard routes a user without a membership to
/create-company. create_company_with_admin(name,slug)inserts company and creator admin membership transactionally.- The user enters
/app.
The old two-step browser insert could orphan a company if membership creation failed. Do not reintroduce it.
Path B: Company invitation
- Company admin/consultant inserts an invitation.
- The database email-domain trigger validates the normalized domain.
send-inviteloads invitation/company/inviter server-side.- The link origin comes from
APP_URL. - Logged-out invitee previews through
get_invitation_by_token(token). - A new invitee can sign up without a reusable code because the Auth hook recognizes the exact pending invitation email.
- The authenticated user calls
accept_invitation(token). - The RPC verifies email, expiry, status and creates membership atomically.
Invitation default expiry is 14 days.
Resend Email Integration
send-invite accepts only invitation_id. It does not trust company name,
inviter name, recipient, role, token, locale, or application URL from the
request.
The function:
- verifies the caller,
- verifies admin/consultant access to the invitation's company,
- loads current rows,
- derives the origin through
appOrigin()andAPP_URL, - selects a locale-aware template,
- sends through Resend when configured,
- returns a controlled
sent:falseresult when mail cannot be sent.
notify-feedback and notify-session-add use the same trusted-origin rule.
Email Domain Whitelisting
companies.allowed_email_domains is enforced by
invitations_enforce_email_domains() on insert and relevant updates.
- comparison is case-insensitive,
- an empty/null list means unrestricted,
- system admins have an explicit escape hatch,
- direct PostgREST writes cannot bypass it.
Frontend validation is helpful feedback, not the control.
Public Token Surfaces
Invitation token
The token is a capability to preview one unexpired invitation, not to enumerate the table or accept as another email.
Session share token
The share token grants a narrow, read-only summary view until expiry/revocation. The function response allowlists fields.
OAuth authorization identifier
The consent-page authorization identifier references a pending Supabase OAuth request. It must survive sign-in but must not be treated as an arbitrary redirect.
Security Features
| Control | Implementation |
|---|---|
| Passwordless login | Supabase email OTP |
| Unknown-email sign-in protection | shouldCreateUser:false |
| Account creation gate | database Before User Created hook |
| Code replay protection | atomic count plus (code,email) ledger |
| Invitation impersonation protection | email checked in accept_invitation |
| Invitation enumeration protection | scoped RPC, no blanket SELECT |
| Domain restriction | database trigger |
| Tenant isolation | active membership RLS/functions |
| Role equivalence | centralized admin role helpers |
| JWT verification | auth.getUser in functions |
| Open redirect defense | same-origin relative target validation |
| Token leakage defense | strict referrer policy and no URL logging |
| Account deletion | authenticated Edge Function plus transactional cleanup RPC |
Failure Modes and Troubleshooting
Signup works locally but not hosted
Check both the migration and hosted Auth Hook configuration. Creating the function alone does not enable the control-plane hook.
Unknown users are accidentally created
Audit every sign-in call for shouldCreateUser:false and verify the hosted
Before User Created hook is enabled.
Valid invite cannot be accepted
Check:
- normalized authenticated email matches invitation email,
- invitation is unexpired/unaccepted,
accept_invitationexecute grant exists,- role constraint includes consultant when applicable.
Company invitation fails on domain
Read allowed_email_domains; the database trigger may reject a direct write
even if the UI did not show a validation error.
Edge Function receives gateway 401 before code runs
Redeploy with the required --no-verify-jwt flag.
OAuth connector cannot discover metadata
Verify hosted OAuth server/DCR configuration, MCP deployment flags, and the unauthenticated challenge/metadata routes. See MCP and OAuth.
Extension Guidance
When adding an identity provider or auth flow:
- keep the signup gate fail closed for identities without a verified email,
- decide how the identity maps to an invitation/code,
- update redirect allowlists and safe-redirect validation,
- test session refresh, sign-out and cross-tab changes,
- test unauthenticated and cross-tenant paths,
- update OAuth consent/scopes if applicable,
- never weaken RLS to compensate for a frontend timing issue.