SaaS Security Best Practices: Auth, Authorization, and Data Protection
Security is not a feature — it is a property of your entire architecture. This guide covers the security practices implemented in production SaaS applications like tanstackship.com: authentication with password hashing and session management, role-based and attribute-based authorization, data encryp

tion at rest and in transit, API security with CSRF and rate limiting, and ongoing monitoring for vulnerabilities. Aspect Session Auth JWT Auth Hybrid (Recommended) Storage Server-side (D1/Redis) Client-side (localStorage) Server + client Expiry Server-managed Self-contained Dual expiry Revocation Immediate Difficult (until expiry) Session invalidation + JWT refresh Scale Database lookups per request Stateless Cached sessions XSS risk Lower (HTTP-only cookie) Higher (JS-accessible) HTTP-only cookie for session // src/lib/auth.ts — using Better Auth with Drizzle import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { createDb } from "../db" export const auth = betterAuth({ database: drizzleAdapter(createDb(env), { provider: "sqlite", }), emailAndPassword: { enabled: true, autoSignIn: true, passwordHash: { algorithm: "argon2", // Argon2id — OWASP recommended params: { memoryCost: 19456, timeCost: 2, parallelism: 1, }, }, }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }, github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }, }, session: { expiresIn: 7 * 24 * 60 * 60, // 7 days updateAge: 24 * 60 * 60, // Refresh every 24 hours }, }) [ ] Passwords hashed with Argon2id (not bcrypt, not scrypt) [ ] Minimum 8 characters, no arbitrary complexity rules [ ] Rate-limited login attempts (5 per minute per IP) [ ] Email verification required before first login [ ] Session tokens stored in HTTP-only, Secure, SameSite=Strict cookies [ ] Session rotation on login and privilege escalation [ ] MFA available (TOTP or WebAuthn) [ ] Account lockout after 10 failed attempts // src/lib/auth.ts export const roles = { admin: { permissions: [ "user:*", "subscription:*", "billing:*", "settings:*", "analytics:*", ], }, member: { permissions: [ "user:read", "subscription:read", "settings:read", "settings:update", ], }, viewer: { permissions: ["user:read", "subscription:read"], }, } // Middleware guard export function requirePermission(permission: string) { return createServerFn({ method: "GET" }).handler( async ({}, { context }) => { const userRole = context.user.role as keyof typeof roles const allowed = roles[userRole]?.permissions.some( (p) => p === permission || p.endsWith(":*") ) if (!allowed) { throw new Error("Forbidden") } } ) } Every SaaS with multiple users needs data isolation: // Ensure all queries are scoped to the user's organization export const getTeamSubscriptions = createServerFn({ method: "GET" }).handler( async ({}, { context }) => { // context.user.orgId is set by auth middleware const subscriptions = await env.DB.prepare(` SELECT * FROM subscriptions WHERE organization_id = ? ORDER BY created_at DESC `).bind(context.user.orgId).all() return subscriptions.results } ) Pattern Description When to Use RBAC Role-based permissions Simple apps, small teams ABAC Attribute-based (resource owner, department) Multi-tenant, complex orgs ReBAC Relationship-based (Google Zanzibar model) Large-scale, nested orgs Permissions as Data Permissions stored in database, checked at runtime User-customizable roles Cloudflare D1 encrypts all data at rest by default. For additional protection, encrypt sensitive fields before storing: // Encrypt PII before storing in D1 import { AesGcm } from "@cloudflare/workers-types" export const encryptField = async (plaintext: string, key: CryptoKey) => { const iv = crypto.getRandomValues(new Uint8Array(12)) const encoded = new TextEncoder().encode(plaintext) const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, encoded ) return { ciphertext: arrayBufferToBase64(ciphertext), iv: arrayBufferToBase64(iv), } } export const decryptField = async ( ciphertext: string, iv: string, key: CryptoKey ) => { const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToArrayBuffer(iv) }, key, base64ToArrayBuffer(ciphertext) ) return new TextDecoder().decode(decrypted) } // middleware.ts — TanStack Router middleware export const Route = createRootRouteWithContext()({ beforeLoad: async ({ context }) => { // Security headers context.response.headers.set( "Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.tanstackship.com; style-src 'self' 'unsafe-inline'" ) context.response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") context.response.headers.set("X-Content-Type-Options", "nosniff") context.response.headers.set("X-Frame-Options", "DENY") context.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin") }, }) // server/middleware/rate-limit.ts import { createServerFn } from "@tanstack/react-start" const rateLimitStore = new Map<string, { count: number; resetAt: number }>() export function rateLimit(limit: number, windowMs: number) { return async (request: Request): Promise<boolean> => { const ip = request.headers.get("CF-Connecting-IP") ??
"unknown" const key = `${ip}:${request.url}` const now = Date.now() const entry = rateLimitStore.get(key) if (!entry || entry.resetAt < now) { rateLimitStore.set(key, { count: 1, resetAt: now + windowMs }) return true } if (entry.count >= limit) { return false // Rate limited } entry.count++ return true } } // Usage in server function export const login = createServerFn({ method: "POST" }).handler( async ({ request }) => { const allowed = await rateLimit(5, 60_000)(request) // 5 attempts per minute if (!allowed) { throw new Error("Too many requests. Try again later.") } // ... login logic } ) // TanStack Start includes CSRF protection via server functions // All POST/PUT/DELETE server functions include a CSRF token in the request // The CSRF token is automatically attached by the client SDK: createServerFn({ method: "POST" }) .handler(async ({ request }) => { // request.headers includes X-CSRF-Token // Validated automatically by TanStack Start }) import { z } from "zod" const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(2).max(100), role: z.enum(["admin", "member"]).optional(), }) export const createUser = createServerFn({ method: "POST" }) .validator((data: unknown) => createUserSchema.parse(data)) .handler(async ({ data }) => { // data is validated and typed as { email: string; name: string; role?: string } // Never trust raw input from the client }) // server/security/audit-log.ts export const logSecurityEvent = createServerFn({ method: "POST" }).handler( async ({ data }: { data: SecurityEvent }) => { await env.DB.prepare(` INSERT INTO audit_log (id, user_id, event_type, ip_address, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?) `).bind( crypto.randomUUID(), data.userId, data.eventType, data.ipAddress, JSON.stringify(data.metadata), Date.now() ).run() // Alert on suspicious events if (["failed_login", "password_change", "role_escalation"].includes(data.eventType)) { await sendAlert(data) } } ) Event Action Severity login_success Log Info login_failed Log + count Medium (after 5: high) password_reset Log + notify user Medium email_changed Log + notify old email High role_changed Log + notify admin High api_key_created Log Medium suspicious_ip Log + block High [ ] All passwords hashed with Argon2id [ ] All authenticated routes require session validation [ ] All data queries scoped to user's organization [ ] Rate limiting on auth endpoints and public APIs [ ] CSP headers configured (strict mode) [ ] CORS restricted to known origins [ ] SQL injection prevented (parameterized queries via Drizzle) [ ] XSS prevented (React auto-escapes output) [ ] CSRF protection enabled [ ] Security audit log with retention policy [ ] npm audit run weekly [ ] Dependencies updated within 14 days of security patch [ ] Secrets managed via environment variables (not committed) [ ] D1 backup configured (automatic with Cloudflare) [ ] Session expiry and rotation configured # Regular vulnerability scanning npm audit # Or use GitHub Dependabot for automated PRs # Critical packages to keep updated: # - better-auth (authentication) # - @tanstack/react-router (routing middleware) # - @tanstack/react-query (data fetching) # - drizzle-orm (database) # - zod (input validation) Security in a SaaS application is a layered concern. You cannot solve it with a single library or configuration file — it requires attention at every layer of the stack: Authentication: Strong password hashing, session management, MFA Authorization: Role-based or attribute-based access control Data protection: Encryption, secure headers, input validation API security: Rate limiting, CSRF, CORS Monitoring: Audit logging, anomaly detection, dependency scanning The good news: most of these practices can be implemented once and applied universally.
A well-configured auth library handles authentication. A middleware layer handles authorization. Security headers are set in a single configuration file. For a SaaS starter that implements these security patterns out of the box, see tanstackship.com.
SaaS Database Architecture: From User to Billing SaaS Monitoring and Observability Guide File Upload Architecture: R2, Multipart, and Streaming TanStack Start Deployment Guide
Key Takeaways
- •Aspect Session Auth JWT Auth Hybrid (Recommended) Storage Server-side (D1/Redis) Client-side (localStorage) Server + client Expiry Server-managed Self-contained Dual expiry Revocation Immediate Difficult (until expiry) Session invalidation + JWT refresh Scale Database lookups per request Stateless Cached sessions XSS risk Lower (HTTP-only cookie) Higher (JS-accessible) HTTP-only cookie for session // src/lib/auth.ts — using Better Auth with Drizzle import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { createDb } from "../db" export const auth = betterAuth({ database: drizzleAdapter(createDb(env), { provider: "sqlite", }), emailAndPassword: { enabled: true, autoSignIn: true, passwordHash: { algorithm: "argon2", // Argon2id — OWASP recommended params: { memoryCost: 19456, timeCost: 2, parallelism: 1, }, }, }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }, github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }, }, session: { expiresIn: 7 * 24 * 60 * 60, // 7 days updateAge: 24 * 60 * 60, // Refresh every 24 hours }, }) [ ] Passwords hashed with Argon2id (not bcrypt, not scrypt) [ ] Minimum 8 characters, no arbitrary complexity rules [ ] Rate-limited login attempts (5 per minute per IP) [ ] Email verification required before first login [ ] Session tokens stored in HTTP-only, Secure, SameSite=Strict cookies [ ] Session rotation on login and privilege escalation [ ] MFA available (TOTP or WebAuthn) [ ] Account lockout after 10 failed attempts // src/lib/auth.ts export const roles = { admin: { permissions: [ "user:*", "subscription:*", "billing:*", "settings:*", "analytics:*", ], }, member: { permissions: [ "user:read", "subscription:read", "settings:read", "settings:update", ], }, viewer: { permissions: ["user:read", "subscription:read"], }, } // Middleware guard export function requirePermission(permission: string) { return createServerFn({ method: "GET" }).handler( async ({}, { context }) => { const userRole = context.user.role as keyof typeof roles const allowed = roles[userRole]?.permissions.some( (p) => p === permission || p.endsWith(":*") ) if (!allowed) { throw new Error("Forbidden") } } ) } Every SaaS with multiple users needs data isolation: // Ensure all queries are scoped to the user's organization export const getTeamSubscriptions = createServerFn({ method: "GET" }).handler( async ({}, { context }) => { // context.user.orgId is set by auth middleware const subscriptions = await env.DB.prepare(` SELECT * FROM subscriptions WHERE organization_id = ?
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


