Security Checklist
The essential playbook for implementing security checklist in your SaaS.
Use this page as a pre-launch and recurring audit checklist for a small SaaS. It focuses on the minimum security controls that prevent the most common production failures: leaked secrets, weak auth, exposed admin paths, insecure headers, broken TLS, unpatched dependencies, missing backups, and poor incident visibility.
Quick Fix / Quick Setup
# 1) Check TLS and security headers
curl -I https://yourapp.com
# 2) Scan open ports on your server
ss -tulpn
sudo ufw status verbose
# 3) Verify app secrets are not committed
git grep -nE '(SECRET_KEY|API_KEY|STRIPE_SECRET|DATABASE_URL|AWS_SECRET)'
# 4) Review dependency vulnerabilities
pip-audit || safety check
npm audit --production
# 5) Confirm secure cookies and HSTS from app response
curl -s -D - https://yourapp.com/login -o /dev/null | grep -Ei 'set-cookie|strict-transport-security|content-security-policy|x-frame-options|x-content-type-options|referrer-policy'
# 6) Validate DB and Redis are not publicly exposed
sudo ss -tulpn | grep -E ':5432|:3306|:6379'
# 7) Check failed auth attempts and server errors
journalctl -u gunicorn -n 200 --no-pager
sudo nginx -T
# 8) Confirm backups exist and can be listed
ls -lah /backups || true
aws s3 ls s3://your-backup-bucket/ || trueRun this before launch, after infrastructure changes, and after adding auth, billing, admin tools, file uploads, or background jobs. If any item fails, fix it before shipping.
What’s happening
Most early SaaS security issues are configuration issues, not novel exploits.
Small deployments often ship with default settings:
- debug mode enabled
- permissive CORS
- weak cookie config
- broad firewall rules
- public infrastructure services
- no rate limiting
- no alerting on abuse
Security failures usually chain together:
- exposed admin panel
- weak password reset flow
- missing rate limiting
- shared credentials
- no monitoring
A checklist reduces risk by forcing verification across:
- application code
- auth
- billing
- hosting
- networking
- storage
- secrets
- monitoring
- recovery
Use this page as both a release gate and a monthly audit.
Step-by-step implementation
1. Review runtime configuration
Confirm production settings are actually active.
Minimum checks:
python -c "import os; print(os.getenv('DEBUG'), os.getenv('ENVIRONMENT'))"For Django-style apps:
DEBUG = False
ALLOWED_HOSTS = ["yourapp.com", "www.yourapp.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = TrueFor Node/Express-style apps, verify:
NODE_ENV=production- trust proxy configured correctly if behind Nginx or a load balancer
- secure cookies enabled in production only when TLS/proxy is correct
Example:
app.set('trust proxy', 1)
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax'
}
}))Check that:
- debug toolbars are off
- test API keys are removed
- development CORS origins are removed
- staging domains are not trusted in production
2. Audit secrets handling
Secrets must not live in source control, screenshots, CI logs, or shared docs.
Check for leaks:
git grep -nE '(SECRET_KEY|API_KEY|TOKEN|DATABASE_URL|STRIPE_SECRET|AWS_SECRET)'Baseline rules:
- use environment variables or a secrets manager
- separate prod and non-prod credentials
- rotate anything exposed
- never share root credentials
- scope credentials per service
Example .env shape:
DATABASE_URL=postgres://app_user:strongpassword@db.internal:5432/app
REDIS_URL=redis://redis.internal:6379/0
STRIPE_SECRET_KEY=sk_live_xxx
DJANGO_SECRET_KEY=replace-me
S3_BUCKET=your-private-bucketIf secrets were committed:
- rotate them
- redeploy with new values
- revoke old access
- review logs and provider dashboards
- remove from repo history if needed
Related setup: Environment Variables and Secrets Management
3. Lock down authentication
Minimum auth controls:
- strong password hashing
- secure session cookies
- CSRF protection for cookie-based auth
- email verification where identity matters
- rate limiting on login/reset/signup
- admin MFA if available
Password hashing:
- preferred: Argon2id
- acceptable: bcrypt with current cost settings
Cookie baseline:
SecureHttpOnlySameSite=LaxorStrictif flow allows
Django example:
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"Rate-limit endpoints:
/login/signup/password-reset/api/auth/*
Nginx example:
limit_req_zone $binary_remote_addr zone=authlimit:10m rate=5r/m;
location /login {
limit_req zone=authlimit burst=10 nodelay;
proxy_pass http://app;
}Related guide: Implement User Authentication (Login/Register)
Checklist companion: Auth System Checklist
4. Verify authorization
Do not rely on frontend checks.
Test server-side access for:
- admin routes
- billing routes
- team management routes
- tenant-specific resources
- exports
- file downloads
- internal dashboards
- support tooling
Checklist:
- role checks enforced in backend
- tenant ID checked on every sensitive query
- admin actions audited
- support accounts restricted
- subscription state validated server-side
Bad pattern:
if (user.isAdmin) {
showAdminButton()
}Required pattern:
app.get('/admin/users', requireAdmin, handler)And for multi-tenant queries:
SELECT * FROM invoices WHERE tenant_id = $1 AND id = $2;Never trust:
- client role flags
- client subscription flags
- hidden buttons as "protection"
5. Harden server and network access
Expose only required ports.
Check listening ports:
ss -tulpnCheck firewall:
sudo ufw status verboseRecommended public exposure:
- 80/tcp
- 443/tcp
- 22/tcp only if necessary and ideally restricted by IP
UFW baseline:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow from YOUR_IP to any port 22 proto tcp
sudo ufw enableSSH hardening in /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yesThen reload:
sudo systemctl reload sshDo not expose:
- Postgres
5432 - MySQL
3306 - Redis
6379 - Celery Flower
- RQ dashboard
- Adminer
- phpMyAdmin
6. Confirm TLS and domain security
Check certificate and TLS response:
curl -I https://yourapp.com
openssl s_client -connect yourapp.com:443 -servername yourapp.com </dev/nullYou want:
- valid cert
- automatic renewal
- redirect HTTP to HTTPS
- correct proxy headers
- app aware that request is secure
Nginx redirect:
server {
listen 80;
server_name yourapp.com www.yourapp.com;
return 301 https://$host$request_uri;
}TLS server block:
server {
listen 443 ssl http2;
server_name yourapp.com www.yourapp.com;
ssl_certificate /etc/letsencrypt/live/yourapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourapp.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8000;
}
}Enable HSTS only after confirming all traffic and subdomains support HTTPS.
Related deploy guide: Deploy SaaS with Nginx + Gunicorn
7. Set browser security headers
Baseline headers:
Strict-Transport-SecurityContent-Security-PolicyX-Content-Type-Options: nosniffReferrer-Policy- clickjacking protection with
X-Frame-Optionsor CSPframe-ancestors
Check live response:
curl -s -D - https://yourapp.com/login -o /dev/nullNginx example:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https://js.stripe.com; style-src 'self' 'unsafe-inline'; frame-src https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;If using third-party scripts, start with a report-only CSP if needed, then tighten.
Incident Response
8. Protect data stores
Ensure databases and caches are private.
Check ports:
sudo ss -tulpn | grep -E ':5432|:3306|:6379'Basic validation:
redis-cli -h 127.0.0.1 ping
psql "$DATABASE_URL" -c 'select now();'Rules:
- bind to private interface or localhost
- firewall blocks public access
- strong passwords enabled
- least-privilege DB users
- no app using DB superuser credentials
Example Postgres app user permissions:
CREATE USER app_user WITH PASSWORD 'strongpassword';
GRANT CONNECT ON DATABASE app TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;Avoid:
- public Redis without auth
- default DB users
- shared root DB creds across apps
9. Secure file uploads and storage
Validate:
- MIME type
- extension
- size
- destination path
Rules:
- randomize filenames
- never trust client filename
- do not serve private uploads directly from a public directory
- block executable extensions
- use signed URLs or permission-checked downloads
Nginx upload/body limit:
client_max_body_size 10M;Application checks:
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024Storage rules:
- public assets and private user files should be separate
- private buckets should require signed access
- scan uploads if risk justifies it
10. Review payments and webhooks
For Stripe and similar providers:
- verify webhook signatures
- never trust client-reported payment status
- use server-side event handling
- scope dashboard access
- log subscription state changes
Minimum webhook checks:
# pseudo-example
event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
)Do not grant access because the frontend says payment succeeded.
Grant access only after:
- verified provider event
- stored subscription state updated server-side
- replay/duplicate events handled idempotently
Related guide: Stripe Subscription Setup (Step-by-Step)
Checklist companion: Payment System Checklist
11. Audit dependencies and base images
Run vulnerability scans regularly and in CI.
Python:
pip-audit
safety checkNode:
npm audit --productionDocker images:
docker images
docker run --rm your-image-name cat /etc/os-releaseProcess:
- pin dependencies
- update high severity issues first
- rebuild base images regularly
- remove unused packages
- do not ship old language runtimes
12. Enable monitoring, logging, and alerting
You need visibility for:
- 401/403/429 spikes
- 5xx spikes
- failed webhooks
- auth abuse
- unusual CPU/memory/disk usage
- repeated password resets
- suspicious admin actions
Useful commands:
journalctl -u nginx -n 200 --no-pager
journalctl -u gunicorn -n 200 --no-pager
sudo systemctl status nginx gunicornTrack at minimum:
- app errors
- reverse proxy errors
- auth events
- payment events
- admin changes
- backup job results
Related checklist: Monitoring Checklist
13. Validate backups and recovery
Backups that were never restored are unverified.
Check backup presence:
ls -lah /backups || true
aws s3 ls s3://your-backup-bucket/Requirements:
- encrypted at rest
- retention policy defined
- restore tested
- owner assigned
- restore steps documented
Run restore drills into a disposable environment, not production.
Minimum backup scope:
- database
- uploaded files if required
- critical configuration snapshots
- infrastructure state if relevant
14. Test failure scenarios
Run explicit tests before release:
- brute-force login attempt triggers rate limit
- expired reset link rejected
- revoked session no longer works
- private file blocked without auth
- invalid webhook signature rejected
- unauthorized tenant access denied
- backup restore works end-to-end
This catches integration bugs between proxy, app, auth, and background workers.
15. Document ownership
Assign an owner for:
- key rotation
- incident response
- access approvals
- release sign-off
- backup restore verification
- dependency patch review
Small teams still need named ownership, even if one person owns multiple steps.
Auth Lifecycle
Common causes
- Secrets committed to Git or shared in chat/docs
- Production running with
DEBUG=trueor equivalent - Session cookies missing
SecureorHttpOnly - Missing CSRF protection in cookie-based auth flows
- Publicly accessible Redis, Postgres, MySQL, or admin dashboards
- No rate limiting on login, reset, signup, or API endpoints
- Weak role checks or missing tenant isolation
- Trusting frontend state for subscription or permission decisions
- File uploads stored with predictable names or executable extensions
- Overly broad CORS allowing unintended origins
- Outdated packages with known CVEs
- SSH password login left enabled on a public VPS
- No monitoring for auth abuse, webhook failures, or server errors
- Backups exist but restores were never tested
- Excessive permissions in cloud storage, payment dashboards, or CI/CD tokens
Debugging tips
Check response headers first. Many issues are visible immediately.
Useful commands:
curl -I https://yourapp.com
curl -s -D - https://yourapp.com/login -o /dev/null
sudo nginx -T
sudo systemctl status nginx gunicorn
journalctl -u nginx -n 200 --no-pager
journalctl -u gunicorn -n 200 --no-pager
ss -tulpn
sudo ufw status verbose
git grep -nE '(SECRET_KEY|API_KEY|TOKEN|DATABASE_URL|STRIPE_SECRET|AWS_SECRET)'
pip-audit
safety check
npm audit --production
python -c "import os; print(os.getenv('DEBUG'), os.getenv('ENVIRONMENT'))"
openssl s_client -connect yourapp.com:443 -servername yourapp.com </dev/null
redis-cli -h 127.0.0.1 ping
psql "$DATABASE_URL" -c 'select now();'
aws s3 ls s3://your-backup-bucket/Practical checks:
- inspect effective Nginx config with
nginx -T - verify
X-Forwarded-Protoif secure cookies or redirects fail - test unauthenticated access against admin/internal endpoints
- try external connection attempts to DB/Redis ports
- inspect cookies in browser devtools
- replay valid and invalid webhook payloads
- run backup restoration to a disposable environment
Checklist
- ✓ HTTPS enforced with valid certs and auto-renewal confirmed
- ✓ HSTS enabled after full HTTPS verification
- ✓ Secure headers configured and verified in live responses
- ✓ Debug mode disabled in production
- ✓ Secrets stored outside source control and rotated if previously exposed
- ✓ Separate credentials used for dev, staging, and production
- ✓ Session cookies set to
SecureandHttpOnly;SameSitereviewed - ✓ CSRF protection enabled for cookie-based forms and unsafe methods
- ✓ Password hashing uses Argon2id or bcrypt with current recommended settings
- ✓ Rate limiting enabled on auth, reset, signup, public API, and webhook-heavy endpoints where applicable
- ✓ Admin accounts restricted and protected with stronger controls
- ✓ Role and tenant access checks tested for sensitive actions
- ✓ Database and Redis are not publicly reachable
- ✓ Firewall restricts inbound traffic to required ports only
- ✓ SSH uses keys; password login disabled where possible
- ✓ Dependencies audited; critical issues remediated or documented
- ✓ File upload validation and storage access rules tested
- ✓ Webhook signatures validated for payment and external integrations
- ✓ Backups encrypted, retained, and restore-tested
- ✓ Error tracking, logging, and alerting enabled for suspicious activity
- ✓ Incident response steps documented: key rotation, user lockout, rollback, and restore
- ✓ Access to Stripe, cloud console, DNS, and CI/CD limited to necessary users only
- ✓ Periodic security review scheduled monthly or before each release
Related guides
- SaaS Production Checklist
- Auth System Checklist
- Payment System Checklist
- Deploy SaaS with Nginx + Gunicorn
- Monitoring Checklist
FAQ
How often should I run this checklist?
At minimum:
- before launch
- before major releases
- after infrastructure changes
- after adding auth or billing features
- monthly
What are the highest-priority checks for a small SaaS?
Start with:
- HTTPS
- secret handling
- secure cookies
- auth rate limiting
- private database and Redis access
- dependency patching
- backups
- monitoring
Do I need a full CSP before launch?
No, but you should not skip CSP entirely. A basic policy is better than none. If needed, start with report-only mode, identify required sources, then tighten the policy.
Is a firewall enough to protect my database?
No. Also use:
- strong credentials
- private networking where possible
- least-privilege users
- monitoring for abuse or failed connections
Should indie SaaS apps use MFA?
At least for admin and staff accounts. End-user MFA depends on account sensitivity, but admin MFA is a strong baseline.
What if I already committed secrets?
Assume compromise.
Do this immediately:
- rotate the secrets
- update deployments
- revoke old credentials
- inspect provider logs and usage
- remove secrets from repo history if possible
Final takeaway
Security for a small SaaS is mostly disciplined configuration and verification.
Before shipping, confirm:
- transport is secure
- secrets are controlled
- auth and authorization are enforced server-side
- infrastructure is not publicly exposed
- dependencies are patched
- monitoring exists
- backups restore successfully
- incident response is documented
Use this as a release gate, not a one-time setup page.