Best Auth Patterns for SaaS Applications

Best Auth Patterns for SaaS Applications

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

Best Auth Patterns for SaaS Applications

Authentication failures cost SaaS companies millions in lost revenue and customer trust. A compromised account can expose sensitive business data across multiple tenants, trigger compliance violations, and destroy the credibility you've spent months building. Yet most developers implement auth as an afterthought, copying patterns from tutorials that ignore multi-tenancy, organization hierarchies, and the session management challenges unique to SaaS.

This article breaks down the authentication patterns that actually work in production SaaS environments. You'll learn how to structure user identity across organizations, implement role-based access that scales beyond simple admin/user splits, handle session management for both B2C and B2B contexts, and secure API authentication for mobile and third-party integrations. These patterns come from analyzing auth systems in production SaaS apps serving millions of users.

We'll cover session-based vs token-based approaches, organization-scoped authentication, permission inheritance patterns, and the specific security concerns that emerge when your database stores data for competing companies.

Why Authentication Architecture Matters More in SaaS

SaaS authentication carries constraints that traditional web applications don't face. Every authenticated request crosses tenant boundaries, every session decision affects your data isolation guarantees, and every token you issue becomes a potential vector for accessing another customer's data if your authorization layer has gaps.

The core challenge: your authentication system must answer "who is this user?" while simultaneously answering "which tenant do they belong to?" and "what permissions does this tenant grant them?" Most authentication libraries solve the first question. The second and third are where SaaS applications diverge from the standard patterns.

Consider what happens when a user belongs to multiple organizations in your SaaS product. With traditional session-based auth, you store one user_id in the session. But which organization context are they operating in? You need to either maintain organization switching state (complex), embed organization context in every route (brittle), or encode multi-tenancy information directly in your authentication tokens (which is why most mature SaaS products use tokens despite the added complexity).

Key Insight: The authentication pattern that works for a single-tenant app will fail when you add multi-tenancy. The pattern that works for one region will fail when you need global presence. Choose based on your two-year roadmap, not your launch requirements.

The Hidden Cost of Session State

Session-based authentication stores user state on the server. This seems simple until you need to answer: which server? When you're running six application servers behind a load balancer, every request must either reach the same server (sticky sessions, which limits your load balancing options) or fetch session data from shared storage (Redis, which adds latency and a critical failure point).

The breaking point comes earlier than you expect. At 1,000 concurrent users with 50 requests per user per session, you're managing 50,000 session lookups per session lifecycle. If each Redis lookup adds 2ms of latency, that's an aggregate 100 seconds of wait time distributed across your request handlers. Your application servers sit idle waiting for session data.

This is why token-based authentication exists. Tokens move state to the client. The server validates the token signature and extracts claims without any database or cache lookup. You trade storage costs (tokens are larger than session IDs) for computational costs (cryptographic signature validation). At scale, this trade almost always favors tokens because computation parallelizes better than storage IO.

Pattern 1: Session-Based Authentication with Tenant Context

Traditional session-based authentication stores a session ID in a cookie. The server looks up session data (user ID, tenant ID, permissions) in a session store on each request. For SaaS, you extend this by storing tenant context alongside user identity.

Implementation Architecture

// Session data structure for SaaS
{
  userId: "user_abc123",
  tenantId: "tenant_xyz789",
  currentOrgId: "org_primary", // For users in multiple orgs
  permissions: ["read:data", "write:data"],
  sessionCreatedAt: 1709856234,
  lastActivityAt: 1709856890
}

// Middleware to load session and tenant context
async function authenticateRequest(req, res, next) {
  const sessionId = req.cookies.sessionId;
  const session = await redis.get(`session:${sessionId}`);

  if (!session) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  // Critical: Load tenant context to prevent cross-tenant access
  const tenant = await db.tenants.findById(session.tenantId);

  if (!tenant.active) {
    return res.status(403).json({ error: "Tenant suspended" });
  }

  req.user = session;
  req.tenant = tenant;
  next();
}

This pattern works well when you control the entire session lifecycle and need to revoke access immediately. Logging out a user means deleting their session from Redis—they're logged out across all devices within milliseconds.

When This Pattern Works

Session-based auth excels in scenarios where real-time session control matters more than stateless scalability. If your SaaS handles sensitive financial data and needs to force-logout users instantly when security events occur, sessions give you that capability. Tokens can't be revoked without additional infrastructure (token blocklists, which reintroduce state).

