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

bash
# 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/ || true

Run 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:

bash
python -c "import os; print(os.getenv('DEBUG'), os.getenv('ENVIRONMENT'))"

For Django-style apps:

python
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 = True

For 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:

js
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:

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

env
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-bucket

If secrets were committed:

  1. rotate them
  2. redeploy with new values
  3. revoke old access
  4. review logs and provider dashboards
  5. 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:

  • Secure
  • HttpOnly
  • SameSite=Lax or Strict if flow allows

Django example:

python
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:

nginx
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:

js
if (user.isAdmin) {
  showAdminButton()
}

Required pattern:

js
app.get('/admin/users', requireAdmin, handler)

And for multi-tenant queries:

sql
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:

bash
ss -tulpn

Check firewall:

bash
sudo ufw status verbose

Recommended public exposure:

  • 80/tcp
  • 443/tcp
  • 22/tcp only if necessary and ideally restricted by IP

UFW baseline:

bash
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 enable

SSH hardening in /etc/ssh/sshd_config:

conf
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes

Then reload:

bash
sudo systemctl reload ssh

Do 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:

bash
curl -I https://yourapp.com
openssl s_client -connect yourapp.com:443 -servername yourapp.com </dev/null

You want:

  • valid cert
  • automatic renewal
  • redirect HTTP to HTTPS
  • correct proxy headers
  • app aware that request is secure

Nginx redirect:

nginx
server {
    listen 80;
    server_name yourapp.com www.yourapp.com;
    return 301 https://$host$request_uri;
}

TLS server block:

nginx
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-Security
  • Content-Security-Policy
  • X-Content-Type-Options: nosniff
  • Referrer-Policy
  • clickjacking protection with X-Frame-Options or CSP frame-ancestors

Check live response:

bash
curl -s -D - https://yourapp.com/login -o /dev/null

Nginx example:

nginx
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.

Detect
Triage
Mitigate
Resolve
Postmortem

Incident Response

8. Protect data stores

Ensure databases and caches are private.

Check ports:

bash
sudo ss -tulpn | grep -E ':5432|:3306|:6379'

Basic validation:

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

sql
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:

nginx
client_max_body_size 10M;

Application checks:

python
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024

Storage 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:

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

bash
pip-audit
safety check

Node:

bash
npm audit --production

Docker images:

bash
docker images
docker run --rm your-image-name cat /etc/os-release

Process:

  • 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:

bash
journalctl -u nginx -n 200 --no-pager
journalctl -u gunicorn -n 200 --no-pager
sudo systemctl status nginx gunicorn

Track 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:

bash
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.

Register
Verify Email
Login
Session
Logout

Auth Lifecycle

Common causes

  • Secrets committed to Git or shared in chat/docs
  • Production running with DEBUG=true or equivalent
  • Session cookies missing Secure or HttpOnly
  • 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:

bash
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-Proto if 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 Secure and HttpOnly; SameSite reviewed
  • 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

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:

  1. rotate the secrets
  2. update deployments
  3. revoke old credentials
  4. inspect provider logs and usage
  5. 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.