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

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 encryption 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
- •Security is not a feature — it is a property of your entire architecture
- •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 →


