| name | saas-auth-patterns |
| description | SaaS authentication and authorization patterns including JWT vs session strategies, multi-tenant isolation, RBAC, API key management, passwordless flows, MFA, and secure session handling. |
SaaS Auth Patterns
Authentication and authorization patterns for multi-tenant SaaS applications.
Auth Strategy Decision Matrix
| Strategy | Stateless | Scalable | Revocable | Best For |
|---|
| JWT + Refresh | Yes | High | Hard (needs blocklist) | API-first, mobile clients |
| Session (server) | No | Medium (sticky/shared store) | Instant | Traditional web apps |
| OAuth 2.0 + PKCE | Yes | High | Via provider | Third-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
}
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,
}
}
async function getTenantUsers(ctx: TenantContext): Promise<User[]> {
return db.users.findMany({
where: { tenantId: ctx.tenantId },
})
}
Shared DB vs Isolated DB
async function withTenantScope<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
await db.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`
return fn()
}
function getTenantConnection(tenantId: string): PrismaClient {
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
throw new Error('Invalid tenant ID format')
}
const schema = `tenant_${tenantId}`
return new PrismaClient({ datasources: { db: { url: `${DB_URL}?schema=` } } })
}
Account Linking (Email + Social Merge)
async function linkOrCreateAccount(provider: string, profile: OAuthProfile): Promise<User> {
const existing = await db.socialAccounts.findUnique({
where: { provider_providerAccountId: { provider, providerAccountId: profile.id } },
include: { user: true },
})
if (existing) return existing.user
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) {
db..({
: { : emailUser., provider, : profile. },
})
emailUser
}
db..({
: {
: profile.,
: profile.,
: {
: { provider, : profile. },
},
},
})
}
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)
}
function requirePermission(permission: Permission) {
return async (req: Request): <> => {
ctx = (req)
(!(ctx., permission)) {
()
}
}
}
API Key Management
import { randomBytes, createHash } from 'crypto'
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 }
}
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, : ( (), ) },
})
fullKey
}
(): <{ : ; : [] }> {
hashedKey = ().(key).()
record = db..({ : { hashedKey } })
(!record) ()
(record. < ()) ()
(record.) ()
db..({ : { : record. }, : { : () } })
{ : record., : record. }
}
(): <> {
oldKey = db..({ : { : oldKeyId } })
(!oldKey) ()
newFullKey = (tenantId, , oldKey.)
db..({ : { : oldKeyId }, : { : ( (), ) } })
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) },
})
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 = ().(token).()
result = db..({
: { hashedToken, : , : { : () } },
: { : () },
})
(result. === ) ()
record = db..({ : { hashedToken } })
user = (record.)
sessionToken = (user.)
{ : user., sessionToken }
}
MFA Integration
import { authenticator } from 'otplib'
async function enrollMfa(userId: string): Promise<{ secret: string; qrUri: string }> {
const secret = authenticator.generateSecret()
await db.mfaSecrets.create({ data: { userId, secret, verified: false } })
const qrUri = authenticator.keyuri(userId, process.env.APP_NAME ?? 'My App', secret)
return { secret, qrUri }
}
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')
(!authenticator.(code, record.)) {
()
}
db..({ : { userId }, : { : } })
}
(): <> {
user = (email, password)
mfa = db..({ : { : user., : } })
(mfa) {
(!mfaCode) ()
(!authenticator.(mfaCode, mfa.)) ()
}
(user.)
}
Session Management
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 }
}
async function refreshSession(oldRefreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
hashed = ().(oldRefreshToken).()
session = db..({ : { : hashed } })
(!session || session. < ()) ()
(session.) {
db..({ : { : session. }, : { : () } })
()
}
db..({ : { : session. }, : { : () } })
(session.)
}
(): <> {
activeSessions = db..({
: { userId, : , : { : () } },
: { : },
})
(activeSessions. >= maxSessions) {
oldest = activeSessions[]
db..({ : { : oldest. }, : { : () } })
}
}
Token Storage: GOOD vs BAD
localStorage.setItem('token', accessToken)
fetch('/api/data', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
function setAuthCookie(res: Response, accessToken: string): void {
res.headers.set('Set-Cookie', [
`access_token=${accessToken}`,
'HttpOnly',
'Secure',
'SameSite=Lax',
'Path=/',
'Max-Age=900',
].join('; '))
}
function getTokenFromCookie(req: Request): string {
const cookies = req.headers.get() ||
match = cookies.()
(!match) ()
match[]
}
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.