It also simplifies development in the early stages. No JWT libraries to integrate, no public/private key management, no token refresh flows. You store user info in Redis and read it on each request. This simplicity has value when you're validating product-market fit and authentication complexity isn't your bottleneck.

Where This Pattern Breaks

The failure mode is predictable: Redis becomes a single point of failure. Every request depends on Redis availability. In practice, this means running Redis in a high-availability configuration (Redis Sentinel or Redis Cluster), which adds operational complexity that most early-stage teams underestimate.

Cross-region latency becomes problematic. If your application servers are in us-east-1 but your Redis cluster is in us-west-2, you're adding 60-80ms of latency to every authenticated request. You can't just run multiple Redis instances because sessions need to be globally consistent—if a user logs out in one region, they should be logged out everywhere.

Aspect Performance Impact Mitigation Strategy
Session lookup latency +2-5ms per request Co-locate Redis with app servers
Redis memory consumption ~5KB per active session Aggressive TTL, session cleanup jobs
Multi-region consistency 60-200ms cross-region Regional Redis clusters with fallback
Horizontal scaling complexity Sticky sessions or shared state Use consistent hashing for session distribution
Warning: Don't store complex objects in session state. Every time you add a field to your session object, you increase Redis memory usage across all active sessions. Session data should be minimal: user ID, tenant ID, and authentication timestamp. Load everything else on-demand.

Pattern 2: JWT-Based Stateless Authentication

JSON Web Tokens eliminate session storage by encoding authentication data directly into a signed token. The server verifies the token signature and trusts the claims inside without consulting a database. For SaaS applications, this means embedding tenant context in the token itself.

Token Structure for Multi-Tenant SaaS

// JWT payload for SaaS application
{
  "sub": "user_abc123",              // Subject (user ID)
  "tenant_id": "tenant_xyz789",       // Tenant context
  "org_id": "org_primary",            // Current organization
  "role": "admin",                    // User role within tenant
  "permissions": [
    "read:users",
    "write:users",
    "manage:billing"
  ],
  "iat": 1709856234,                  // Issued at
  "exp": 1709859834                   // Expires (1 hour)
}

// Token validation middleware
const jwt = require('jsonwebtoken');

async function validateJWT(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ error: "No token provided" });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Attach user and tenant context to request
    req.user = {
      id: decoded.sub,
      tenantId: decoded.tenant_id,
      orgId: decoded.org_id,
      role: decoded.role,
      permissions: decoded.permissions
    };

    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: "Token expired" });
    }
    return res.status(401).json({ error: "Invalid token" });
  }
}

The key architectural advantage: no database lookup on authenticated requests. Your application servers validate the signature (a CPU-bound operation that takes microseconds) and proceed. This scales horizontally without coordination—add more servers and they all validate tokens independently.

The Token Refresh Problem

Stateless tokens introduce a different complexity: you can't revoke them. Once issued, a JWT remains valid until it expires. This creates a security/usability tradeoff: short-lived tokens (5-15 minutes) are more secure but create poor user experience because users must re-authenticate frequently. Long-lived tokens (hours or days) provide smooth UX but can't be revoked without maintaining a token blocklist (which reintroduces state).

The industry-standard solution is the refresh token pattern. You issue two tokens: a short-lived access token (15 minutes) for API requests and a long-lived refresh token (7-30 days) that can only be used to obtain new access tokens. The refresh token is stored server-side, giving you revocation capability, while the access token remains stateless.

// Refresh token flow
async function refreshAccessToken(req, res) {
  const { refreshToken } = req.body;

  // Lookup refresh token in database (yes, we're back to state)
  const storedToken = await db.refreshTokens.findOne({
    token: refreshToken,
    revoked: false,
    expiresAt: { $gt: new Date() }
  });

  if (!storedToken) {
    return res.status(401).json({ error: "Invalid refresh token" });
  }

  // Load current user/tenant data (permissions may have changed)
  const user = await db.users.findById(storedToken.userId);
  const tenant = await db.tenants.findById(user.tenantId);

  // Issue new access token with current permissions
  const accessToken = jwt.sign({
    sub: user.id,
    tenant_id: tenant.id,
    org_id: user.currentOrgId,
    role: user.roleInTenant(tenant.id),
    permissions: user.permissionsInTenant(tenant.id)
  }, process.env.JWT_SECRET, { expiresIn: '15m' });

  res.json({ accessToken, expiresIn: 900 });
}

This hybrid approach gives you the best of both worlds: stateless authentication for high-throughput API requests and stateful refresh tokens for revocation capability. The tradeoff is complexity—you now maintain two token lifecycles and must handle edge cases like refresh token rotation and concurrent refresh attempts.

