agentsclimarketplace

Saas auth patterns

Skill vibeeval/vibecosystem/skills/saas-auth-patterns

AI software team for Claude Code - 138 agents, 295 skills, 73 hooks. Self-learning, multi-agent swarm, autonomous skill evolution.

Install
npx -y skills add vibeeval/vibecosystem --skill saas-auth-patterns

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

What its author says it does

Copied from the file, not written here

SaaS authentication and authorization patterns including JWT vs session strategies, multi-tenant isolation, RBAC, API key management, passwordless flows, MFA, and secure session handling.

SKILL.md

12.5 KB, as published. Nobody here has run it

SaaS Auth Patterns

Authentication and authorization patterns for multi-tenant SaaS applications.

Auth Strategy Decision Matrix

StrategyStatelessScalableRevocableBest For
JWT + RefreshYesHighHard (needs blocklist)API-first, mobile clients
Session (server)NoMedium (sticky/shared store)InstantTraditional web apps
OAuth 2.0 + PKCEYesHighVia providerThird-party login, SSO

Pick JWT when you control both client and server and need horizontal scaling. Pick sessions when you need instant revocation and serve server-rendered pages. Pick OAuth when users expect "Sign in with Google/GitHub" or you federate identity.

Multi-Tenant Auth

Tenant Isolation Middleware

interface TenantContext {
  tenantId: string
  userId: string
  role: string
}

// Extract tenant from JWT claims or subdomain
function resolveTenant(req: Request): TenantContext {
  const token = req.headers.get('authorization')?.replace('Bearer ', '')
  if (!token) throw new AuthError('Missing token')

  const payload = verifyJwt(token)
  return {
    tenantId: payload.tenantId,
    userId: payload.sub,
    role: payload.role,
  }
}

// Every DB query scoped to tenant - no cross-tenant leakage
async function getTenantUsers(ctx: TenantContext): Promise<User[]> {
  return db.users.findMany({
    where: { tenantId: ctx.tenantId },
  })
}

Shared DB vs Isolated DB

// Shared DB (row-level isolation) - simpler ops, lower cost
// Every table has tenant_id column + RLS policy
// SQL: CREATE POLICY tenant_isolation ON users
//        USING (tenant_id = current_setting('app.tenant_id'))

