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.
Authentication: The Identity Layer
Session vs Token-Based Auth
| 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 |
Implementation with Better Auth
// 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
},
})
Password Security Checklist
- [ ] 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
Authorization: Who Can Do What
Role-Based Access Control (RBAC)
// 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")
}
}
)
}
Organization Scoping
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
}
)
Authorization Patterns
| 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 |
Data Protection
Encryption at Rest
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)
}
HTTPS and Headers
// 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")
},
})
API Security
Rate Limiting
// 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
}
)
CSRF Protection
// 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
})
API Input Validation
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
})
Security Monitoring
Incident Detection
// 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)
}
}
)
Logged Security Events
| 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 |
Production Security Checklist
- [ ] 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
Dependency Security
# 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)
Conclusion
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.
Top comments (0)