Skip to main content

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:

SettingValue
OTP length6
OTP expiry3600 seconds
Email sends2 per hour
Sign-in/signup requests30 per 5 minutes
Token verifications30 per 5 minutes
Refresh-token rotationenabled
Refresh-token reuse interval10 seconds
Inactivity timeoutnot 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_KEY Edge 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:

  1. subscribes to Supabase Auth state,
  2. loads the current application profile,
  3. loads active memberships,
  4. selects/restores an accessible company,
  5. clears identity state on sign-out,
  6. leaves device-local sidebarHidden intact.

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

RoleCapability summary
company_adminCompany settings, users, invitations, usage and knowledge
company_consultantSame permissions as company admin
company_managerCompany-wide sessions/tasks without full user/settings administration
company_userOwn 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:

  • getUserRole
  • isAdminRole
  • canManageUsers
  • canViewCompanySessions
  • canViewSession

Keep the frontend role sets, _shared/authz.ts, and database is_company_admin() synchronized.

Permission matrix

ActionUserManagerAdmin/consultantSystem admin
Create own sessionYesYesYesYes
View own sessionYesYesYesYes
View all company sessionsNoYesYesYes
Update company tasksLimited member policyYesYesYes
Invite/remove membersNoNoYesYes
Edit company knowledgeNoNoYesYes
Change company budget defaultsNoNoNoYes
Manage prompts/config/invite codesNoNoNoYes

Specific table/function policies are the final authority.

Registration Paths

Path A: Invite-code company creation

  1. Gated signup and OTP verification.
  2. handle_new_user creates the profile.
  3. AuthGuard routes a user without a membership to /create-company.
  4. create_company_with_admin(name,slug) inserts company and creator admin membership transactionally.
  5. 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

  1. Company admin/consultant inserts an invitation.
  2. The database email-domain trigger validates the normalized domain.
  3. send-invite loads invitation/company/inviter server-side.
  4. The link origin comes from APP_URL.
  5. Logged-out invitee previews through get_invitation_by_token(token).
  6. A new invitee can sign up without a reusable code because the Auth hook recognizes the exact pending invitation email.
  7. The authenticated user calls accept_invitation(token).
  8. 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() and APP_URL,
  • selects a locale-aware template,
  • sends through Resend when configured,
  • returns a controlled sent:false result 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

ControlImplementation
Passwordless loginSupabase email OTP
Unknown-email sign-in protectionshouldCreateUser:false
Account creation gatedatabase Before User Created hook
Code replay protectionatomic count plus (code,email) ledger
Invitation impersonation protectionemail checked in accept_invitation
Invitation enumeration protectionscoped RPC, no blanket SELECT
Domain restrictiondatabase trigger
Tenant isolationactive membership RLS/functions
Role equivalencecentralized admin role helpers
JWT verificationauth.getUser in functions
Open redirect defensesame-origin relative target validation
Token leakage defensestrict referrer policy and no URL logging
Account deletionauthenticated 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_invitation execute 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:

  1. keep the signup gate fail closed for identities without a verified email,
  2. decide how the identity maps to an invitation/code,
  3. update redirect allowlists and safe-redirect validation,
  4. test session refresh, sign-out and cross-tab changes,
  5. test unauthenticated and cross-tenant paths,
  6. update OAuth consent/scopes if applicable,
  7. never weaken RLS to compensate for a frontend timing issue.