Team onboarding in multi-tenant SaaS applications requires a secure mechanism for existing team members to invite new users. The invitation token system solves this by generating cryptographically secure, time-expiring tokens that encode the target team, assigned role, and inviter identity. When a new user follows the invitation link, the system validates the token, creates or links the user account, and assigns the specified role within the correct tenant—all in a single atomic operation.
The security requirements are non-trivial. Tokens must be unforgeable (cryptographically random), time-bounded (expire after a configurable window), single-use (consumed on acceptance), and revocable (an admin can cancel pending invitations). The token itself should not contain sensitive information in plaintext; instead, it serves as an opaque lookup key into a server-side record that holds the invitation details.
As a concrete example, consider a multi-tenant search application that implements this pattern for onboarding team members to shared search workspaces. Each invitation encodes the workspace ID, target role (viewer, editor, admin), and a 72-hour expiration window. The multi-step onboarding flow handles both new users (who need to create an account) and existing users (who just need the team membership added).
Invitation tokens are generated using a cryptographically secure random number generator (crypto/rand in Go, secrets module in Python). The token is typically 32 bytes of randomness, URL-safe base64 encoded, producing a 43-character string. This gives 256 bits of entropy, making brute-force guessing computationally infeasible. The token is stored hashed (SHA-256) in the database so that a database breach does not expose valid invitation links.
The token is an opaque key into a database record that stores the actual invitation details: inviter ID, target email, tenant/workspace ID, assigned role, creation timestamp, expiration timestamp, and status (pending, accepted, revoked, expired). Keeping details server-side means the token URL reveals nothing about the invitation, and all validation logic runs against the authoritative database record.
When a user clicks an invitation link, the system must handle three distinct cases: (1) the user is already authenticated and a member of the tenant—show an “already a member” message; (2) the user is authenticated but not a member—add the team membership and role; (3) the user has no account—redirect to account creation with the invitation token preserved, then complete acceptance after signup. Each path must atomically consume the token to prevent replay.
An invitation token transitions through a strict state machine: pending → accepted (user joined), pending → revoked (admin cancelled), or pending → expired (TTL elapsed). Only tokens in the pending state can be accepted. State transitions are enforced with database-level constraints (optimistic locking or compare-and-swap) to prevent race conditions where two concurrent requests try to accept the same token.
Without rate limits, an attacker could flood a target email with invitation spam, or a compromised account could generate thousands of tokens for enumeration. Invitation creation should be rate-limited per inviter and per tenant. Token validation endpoints should be rate-limited per IP to prevent brute-force scanning. Additionally, invitation emails should include the inviter’s name so recipients can verify legitimacy.
The system has three main components: token generation, email delivery with the invitation link, and a multi-step acceptance handler that validates the token and provisions the membership.
package invitation
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
)
const (
TokenBytes = 32
DefaultTTL = 72 * time.Hour
MaxPendingPerTeam = 50
)
type Invitation struct {
ID string
TokenHash string
InviterID string
Email string
TenantID string
Role string
Status string // pending, accepted, revoked, expired
CreatedAt time.Time
ExpiresAt time.Time
AcceptedAt *time.Time
AcceptedBy *string
}
func GenerateToken() (plaintext string, hash string, err error) {
buf := make([]byte, TokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate token: %w", err)
}
plaintext = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(buf)
h := sha256.Sum256([]byte(plaintext))
hash = fmt.Sprintf("%x", h[:])
return plaintext, hash, nil
}
func NewInvitation(inviterID, email, tenantID, role string) (*Invitation, string, error) {
plaintext, hash, err := GenerateToken()
if err != nil {
return nil, "", err
}
now := time.Now().UTC()
inv := &Invitation{
ID: generateULID(),
TokenHash: hash,
InviterID: inviterID,
Email: email,
TenantID: tenantID,
Role: role,
Status: "pending",
CreatedAt: now,
ExpiresAt: now.Add(DefaultTTL),
}
return inv, plaintext, nil
}
package invitation
import (
"context"
"crypto/sha256"
"fmt"
"time"
)
type Store interface {
Save(ctx context.Context, inv *Invitation) error
FindByTokenHash(ctx context.Context, hash string) (*Invitation, error)
CountPendingByTenant(ctx context.Context, tenantID string) (int, error)
AcceptAtomically(ctx context.Context, invID string, userID string, at time.Time) error
Revoke(ctx context.Context, invID string) error
}
type Service struct {
store Store
}
func (s *Service) Create(ctx context.Context, inviterID, email, tenantID, role string) (string, error) {
count, err := s.store.CountPendingByTenant(ctx, tenantID)
if err != nil {
return "", err
}
if count >= MaxPendingPerTeam {
return "", fmt.Errorf("too many pending invitations for tenant %s", tenantID)
}
inv, plaintext, err := NewInvitation(inviterID, email, tenantID, role)
if err != nil {
return "", err
}
if err := s.store.Save(ctx, inv); err != nil {
return "", err
}
return plaintext, nil
}
func (s *Service) Validate(ctx context.Context, token string) (*Invitation, error) {
h := sha256.Sum256([]byte(token))
hash := fmt.Sprintf("%x", h[:])
inv, err := s.store.FindByTokenHash(ctx, hash)
if err != nil {
return nil, fmt.Errorf("invalid invitation token")
}
if inv.Status != "pending" {
return nil, fmt.Errorf("invitation already %s", inv.Status)
}
if time.Now().UTC().After(inv.ExpiresAt) {
return nil, fmt.Errorf("invitation expired")
}
return inv, nil
}
func (s *Service) Accept(ctx context.Context, token string, userID string) error {
inv, err := s.Validate(ctx, token)
if err != nil {
return err
}
return s.store.AcceptAtomically(ctx, inv.ID, userID, time.Now().UTC())
}
package invitation
import (
"net/http"
)
type AcceptHandler struct {
service *Service
membership MembershipService
}
func (h *AcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing invitation token", http.StatusBadRequest)
return
}
inv, err := h.service.Validate(r.Context(), token)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userID := getUserIDFromSession(r)
if userID == "" {
// Not authenticated: redirect to signup with token preserved
http.Redirect(w, r, "/signup?invite="+token, http.StatusFound)
return
}
// Authenticated: accept invitation and add membership
if err := h.service.Accept(r.Context(), token, userID); err != nil {
http.Error(w, "failed to accept invitation", http.StatusConflict)
return
}
if err := h.membership.AddMember(r.Context(), inv.TenantID, userID, inv.Role); err != nil {
http.Error(w, "failed to add membership", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/workspace/"+inv.TenantID, http.StatusFound)
}
In a typical multi-tenant search application, invitations are the primary mechanism for growing teams within a shared search workspace. An admin sends invitations from the workspace settings page, specifying email addresses and roles. The system sends branded emails with a unique invitation link, and the acceptance flow handles both existing platform users and completely new signups.
The invitation system also supports bulk onboarding for enterprise customers. During initial setup, an admin can upload a CSV of email addresses with role assignments, and the system generates and sends all invitations in a single batch. Each invitation is independent—if one email bounces, it doesn’t affect the others.
For audit and compliance, every invitation event (created, sent, accepted, revoked, expired) is logged with the full context: who invited whom, to which workspace, with what role, and when. This provides a complete paper trail for access provisioning, which enterprise customers require for SOC 2 compliance.
CREATE TABLE invitations (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
inviter_id TEXT NOT NULL REFERENCES users(id),
email TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
accepted_by TEXT REFERENCES users(id),
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_invitations_token_hash ON invitations(token_hash)
WHERE status = 'pending';
CREATE INDEX idx_invitations_tenant_pending ON invitations(tenant_id)
WHERE status = 'pending';
CREATE INDEX idx_invitations_expires ON invitations(expires_at)
WHERE status = 'pending';
func (s *pgStore) AcceptAtomically(ctx context.Context, invID, userID string, at time.Time) error {
result, err := s.db.ExecContext(ctx, `
UPDATE invitations
SET status = 'accepted', accepted_at = $1, accepted_by = $2, version = version + 1
WHERE id = $3 AND status = 'pending' AND expires_at > NOW()
`, at, userID, invID)
if err != nil {
return fmt.Errorf("accept invitation: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("invitation no longer available")
}
return nil
}