Security & Authentication
Comprehensive security model for the Noozer platform
Table of Contents
- Overview
- Authentication Flows
- API Key Management
- Admin Authentication
- Authorization & Scopes
- Rate Limiting
- Data Security
- Webhook Security
- Infrastructure Security
- Compliance & Privacy
- Security Monitoring
Overview
Security Layers
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY ARCHITECTURE │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 1: NETWORK │
│ ├── Cloudflare WAF (Web Application Firewall) │
│ ├── DDoS protection (automatic) │
│ ├── TLS 1.3 enforced │
│ └── Geographic restrictions (optional) │
├─────────────────────────────────────────────────────────────────────────────┤
│ LAYER 2: AUTHENTICATION │
│ ├── API Keys (client apps) │
│ ├── Bearer Tokens (admin console) │
│ ├── Service Bindings (worker-to-worker) │
│ └── HMAC Signatures (webhooks) │
├─────────────────────────────────────────────────────────────────────────────┤
│ LAYER 3: AUTHORIZATION │
│ ├── Scope-based permissions │
│ ├── Resource ownership validation │
│ ├── Tier-based feature access │
│ └── Admin role-based access control │
├─────────────────────────────────────────────────────────────────────────────┤
│ LAYER 4: DATA │
│ ├── Encryption at rest (D1, R2, KV) │
│ ├── Field-level encryption (PII) │
│ ├── Audit logging │
│ └── Data isolation (multi-tenant) │
└─────────────────────────────────────────────────────────────────────────────┘
Trust Boundaries
Authentication Flows
Client Authentication (API Keys)
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT AUTHENTICATION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. REGISTRATION
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ ──────► │ API │ ──────► │ D1 │
└─────────┘ POST └─────────┘ INSERT └─────────┘
/v1/auth/register
{email, password, name}
Response: {customer_id, email, message: "Verify email"}
2. EMAIL VERIFICATION
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Email │ ──────► │ Client │ ──────► │ API │
└─────────┘ click └─────────┘ GET └─────────┘
/v1/auth/verify?token=xxx
Response: {verified: true}
3. API KEY GENERATION
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ ──────► │ API │ ──────► │ D1 │
└─────────┘ POST └─────────┘ INSERT └─────────┘
/v1/auth/keys
{name: "Production"}
Response: {
api_key: "nz_live_a1b2c3d4e5f6...", // ONLY SHOWN ONCE
key_id: "uuid",
prefix: "nz_live_a1b2",
created_at: "..."
}
4. API REQUEST
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ ──────► │ API │ ──────► │ Handler │
└─────────┘ └─────────┘ └─────────┘
GET /v1/feed
X-API-Key: nz_live_a1b2c3d4e5f6...
Admin Authentication (Sessions + Tokens)
┌──────────────────────────────────────────────── ─────────────────────────────┐
│ ADMIN AUTHENTICATION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. LOGIN (Session Creation)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Admin │ ──────► │ Admin │ ──────► │ D1 │
│ Console │ POST │ API │ Verify │ + KV │
└─────────┘ └─────────┘ └─────────┘
/v1/admin/auth/login
{email, password, mfa_code?}
Response: {
access_token: "eyJ...", // JWT, 15 min expiry
refresh_token: "rt_...", // Opaque, 7 day expiry
expires_at: "..."
}
2. TOKEN REFRESH
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Admin │ ──────► │ Admin │ ──────► │ KV │
│ Console │ POST │ API │ Lookup │ │
└─────────┘ └─────────┘ └─────────┘
/v1/admin/auth/refresh
{refresh_token: "rt_..."}
Response: {access_token: "eyJ...", expires_at: "..."}
3. API REQUEST
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Admin │ ──────► │ Admin │ ──────► │ Handler │
│ Console │ │ API │ Verify └─────────┘
└─────────┘ └─────────┘ JWT
GET /v1/admin/pipeline/status
Authorization: Bearer eyJ...
4. LOGOUT
POST /v1/admin/auth/logout
- Revokes refresh token
- Clears KV session
API Key Management
Key Format
┌─────────────────────────────────────────────────────────────────────────────┐
│ API KEY FORMAT │
└─────────────────────────────────────────────────────────────────────────────┘
Format: nz_{environment}_{random}
Examples:
nz_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 (production)
nz_test_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4 (testing)
nz_dev_1234567890abcdef1234567890abcdef (development)
Components:
├── Prefix: "nz_" (identifies as Noozer key)
├── Environment: "live" | "test" | "dev"
└── Random: 32 chars, base62 (a-z, A-Z, 0-9)
Total length: 41 characters
Key Generation
// Key generation pseudocode
function generateApiKey(environment: 'live' | 'test' | 'dev'): {
key: string;
hash: string;
prefix: string;
} {
// Generate 32 bytes of cryptographic randomness
const random = crypto.getRandomValues(new Uint8Array(32));
const randomStr = base62Encode(random); // 32 chars
const key = `nz_${environment}_${randomStr}`;
const prefix = key.substring(0, 12); // "nz_live_a1b2"
// Hash for storage (NEVER store plaintext)
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
return {
key, // Return to user ONCE, never store
hash: hexEncode(hash), // Store this
prefix // Store for identification
};
}
Key Storage
API_KEYS table:
├── id: uuid
├── customer_id: uuid
├── key_hash: string # SHA-256 hash of full key
├── key_prefix: string # First 12 chars for identification
├── name: string # User-provided name
├── environment: enum # live | test | dev
├── scopes: string[] # Permissions granted
├── rate_limit_tier: enum # standard | elevated | unlimited
├── last_used_at: timestamp
├── last_used_ip: string?
├── expires_at: timestamp? # Optional expiration
├── is_active: boolean
├── created_at: timestamp
└── revoked_at: timestamp?
Key Validation
// Key validation pseudocode
async function validateApiKey(key: string): Promise<{
valid: boolean;
customer_id?: string;
scopes?: string[];
error?: string;
}> {
// 1. Check format
if (!key.match(/^nz_(live|test|dev)_[a-zA-Z0-9]{32}$/)) {
return { valid: false, error: 'Invalid key format' };
}
// 2. Hash and lookup
const hash = sha256(key);
const keyRecord = await db.query(
'SELECT * FROM api_keys WHERE key_hash = ? AND is_active = true',
[hash]
);
if (!keyRecord) {
return { valid: false, error: 'Invalid API key' };
}
// 3. Check expiration
if (keyRecord.expires_at && keyRecord.expires_at < now()) {
return { valid: false, error: 'API key expired' };
}
// 4. Update last used
await db.query(
'UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?',
[now(), request.ip, keyRecord.id]
);
return {
valid: true,
customer_id: keyRecord.customer_id,
scopes: keyRecord.scopes
};
}
Key Rotation
┌─────────────────────────────────────────────────────────────────────────────┐
│ KEY ROTATION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. Create new key (old key still active)
POST /v1/auth/keys
2. Update application with new key
3. Verify new key works
GET /v1/auth/me (with new key)
4. Revoke old key
DELETE /v1/auth/keys/{old_key_id}
Grace period: Keys can coexist for migration
Recommendation: Rotate every 90 days
Admin Authentication
JWT Structure
┌─────────────────────────────────────────────────────────────────────────────┐
│ ADMIN JWT TOKEN │
└─────────────────────────────────────────────────────────────────────────────┘
Header:
{
"alg": "ES256", // ECDSA with P-256 curve
"typ": "JWT"
}
Payload:
{
"sub": "admin_user_id", // Admin user ID
"email": "admin@example.com",
"roles": ["admin", "reviewer"],
"permissions": [
"admin:read",
"admin:write",
"review:read",
"review:write"
],
"iat": 1702750800, // Issued at
"exp": 1702751700, // Expires (15 min later)
"iss": "noozer", // Issuer
"aud": "noozer-admin" // Audience
}
Signature:
ECDSA signature using private key
Refresh Token
Refresh Token Format: rt_{random_64_chars}
Storage (KV):
Key: refresh_token:{hash}
Value: {
admin_user_id: string,
created_at: timestamp,
expires_at: timestamp, // 7 days
user_agent: string,
ip_address: string
}
TTL: 7 days
Multi-Factor Authentication (MFA)
┌─────────────────────────────────────────────────────────────────────────────┐
│ MFA OPTIONS │
└─────────────────────────────────────────────────────────────────────────────┘
Option 1: TOTP (Time-based One-Time Password)
- Standard: RFC 6238
- Apps: Google Authenticator, Authy, 1Password
- Setup: QR code + secret key backup
- Validation: 6-digit code, 30-second window
Option 2: Email OTP (fallback)
- 6-digit code sent to verified email
- Expires in 10 minutes
- Rate limited: 3 requests per hour
Storage:
ADMIN_USERS:
├── mfa_enabled: boolean
├── mfa_secret: string (encrypted) # TOTP secret
├── mfa_backup_codes: string[] # 10 one-time codes
└── mfa_verified_at: timestamp
Session Management
┌─────────────────────────────────────────────────────────────────────────────┐
│ ADMIN SESSION CONTROLS │
└─────────────────────────────────────────────────────────────────────────────┘
Session Limits:
- Max 5 concurrent sessions per admin
- Oldest session revoked when limit exceeded
Session Timeout:
- Idle timeout: 30 minutes
- Absolute timeout: 8 hours
- Refresh token: 7 days
Session Revocation:
- On password change: all sessions revoked
- On role change: all sessions revoked
- Manual: individual or all sessions
IP Restrictions (optional):
- Allowlist specific IPs/ranges
- Block on geo mismatch
- Alert on new location
Authorization & Scopes
Client Scopes
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT API SCOPES │
└─────────────────────────────────────────────────────────────────────────────┘
Scope Description
─────────────────────────────────────────────────────────────────────────────
read:feed Read personalized feed
read:articles Read article details
read:stories Read story details
read:search Use search endpoints
read:briefings Access AI briefings
read:entities Read entity information
read:profile Read own profile/preferences
write:keywords Create/update keyword sets
write:profile Update profile/preferences
write:feedback Submit article feedback
write:subscriptions Manage story subscriptions
admin:billing Access billing (enterprise)
───────────────────────────────────────────────────────────────────────── ────
Default scopes by tier:
Free: read:feed, read:articles, read:stories, write:feedback
Pro: All read:*, write:keywords, write:profile, write:subscriptions
Enterprise: All scopes
Admin Permissions
┌─────────────────────────────────────────────────────────────────────────────┐
│ ADMIN PERMISSIONS │
└─────────────────────────────────────────────────────────────────────────────┘
Permission Description
─────────────────────────────────────────────────────────────────────────────
admin:read View admin dashboard, metrics
admin:write Trigger pipelines, update config
admin:delete Delete resources, run retention
review:read View review queue
review:write Submit review decisions
taxonomy:read View taxonomy/rules
taxonomy:write Edit taxonomy/rules
taxonomy:delete Delete labels, rollback
sources:read View sources
sources:write Add/edit sources
sources:delete Block/remove sources
customers:read View customer list
customers:write Update customer tier/settings
customers:delete Delete customer accounts
costs:read View cost reports
costs:write Set budgets/alerts
system:config Modify system configuration
system:secrets Access/rotate secrets
─────────────────────────────────────────────────── ──────────────────────────
Roles (bundled permissions):
viewer: *:read
operator: *:read, admin:write, review:write
editor: *:read, *:write
admin: *:read, *:write, *:delete
superadmin: All permissions including system:*
Resource Ownership
// Resource ownership validation
async function checkOwnership(
customerId: string,
resourceType: 'article' | 'keyword_set' | 'profile',
resourceId: string
): Promise<boolean> {
switch (resourceType) {
case 'keyword_set':
const ks = await db.query(
'SELECT customer_id FROM keyword_sets WHERE id = ?',
[resourceId]
);
return ks?.customer_id === customerId;
case 'profile':
const p = await db.query(
'SELECT customer_id FROM customer_profiles WHERE id = ?',
[resourceId]
);
return p?.customer_id === customerId;
case 'article':
// Articles are shared, but scores are per-customer
return true; // Anyone can read articles
}
}
Rate Limiting
Rate Limit Tiers
┌─────────────────────────────────────────────────────────────────────────────┐
│ RATE LIMIT TIERS │
└─────────────────────────────────────────────────────────────────────────────┘
Tier Requests/min Requests/hour Requests/day
─────────────────────────────────────────────────────────────────────────────
Free 60 1,000 10,000
Pro 300 5,000 100,000
Enterprise 1,000 50,000 1,000,000
Unlimited N/A N/A N/A
────────────────────────────────────────────── ───────────────────────────────
Burst allowance: 2x rate for 10 seconds
Rate Limit Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1702751700
X-RateLimit-Tier: free
# On rate limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1702751700
Rate Limit Implementation
KV Storage:
Key: rate:{customer_id}:min:{minute_bucket}
Value: count (integer)
TTL: 2 minutes
Key: rate:{customer_id}:hour:{hour_bucket}
Value: count (integer)
TTL: 2 hours
Key: rate:{customer_id}:day:{day_bucket}
Value: count (integer)
TTL: 2 days
Endpoint-Specific Limits
┌─────────────────────────────────────────────────────────────────────────────┐
│ ENDPOINT-SPECIFIC RATE LIMITS │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoint Limit (per min) Notes
─────────────────────────────────────────────────────────────────────────────
POST /v1/auth/register 5 Prevent spam signups
POST /v1/auth/login 10 Prevent brute force
POST /v1/qa 10 Expensive (LLM calls)
GET /v1/briefings/* 10 Expensive (LLM calls)
POST /v1/profiles/:id/rebuild 5 Expensive (embedding)
POST /v1/keywords 20 Reasonable creation rate
GET /v1/feed 60 Normal read rate
GET /v1/search 30 Moderate (may hit vectors)
─────────────────────────────────────────────────────────────────── ──────────
Data Security
Encryption at Rest
┌─────────────────────────────────────────────────────────────────────────────┐
│ ENCRYPTION AT REST │
└─────────────────────────────────────────────────────────────────────────────┘
Cloudflare-managed encryption:
- D1: AES-256 encryption (automatic)
- R2: AES-256 encryption (automatic)
- KV: AES-256 encryption (automatic)
- Vectorize: Encrypted (automatic)
Application-level encryption (sensitive fields):
- customer.email → encrypted (for GDPR)
- admin_user.mfa_secret → encrypted
- api_keys.key_hash → already hashed (SHA-256)
- webhook configs with secrets → encrypted
Encryption key management:
- Stored in Cloudflare Secrets
- Rotated quarterly
- Separate keys per environment
Field-Level Encryption
// Field encryption using AES-256-GCM
const ENCRYPTION_KEY = env.FIELD_ENCRYPTION_KEY; // 256-bit key
async function encryptField(plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.importKey(
'raw',
hexDecode(ENCRYPTION_KEY),
'AES-GCM',
false,
['encrypt']
);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Format: base64(iv) + "." + base64(ciphertext)
return base64Encode(iv) + '.' + base64Encode(ciphertext);
}
async function decryptField(encrypted: string): Promise<string> {
const [ivB64, ciphertextB64] = encrypted.split('.');
const iv = base64Decode(ivB64);
const ciphertext = base64Decode(ciphertextB64);
const key = await crypto.subtle.importKey(
'raw',
hexDecode(ENCRYPTION_KEY),
'AES-GCM',
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return new TextDecoder().decode(plaintext);
}
Data Isolation (Multi-tenancy)
┌─────────────────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT DATA ISOLATION │
└─────────────────────────────────────────────────────────────────────────────┘
Principle: Every query includes customer_id filter
Example:
✅ SELECT * FROM keyword_sets WHERE customer_id = ? AND id = ?
❌ SELECT * FROM keyword_sets WHERE id = ?
Implementation:
- All customer-owned tables have customer_id column
- customer_id extracted from validated API key
- ORM/query builder enforces customer_id filter
- Audit log tracks any admin override
Shared data (no customer filter):
- articles (public content)
- sources (shared catalog)
- taxonomy_labels (shared classification)
- locations (reference data)
Webhook Security
Outbound Webhook Signing
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEBHOOK SIGNATURE │
└─────────────────────────────────────────────────────────────────────────────┘
Signature format: HMAC-SHA256
Headers sent:
X-Noozer-Signature: sha256=abc123...
X-Noozer-Timestamp: 1702751700
X-Noozer-Delivery-ID: uuid
Signature computation:
payload = timestamp + "." + request_body
signature = HMAC-SHA256(webhook_secret, payload)
Verification (recipient side):
1. Extract timestamp from header
2. Check timestamp is within 5 minutes of now (replay protection)
3. Compute expected signature
4. Compare signatures (constant-time comparison)
Webhook Configuration
CUSTOMER_PROFILES.notify_channels:
{
"webhook": {
"url": "https://example.com/webhook",
"secret": "encrypted_secret", // Encrypted at rest
"headers": { // Custom headers (optional)
"X-Custom-Header": "value"
},
"retry_policy": {
"max_attempts": 5,
"backoff": "exponential" // 1s, 2s, 4s, 8s, 16s
},
"events": ["new_article", "story_update"]
}
}
Webhook Retry Policy
Attempt 1: Immediate
Attempt 2: +1 second
Attempt 3: +2 seconds (total: 3s)
Attempt 4: +4 seconds (total: 7s)
Attempt 5: +8 seconds (total: 15s)
After 5 failures:
- Mark webhook as failing
- Alert customer (if email configured)
- Continue trying every hour for 24h
- Then disable and notify
Infrastructure Security
Cloudflare Workers Security
┌─────────────────────────────────────────────────────────────────────────────┐
│ WORKER SECURITY CONFIGURATION │
└─────────────────────────────────────────────────────────────────────────────┘
wrangler.toml:
compatibility_date = "2024-01-01"
[vars]
ENVIRONMENT = "production"
# Secrets (stored encrypted, not in code)
# wrangler secret put JWT_SIGNING_KEY
# wrangler secret put FIELD_ENCRYPTION_KEY
# wrangler secret put ZENROWS_API_KEY
# wrangler secret put DATA4SEO_API_KEY
# Service bindings (worker-to-worker auth)
[[services]]
binding = "CRAWLER_SERVICE"
service = "noozer-crawler"
# D1 binding
[[d1_databases]]
binding = "DB"
database_name = "noozer-production"
database_id = "xxx"
Secret Management
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECRETS │
└─────────────────────────────────────────────────────────────────────────────┘
Secret Purpose Rotation
─────────────────────────────────────────────────────────────────────────────
JWT_SIGNING_KEY Sign admin JWTs Yearly
FIELD_ENCRYPTION_KEY Encrypt PII fields Yearly
ZENROWS_API_KEY External API auth On compromise
DATA4SEO_API_KEY External API auth On compromise
SHAREDCOUNT_API_KEY External API auth On compromise
WEBHOOK_SIGNING_KEY Sign outbound webhooks Yearly
ADMIN_BOOTSTRAP_KEY Initial admin setup Delete after use
─────────────────────────────────────────────────────────────────────────────
Storage: Cloudflare Secrets (wrangler secret put)
Access: Only available in worker runtime, not logged
Service Bindings
// Worker-to-worker authentication via Service Bindings
// No explicit auth needed - Cloudflare handles it
// In API Worker:
export default {
async fetch(request: Request, env: Env) {
// Call crawler service - automatically authenticated
const response = await env.CRAWLER_SERVICE.fetch(
new Request('https://internal/crawl', {
method: 'POST',
body: JSON.stringify({ keyword_set_id: '...' })
})
);
return response;
}
};
// env.CRAWLER_SERVICE is a service binding
// Only workers in your account can call it
// No network exposure, no auth tokens needed
Compliance & Privacy
GDPR Compliance
┌── ───────────────────────────────────────────────────────────────────────────┐
│ GDPR REQUIREMENTS │
└─────────────────────────────────────────────────────────────────────────────┘
Data Subject Rights:
1. Right to Access (Article 15)
GET /v1/privacy/export
- Returns all personal data
- Format: JSON or CSV
- Delivered within 30 days
2. Right to Rectification (Article 16)
PUT /v1/profiles, PUT /v1/auth/me
- Users can update their data
3. Right to Erasure (Article 17)
DELETE /v1/auth/me
- Deletes: customer, profiles, keywords, scores, events
- Retains: articles (public), anonymized analytics
- Processing: within 30 days
4. Right to Data Portability (Article 20)
GET /v1/privacy/export?format=portable
- Machine-readable format
- Includes: keywords, profiles, scores, events
5. Right to Object (Article 21)
PUT /v1/privacy/preferences
- Opt out of marketing
- Opt out of analytics
Personal Data Inventory:
- Email (encrypted)
- Name
- API keys (hashed)
- Usage logs (retained 90 days)
- Feedback submitted
Data Retention
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA RETENTION POLICY │
└─────────────────────────────────────────────────────────────────────────────┘
Data Type Retention After Expiry
─────────────────────────────────────────────────────────────────────────────
Articles (metadata) 90 days Archive to R2, then delete
Articles (raw HTML) 30 days Delete
Customer events 90 days Aggregate, then delete
API request logs 30 days Delete
Cost events 90 days Aggregate to rollups
Notification logs 30 days Delete
Pipeline errors 30 days Delete
Stories Indefinite Archive after 1 year inactive
─────────────────────────────────────────────────────────────────────────────
Customer data after account deletion:
- Immediate: Anonymize or delete PII
- 30 days: Complete deletion from all storage
- Retained: Aggregated, anonymized statistics
Audit Logging
AUDIT_LOGS table:
├── id: uuid
├── timestamp: timestamp
├── actor_type: enum # customer | admin | system | api_key
├── actor_id: string # Who performed the action
├── action: string # create | read | update | delete
├── resource_type: string # customer | keyword_set | taxonomy | etc
├── resource_id: string # ID of affected resource
├── changes: json # Before/after for updates
├── ip_address: string
├── user_agent: string
├── request_id: string # For correlation
└── created_at: timestamp
Logged actions:
- All admin write/delete operations
- Customer account changes
- API key creation/revocation
- Taxonomy/rules changes
- Configuration changes
- Failed authentication attempts
Security Monitoring
Security Events
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY EVENTS TO MONITOR │
└─────────────────────────────────────────────────────────────────────────────┘
Event Threshold Action
─────────────────────────────────────────────────────────────────────────────
Failed logins (same IP) 5 in 5 min Block IP 1 hour
Failed logins (same account) 10 in 1 hour Lock account
Invalid API keys 100 in 1 min Alert + investigate
Rate limit hits 1000 in 1 min Alert if unusual
Admin login from new IP Any Email notification
Privilege escalation attempt Any Alert + audit
API key usage from new country Any Optional: alert
Unusual API pattern ML-detected Alert + investigate
─────────────────────────────────────────────────────────────────────────────
Security Alerts
Alert Channels:
- PagerDuty (critical)
- Slack #security (high/medium)
- Email digest (low)
Critical (immediate page):
- Suspected breach
- Data exfiltration attempt
- System compromise indicators
High (immediate Slack):
- Brute force attacks
- Unusual admin activity
- Service degradation
Medium (batched Slack):
- Rate limit abuse
- Failed auth spikes
- New source patterns
Low (daily email):
- Login anomalies
- API usage trends
- Compliance reminders
Incident Response
┌─────────────────────────────────────────────────────────────────────────────┐
│ INCIDENT RESPONSE PLAYBOOK │
└─────────────────────────────────────────────────────────────────────────────┘
1. DETECT
- Automated alerts
- Customer reports
- Routine monitoring
2. CONTAIN
- Isolate affected systems
- Revoke compromised credentials
- Block malicious IPs/actors
3. INVESTIGATE
- Collect logs (audit, access, error)
- Identify scope and impact
- Determine root cause
4. REMEDIATE
- Patch vulnerability
- Rotate affected secrets
- Restore from backup if needed
5. RECOVER
- Restore normal operations
- Monitor for recurrence
- Validate integrity
6. POST-MORTEM
- Document timeline
- Identify improvements
- Update runbooks
- Notify affected parties (if required)
Security Checklist
Pre-Launch
[ ] All secrets in Cloudflare Secrets (not code)
[ ] JWT signing key generated and stored
[ ] Field encryption key generated and stored
[ ] Admin MFA enforced
[ ] Rate limiting configured
[ ] WAF rules enabled
[ ] Audit logging enabled
[ ] GDPR export endpoint working
[ ] Account deletion flow tested
[ ] Webhook signing working
[ ] IP allowlist for admin (if required)
[ ] Security monitoring alerts configured
[ ] Incident response runbook documented
Ongoing
[ ] Rotate JWT signing key (yearly)
[ ] Rotate field encryption key (yearly)
[ ] Review admin access (quarterly)
[ ] Review API key usage (monthly)
[ ] Audit log review (weekly)
[ ] Penetration test (yearly)
[ ] Dependency vulnerability scan (weekly)
[ ] Review rate limit thresholds (monthly)
[ ] Update WAF rules (as needed)
[ ] Incident response drill (yearly)
Last updated: [DATE] Security contact: [EMAIL]