async function withTenantScope<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
  await db.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`
  return fn()
}

// Isolated DB (schema-per-tenant) - stronger isolation, harder ops
// Use when: compliance requires it, tenants have wildly different data volumes
function getTenantConnection(tenantId: string): PrismaClient {
  // SECURITY: Validate tenantId to prevent schema injection
  if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
    throw new Error('Invalid tenant ID format')
  }
  const schema = `tenant_${tenantId}`
  // Note: Cache PrismaClient instances per tenant to avoid connection leaks
  return new PrismaClient({ datasources: { db: { url: `${DB_URL}?schema=${schema}` } } })
}

Account Linking (Email + Social Merge)

async function linkOrCreateAccount(provider: string, profile: OAuthProfile): Promise<User> {
  // Step 1: Check if social account already linked
  const existing = await db.socialAccounts.findUnique({
    where: { provider_providerAccountId: { provider, providerAccountId: profile.id } },
    include: { user: true },
  })
  if (existing) return existing.user

  // Step 2: Check if email matches an existing user
  // SECURITY: Only auto-link if provider verified the email
  if (!profile.email_verified) {
    return db.users.create({
      data: {
        email: null, name: profile.name,
        socialAccounts: { create: { provider, providerAccountId: profile.id } },
      },
    })
  }

  const emailUser = await db.users.findUnique({
    where: { email: profile.email },
  })

  if (emailUser) {
    // Link social account to existing user (merge)
    await db.socialAccounts.create({
      data: { userId: emailUser.id, provider, providerAccountId: profile.id },
    })
    return emailUser
  }

  // Step 3: Brand new user - create both records
  return db.users.create({
    data: {
      email: profile.email,
      name: profile.name,
      socialAccounts: {
        create: { provider, providerAccountId: profile.id },
      },
    },
  })
}

Role-Based Access Control (RBAC)

type Permission = 'read' | 'write' | 'delete' | 'manage_users' | 'billing'

const ROLE_PERMISSIONS: Record<string, Permission[]> = {
  owner:  ['read', 'write', 'delete', 'manage_users', 'billing'],
  admin:  ['read', 'write', 'delete', 'manage_users'],
  member: ['read', 'write'],
  viewer: ['read'],
}

function authorize(role: string, required: Permission): boolean {
  const permissions = ROLE_PERMISSIONS[role]
  if (!permissions) return false
  return permissions.includes(required)
}

// Middleware factory - attach to any route
function requirePermission(permission: Permission) {
  return async (req: Request): Promise<void> => {
    const ctx = resolveTenant(req)
    if (!authorize(ctx.role, permission)) {
      throw new AuthError('Insufficient permissions')
    }
  }
}

// Usage
// await requirePermission('manage_users')(req)
// await requirePermission('billing')(req)

API Key Management

import { randomBytes, createHash } from 'crypto'

// Generate: show full key once, store only the hash
function generateApiKey(): { fullKey: string; hashedKey: string; prefix: string } {
  const raw = randomBytes(32).toString('base64url')
  const prefix = raw.slice(0, 8)
  const fullKey = `sk_live_${raw}`
  const hashedKey = createHash('sha256').update(fullKey).digest('hex')
  return { fullKey, hashedKey, prefix }
}

// Store key with scopes and expiry
async function createApiKey(tenantId: string, name: string, scopes: string[]): Promise<string> {
  const { fullKey, hashedKey, prefix } = generateApiKey()
  await db.apiKeys.create({
    data: { tenantId, name, hashedKey, prefix, scopes, expiresAt: addDays(new Date(), 90) },
  })
  return fullKey  // Return ONCE - never stored in plaintext
}

// Validate incoming API key
async function validateApiKey(key: string): Promise<{ tenantId: string; scopes: string[] }> {
  const hashedKey = createHash('sha256').update(key).digest('hex')
  const record = await db.apiKeys.findUnique({ where: { hashedKey } })

  if (!record) throw new AuthError('Invalid API key')
  if (record.expiresAt < new Date()) throw new AuthError('API key expired')
  if (record.revokedAt) throw new AuthError('API key revoked')

  await db.apiKeys.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
  return { tenantId: record.tenantId, scopes: record.scopes }
}

// Rotation: create new key, mark old as deprecated, revoke after grace period
async function rotateApiKey(oldKeyId: string, tenantId: string): Promise<string> {
  const oldKey = await db.apiKeys.findUnique({ where: { id: oldKeyId } })
  if (!oldKey) throw new Error('Key not found')

  const newFullKey = await createApiKey(tenantId, `${oldKey.name} (rotated)`, oldKey.scopes)
  await db.apiKeys.update({ where: { id: oldKeyId }, data: { revokedAt: addDays(new Date(), 7) } })
  return newFullKey
}

Magic Link / Passwordless Flow

async function sendMagicLink(email: string): Promise<void> {
  const token = randomBytes(32).toString('base64url')
  const hashedToken = createHash('sha256').update(token).digest('hex')

  await db.magicLinks.create({
    data: { email, hashedToken, expiresAt: new Date(Date.now() + 15 * 60 * 1000) },  // 15 min
  })

  const link = `${process.env.APP_URL}/auth/verify?token=${token}`
  await sendEmail(email, 'Sign in', `Click to sign in: ${link}`)
}

async function verifyMagicLink(token: string): Promise<{ userId: string; sessionToken: string }> {
  const hashedToken = createHash('sha256').update(token).digest('hex')

  // Atomic: mark as used only if not already used (prevents TOCTOU race)
  const result = await db.magicLinks.updateMany({
    where: { hashedToken, usedAt: null, expiresAt: { gt: new Date() } },
    data: { usedAt: new Date() },
  })
  if (result.count === 0) throw new AuthError('Invalid, expired, or already used link')

  const record = await db.magicLinks.findUnique({ where: { hashedToken } })

  const user = await findOrCreateUser(record.email)
  const sessionToken = await createSession(user.id)
  return { userId: user.id, sessionToken }
}

MFA Integration

import { authenticator } from 'otplib'

// Enrollment: generate secret, user scans QR code
async function enrollMfa(userId: string): Promise<{ secret: string; qrUri: string }> {
  const secret = authenticator.generateSecret()
  // SECURITY: Encrypt secret at rest in production (AES-256-GCM)
  await db.mfaSecrets.create({ data: { userId, secret, verified: false } })

  const qrUri = authenticator.keyuri(userId, process.env.APP_NAME ?? 'My App', secret)
  return { secret, qrUri }
}

// Verify first code to activate MFA
async function activateMfa(userId: string, code: string): Promise<void> {
  const record = await db.mfaSecrets.findUnique({ where: { userId } })
  if (!record) throw new AuthError('MFA not enrolled')

  if (!authenticator.check(code, record.secret)) {
    throw new AuthError('Invalid MFA code')
  }

  await db.mfaSecrets.update({ where: { userId }, data: { verified: true } })
}

// Login: after password check, require MFA if enabled
async function loginWithMfa(email: string, password: string, mfaCode?: string): Promise<string> {
  const user = await verifyPassword(email, password)

  const mfa = await db.mfaSecrets.findUnique({ where: { userId: user.id, verified: true } })
  if (mfa) {
    if (!mfaCode) throw new MfaRequiredError('MFA code required')
    if (!authenticator.check(mfaCode, mfa.secret)) throw new AuthError('Invalid MFA code')
  }

  return createSession(user.id)
}

Session Management

// Session with refresh token rotation
async function createSession(userId: string): Promise<{ accessToken: string; refreshToken: string }> {
  const accessToken = signJwt({ sub: userId }, { expiresIn: '15m' })
  const refreshToken = randomBytes(32).toString('base64url')
  const hashedRefresh = createHash('sha256').update(refreshToken).digest('hex')

  await db.sessions.create({
    data: { userId, hashedRefreshToken: hashedRefresh, expiresAt: addDays(new Date(), 30) },
  })

  return { accessToken, refreshToken }
}

// Refresh: issue new pair, invalidate old refresh token (rotation)
async function refreshSession(oldRefreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
  const hashed = createHash('sha256').update(oldRefreshToken).digest('hex')
  const session = await db.sessions.findUnique({ where: { hashedRefreshToken: hashed } })

  if (!session || session.expiresAt < new Date()) throw new AuthError('Session expired')
  if (session.revokedAt) {
    // Refresh token reuse detected - revoke ALL sessions for this user
    await db.sessions.updateMany({ where: { userId: session.userId }, data: { revokedAt: new Date() } })
    throw new AuthError('Token reuse detected, all sessions revoked')
  }

  // Revoke old, issue new
  await db.sessions.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
  return createSession(session.userId)
}

// Concurrent session limit
async function enforceSessionLimit(userId: string, maxSessions: number): Promise<void> {
  const activeSessions = await db.sessions.findMany({
    where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
    orderBy: { createdAt: 'asc' },
  })

  if (activeSessions.length >= maxSessions) {
    const oldest = activeSessions[0]
    await db.sessions.update({ where: { id: oldest.id }, data: { revokedAt: new Date() } })
  }
}

Token Storage: GOOD vs BAD

// BAD: localStorage is accessible to any JS on the page (XSS = full account takeover)
localStorage.setItem('token', accessToken)
fetch('/api/data', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })

// GOOD: httpOnly cookie - JS cannot read it, browser sends it automatically
// Server sets the cookie on login response:
function setAuthCookie(res: Response, accessToken: string): void {
  res.headers.set('Set-Cookie', [
    `access_token=${accessToken}`,
    'HttpOnly',        // JS cannot access
    'Secure',          // HTTPS only
    'SameSite=Lax',    // CSRF protection
    'Path=/',
    'Max-Age=900',     // 15 minutes
  ].join('; '))
}

// Server reads from cookie, not from Authorization header:
function getTokenFromCookie(req: Request): string {
  const cookies = req.headers.get('cookie') || ''
  const match = cookies.match(/access_token=([^;]+)/)
  if (!match) throw new AuthError('No session cookie')
  return match[1]
}

Core rule: Store tokens in httpOnly cookies, hash secrets before persisting, rotate keys on a schedule, and treat refresh token reuse as a breach signal. Auth is the one system where "good enough" is never good enough.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.