Pro Tip: Implement refresh token rotation. Each time a refresh token is used, invalidate it and issue a new one. This limits the damage if a refresh token is stolen—attackers get one use before the token is revoked. Store a token family identifier to detect stolen tokens: if two different clients present tokens from the same family, revoke all tokens for that user.

JWT Claims and Tenant Data Staleness

The hidden gotcha with JWTs in SaaS: tenant data encoded in the token becomes stale. If you put a user's permissions in a JWT with a 1-hour expiration, and an admin revokes those permissions 5 minutes later, the user can still use the old token for another 55 minutes.

This is functionally a 1-hour cache on permissions. For many SaaS applications, this is acceptable—permissions don't change frequently, and 1-hour staleness is reasonable. For others (particularly admin dashboards or sensitive operations), it's not.

The mitigation strategies depend on your threat model. Low-risk approach: short-lived access tokens (5-15 minutes) with refresh token flow. Higher-risk approach: maintain a cache of permission hashes and check if the hash in the token matches the current hash on critical operations. Highest-risk approach: don't use JWTs—use opaque tokens that require a database lookup on every request (more on this in Pattern 3).

Pattern 3: Opaque Tokens with Token Introspection

Opaque tokens are random strings that carry no information themselves. The server stores a mapping between token and user/tenant data. This is functionally similar to session-based auth but uses tokens instead of cookies, making it friendlier for mobile apps and third-party integrations.

// Generate opaque token
const crypto = require('crypto');

async function createOpaqueToken(userId, tenantId) {
  const token = crypto.randomBytes(32).toString('hex');

  await db.tokens.create({
    token,
    userId,
    tenantId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 3600000), // 1 hour
    lastUsedAt: new Date()
  });

  return token;
}

// Token introspection endpoint
async function introspectToken(req, res) {
  const { token } = req.body;

  const tokenData = await db.tokens.findOne({
    token,
    expiresAt: { $gt: new Date() }
  });

  if (!tokenData) {
    return res.json({ active: false });
  }

  // Load full user/tenant context
  const user = await db.users.findById(tokenData.userId);
  const tenant = await db.tenants.findById(tokenData.tenantId);

  res.json({
    active: true,
    sub: user.id,
    tenant_id: tenant.id,
    permissions: user.permissionsInTenant(tenant.id),
    exp: Math.floor(tokenData.expiresAt.getTime() / 1000)
  });
}

This pattern is common in OAuth 2.0 implementations. The authorization server issues opaque tokens and provides an introspection endpoint that resource servers call to validate tokens and retrieve associated claims. This centralized validation means permission changes take effect immediately—no stale token claims.

Performance Characteristics

Opaque tokens with introspection require a database lookup on every authenticated request, similar to session-based auth. The difference is architectural flexibility: the introspection endpoint can be a separate service, allowing you to scale authentication separately from your main application logic.

The performance optimization strategy is aggressive caching. You cache token introspection results for a short TTL (30-60 seconds). This gives you near-immediate permission revocation (worst case 60 seconds) while reducing database load by 98-99% compared to uncached introspection.

Pattern Lookup Required Revocation Latency Stale Claim Risk
Session-based Every request Immediate None (always fresh)
JWT stateless Never (signature check only) Token lifetime (5-60min) High until expiration
Opaque uncached Every request Immediate None
Opaque cached (60s TTL) First in cache window Up to 60 seconds Low (60s window)

Pattern 4: OAuth 2.0 for Third-Party Integration

When your SaaS needs to act on behalf of users in external systems (reading their Gmail, posting to their Slack workspace, accessing their GitHub repositories), you implement OAuth 2.0. This pattern is distinct from using OAuth for your own authentication—here, you're the OAuth client, not the provider.

The architectural challenge: you must store OAuth tokens per user per integration per tenant. A user in TenantA might connect their Google account, their Salesforce account, and their HubSpot account. Each integration provides an access token and refresh token that you must store securely and refresh before expiration.

Token Storage Schema

// OAuth token storage for SaaS
{
  userId: "user_abc123",
  tenantId: "tenant_xyz789",
  provider: "google",
  accessToken: "ya29.a0AfH6...", // Encrypted at rest
  refreshToken: "1//0gHh...",     // Encrypted at rest
  expiresAt: "2024-03-28T14:30:00Z",
  scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
  tokenType: "Bearer",
  createdAt: "2024-03-28T12:00:00Z",
  lastRefreshedAt: "2024-03-28T13:45:00Z"
}

