DEV Community

sweet
sweet

Posted on

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 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
  },
})
Enter fullscreen mode Exit fullscreen mode

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")
      }
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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")
  },
})
Enter fullscreen mode Exit fullscreen mode

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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
  })
Enter fullscreen mode Exit fullscreen mode

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
  })
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Authentication: Strong password hashing, session management, MFA
  2. Authorization: Role-based or attribute-based access control
  3. Data protection: Encryption, secure headers, input validation
  4. API security: Rate limiting, CSRF, CORS
  5. 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.

Related Resources

Top comments (0)