SecuritySaaSAuthenticationAuthorizationData ProtectionTypeScript

SaaS Security Best Practices: Auth, Authorization, and Data Protection

A comprehensive guide to securing a modern SaaS application — covering authentication strategies, authorization models, data encryption, API security, rate limiting, and production monitoring.

Priya Sharma
Priya Sharma
July 8, 202614 min read
Also available in:Deutsch · 中文

TL;DR: Security is not a feature — it is a property of your entire architecture. This guide covers security practices for production SaaS applications: 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

AspectSession AuthJWT AuthHybrid (Recommended)
StorageServer-side (D1/Redis)Client-side (localStorage)Server + client
ExpiryServer-managedSelf-containedDual expiry
RevocationImmediateDifficult (until expiry)Session invalidation + JWT refresh
ScaleDatabase lookups per requestStatelessCached sessions
XSS riskLower (HTTP-only cookie)Higher (JS-accessible)HTTP-only cookie for session

Implementation with Better Auth

typescript
// 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)

typescript
// 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:

typescript
// 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

PatternDescriptionWhen to Use
RBACRole-based permissionsSimple apps, small teams
ABACAttribute-based (resource owner, department)Multi-tenant, complex orgs
ReBACRelationship-based (Google Zanzibar model)Large-scale, nested orgs
Permissions as DataPermissions stored in database, checked at runtimeUser-customizable roles

Data Protection

Encryption at Rest

Cloudflare D1 encrypts all data at rest by default. For additional protection, encrypt sensitive fields before storing them. The underlying database architecture should account for encrypted columns and key management from the schema design phase:

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
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

A robust security monitoring system logs critical events and alerts on suspicious activity. Integrating with a SaaS monitoring and observability stack ensures you can detect and respond to incidents in real time:

typescript
// 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

EventActionSeverity
login_successLogInfo
login_failedLog + countMedium (after 5: high)
password_resetLog + notify userMedium
email_changedLog + notify old emailHigh
role_changedLog + notify adminHigh
api_key_createdLogMedium
suspicious_ipLog + blockHigh

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

bash
# 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:

  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 complete walkthrough of deploying a secure SaaS app, see the TanStack Start deployment guide.