Best practices for securing SaaS applications

Fri Nov 21 2025

This is a concise, production‑focused checklist for securing multi‑tenant SaaS apps. It’s opinionated, code‑first, and prioritizes defenses that measurably reduce risk.

TL;DR checklist

  • Identity: SSO/OIDC, MFA, passwordless where possible
  • Sessions: Secure+HttpOnly+SameSite, short lifetimes, refresh tokens rotated
  • Authorization: explicit tenant scoping + RBAC/ABAC; default‑deny
  • Tenant isolation: org_id everywhere, DB RLS or service‑level isolation
  • Secrets: managed store + rotation; no secrets in git
  • HTTPS + HSTS; strict security headers; CSP (nonces or hashes)
  • CSRF on state‑changing routes; strict CORS on APIs
  • Rate limits per IP and per account; bot/abuse controls
  • Data protection: encrypt at rest + field‑level where needed; proper KMS
  • File uploads: MIME sniffing, size caps, AV scan, signed URLs, private buckets
  • Webhooks: HMAC signatures, idempotency, retries with jitter, least privilege
  • Observability: structured audit logs, anomaly alerts, tamper‑evident storage
  • Supply chain: pinned deps, SBOM, SCA/SAST, secret scanning in CI
  • Cloud/IAM: least privilege, boundaries, break‑glass access, strong per‑env isolation
  • DR/BCP: backups, tested restores, RPO/RTO defined, keys backed by HSM/KMS

1) Identity and access (SSO/OIDC + MFA)

Prefer federated identity (OIDC/SAML) with enforced MFA. If you offer local auth, enforce strong passwords and progressive profiling.

OIDC example (pseudo‑config):

oidc:
  issuer: "https://accounts.example-idp.com"
  client_id: "YOUR_CLIENT_ID"
  client_secret: "${OIDC_CLIENT_SECRET}" # from secret store
  redirect_uris:
    - "https://app.example.com/auth/callback"
  scopes: ["openid", "profile", "email", "offline_access"]
  prompt: "login" # force fresh auth for sensitive actions
  max_age: 300 # re-auth window for step-up operations

Best practices:

  • Enforce MFA in IdP or application policy for admin/privileged roles
  • Short‑lived access tokens (5–15 min), rotate refresh tokens, bind tokens to client
  • Store revocation lists; kill sessions on password/2FA change

2) Authorization and tenant scoping

Authorization is where most SaaS data leaks happen. Guard all data access with explicit tenant scoping and default‑deny policies.

  • Use tenant_id/org_id consistently in every table and API boundary
  • Apply RBAC/ABAC with clear permission names; avoid ad‑hoc “is_admin” checks spread across code
  • Add guard clauses early in requests to check tenant membership + permission

Postgres Row Level Security (RLS) example:

-- Enable RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Current tenant is set per session/connection:
-- SELECT set_config('app.current_tenant', 'acme', true);

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Optional: restrict writes similarly
CREATE POLICY tenant_isolation_write ON invoices
  FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);

Service‑level isolation alternative:

  • Dedicated db per tenant for high‑risk customers
  • Or at least dedicated schema; weigh operational complexity vs blast radius

3) Secrets and configuration

  • Use a managed secrets store (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, 1Password Connect)
  • Rotate keys/tokens on a schedule and when staff leave
  • Never log secrets; use structured allow‑list logging

.env (dev only):

OIDC_CLIENT_SECRET="change-me"
STRIPE_WEBHOOK_SECRET="whsec_..."
DATABASE_URL="postgresql://user:pass@localhost:5432/app"

4) HTTPS, HSTS, and security headers

Set strict headers platform‑wide. For browser apps, enforce CSP with nonces or hashes.

Example (generic pseudo‑middleware):

// Express-style example
app.use((req, res, next) => {
  res.setHeader("X-Frame-Options", "SAMEORIGIN");
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
  // CSP with nonce (attach to res.locals.nonce earlier)
  res.setHeader(
    "Content-Security-Policy",
    `default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:`
  );
  next();
});

5) Sessions and cookies

  • Use Secure+HttpOnly cookies; SameSite=Lax or Strict for same‑site apps
  • Keep sessions short, rotate session IDs on privilege change and login
  • Consider server‑side sessions (Redis) for revocation and centralized control

Cookie settings (framework‑agnostic):

{
  "SESSION_COOKIE_SECURE": true,
  "SESSION_COOKIE_HTTPONLY": true,
  "SESSION_COOKIE_SAMESITE": "Lax",
  "PERMANENT_SESSION_LIFETIME_MIN": 0
}

6) CSRF and CORS

  • CSRF protect all unsafe methods (POST/PUT/PATCH/DELETE) for browser‑cookie flows
  • CORS: restrict to known origins; do not use wildcard with credentials

Strict CORS (example):

const allowed = new Set(["https://app.example.com"]);
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowed.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader(
      "Access-Control-Allow-Headers",
      "authorization, content-type, x-request-id"
    );
    res.setHeader(
      "Access-Control-Allow-Methods",
      "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    );
  }
  if (req.method === "OPTIONS") return res.status(204).end();
  next();
});