Critical security consideration: OAuth tokens must be encrypted at rest. You're storing credentials that can access user data in external systems. If your database is compromised and tokens are stored in plaintext, attackers gain access to every connected third-party account. Use field-level encryption with a key management service (AWS KMS, Google Cloud KMS, HashiCorp Vault).

Token Refresh Strategy

OAuth access tokens expire (typically 1 hour for most providers). Your application must detect expiration and use the refresh token to obtain a new access token before making API calls. The naive approach—refresh on failure—creates user-visible errors. The correct approach is proactive refresh based on expiration time.

async function getValidAccessToken(userId, tenantId, provider) {
  const tokenRecord = await db.oauthTokens.findOne({
    userId,
    tenantId,
    provider
  });

  if (!tokenRecord) {
    throw new Error('No OAuth connection found');
  }

  // Refresh if token expires in next 5 minutes
  const expiresIn = (tokenRecord.expiresAt - Date.now()) / 1000;

  if (expiresIn < 300) {
    return await refreshOAuthToken(tokenRecord);
  }

  return decrypt(tokenRecord.accessToken);
}

async function refreshOAuthToken(tokenRecord) {
  const response = await fetch(getTokenEndpoint(tokenRecord.provider), {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: decrypt(tokenRecord.refreshToken),
      client_id: process.env[`${tokenRecord.provider.toUpperCase()}_CLIENT_ID`],
      client_secret: process.env[`${tokenRecord.provider.toUpperCase()}_CLIENT_SECRET`]
    })
  });

  const tokens = await response.json();

  await db.oauthTokens.updateOne(
    { _id: tokenRecord._id },
    {
      $set: {
        accessToken: encrypt(tokens.access_token),
        expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
        lastRefreshedAt: new Date()
      }
    }
  );

  return tokens.access_token;
}
Critical Security Warning: Never log OAuth tokens, even in development. Logging infrastructure is often less secured than application databases. A token in a log file is a credential leak. Redact tokens in error messages, request logs, and debug output. Use structured logging with field-level redaction for any authentication-related data.

Pattern 5: SSO with SAML 2.0 for Enterprise Customers

Enterprise customers expect single sign-on. They want employees to authenticate through their corporate identity provider (Okta, Azure AD, Google Workspace) rather than maintaining separate credentials for your SaaS. This is a requirement for landing contracts with companies over 500 employees.

SAML 2.0 is the federation protocol that enables this. Your SaaS application becomes a Service Provider (SP) that trusts assertions from enterprise Identity Providers (IdP). The flow: user tries to access your app, you redirect them to their IdP, they authenticate there, IdP sends a signed SAML assertion back to you, you trust that assertion and create a session.

SAML Configuration per Tenant

Unlike OAuth where you configure one set of credentials per provider, SAML requires per-tenant configuration. Each enterprise customer provides their IdP metadata (SSO URL, certificate, entity ID), and you must store this per tenant and route authentication requests accordingly.

// Tenant-specific SAML configuration
{
  tenantId: "tenant_enterprise_acme",
  ssoEnabled: true,
  ssoProvider: "saml",
  samlConfig: {
    entryPoint: "https://acme.okta.com/app/saml/abc123/sso/saml",
    issuer: "http://www.okta.com/abc123",
    cert: "MIIDXTCCAkWgAwIBAgIJAK...", // IdP signing certificate
    identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
    wantAssertionsSigned: true,
    wantAuthnResponseSigned: true
  },
  attributeMapping: {
    email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
    firstName: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
    lastName: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
    role: "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
  },
  jitProvisioningEnabled: true, // Automatically create users on first SSO
  defaultRole: "member"
}

The attribute mapping is where enterprise SSO gets messy. Every IdP sends user attributes in different claim names. Okta might send email as "emailAddress", Azure AD as "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", and Google as "email". Your application must map these per tenant.

Just-In-Time Provisioning

Enterprise customers expect just-in-time (JIT) provisioning: when a new employee authenticates via SSO for the first time, your application automatically creates a user account. The alternative—requiring admins to pre-create accounts—creates friction that kills enterprise deals.

