Startup security is not about achieving enterprise-grade compliance from day one. It is about locking down the controls that prevent the most damaging attacks first, then layering additional protections as the product and team grow. This checklist covers authentication with OAuth and JWT, authorization with RBAC, input validation, SQL injection prevention, XSS protection, secrets management, dependency scanning, and incident response -- prioritized by risk and effort so a 2-5 person engineering team can implement them in order.
Authentication: OAuth, JWT, and Session Security
Authentication is the front door. If it is weak, nothing else matters.
OAuth 2.0 Implementation
For customer-facing login, use OAuth 2.0 with a proven identity provider rather than building your own:
// OAuth 2.0 Authorization Code flow with PKCE (recommended for SPAs)
const authConfig = {
authority: "https://auth.example.com",
client_id: "your-app-client-id",
redirect_uri: "https://app.example.com/callback",
response_type: "code",
scope: "openid profile email",
code_challenge_method: "S256",
};
// Generate PKCE challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
OAuth checklist:
- Always use Authorization Code flow with PKCE for browser apps (never Implicit flow).
- Validate the `state` parameter to prevent CSRF.
- Store tokens securely: HTTP-only cookies for web, secure storage for mobile.
- Implement token refresh with rotation (invalidate old refresh tokens).
JWT Best Practices
If you issue your own JWTs for API authentication:
<h1>Python JWT creation with proper claims</h1>
import jwt
from datetime import datetime, timedelta
def create_access_token(user_id: int, role: str) -> str:
payload = {
"sub": str(user_id),
"role": role,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=1), # Short-lived
"iss": "https://api.example.com",
"aud": "https://app.example.com",
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_token(token: str) -> dict:
return jwt.decode(
token,
JWT_SECRET,
algorithms=["HS256"], # Explicit algorithm - prevents alg:none attack
audience="https://app.example.com",
issuer="https://api.example.com",
)
JWT security rules:
| Rule | Why |
| Set `exp` to 1 hour or less | Limits damage from stolen tokens |
| Always specify `algorithms` in verify | Prevents algorithm confusion attacks |
| Include `iss` and `aud` claims | Prevents token reuse across services |
| Use HS256 minimum, RS256 for multi-service | Stronger signing for distributed systems |
| Never store secrets in JWT payload | JWTs are base64-encoded, not encrypted |
| Implement token revocation for admin actions | Critical for account compromise response |
Multi-Factor Authentication
- Enforce MFA for all admin and internal accounts from day one.
- Offer MFA to all users, require it for sensitive operations (payment changes, data export).
- Support TOTP (Google Authenticator, Authy) as baseline. Add WebAuthn/passkeys as the product matures.
- Never use SMS-based MFA as the only option -- it is vulnerable to SIM swapping.
Authorization: Role-Based Access Control (RBAC)
Authentication tells you who someone is. Authorization tells you what they can do.
RBAC Implementation
<h1>Simple RBAC middleware for FastAPI</h1>
from enum import Enum
from functools import wraps
class Role(str, Enum):
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"
PERMISSIONS = {
Role.ADMIN: ["read", "write", "delete", "manage_users", "manage_billing"],
Role.MEMBER: ["read", "write"],
Role.VIEWER: ["read"],
}
def require_permission(permission: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
user = get_current_user() # From JWT/session
user_permissions = PERMISSIONS.get(user.role, [])
if permission not in user_permissions:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return await func(*args, **kwargs)
return wrapper
return decorator
@app.delete("/api/services/{service_id}")
@require_permission("delete")
async def delete_service(service_id: int):
# Only admins and members with delete permission reach here
pass
RBAC checklist:
- Default to least privilege: new users get Viewer role.
- Log all privilege escalation events.
- Review role assignments quarterly.
- Never hardcode role checks by user ID -- always check the role.
- For multi-tenant SaaS, always filter by `tenant_id` in every database query.
Review multi-tenant patterns in Services Overview.
Input Validation
Every piece of data that crosses a trust boundary (user input, API calls, webhooks) must be validated server-side.
<h1>Pydantic models for request validation (FastAPI)</h1>
from pydantic import BaseModel, Field, validator
import re
class CreateServiceRequest(BaseModel):
name: str = Field(..., min_length=3, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$")
type: str = Field(..., pattern=r"^(compute
database
cache)$")
region: str = Field(..., pattern=r"^[a-z]{2}-[a-z]+-[0-9]$")
plan: str = Field(..., pattern=r"^(basic
standard
premium)$")
@validator("name")
def validate_name(cls, v):
if "--" in v:
raise ValueError("Name cannot contain consecutive hyphens")
reserved = ["admin", "system", "internal", "api"]
if v in reserved:
raise ValueError(f"Name '{v}' is reserved")
return v
Validation rules:
- Validate type, length, format, and range for every field.
- Use allowlists (what is permitted) not denylists (what is blocked).
- Validate on the server even if you validate on the client.
- Return generic error messages to users (do not leak internal details).
SQL Injection Prevention
SQL injection remains one of the most common and devastating attacks. Prevention is straightforward:
<h1>CORRECT: Parameterized queries (asyncpg)</h1>
user = await conn.fetchrow(
"SELECT * FROM users WHERE email = $1 AND tenant_id = $2",
email, tenant_id
)
<h1>CORRECT: ORM queries (SQLAlchemy)</h1>
user = session.query(User).filter(
User.email == email,
User.tenant_id == tenant_id
).first()
<h1>WRONG: String formatting (vulnerable to injection)</h1>
<h1>user = await conn.fetchrow(f"SELECT * FROM users WHERE email = '{email}'")</h1>
<h1>NEVER do this</h1>
| Approach | SQL injection risk | Performance |
| Parameterized queries | None | Best (prepared statements cached) |
| ORM with bound parameters | None | Good |
| Stored procedures with params | None | Good |
| String concatenation | Critical vulnerability | N/A -- never use |
| String formatting (f-strings) | Critical vulnerability | N/A -- never use |
XSS (Cross-Site Scripting) Protection
XSS allows attackers to inject malicious scripts into your application that run in other users' browsers.
Prevention Layers
// React auto-escapes JSX content -- this is safe:
const UserName = ({ name }) => <span>{name}</span>;
// DANGEROUS: Never use dangerouslySetInnerHTML with user content
// <div dangerouslySetInnerHTML={{ __html: userInput }} /> // NEVER
// If you must render HTML, sanitize first:
import DOMPurify from "dompurify";
const cleanHTML = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href"],
});
Server-side headers that prevent XSS:
<h1>Security headers middleware (FastAPI)</h1>
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
Secrets Management
Secrets in source code are the number one cause of startup security breaches. The fix is simple but requires discipline:
<h1>.gitignore -- MUST include these</h1>
.env
.env.*
*.pem
*.key
kubeconfig*
<h1>Pre-commit hook to catch secrets</h1>
<h1>Install: pip install detect-secrets</h1>
<h1>Setup: detect-secrets scan > .secrets.baseline</h1>
<h1>Hook: detect-secrets-hook --baseline .secrets.baseline</h1>
Secrets management rules:
- Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler).
- Never commit secrets to git. Use `detect-secrets` or `gitleaks` as a pre-commit hook.
- Separate secrets by environment (dev, staging, production).
- Rotate credentials on a schedule: API keys every 90 days, database passwords every 180 days.
- Immediately rotate any credential that may have been exposed.
- Audit who has access to production secrets quarterly.
Reference secure deployment patterns in DevOps Tools.
Dependency Scanning
Your application is only as secure as its weakest dependency. Automate vulnerability scanning in CI:
<h1>GitHub Actions dependency scanning</h1>
name: Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Node.js dependency audit
- name: npm audit
run: npm audit --audit-level=high
# Python dependency scan
- name: pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.txt
# Container image scan
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
severity: CRITICAL,HIGH
exit-code: 1
# Secret detection
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
Scanning SLA by severity:
| Severity | Patch SLA | Action |
| Critical (CVSS 9.0+) | 24 hours | Block deploys until fixed |
| High (CVSS 7.0-8.9) | 7 days | Create ticket, prioritize |
| Medium (CVSS 4.0-6.9) | 30 days | Schedule in sprint |
| Low (CVSS 0.1-3.9) | 90 days | Batch with other updates |
Incident Response Plan
Every startup needs an incident response plan before the first incident. Here is a minimal but effective framework:
Severity Definitions
| Severity | Definition | Response time | Examples |
| SEV1 | Active data breach or complete outage | 15 minutes | Exposed database, ransomware, auth bypass |
| SEV2 | Security vulnerability with no confirmed exploit | 4 hours | Critical CVE in production dependency |
| SEV3 | Security improvement needed | Next sprint | Missing security header, audit finding |
Incident Response Steps
Communication During Security Incidents
- Notify the relevant supervisory authority within 72 hours of becoming aware of a breach (GDPR Art. 33, if applicable); notify affected individuals without undue delay when the breach poses high risk to their rights (GDPR Art. 34).
- Use pre-written templates to avoid delays.
- Designate one person as the external communicator.
- Log all actions taken with timestamps for potential legal proceedings.
Review incident response documentation at Documentation.
Security Implementation Priority Order
For a startup with limited engineering time, implement controls in this order:
| Priority | Control | Effort | Impact |
| Week 1 | HTTPS everywhere + security headers | 2 hours | High |
| Week 1 | Parameterized queries (no SQL injection) | 2 hours | Critical |
| Week 1 | Secrets out of source code | 4 hours | Critical |
| Week 2 | MFA for admin accounts | 4 hours | High |
| Week 2 | Input validation on all endpoints | 8 hours | High |
| Week 2 | Dependency scanning in CI | 2 hours | Medium |
| Week 3 | RBAC implementation | 8 hours | High |
| Week 3 | CSP and XSS headers | 2 hours | Medium |
| Week 4 | Incident response plan documented | 4 hours | High |
| Week 4 | Secret rotation schedule | 2 hours | Medium |
Frequently Asked Questions
Do startups really need to worry about security this early?
Yes. Startups are increasingly targeted because attackers know early-stage companies have weak security. A data breach in the first two years can kill a startup outright through customer trust loss, legal liability, and remediation costs. The controls in this checklist take 2-4 weeks of part-time effort and prevent 90% of common attacks.
Should we get SOC 2 certified?
Not immediately. Focus on the security controls first. When enterprise customers start requiring SOC 2 in procurement (usually Series A and beyond), you will already have the technical controls in place. The certification process then becomes a documentation exercise rather than a technical scramble.
Is it safe to use JWTs for authentication?
JWTs are safe when implemented correctly: short expiration (1 hour or less), explicit algorithm verification, proper secret management, and a token revocation mechanism for admin actions. The most common JWT vulnerability is not the format itself but poor implementation (no expiration, algorithm confusion, secrets in payload).
How do we handle security for third-party integrations?
Apply the same input validation to webhook payloads that you apply to user input. Verify webhook signatures. Use separate API keys per integration with minimal required permissions. Monitor third-party API usage for anomalies. Review third-party access quarterly and revoke unused integrations.
What is the biggest security mistake startups make?
Storing secrets in source code. This includes database passwords in config files committed to git, API keys in frontend JavaScript, and deployment credentials in CI/CD configs without proper secret management. One exposed repository makes all those secrets public permanently (git history is forever).
How much should a startup spend on security tooling?
Zero to $50/month in the first year. Most critical security tools are free: OWASP ZAP for scanning, detect-secrets for secret detection, npm audit and pip-audit for dependency scanning, Let's Encrypt for TLS. Paid tools become worthwhile when you need continuous monitoring or compliance automation.
Final Takeaway
Strong startup security is built from consistent fundamentals: authentication with MFA, authorization with RBAC, parameterized queries, input validation, secrets management, dependency scanning, and a practiced incident response plan. You do not need a security team to implement these controls. You need a checklist, 2-4 weeks of focused effort, and the discipline to maintain them as the product grows.
For architecture alignment and security review, visit Services Overview or contact us.