Skip to main content

Authentication Flows

Three sign-in paths, one codebase, one user context — which then resolves to an active tenant. Ticku is multi-tenant, so authentication answers two questions: who are you (identity) and which tenant are you working in (context). See Multi-Tenancy.

PathAudienceCredentialEmail sending
Teams SSOTeams tab usersEntra JWT via Teams SDK (silent)Microsoft Graph on-behalf-of
Azure SSO (web)Browser, Microsoft accountOAuth → session cookieMicrosoft Graph
Custom login (web)Browser, email + passwordSession cookieSMTP

Request Resolution

Every API request resolves identity in priority order:

After identity is resolved, the server picks the active tenant from the user's ACTIVE memberships — the one matching the requested /t/{slug} (forwarded as x-tenant-slug) or the ticku_tenant cookie, else the home membership, else the first available. role, isAdmin, and permissions all come from that membership. If every membership is suspended, the request is treated as unauthenticated. Full logic in Multi-Tenancy → The active tenant.

Teams SSO (unchanged from day one)

  1. The Teams tab requests a token silently via the Teams SDK.
  2. The server validates issuer, audience, signature, and timestamps.
  3. First sign-in links the Microsoft identity to the pre-registered user record.
caution

Timestamp validation means client clock skew > ~5 minutes breaks sign-in — see Troubleshooting.

Web — Microsoft SSO

Standard OAuth flow against Entra ID. The app registration is multi-tenant (authority common), so sign-ins are accepted from any Azure directory — which means the authority is not the security boundary. The per-tenant directory check below is.

On first sign-in the user is created and assigned to a tenant by domain resolution (below).

Directory verification

The tid claim identifies which Azure directory the user actually authenticated against. It's checked on every Microsoft sign-in, not just the first.

This matters because account linking is enabled for Microsoft (trustedProviders). An existing user — a password signup, a Teams user, a guest invited before their domain was registered — can sign in via Microsoft without ever passing through account creation. Checking only at creation left every returning user gated on an email match alone, and the email claim in a multi-tenant Entra app is not a verified identifier.

The rule has two halves:

  1. Verify — if the tenant has a registered entraDirectoryId, the sign-in's tid must equal it. A mismatch means the account belongs to a different directory than the organization registered: either a misconfiguration or impersonation via a look-alike domain.
  2. Pin (trust on first use) — if no directory is registered yet, adopt the tid from the tenant's designated first admin only. That account is already the tenant's trust anchor, so it's the one sign-in worth trusting to define the directory. Anyone else signing in leaves the field null and stays on domain trust.

Pinning only ever writes into an empty slot, and never claims a directory another tenant already holds.

:::caution Enforcement is behind a flag A mismatch is always logged, but only blocks when ENTRA_TID_ENFORCE=true. With the flag off — the default — verification is observe-only. Turn it on once your tenants' directories are correctly registered. :::

The check fails open on anything it can't determine: no Microsoft account, no ID token, no tid claim, unknown email domain. Only a tid that is present and different ever blocks, so a token oddity can't lock a tenant out.

Implementation note: the check runs in the session create.before hook. It has to be before — better-auth queues create.after hooks past the transaction, so a throw there could not stop the sign-in. It's also gated on the Microsoft callback path, so a password login is never judged against the stale ID token of a linked Microsoft account.

Web — Email + Password

  1. Register at /signup → OTP email verification → pending approval.
  2. A tenant admin approves (Approval Workflow) → membership active.
  3. Forgot-password also uses OTP via email.

Tenant Assignment (domain resolution)

A new user is routed to a tenant by their email domain, not by manual selection:

  • Home vs guest. The domain-matched tenant becomes the user's home; an invited user with no matching tenant gets a guest membership and a null home tenant.
  • Guest adoption. A session hook adopts a homeless guest into their real tenant the first time they sign in after that tenant registers.
  • First-admin bootstrap. When the firstAdminEmail address signs up, the create-user hook auto-promotes it to an active admin and seeds default projects — so a brand-new tenant has someone who can approve everyone else.

Self-Serve Organization Registration

A new organization registers itself before anyone can sign in under its domain:

  1. A prospective admin submits their work email at /register-organization.
  2. The email is verified via OTP (reusing the Better Auth verification table).
  3. A Tenant is created with status PENDING_APPROVAL (rate-limited per IP via RegistrationAttempt).
  4. A platform admin approves it → status ACTIVE.
  5. Users at the tenant's domain can now sign up and are routed in automatically.

Users whose tenant/membership is not yet active land on /pending-approval.

Session & Token Hygiene

  • Web sessions are cookie-based, signed with BETTER_AUTH_SECRET; a rolling session re-issues the cookie so active users aren't logged out mid-work.
  • Teams tokens are validated per-request; nothing token-related is persisted client-side by Ticku.
  • Deactivating a user globally (User.isActive) blocks all three paths immediately; suspending a single Membership blocks only that tenant.
  • The active tenant is remembered in the ticku_tenant cookie; switching tenants never re-authenticates — it re-resolves context from the user's memberships.