async function handleSAMLAssertion(profile, tenantId) {
  const config = await db.tenantSAMLConfig.findOne({ tenantId });

  // Extract user attributes using tenant-specific mapping
  const email = profile[config.attributeMapping.email];
  const firstName = profile[config.attributeMapping.firstName];
  const lastName = profile[config.attributeMapping.lastName];
  const role = profile[config.attributeMapping.role] || config.defaultRole;

  // Look for existing user
  let user = await db.users.findOne({ email, tenantId });

  if (!user && config.jitProvisioningEnabled) {
    // Create user on first SSO
    user = await db.users.create({
      email,
      firstName,
      lastName,
      tenantId,
      role,
      createdVia: 'sso',
      ssoProvider: 'saml',
      createdAt: new Date()
    });
  }

  if (!user) {
    throw new Error('User not found and JIT provisioning disabled');
  }

  // Create session for SSO user
  const session = await createSession(user.id, tenantId);
  return session;
}

The security consideration: SSO bypasses your normal registration flow, which might include email verification or terms of service acceptance. You need separate logic to handle these requirements for SSO users. Most SaaS platforms show a one-time setup screen after first SSO login where users complete profile setup and accept terms.

Enterprise Reality Check: Implementing SAML properly takes 3-4 weeks, not 3-4 days. Budget time for metadata exchange with customers, certificate validation debugging, attribute mapping issues, and testing with multiple IdPs. Use a library like passport-saml or enterprise auth services like WorkOS or Auth0 to handle protocol complexity.

Pattern 6: Magic Link Authentication for Passwordless

Magic links eliminate passwords by sending a one-time authentication link via email. User enters their email, receives a link, clicks it, and is authenticated. This pattern has gained adoption in SaaS because it removes password management burden from both users and developers.

The security model relies on email account security. If an attacker controls the user's email, they can authenticate as that user. This is functionally equivalent to password reset security—if attackers can reset your password via email, they can also use magic links. Most SaaS applications accept this tradeoff because email compromise is already game-over for account security.

Implementation with Token Expiration

const crypto = require('crypto');

