Version: 1.0 Status: Draft (Phase 5) Last updated: 2026-03-12 Depends on: PRD.md, ARCHITECTURE.md, LLD/, CODE-STRUCTURE.md
This document covers security controls not already detailed in prior phases. For reference:
| Topic | Primary Source |
|---|---|
| JWT auth flow, token rotation, refresh blacklist | ARCHITECTURE.md §3.2, LLD/api.md §1.2 |
| Role-based access (user/moderator/admin/premium) | LLD/api.md §1.2 |
| Rate limiting (per-user, per-IP tiers) | LLD/api.md §1.7 |
| Razorpay webhook HMAC-SHA256 verification | LLD/payments.md §3.2 |
| Atomic credit transactions, SELECT FOR UPDATE | LLD/payments.md §5, LLD/database.md §3.12-13 |
| Admin audit logging | LLD/database.md §3.16 |
| Prompt injection defence (preamble + XML sandboxing) | LLD/ai-service.md §4.7-4.8 |
| AI output validation (schema, score bounds, retry) | LLD/ai-service.md §5 |
| Parameterised SQL via pgx (no string interpolation) | CODE-STRUCTURE.md §2.7 |
This document fills the gaps: security headers, CORS, CSRF, password policy, secrets management, input hardening, infrastructure security, dependency scanning, data privacy, and incident response.
All responses from the Go API pass through Nginx reverse proxy, which injects security headers. The frontend (Vercel) has its own header configuration via next.config.js.
# infra/docker/nginx.conf (security headers section)
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Enforce HTTPS (1 year, include subdomains)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Control referrer information
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Restrict browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://*.cloudfront.net data:; connect-src 'self' https://api.careerdock.skriptvalley.com; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Hide server identity
server_tokens off;// frontend/next.config.js (headers section)
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
],
},
];
},| Header | Value | Threat Mitigated |
|---|---|---|
X-Frame-Options |
DENY |
Clickjacking |
X-Content-Type-Options |
nosniff |
MIME-type confusion attacks |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Protocol downgrade, cookie hijacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Referrer data leakage |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
Unnecessary browser API access |
Content-Security-Policy |
See §2.1 | XSS, data injection, clickjacking |
server_tokens |
off |
Server fingerprinting |
// internal/middleware/cors.go
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "X-Request-ID"},
ExposedHeaders: []string{"X-Request-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"},
AllowCredentials: true, // Required for httpOnly cookie auth
MaxAge: 86400, // Preflight cache: 24 hours
})
}| Environment | Allowed Origins |
|---|---|
| Development | http://localhost:3000 |
| Staging | https://staging.careerdock.skriptvalley.com |
| Production | https://careerdock.skriptvalley.com |
Rules:
- No wildcard (
*) origins — ever.AllowCredentials: trueforbids it anyway. - Origins set via
ALLOWED_ORIGINSenv var (comma-separated). - Requests from unlisted origins receive no
Access-Control-Allow-Originheader — the browser blocks them.
CareerDock's auth uses httpOnly cookies with SameSite=Strict. This means:
- Cookies are never sent on cross-origin requests (no cross-site form submission, no cross-site AJAX).
- There is no
Authorizationheader — the cookie is the only credential. - All state-changing endpoints require
Content-Type: application/json, which cannot be sent by HTML forms (form submissions useapplication/x-www-form-urlencodedormultipart/form-data).
These three factors together eliminate CSRF without needing a separate token mechanism.
| Endpoint | CSRF Risk | Mitigation |
|---|---|---|
POST /api/webhooks/razorpay |
None — no cookie auth | HMAC-SHA256 signature verification |
GET /api/auth/verify-email/{token} |
None — idempotent, token-gated | Single-use token with 24h expiry |
POST /api/auth/reset-password |
Low — no session needed | Single-use token with 1h expiry |
If CareerDock ever downgrades to SameSite=Lax (e.g., to support OAuth redirects), a double-submit cookie pattern must be added. Document this in an ADR if it happens.
| Setting | Value | Rationale |
|---|---|---|
| Algorithm | bcrypt | Industry standard, built into Go (golang.org/x/crypto/bcrypt) |
| Cost factor | 12 | ~250ms per hash on modern hardware. Balances security with UX on login |
| Upgrade strategy | Re-hash on next login if cost factor increases | Transparent to user |
// internal/service/auth_service.go
import "golang.org/x/crypto/bcrypt"
const bcryptCost = 12
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
return string(bytes), err
}
func checkPassword(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}| Rule | Constraint | Enforced At |
|---|---|---|
| Minimum length | 8 characters | Handler (input validation) |
| Maximum length | 72 characters (bcrypt limit) | Handler |
| Complexity | At least 1 uppercase, 1 lowercase, 1 digit | Handler |
| Common password check | — | Deferred to v2 (HaveIBeenPwned k-anonymity API) |
Brute-force protection via Redis sliding window:
// internal/middleware/brute_force.go
// Key: bruteforce:{ip}:{email_hash}
// Threshold: 5 failed attempts in 15 minutes → lock for 15 minutes
// After lockout expires, counter resets
const (
maxLoginAttempts = 5
lockoutWindow = 15 * time.Minute
)| Attempt | Result |
|---|---|
| 1-5 | Normal login (success or "Invalid credentials") |
| 6+ | 429 RATE_LIMITED — "Too many login attempts. Try again in 15 minutes." |
| After 15 min | Counter resets, user can try again |
Important: Error messages never reveal whether the email exists. Both "wrong email" and "wrong password" return the same generic message: "Invalid email or password".
- User provides current password + new password.
- Verify current password against stored hash.
- Hash new password, update in DB.
- Invalidate all existing refresh tokens for this user (force re-login on all devices).
- Issue new access + refresh token pair for the current session.
| Setting | Value | Rationale |
|---|---|---|
| Algorithm | HS256 (HMAC-SHA256) | Symmetric — sufficient for single-backend architecture |
| Secret length | Minimum 256 bits (32 bytes) | Matches HMAC-SHA256 key size |
| Secret source | JWT_SECRET env var |
Loaded via Viper |
Access token (15-min TTL):
{
"sub": "01912345-6789-7abc-def0-123456789abc",
"email": "user@example.com",
"role": "user",
"premium": true,
"iat": 1709251200,
"exp": 1709252100,
"jti": "unique-token-id"
}Refresh token (7-day TTL):
{
"sub": "01912345-6789-7abc-def0-123456789abc",
"iat": 1709251200,
"exp": 1709856000,
"jti": "unique-refresh-token-id"
}Rules:
- Access token carries role and premium status — avoids DB lookup on every request.
- Refresh token is minimal — only used to issue a new access token (which re-reads from DB).
jti(JWT ID) is a UUID v4 — used for blacklisting on logout.
Login
├── Verify credentials (bcrypt)
├── Generate access token (15 min) + refresh token (7 days)
├── Store refresh token JTI in Redis: session:{user_id}:{jti} → TTL 7d
└── Set both as httpOnly/Secure/SameSite=Strict cookies
API Request
├── Middleware reads access token from cookie
├── Verify signature (HS256) + expiry
├── Extract claims (sub, role, premium)
└── Proceed to handler
Token Refresh (access token expired)
├── Read refresh token from cookie
├── Verify signature + expiry
├── Check JTI not in blacklist (Redis)
├── Blacklist old refresh token JTI
├── Issue new access token + new refresh token (rotation)
└── Set new cookies
Logout
├── Blacklist current refresh token JTI in Redis (TTL = remaining token lifetime)
└── Clear both cookies (Set-Cookie with Max-Age=0)
Password Change
├── Delete ALL session:{user_id}:* keys in Redis
└── Issue new token pair for current session only
For HS256, key rotation requires a dual-key window:
- Generate new
JWT_SECRET_NEWalongside existingJWT_SECRET. - Deploy with both keys:
- Sign new tokens with
JWT_SECRET_NEW. - Verify with
JWT_SECRET_NEWfirst, fallback toJWT_SECRET.
- Sign new tokens with
- Wait 7 days (max refresh token lifetime) for all old tokens to expire.
- Remove
JWT_SECRET(old key). - Rename
JWT_SECRET_NEW→JWT_SECRET.
// internal/middleware/auth.go
func (a *Auth) verifyToken(tokenString string) (*Claims, error) {
// Try current key first
claims, err := parseJWT(tokenString, a.currentKey)
if err == nil {
return claims, nil
}
// Fallback to previous key (during rotation window)
if a.previousKey != "" {
return parseJWT(tokenString, a.previousKey)
}
return nil, err
}Rotation frequency: At minimum annually, or immediately if key compromise is suspected.
Applied at Nginx (first line of defence) and Go handler (second line):
| Limit | Nginx | Go Handler |
|---|---|---|
| JSON body | client_max_body_size 1m |
http.MaxBytesReader(w, r.Body, 1<<20) |
| File upload (resume) | client_max_body_size 6m (5MB + overhead) |
Multipart reader with 5MB file limit |
| URL length | large_client_header_buffers 4 8k |
— (Go default 1MB is fine) |
| Header size | large_client_header_buffers 4 8k |
— |
Resume uploads are the only file upload in the system. Validate defensively:
// internal/handler/resume.go — upload validation
func validateResumeUpload(file multipart.File, header *multipart.FileHeader) error {
// 1. Size check (5MB max)
if header.Size > 5*1024*1024 {
return domain.ValidationError("File exceeds 5MB limit", map[string]any{
"max_size_bytes": 5 * 1024 * 1024,
"actual_size": header.Size,
})
}
// 2. Extension check
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".pdf" {
return domain.ValidationError("Only PDF files are accepted", map[string]any{
"allowed_extensions": []string{".pdf"},
})
}
// 3. Magic bytes check (PDF starts with %PDF-)
buf := make([]byte, 5)
if _, err := file.Read(buf); err != nil {
return domain.ValidationError("Unable to read file", nil)
}
if string(buf) != "%PDF-" {
return domain.ValidationError("File is not a valid PDF", nil)
}
// 4. Reset reader position
file.Seek(0, io.SeekStart)
return nil
}Consolidated from LLD/api.md:
| Field | Rules | Max Length |
|---|---|---|
email |
RFC 5322 format, lowercase, trimmed | 254 chars |
password |
8-72 chars, 1 upper + 1 lower + 1 digit | 72 (bcrypt limit) |
name |
Non-empty, trimmed | 100 chars |
company.slug |
Lowercase alphanumeric + hyphens, no leading/trailing hyphens | 100 chars |
company.name |
Non-empty, trimmed | 200 chars |
company.website |
Valid URL (https preferred) | 500 chars |
list.name |
Non-empty, trimmed | 100 chars |
list.description |
Optional | 500 chars |
ats.job_url |
Valid URL | 2000 chars |
ats.job_description |
Non-empty | 10,000 chars |
application_status |
One of: wishlist, applied, screening, interviewing, offer, accepted, rejected, withdrawn | — |
cursor |
Base64-encoded, validated on decode | 200 chars |
limit (pagination) |
Integer, 1-100, default 20 | — |
Already enforced by architecture (parameterised queries via pgx), but explicitly codified:
Allowed:
conn.QueryRow(ctx, `SELECT * FROM users WHERE email = $1`, email)Forbidden (will fail code review):
conn.QueryRow(ctx, fmt.Sprintf(`SELECT * FROM users WHERE email = '%s'`, email))The golangci-lint config includes gocritic which flags string concatenation in SQL-like contexts.
Internet
│
▼
┌──────────────────────────────────────────────┐
│ EC2 Security Group: careerdock-api-sg │
│ │
│ Inbound Rules: │
│ 443 (HTTPS) ← 0.0.0.0/0 │
│ 80 (HTTP) ← 0.0.0.0/0 → redirect 443 │
│ 22 (SSH) ← your-ip/32 only │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Nginx (:443) — SSL termination │ │
│ │ ├── /api/* → Go API (:8080) │ │
│ │ └── health → Go API (:8080) │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Go API │ │ Go Worker │ │
│ │ :8080 │ │ (no port) │ │
│ │ (internal) │ │ (internal) │ │
│ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │
│ ┌─────▼────────────────▼──────┐ │
│ │ Redis :6379 (internal only) │ │
│ │ bind 127.0.0.1 │ │
│ │ requirepass <strong-pass> │ │
│ └──────────────────────────────┘ │
└──────────────────────┬───────────────────────┘
│ (VPC internal)
┌────────────▼────────────────┐
│ RDS PostgreSQL │
│ Security Group: │
│ 5432 ← careerdock-api-sg │
│ No public IP │
│ Encryption at rest: ON │
└─────────────────────────────┘
┌─────────────────────────────┐
│ S3 │
│ careerdock-resumes (private)│
│ careerdock-logos (public/CF)│
│ SSE-S3 encryption │
└─────────────────────────────┘
| Setting | Value |
|---|---|
| AMI | Amazon Linux 2023 (latest) |
| SSH access | Key-based only, restricted to admin IP |
| Unattended updates | Enabled for security patches |
| Open ports | 443, 80 (redirect), 22 (restricted) |
| Docker rootless | Not for MVP — standard Docker with non-root containers |
| Go API container | Runs as non-root user (UID 1000) |
Dockerfile non-root user:
# Final stage of Dockerfile.api / Dockerfile.worker
RUN adduser -D -u 1000 appuser
USER appuser| Setting | Value |
|---|---|
| Public access | Disabled |
| Security group | Inbound 5432 only from careerdock-api-sg |
| Encryption at rest | AWS-managed KMS key (default, free) |
| Encryption in transit | sslmode=require in connection string |
| Automated backups | Enabled, 7-day retention |
| Multi-AZ | Disabled for MVP (cost). Enable when revenue supports it |
| Deletion protection | Enabled in production |
| IAM auth | Not for MVP — password-based via Secrets Manager |
Connection string (production):
postgres://careerdock:<password>@careerdock-db.xxxxx.rds.amazonaws.com:5432/careerdock?sslmode=require
| Setting | Value |
|---|---|
| Bind address | 127.0.0.1 (localhost only, Docker network in compose) |
| Authentication | requirepass set in production |
| TLS | Not for MVP (localhost-only traffic). Add if Redis moves to ElastiCache |
| Persistence | AOF enabled (appendonly yes) for durability |
| Max memory | 256MB (plenty for MVP: sessions + rate limits + AI cache) |
| Eviction policy | allkeys-lru (least-recently-used eviction when full) |
Redis config (production):
bind 127.0.0.1
requirepass <strong-random-password>
appendonly yes
maxmemory 256mb
maxmemory-policy allkeys-lru
careerdock-resumes (private):
| Setting | Value |
|---|---|
| Public access | Block all public access (bucket policy) |
| Encryption | SSE-S3 (AES-256, AWS-managed keys) |
| Versioning | Disabled (no need — PDFs are immutable, identified by resume_id) |
| Access | IAM user with scoped policy (PutObject, GetObject, DeleteObject on this bucket only) |
| Signed URLs | 15-minute expiry, generated server-side |
IAM policy for API/Worker:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::careerdock-resumes/*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::careerdock-logos/*"
}
]
}careerdock-logos (public via CloudFront):
| Setting | Value |
|---|---|
| Public access | Via CloudFront OAC (Origin Access Control) only |
| Direct S3 access | Blocked (bucket policy allows only CloudFront) |
| Encryption | SSE-S3 |
# infra/docker/nginx.conf
# Force HTTPS
server {
listen 80;
server_name api.careerdock.skriptvalley.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.careerdock.skriptvalley.com;
# SSL (Let's Encrypt via certbot)
ssl_certificate /etc/letsencrypt/live/api.careerdock.skriptvalley.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.careerdock.skriptvalley.com/privkey.pem;
# TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
# Security headers (from §2.1)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
server_tokens off;
# Request size limits
client_max_body_size 6m; # 5MB PDF + multipart overhead
large_client_header_buffers 4 8k;
# Timeouts
proxy_connect_timeout 10s;
proxy_read_timeout 60s; # AI operations can take up to 60s
proxy_send_timeout 30s;
# Proxy to Go API
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
}
# Health check (no auth, no rate limit)
location = /api/health {
proxy_pass http://127.0.0.1:8080;
}
# Block everything else
location / {
return 404;
}
}| Secret | Environment Variable | Rotation Frequency | Notes |
|---|---|---|---|
| JWT signing key | JWT_SECRET |
Annually (or on compromise) | 256-bit minimum. Dual-key rotation (§6.4) |
| JWT previous key | JWT_SECRET_PREVIOUS |
Removed 7 days after rotation | Only present during rotation window |
| Database password | DATABASE_URL |
Annually | RDS master password |
| Redis password | REDIS_PASSWORD |
Annually | Set via requirepass |
| Claude API key | CLAUDE_API_KEY |
Per Anthropic policy | Regenerate on suspected leak |
| OpenAI API key | OPENAI_API_KEY |
Per OpenAI policy | Fallback provider |
| Razorpay key ID | RAZORPAY_KEY_ID |
Annually | Separate test/live keys |
| Razorpay key secret | RAZORPAY_KEY_SECRET |
Annually | Never log this value |
| Razorpay webhook secret | RAZORPAY_WEBHOOK_SECRET |
Annually | Used for HMAC-SHA256 verification |
| Resend API key | RESEND_API_KEY |
Annually | Transactional email |
| S3 access key | S3_ACCESS_KEY_ID |
Annually | Scoped IAM user |
| S3 secret key | S3_SECRET_ACCESS_KEY |
Annually | Scoped IAM user |
| Sentry DSN | SENTRY_DSN |
Never (not sensitive) | Project identifier, not a secret |
| Environment | Storage | Access |
|---|---|---|
| Local dev | .env file (gitignored) |
Developer's machine |
| Staging | AWS Secrets Manager | ECS task role / EC2 instance profile |
| Production | AWS Secrets Manager | ECS task role / EC2 instance profile |
# .gitignore
.env
.env.local
.env.*.local
*.pem
*.keyPre-commit hook (from CODE-STRUCTURE.md) uses detect-secrets:
# One-time baseline setup
detect-secrets scan > .secrets.baseline
# Hook checks for new secrets on every commit
# If a real secret is detected, the commit is blockedThe structured logger (slog) must never include:
| Data | Why |
|---|---|
| Passwords (plain or hashed) | Credential exposure |
| JWT tokens (access or refresh) | Session hijacking |
| API keys (Claude, OpenAI, Razorpay, etc.) | Third-party account takeover |
| Full resume text | PII exposure (names, addresses, phones) |
| Credit card data | N/A (Razorpay handles this), but guard against accidental logging |
| S3 signed URLs | Contain temporary credentials |
| Password reset tokens | Account takeover |
Enforcement: Code review + custom slog handler that redacts fields matching known secret patterns.
// internal/middleware/logger.go — redaction example
var sensitiveFields = map[string]bool{
"password": true, "token": true, "secret": true,
"authorization": true, "cookie": true, "api_key": true,
}
// Custom slog handler that replaces sensitive field values with "[REDACTED]"Go (backend):
# In .github/workflows/ci.yml — add to backend job
- name: Vulnerability scan
working-directory: backend
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...Node.js (frontend):
# In .github/workflows/ci.yml — add to frontend job
- name: Vulnerability scan
working-directory: frontend
run: npm audit --audit-level=highEnable GitHub Dependabot:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "backend"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- package-ecosystem: "docker"
directory: "/infra/docker"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "infra"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"- Dependabot opens PR with version bump.
- CI runs lint + test + build + vulnerability scan.
- Review changelog for breaking changes.
- Merge if CI passes and no breaking changes.
- For major version bumps: manual review required.
| Classification | Examples | Access | Encryption |
|---|---|---|---|
| Public | Company directory, company logos | Anyone | In transit (HTTPS) |
| Internal | Feature flags, admin audit log | Admin only | At rest (RDS) + in transit |
| Confidential | User email, name, resume text, parsed data | User + admin | At rest (RDS/S3) + in transit |
| Restricted | Password hashes, JWT secrets, API keys | System only | At rest + in transit |
Registration
└── Stored: email, name, password_hash
Premium Purchase
└── Stored: payment record (no card data), credits allocated
Resume Upload
└── Stored: PDF in S3, extracted_text + parsed_data in Postgres
Account Deletion Request
├── Immediate: Soft delete (deleted_at set), all tokens invalidated
├── 30 days: Hard delete of user + cascaded data (lists, credits, transactions)
└── 90 days: S3 resume PDFs purged (cleanup job)
Users can export their data via GET /api/settings/export (authenticated):
{
"user": {
"email": "user@example.com",
"name": "Jane Doe",
"role": "user",
"premium_since": "2026-01-15T10:00:00Z",
"created_at": "2025-12-01T08:30:00Z"
},
"lists": [ ... ],
"resumes": [
{
"filename": "jane-doe-resume.pdf",
"uploaded_at": "2026-01-20T14:00:00Z",
"parsed_data": { ... },
"download_url": "https://...signed-url..."
}
],
"ats_checks": [ ... ],
"credits": { ... },
"payments": [ ... ]
}Resume PDFs are included as signed download URLs (15-minute expiry). No credit card data is included (Razorpay handles all card data).
| Principle | Implementation |
|---|---|
| Collect only what's needed | Registration: email + name + password. No phone, no address |
| Don't store what you don't need | Card data never touches CareerDock (Razorpay handles it) |
| Limit access | User data scoped by user_id in all queries. Admin access logged |
| Limit retention | Soft delete → hard delete cascade (30 days). S3 cleanup (90 days) |
Lightweight incident response plan for a solo-founder MVP.
| Level | Definition | Response Time | Example |
|---|---|---|---|
| P1 — Critical | Data breach, full system down, payment compromise | Immediate | Database exposed, API keys leaked, payment double-charge |
| P2 — High | Partial outage, auth bypass, data loss risk | < 4 hours | Login broken, resume uploads failing, credit deduction bug |
| P3 — Medium | Degraded performance, non-critical bug, single user affected | < 24 hours | Slow API response, one user's ATS check stuck, Sentry spike |
For any incident:
- Detect — Sentry alert, uptime monitor, user report, or log anomaly.
- Assess — Determine severity (P1/P2/P3) and blast radius.
- Contain — Stop the bleeding:
- P1: Take affected service offline if necessary.
- Rotate any compromised credentials immediately.
- Force-expire all sessions:
redis-cli DEL session:*
- Fix — Deploy hotfix (tag, push, deploy).
- Verify — Confirm fix via monitoring + manual testing.
- Postmortem — Write a brief incident report in
docs/ADR/(what happened, root cause, fix, prevention).
| Scenario | Immediate Action |
|---|---|
| JWT secret compromised | Rotate JWT_SECRET (§6.4). All sessions invalidated |
| Database credentials leaked | Rotate RDS password. Redeploy API + worker |
| API key (Claude/OpenAI) leaked | Regenerate key in provider dashboard. Update Secrets Manager. Redeploy |
| Razorpay keys leaked | Regenerate in Razorpay dashboard. Update webhook secret. Redeploy |
| Resume data breach | Rotate S3 credentials. Invalidate all signed URLs (they expire in 15 min). Assess scope. Notify affected users |
| DDoS attack | Enable AWS Shield Basic (free, auto-enabled). Consider CloudFront in front of API. Rate limits provide first defence |
Before going live, verify every item:
- JWT tokens in httpOnly/Secure/SameSite=Strict cookies
- Access token: 15-minute TTL
- Refresh token: 7-day TTL with rotation
- Logout blacklists refresh token in Redis
- Password change invalidates all sessions
- bcrypt cost factor = 12
- Brute-force lockout active (5 attempts / 15 min)
- Generic error on failed login ("Invalid email or password")
- CORS: explicit origin allowlist (no wildcards)
- Rate limiting active on all endpoints
- All input validated (handler layer)
- JSON body size limited (1MB)
- Resume upload: PDF magic bytes checked, 5MB max
- SQL: all queries parameterised (no string concatenation)
- X-Frame-Options: DENY
- X-Content-Type-Options: nosniff
- Strict-Transport-Security present
- Content-Security-Policy present
- Nginx
server_tokens off
- HTTPS everywhere (Let's Encrypt for API, Vercel for frontend)
- SSH restricted to admin IP only
- RDS: no public access, encryption at rest,
sslmode=require - Redis:
requirepassset, bound to localhost - S3: block public access on resume bucket
- Docker containers run as non-root user
-
.envin.gitignore -
detect-secretspre-commit hook active - No secrets in logs (redaction handler active)
- Production secrets in AWS Secrets Manager
-
govulncheckin CI -
npm auditin CI - Dependabot enabled
- User soft-delete works (30-day grace)
- Data export endpoint functional
- S3 cleanup job scheduled (90-day post-deletion)
- Admin audit log captures all admin actions
| Item | Reason |
|---|---|
| MFA (TOTP/WebAuthn) | Complexity. Add for admin accounts first, then optionally for all users |
| HaveIBeenPwned password checking | Nice-to-have. k-anonymity API is easy to integrate later |
| AWS WAF / Shield Advanced | Cost. Shield Basic (free) + rate limiting is sufficient for MVP |
| Penetration testing | Schedule after launch when features are stable |
| SOC 2 compliance | Only relevant at enterprise scale |
Content Security Policy reporting (report-uri) |
Set up after launch to monitor violations without blocking |
| Redis TLS | Only needed if Redis moves off localhost (e.g., ElastiCache) |
| Database IAM authentication | Simpler with password for MVP. IAM auth adds cold-start latency |