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_idconsistently 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