async function sendMagicLink(email, tenantId) {
  // Generate cryptographically secure token
  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.magicLinks.create({
    token,
    email,
    tenantId,
    expiresAt,
    used: false,
    createdAt: new Date()
  });

  const magicLink = `https://app.example.com/auth/verify?token=${token}`;

  await sendEmail({
    to: email,
    subject: 'Sign in to Your Account',
    html: `
      

Click the link below to sign in:

Sign In

This link expires in 15 minutes.

If you didn't request this, ignore this email.

` }); } async function verifyMagicLink(token) { const link = await db.magicLinks.findOne({ token, used: false }); if (!link) { throw new Error('Invalid or expired magic link'); } if (link.expiresAt < new Date()) { throw new Error('Magic link expired'); } // Mark as used (one-time use only) await db.magicLinks.updateOne( { token }, { $set: { used: true, usedAt: new Date() } } ); // Get or create user let user = await db.users.findOne({ email: link.email, tenantId: link.tenantId }); if (!user) { // Auto-create user on first magic link login user = await db.users.create({ email: link.email, tenantId: link.tenantId, createdVia: 'magic_link', createdAt: new Date() }); } return createSession(user.id, link.tenantId); }

Rate Limiting for Magic Links

Magic links are vulnerable to email flooding attacks. An attacker can request magic links repeatedly for victim email addresses, filling their inbox with authentication emails. This is both an annoyance vector and a way to hide legitimate magic links in noise.

Implement aggressive rate limiting: maximum 3 magic link requests per email address per hour, maximum 10 per IP address per hour. After hitting the limit, temporarily block requests and show a clear error message.

Security Measure Implementation Why It Matters
One-time use tokens Mark token as used after first verification Prevents token replay if email is forwarded
Short expiration (15min) Token expires 15 minutes after creation Limits window for token theft/interception
Cryptographic token (32 bytes) Use crypto.randomBytes, not Math.random Prevents brute force guessing of valid tokens
Rate limiting (3/hour/email) Track requests by email and IP address Prevents email flooding attacks

Pattern 7: API Key Authentication for Programmatic Access

When customers need to access your SaaS programmatically (CI/CD pipelines, scheduled jobs, integrations), they need long-lived credentials that don't expire like user sessions. API keys serve this purpose: a static credential that authenticates requests without interactive login.

The security challenge: API keys are essentially permanent passwords. If a key is leaked in a GitHub commit, exposed in logs, or stolen from a compromised server, it remains valid until explicitly revoked. This makes API key management critical for SaaS security.

API Key Structure and Storage

// Generate API key with prefix for identification
async function createAPIKey(userId, tenantId, name, permissions) {
  // Prefix identifies key type, makes it recognizable in logs
  const prefix = 'sk_live';

  // Generate cryptographically secure random key
  const secret = crypto.randomBytes(32).toString('hex');
  const apiKey = `${prefix}_${secret}`;

  // Hash the key before storing (like passwords)
  const hash = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');

  await db.apiKeys.create({
    hash,
    prefix,
    name,
    userId,
    tenantId,
    permissions,
    createdAt: new Date(),
    lastUsedAt: null,
    usageCount: 0
  });

  // Return unhashed key to user (only shown once)
  return apiKey;
}

// Validate API key on incoming requests
async function validateAPIKey(apiKey) {
  const hash = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');

  const keyRecord = await db.apiKeys.findOne({ hash });

  if (!keyRecord) {
    throw new Error('Invalid API key');
  }

  // Update usage tracking
  await db.apiKeys.updateOne(
    { _id: keyRecord._id },
    {
      $set: { lastUsedAt: new Date() },
      $inc: { usageCount: 1 }
    }
  );

  return {
    userId: keyRecord.userId,
    tenantId: keyRecord.tenantId,
    permissions: keyRecord.permissions
  };
}

Critical design decision: never store API keys in plaintext. Hash them like passwords using SHA-256 or better. When a customer creates an API key, show it once, then store only the hash. This means stolen database dumps don't expose active API keys.

Scoping API Keys with Least Privilege

General-purpose API keys that have full account access are security liabilities. Implement permission scoping so customers can create read-only keys, write-only keys, or keys limited to specific resources.

// API key with scoped permissions
{
  hash: "a1b2c3...",
  name: "CI/CD Deploy Key",
  permissions: [
    "read:deployments",
    "write:deployments",
    "read:logs"
  ],
  excludedPermissions: [
    "delete:*",
    "write:billing",
    "manage:team"
  ],
  resourceScope: {
    projects: ["proj_123", "proj_456"], // Only these projects
    environments: ["production"]         // Only production
  }
}

This allows customers to follow least-privilege principles. A deployment key used in GitHub Actions doesn't need billing permissions. A read-only analytics key doesn't need write access. If a narrowly-scoped key is compromised, the blast radius is limited.

Pro Tip: Implement API key prefixes that identify key type and environment (sk_live_, sk_test_). This helps with automated secret scanning—tools like GitHub secret scanning can detect your API keys in public repositories and notify you. It also prevents production keys from being used in test environments accidentally.

Multi-Factor Authentication Implementation

Multi-factor authentication adds a second verification step after password login. For SaaS applications, this is increasingly mandatory for SOC 2 compliance and enterprise contracts. The standard approach is TOTP (Time-based One-Time Passwords) using apps like Google Authenticator or Authy.

TOTP Setup Flow

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

async function enableMFA(userId) {
  // Generate secret for this user
  const secret = speakeasy.generateSecret({
    name: `YourSaaS (${user.email})`,
    issuer: 'YourSaaS'
  });

  // Store secret (encrypted) but don't enable MFA yet
  await db.users.updateOne(
    { _id: userId },
    {
      $set: {
        mfaSecret: encrypt(secret.base32),
        mfaEnabled: false, // Not enabled until verified
        mfaBackupCodes: generateBackupCodes()
      }
    }
  );

  // Generate QR code for user to scan
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);

  return {
    secret: secret.base32,
    qrCode: qrCodeUrl,
    backupCodes: user.mfaBackupCodes
  };
}

async function verifyAndEnableMFA(userId, token) {
  const user = await db.users.findById(userId);
  const secret = decrypt(user.mfaSecret);

  const verified = speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 1 // Accept tokens from ±30 seconds (allows for clock skew)
  });

  if (!verified) {
    throw new Error('Invalid MFA code');
  }

  // Enable MFA after successful verification
  await db.users.updateOne(
    { _id: userId },
    { $set: { mfaEnabled: true } }
  );
}

MFA in Authentication Flow

With MFA enabled, the login flow becomes two-stage. First stage: verify password. Second stage: verify TOTP code. Critical decision: don't create a full session after stage one. Use a temporary, limited token that only grants access to the MFA verification endpoint.

async function loginWithPassword(email, password, tenantId) {
  const user = await db.users.findOne({ email, tenantId });

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    throw new Error('Invalid credentials');
  }

  if (!user.mfaEnabled) {
    // No MFA, create full session
    return createSession(user.id, tenantId);
  }

  // MFA enabled, create limited pre-MFA token
  const preMFAToken = jwt.sign(
    {
      sub: user.id,
      tenant_id: tenantId,
      stage: 'pre_mfa',
      exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
    },
    process.env.JWT_SECRET
  );

  return {
    requiresMFA: true,
    preMFAToken
  };
}