7) Rate limiting and abuse prevention

  • Global limit (per IP) plus sensitive‑route limits (login, password reset, signup)
  • Per‑account/tenant quotas to prevent noisy neighbor abuse
  • Add proof‑of‑work or CAPTCHA only when needed; measure first

Limiter example:

// Pseudo-code
limiter.global("200/min");
limiter.route("/auth/login", "5/min");
limiter.route("/password/reset", "5/min");
limiter.keyFunc((req) => req.ip + ":" + req.user?.tenant_id);

8) Data protection and encryption

  • Encrypt at rest (cloud default) and in transit (TLS 1.2+)
  • Field‑level encryption for high‑sensitivity PII/PHI/PCI (store only ciphertext; keys in KMS/HSM)
  • Separate KMS keys for environments/tenants with strict IAM allow‑lists

Field‑level encryption (pseudo):

from cryptography.fernet import Fernet

def encrypt_field(value: str, key: bytes) -> str:
  return Fernet(key).encrypt(value.encode()).decode()

def decrypt_field(token: str, key: bytes) -> str:
  return Fernet(key).decrypt(token.encode()).decode()

9) Cloud/IAM boundaries

  • Separate projects/accounts per environment (prod vs staging vs dev)
  • Least privilege IAM for apps and CI; deny by default; short‑lived credentials
  • Use VPC/service perimeters where supported; egress control to approved endpoints

Example IAM policy (read‑only bucket subset):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::customer-uploads",
        "arn:aws:s3:::customer-uploads/*"
      ],
      "Condition": {
        "StringEquals": { "aws:PrincipalTag/role": "uploader" }
      }
    }
  ]
}

10) Webhooks and outbound integrations

  • Verify signatures (HMAC or Ed25519) before processing
  • Enforce idempotency keys; retry with exponential backoff + jitter
  • Use allow‑listed egress and DNS pinning where feasible for critical flows

Webhook verification (HMAC example):

import hmac, hashlib, time

def verify_webhook(raw_body: bytes, signature: str, secret: bytes, tolerance_s: int = 300) -> bool:
    # signature format: t=timestamp,v1=hex
    parts = dict(p.split("=", 1) for p in signature.split(","))
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance_s:
        return False
    expected = hmac.new(secret, f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Idempotency (pseudo):

CREATE TABLE webhook_events (
  id TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ DEFAULT now(),
  payload JSONB
);
-- INSERT ... ON CONFLICT DO NOTHING to avoid duplicates

11) File uploads

  • Enforce size limits, extension allow‑lists, and content‑type sniffing (not just headers)
  • Store in private buckets; serve via short‑lived signed URLs
  • Optional AV scan for untrusted uploads; strip metadata (EXIF)

Signed URL pattern:

// Pseudo: issue a short-lived upload URL; never expose bucket credentials
const url = storage.createSignedUrl({
  bucket: "customer-uploads",
  key: `tenants/${tenantId}/${randomUuid()}.bin`,
  expiresInSeconds: 300,
  contentType: "application/octet-stream",
});

12) Observability, audit, and alerts

  • Emit structured JSON logs; include tenant_id, user_id, ip, action, object, result
  • Immutable/audit log sink (WORM/S3 Object Lock) for critical security events
  • Real‑time alerts on auth anomalies, permission denials, mass exports/deletes

Audit helper (pseudo):

import json, logging, sys
logger = logging.getLogger("audit")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(handler)

def audit(event: str, **kwargs):
    logger.info(json.dumps({"event": event, **kwargs}))

13) Supply chain security

  • Pin dependencies; maintain an SBOM; run SCA (e.g., pip-audit, npm audit, osv-scanner)
  • SAST/linters for common bug classes; signed releases/artifacts (Sigstore)
  • Secret scanning in CI; fail builds on leaked credentials

Example CI steps (generic):

osv-scanner ./ # or npm audit / pip-audit etc.
trivy fs --exit-code 1 .
gitleaks detect --no-banner --redact

14) Operational controls

  • Backups encrypted with separate keys; regular restore tests (automated)
  • Defined RPO/RTO; document and test incident runbooks
  • Break‑glass accounts with hardware keys; monitored and rotated

15) Secure defaults and UX

  • Default‑deny routes and features until explicitly enabled
  • Feature flags: evaluate on the server with tenant scoping; avoid client‑side enforcement only
  • Clear, humane security UX: session timeouts, device management, easy 2FA enrollment/recovery

Final checklist before go‑live

  • SSO/OIDC working; MFA enforced for admins
  • RBAC/ABAC with explicit tenant scoping; default‑deny verified
  • Tenant isolation via RLS or service/db separation
  • HTTPS + HSTS enabled; CSP with nonces/hashes enforced
  • Cookies: Secure+HttpOnly+SameSite; session rotation on privilege change
  • CSRF on unsafe methods; strict CORS configured
  • Rate limits on auth and sensitive endpoints; quotas per tenant
  • Encryption at rest + field‑level where necessary; keys in KMS/HSM
  • Uploads: MIME sniffing, size caps, AV, signed URLs, private buckets
  • Webhooks: signature verification, idempotency, retries, least privilege
  • Structured audit logs shipped; anomaly alerts configured; tamper‑evident sink
  • Deps pinned; SBOM generated; SCA/SAST + secret scanning clean
  • Backups tested; RPO/RTO documented; break‑glass controls in place