A team invitation flow lets an admin grant tenant membership to someone who is not yet a user. If the system treats the link itself as the authorization decision, anyone who gets that email can end up inside the tenant.
System description
Every invitation lives server-side as a pending grant. The email carries only a short-lived claim token. To accept, the recipient signs in; the server matches the session's verified email to the invitation and writes the membership in a single transaction.

Architecture choice
There are two common ways to keep invitation state. One stores it server-side as a database row; the other puts the state inside a signed token sent in the email.
Stateful pending grant
The invitation is stored server-side. The email contains a random token, and the database stores only its hash.
Use this when:
You want only one pending invite at a time for the same target; some products define the target as
(tenant, email), others as(tenant, email, role)Revocation must be cheap: flagging a pending invitation invalid should be one write
Forensic context per issued invitation (inviter, role, IP, user agent) matters
Trade-off: the table is hot-path on accept, so read and write latency matter.
Stateless signed token
The invitation is a signed blob carrying the membership claim (tenant_id, role, invited_email), verified with a shared signing key. No database row per invitation.
Use this when:
Shared state isn't available and a database row per invitation isn't an option
You want to trade revocability for fewer moving parts
Trade-off: revoking a pending invitation before expiry requires rotating the shared signing key, which invalidates every invitation for every tenant. Enforcing the one-pending-invitation rule still requires shared state, so most of the reason to go stateless disappears.
Default to stateful. You need single-invitation revocation without rotating a key that invalidates every other tenant's pending invitations.
Golden path
Build this first. Then relax constraints only if you have a specific reason:
Admin requests an invite for an email and role → API persists a pending grant and sends the email link → Invitee authenticates with a verified email → API matches the session identity to the invitation → API creates membership and consumes the invitation in one transaction → API notifies the inviterRelated patterns:
For the same emailed-token shape applied to account recovery, see Password Reset Flows: The Secure Implementation Guide
For the tenant boundary an invitation crosses and the registry it joins, see Multi-Tenant File Sharing: Secure Control Plane Architecture
If the accept handler is retried by a link preview or a rapid double-click, see Designing API Idempotency Keys to Prevent Duplicate Writes for the write-first pattern
Core design
Pending invitation record
Each invitation has these fields:
invitation_id(opaque UUID)tenant_id(the membership target)role(the role the invitee will hold on accept)invited_email(normalized: lowercased, IDNA, trimmed)inviter_principal_idclaim_token_hash(SHA-256 over the raw token, indexed unique)expires_at(creation + 7 days for member invites, shorter for admin)consumed_at,revoked_at(nullable; the row's lifecycle moves throughpending → consumed | revoked | expiredexactly once)issued_ip,issued_ua(audit context)
tenant_id, role, invited_email, and inviter_principal_id are immutable after creation.
Claim token issuance
The token itself is 32 bytes from a CSPRNG, encoded URL-safe base64 in the email link, with only SHA-256(token) persisted server-side. The link host is sourced from application configuration, never the request Host header.
Acceptance handler
Only POST /invitations/{token}/accept consumes the invitation. The preview endpoint GET /invitations/{token} is side-effect free, so link scanners and email previewers cannot accidentally accept. The accept endpoint requires an authenticated session.
Compute
SHA-256(token)and load the invitation rowVerify
expires_at > NOW(),consumed_at IS NULL,revoked_at IS NULLCompare the session's current verified email to
invited_emailafter normalization. If the invitee's IdP-verified email has changed since issuance, the comparison fails and an admin must reissue the invitation to the new addressOpen a single database transaction. Run an atomic conditional update on the invitation row, setting
consumed_at = NOW()only whenconsumed_at IS NULL AND revoked_at IS NULL AND expires_at > NOW(). If zero rows update, abort the transaction and return the generic invitation errorInside the same transaction, insert the membership row and write the durable audit row, then commit. The user-facing notification email is enqueued only after the commit, so a rolled-back accept never generates a notification
Return the same response (identical status, body shape, and response time within normal jitter) for any failed accept (revoked, expired, unknown, or recipient mismatch), so the endpoint does not become an oracle for invitation state
An invitation token proves possession of the link, not control of the inbox. Acceptance requires an independently verified identity, and that identity is established outside the invitation flow.
For a new invitee, that means either SSO sign-in through a configured IdP, or a separate signup flow with its own email verification step, before the accept handler runs. A combined "click invite + auto-create account + auto-verify email" flow does not satisfy this requirement; the click is treated as link possession only.
For tenants that require SSO, the accept flow must use the tenant’s configured IdP. A local password login is rejected even if the email matches. An approved email-domain rule can allow more addresses, but only for sessions that came through that IdP.
Lifecycle and revocation
Admins revoke a pending invitation by setting revoked_at = NOW() on the row. The accept handler treats revoked rows the same as expired ones, so the old link starts failing immediately with the generic invitation error.
Issuing a new invitation for the same target key supersedes any prior pending row in the same transaction; the old row's revoked_at is set and its token hash is rotated. The target key is typically (tenant_id, invited_email, role). Some products tighten it to (tenant_id, invited_email) so a member-role invitation cannot coexist with a pending admin-role invitation for the same address.
Minimal API shape
POST /tenants/{id}/invitations → 201 { invitation_id, expires_at }
GET /invitations/{token} → 200 { tenant_name, role, invited_email_hint }
POST /invitations/{token}/accept → 204 on success
DELETE /tenants/{id}/invitations/{iid} → 204 (revoke pending)
Threat model
Baseline assumptions
The invitee's email account is semi-trusted. Inbox compromise and forwarding rules are in scope; full end-to-end email confidentiality is not assumed
The invitee is untrusted: the link may be replayed or forwarded, and may be submitted from any account the recipient controls
The control plane derives tenant context from the auth token, not the request body
Invitation emails are sent through a verified sender with SPF, DKIM, and DMARC alignment
Standard infra controls such as TLS, WAF, database AuthN, and logging hygiene are assumed to be in place. This model focuses on the invitation lifecycle itself
A note on risk
This table is not a checklist. Focus on preventing the highest-impact failures first. Detection and response are acceptable where prevention is impractical.
Phase 1: Issuance
Focus: Restricting issuance authority and locking the invitation record at creation time
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Tenant role assignment | Privilege creep: An inviter with a tenant-admin role assigns the invitee a higher role, such as owner, even though they are not allowed to grant it | Inviter must be a tenant admin | 1. Server-side rule based on an explicit assignable-role policy. If roles do not have a clear order, define exactly which roles each role can grant 2. Require a second admin to confirm role-elevating invites 3. Treat owner-role invites as a separate, audited path with longer review | High |
Invitation record | Mass assignment: Request body overwrites server-controlled fields (such as | None | 1. Allowlist invite-creation fields explicitly; derive 2. Make ownership fields immutable in the schema and at the ORM layer | Medium |
Tenant boundary | Wrong-tenant binding: Invite created against a tenant the caller does not administer | Auth context is available per request | 1. Resolve 2. Reject any tenant identifier supplied in the body | Medium |
Phase 2: Token and email
Focus: Protecting the token between issuance and acceptance
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Claim token | Guessing: Attacker brute-forces tokens against the accept endpoint | None | 1. CSPRNG-sourced token with at least 256 bits of entropy 2. Throttle and alert on repeated failed binding checks (per token, per IP, and globally); do not invalidate the token on failed attempts, since invalidation lets a leaked-link holder burn the legitimate user's invitation 3. Per-IP and global rate limits on the accept endpoint | Low |
Token store | Storage exposure: A DB backup or table dump leaks pending invitations | None | 1. Store only 2. Restrict direct access to the invitations table to the auth service role 3. Alert on bulk reads of the table | Medium |
Invitation link | Email transit exposure: Link captured from any place the email reaches (inbox compromise, forwarded message, shared device, etc.) | None | 1. Short TTL: 7 days for member invites, 24 to 48 hours for admin invites 2. 3. Recipient binding at accept (Phase 3) means a captured link is not directly usable | Medium |
Invitation link host | Host header poisoning: Request submitted with a spoofed | None | 1. Build invite URLs from a config value, never 2. Allowlist-validate the host if multi-brand domains exist | Medium |
Phase 3: Acceptance
Focus: Ensuring only the intended recipient can turn the invitation into membership
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Tenant membership | Wrong-account acceptance: Forwarded or leaked link accepted by a different account than the invited address | None | 1. Require an authenticated session at accept; reject anonymous calls 2. Match the session's verified email to 3. For SSO-enforced tenants, require that the session was authenticated by the tenant's configured IdP connection | High |
Identity binding | Click-to-provision: A combined invite-and-signup flow auto-creates an account and treats the invitation click as proof of email ownership, so any unintended actor that received the link (enterprise link scanner, forwarded recipient, mailbox malware) becomes the account owner | None | 1. Treat the invitation token as proof of link possession only, never as email verification 2. Require independent identity establishment (SSO sign-in or signup with a separate email-verification step) before the accept handler runs 3. If the UX combines signup and accept, hold the invitation pending until the new account completes its own email verification | High |
Invitation record | Token reuse: Same token consumed twice by a link preview or a rapid double-click, creating duplicate memberships or partial state | None | 1. Atomic conditional update sets 2. Membership table has a unique constraint on 3. Second attempt returns the same generic error as an invalid token | Medium |
Pending grant | State drift: Role or tenant status on the pending grant is no longer accurate at accept time (admin demoted, tenant suspended) | None | 1. Re-check tenant status ( 2. Surface the role from the pending grant on the preview page so the invitee sees what they are accepting before clicking accept | Medium |
Phase 4: Post-acceptance
Focus: Visibility and revocation after the grant is applied
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Pending invitations | No revocation path: Admin notices a misdirected invite (typo on the email, wrong role) but cannot rescind it before acceptance | None | 1. 2. Cascade revoke when the inviter is offboarded or the tenant is suspended | Medium |
Tenant audit | Silent membership change: Membership appears with no record of who invited or who accepted | Membership row only | 1. Log each lifecycle event (issuance, acceptance, revocation) as a distinct audit event with correlation IDs 2. Notify the inviter on acceptance | Low |
Operational logs | Token leakage in logs: Sensitive payload (raw claim tokens, full invite-link query strings, or recipient PII) appears in operational logs (mailer, debug traces, or error reporters) | None | 1. Redact 2. Mailer logs include 3. Error reporters scrub query strings and bodies on 4xx responses from the accept endpoint | Medium |
If you use stateless tokens
If you use signed tokens instead of storing invitations server-side, the trade-offs change:
To revoke an invite before it expires, you have to rotate the signing key, which invalidates every pending invite
You cannot reliably enforce one active invite per
(tenant, email, role)without shared statePreventing token reuse also needs shared state, so much of the benefit of going stateless disappears
Audit data is limited to what was put into the token when it was created
Use stateless tokens only when a server-side store is not an option.
FAQs
How long should an invitation link last?
Keep it short: 7 days for member invites, and 24 to 48 hours for admin invites. The main protection is still recipient binding at accept. A shorter TTL only limits how long a leaked or forwarded link can be used. On its own, it does not stop misuse.
Can an invitation be accepted by an account whose email differs from the invited address?
By default, no. The session’s verified email must match invited_email after normalization. For tenants that require SSO, an approved email-domain rule can allow any verified address in that domain, but the session must still come through the tenant’s configured IdP. A local password session does not qualify. If the email address changed, an admin must send a new invitation.
Verification checklist
Issuance and grant integrity
Inviter cannot assign a role higher than their own
tenant_idandinviter_idcome from auth context, never from the request bodyOwner-role invites go through a separate, audited path
Ownership fields (
tenant_id,invited_email,role,inviter_id) are immutable after creationIssuing a new invitation for the same target key supersedes any prior pending row in the same transaction (
revoked_atset, token hash rotated)A member-role admin cannot create an admin-role invitation
Claim token storage
Tokens generated from a CSPRNG with at least 256 bits of entropy
Only
SHA-256(token)is stored; the raw token never appears in the database or logsThe
claim_token_hashcolumn is unique and indexedOnly the auth service role has
INSERT/UPDATEon the invitations table (verifiable from an IAM/DB grants audit)
Recipient binding and identity
Invitation acceptance requires an independently verified identity (SSO sign-in or completed signup with a separate email-verification step); the click itself is never treated as proof of email ownership
Submit an unauthenticated POST to the accept endpoint; the response is 401, not 400 or 404
The session's verified email is compared to
invited_emailafter normalization (lowercased, IDNA, trimmed)For SSO-enforced tenants, the accept handler verifies the session's authentication method matches the tenant's configured IdP connection (a local-password session at a matching address is rejected)
The session's current verified email is used for the comparison; an invitee whose IdP email has changed since issuance must be reissued an invitation by an admin
Atomic acceptance
GET /invitations/{token}returns preview data with no state change; onlyPOST /invitations/{token}/acceptmutates stateAcceptance is a single transaction: atomic conditional consume (
consumed_atset), then membership insert, then audit eventConcurrent accepts of the same token result in at most one success
The membership table has a unique constraint on
(tenant_id, principal_id)The durable audit row is written inside the acceptance transaction; the notification email is enqueued only after commit
All failed accepts use the same status, body, and timing within normal jitter
Submitting the same valid token twice succeeds at most once
Lifecycle and revocation
Revoked invitations are rejected at accept with the same generic error as expired ones
Inviter offboarding cascades to revocation of pending invitations they created
Tenant suspension cascades to revocation of all pending invitations for that tenant
The old link returns the generic invitation error immediately after a replacement invitation is issued (same status, body, timing as any revoked row)
Audit and logging
Each lifecycle event (issuance, acceptance, revocation) is logged as a distinct audit event with correlation IDs
The inviter is notified on acceptance
The pending invitations list is visible to tenant admins on a settings screen
Raw claim tokens and full invite-link query strings never appear in any operational log; recipient PII follows the same redaction policy (verifiable with a log-grep test on a known-issued token)
4xx responses from the accept endpoint do not include the submitted token in error bodies or telemetry
Email transport hygiene
Invite links use HTTPS only
The invite landing page sets
Referrer-Policy: no-referrerInvitation emails are sent from a sender aligned with SPF, DKIM, and DMARC
Invite link host comes from server config, not the incoming
Hostheader; a request with a spoofedHostproduces an email whose link still points at the configured domain
Implementation & Review
The full threat model matrix, architectural diagrams, and a printable verification checklist for this pattern are available in the Secure Patterns repository. Use these artifacts to guide your design reviews and internal audits.