async function verifyMFAAndLogin(preMFAToken, mfaCode) {
  const decoded = jwt.verify(preMFAToken, process.env.JWT_SECRET);

  if (decoded.stage !== 'pre_mfa') {
    throw new Error('Invalid token');
  }

  const user = await db.users.findById(decoded.sub);
  const secret = decrypt(user.mfaSecret);

  const verified = speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token: mfaCode,
    window: 1
  });

  if (!verified) {
    // Check if it's a backup code
    const backupCodeIndex = user.mfaBackupCodes.indexOf(mfaCode);
    if (backupCodeIndex === -1) {
      throw new Error('Invalid MFA code');
    }

    // Backup code used, remove it (one-time use)
    await db.users.updateOne(
      { _id: user.id },
      { $pull: { mfaBackupCodes: mfaCode } }
    );
  }

  // MFA verified, create full session
  return createSession(user.id, decoded.tenant_id);
}

The backup codes are critical for account recovery. If a user loses their authenticator device, backup codes are the only way to regain access. Generate 10 random 8-character codes during MFA setup and display them once. Users should save these in a password manager or print them.

Choosing the Right Pattern for Your SaaS

No single authentication pattern works for all SaaS applications. The right choice depends on your customer profile, compliance requirements, and scale characteristics. Here's the decision framework used by engineering teams at mature SaaS companies.

Decision Matrix by Stage and Requirements

Stage / Requirement Recommended Pattern Why
Pre-product/market fit Session-based or magic links Simplicity > scalability. Ship faster, iterate on product.
Growing SaaS (100+ customers) JWT with refresh tokens Stateless scaling, mobile app support, reasonable security.
Enterprise customers needed Add SAML SSO to existing auth Enterprise deals require SSO. Use auth service (WorkOS) to avoid building from scratch.
Third-party integrations OAuth 2.0 client + API keys OAuth for user integrations, API keys for programmatic access.
SOC 2 / compliance requirement Add MFA to existing auth Required for audit, relatively easy to add to any pattern.
High security context (fintech) Opaque tokens + short sessions Immediate revocation capability, audit trail, lower stale claim risk.

The Hybrid Approach: What Production Systems Actually Use

Most mature SaaS platforms don't use a single authentication pattern. They layer multiple patterns to serve different use cases. The typical architecture at a company with 50+ engineers:

  • Web application: JWT access tokens (15min) + refresh tokens (30 days) stored in HttpOnly cookies
  • Mobile apps: Same JWT/refresh pattern but tokens stored in secure storage, sent via Authorization header
  • Enterprise customers: SAML SSO that creates JWT tokens after successful assertion verification
  • API access: API keys with scoped permissions for programmatic access
  • Third-party integrations: OAuth 2.0 client to obtain tokens from external services
  • Admin operations: MFA required, shorter-lived tokens (5min), re-authentication for sensitive actions

This complexity emerges from real business requirements, not over-engineering. Start with the simplest pattern that works, then add additional patterns as customer needs demand them. Don't implement SAML SSO before you have an enterprise customer asking for it.

Practical Guidance: Use an authentication service (Auth0, WorkOS, Clerk, Supabase Auth) unless you have specific requirements they can't meet. Building authentication from scratch takes 3-6 months to get right when you account for edge cases, security hardening, and enterprise features. Buy unless you have a compelling reason to build.

Frequently Asked Questions

Should I store JWT tokens in localStorage or cookies?

Cookies with HttpOnly and SameSite flags are more secure than localStorage for web applications. localStorage is accessible to JavaScript, making tokens vulnerable to XSS attacks—if an attacker injects a script, they can read and exfiltrate tokens. HttpOnly cookies can't be accessed by JavaScript, limiting XSS impact. The tradeoff: cookies add complexity for mobile apps and cross-origin requests. For web-only SaaS, use HttpOnly cookies. For cross-platform, use secure storage on mobile and consider cookies for web.

How do I handle users who belong to multiple tenants?

Store the current active tenant in session or token claims, and provide a tenant switcher in your UI. When a user switches tenants, issue a new token with the new tenant_id or update session state. Critical: validate that the user has access to the target tenant before switching—don't trust client-side tenant selection without server-side authorization checks. In the database, maintain a users_tenants junction table mapping which users belong to which tenants and their role in each.

What's the right JWT expiration time for SaaS applications?

Access tokens should be short-lived (5-15 minutes) to limit the window of exposure if stolen. Use refresh tokens with longer lifetimes (7-30 days) to maintain user sessions without constant re-authentication. Extremely sensitive operations (changing payment methods, deleting data) should require fresh authentication regardless of token age—implement step-up authentication that forces re-entry of password or MFA even with a valid token.

How do I implement logout with JWT tokens?

True logout with stateless JWTs requires a token blocklist—maintain a cache of revoked token IDs (jti claim) and check incoming tokens against it. This reintroduces state, which defeats JWT's stateless benefit. Practical approach: use short-lived access tokens (15min) with long-lived refresh tokens. On logout, delete the refresh token from the database. The access token remains valid until expiration, but the user can't get a new one. For immediate revocation, add token IDs to a Redis blocklist with TTL matching the token expiration time.

What hashing algorithm should I use for API keys?

SHA-256 is sufficient for API key hashing since you're not protecting against rainbow table attacks (API keys are high-entropy random values, not user-chosen passwords). Avoid bcrypt or argon2 for API keys—these slow, memory-hard functions are designed to slow down password brute-forcing, but that's not the threat model for random API keys. SHA-256 provides fast lookups while protecting keys in database dumps. If an API key is 32 random bytes (256 bits of entropy), brute-forcing is infeasible regardless of hash speed.

How do I implement organization/team switching in a multi-tenant SaaS?

Store the active organization ID in the user's session or JWT token. When a user switches organizations, generate a new token with the new org_id or update the session. On the backend, every database query must filter by the current org_id from the authenticated context—never trust org_id from request parameters. Implement an authorization middleware that loads the current organization and verifies the user has access before processing requests. For UI, show an organization picker if user belongs to multiple organizations, and maintain organization context in all navigation.

Should I implement my own SAML SSO or use a service?

Use a service (WorkOS, Auth0, Frontegg) unless you have extremely specific requirements or massive scale that justifies the engineering investment. SAML is complex: certificate management, XML signature validation, IdP metadata parsing, attribute mapping, and debugging federation errors with enterprise customers. A service handles protocol implementation, provides an admin UI for customer IT teams to configure SSO, and supports multiple SSO protocols (SAML, OIDC, OAuth). Building this from scratch takes 6-12 weeks; integrating a service takes 2-3 days.

How do I test authentication flows in development without production OAuth credentials?

Create a mock authentication mode that bypasses OAuth for development. Use environment variables to toggle between real OAuth and mock auth. In mock mode, show a developer login screen where you can impersonate any user or tenant. Never deploy mock auth to production—use feature flags to ensure it's completely disabled in production builds. For staging environments, create separate OAuth applications with development credentials and whitelist your staging URLs in provider settings. This lets you test real OAuth flows without exposing production credentials.

What's the security risk of embedding tenant_id in JWT tokens?

The risk is minimal if your backend properly validates token signatures and never trusts tenant_id without authorization checks. A user can't forge a tenant_id in a JWT because the signature would fail validation. The actual risk: stale tenant data in the token if permissions change after token issuance. Mitigate with short-lived tokens (5-15min) and always verify the user has access to the tenant on sensitive operations. Don't rely solely on token claims for authorization—treat the token as authentication (who they are) and perform authorization checks (what they can access) against current database state.

How do I handle authentication in microservices architecture?

Authenticate at the API gateway and propagate user context to downstream services via signed JWT tokens or headers. The gateway validates the initial authentication (JWT signature, session lookup), then passes a lightweight identity token to internal services. Internal services trust this token without re-validating because it came through the gateway. For sensitive operations, downstream services can validate token signatures independently. Alternative pattern: use service mesh (Istio, Linkerd) to handle authentication propagation at the infrastructure layer, decoupling it from application code. Never pass raw passwords or refresh tokens to downstream services—only propagate identity and authorization claims.

Conclusion

Authentication architecture decisions compound over time. The pattern you choose at launch determines whether you can add enterprise SSO in year two, support multi-region deployment in year three, and maintain security compliance as you scale. Start with the simplest pattern that meets your immediate needs—usually JWT with refresh tokens for modern SaaS—then layer in complexity as business requirements demand it.

The most critical principle: separate authentication (who is this user?) from authorization (what can they access?). Your authentication system proves identity; your authorization system enforces access control based on that identity. This separation lets you swap authentication patterns without rewriting authorization logic. A user authenticated via SAML SSO should have the same authorization model as one authenticated via password or magic link.

Authentication is not a one-time implementation. Budget ongoing maintenance for session management, token rotation, key management, and security updates. The teams that build successful SaaS platforms treat authentication infrastructure as a first-class system that evolves with the product.


Share on Social Media: