Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Security is not optional. Understanding security fundamentals helps you build systems that protect user data and resist attacks. The 2017 Equifax breach — which exposed 147 million people’s Social Security numbers — happened because of a single unpatched Apache Struts vulnerability that had a fix available for two months. The 2013 Target breach (40 million credit cards stolen) started with compromised HVAC vendor credentials. Security failures are rarely sophisticated; they are almost always a known vulnerability that someone failed to address. The good news: most security problems have well-established solutions. You do not need to be a cryptographer — you need to consistently apply known best practices.

Authentication vs Authorization

Authentication (AuthN)

Who are you?
  • Verifies identity
  • Login, passwords, MFA
  • “Prove you are who you claim”

Authorization (AuthZ)

What can you do?
  • Verifies permissions
  • Roles, policies, ACLs
  • “Are you allowed to do this?”

Authentication Methods

Password-Based (Traditional)

Never store passwords in plaintext — not even “temporarily,” not even in logs, not even in a dev database. If your database is breached (and statistically, it will be), properly hashed passwords buy your users time to change their credentials. The algorithm matters: use bcrypt, scrypt, or Argon2 — these are intentionally slow to make brute-force attacks computationally expensive. Fast hashes like MD5 or SHA-256 can be cracked at billions of attempts per second on modern GPUs.
import bcrypt

# Storing passwords -- bcrypt automatically embeds the salt in the hash,
# so you do not need to store the salt separately.
def hash_password(password: str) -> bytes:
    # rounds=12 means 2^12 = 4,096 iterations of the hash function.
    # This makes each hash take ~250ms -- fast enough for login,
    # painfully slow for an attacker trying billions of passwords.
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode(), salt)

# Verifying passwords -- bcrypt extracts the salt from the stored hash
# and recomputes, so this is a constant-time comparison (prevents timing attacks).
def verify_password(password: str, hashed: bytes) -> bool:
    return bcrypt.checkpw(password.encode(), hashed)

JWT (JSON Web Tokens)

┌─────────────────────────────────────────────────────┐
│                     JWT Token                        │
├─────────────────┬─────────────────┬─────────────────┤
│     Header      │     Payload     │    Signature    │
│  {"alg":"HS256"}│ {"sub":"12345"} │   HMACSHA256(   │
│                 │ {"exp":16234...}│   header.payload│
│                 │                 │   , secret)     │
└─────────────────┴─────────────────┴─────────────────┘
         ↓                ↓                 ↓
    Base64URL        Base64URL         Base64URL
         ↓                ↓                 ↓
    eyJhbGci....   eyJzdWIi....    SflKxwRJS...
import jwt
from datetime import datetime, timedelta

# Creating JWT -- the token is self-contained: it carries the user's identity
# and expiration time, signed by your secret key. The server does not need
# to store session state -- it verifies the signature on every request.
def create_token(user_id: str, secret: str) -> str:
    payload = {
        "sub": user_id,                                # Subject: who this token is for
        "iat": datetime.utcnow(),                      # Issued at: when it was created
        "exp": datetime.utcnow() + timedelta(hours=24) # Expires: auto-reject after 24h
    }
    return jwt.encode(payload, secret, algorithm="HS256")

# Verifying JWT -- the library checks: (1) signature is valid (not tampered),
# (2) token has not expired, (3) required claims are present.
# CRITICAL: always specify the allowed algorithms list to prevent
# the "alg: none" attack where an attacker strips the signature.
def verify_token(token: str, secret: str) -> dict:
    try:
        return jwt.decode(token, secret, algorithms=["HS256"])  # Whitelist algorithms!
    except jwt.ExpiredSignatureError:
        raise Exception("Token expired")
    except jwt.InvalidTokenError:
        raise Exception("Invalid token")
Practical tip: JWTs cannot be revoked once issued (they are stateless). If a user logs out or their account is compromised, the token remains valid until it expires. To handle this, use short-lived access tokens (15 minutes) paired with longer-lived refresh tokens stored server-side, which CAN be revoked.

OAuth 2.0 Flow

┌──────────┐                              ┌──────────────┐
│   User   │                              │Authorization │
│          │                              │   Server     │
└────┬─────┘                              └──────┬───────┘
     │                                           │
     │  1. User clicks "Login with Google"       │
     │──────────────────────────────────────────►│
     │                                           │
     │  2. Redirect to authorization page        │
     │◄──────────────────────────────────────────│
     │                                           │
     │  3. User grants permission                │
     │──────────────────────────────────────────►│
     │                                           │
     │  4. Redirect with authorization code      │
     │◄──────────────────────────────────────────│
     │                                           │
┌────┴─────┐  5. Exchange code for tokens ┌──────┴───────┐
│   App    │──────────────────────────────►│Auth Server   │
│          │◄──────────────────────────────│              │
└──────────┘  6. Access + Refresh tokens   └──────────────┘

OWASP Top 10 (2021)

The OWASP Top 10 is the industry-standard awareness document for web application security risks, updated every few years based on data from hundreds of organizations. Broken Access Control moved from #5 to #1 in the 2021 edition because it is the most commonly exploited vulnerability class — and the easiest to prevent.

1. Broken Access Control

The most dangerous category because it lets attackers access other users’ data or perform admin actions. Also called IDOR (Insecure Direct Object Reference) — the attacker simply changes a user ID in the URL and gets someone else’s data.
# ❌ Vulnerable: No authorization check -- change user_id in the URL
# and access ANY user's data. This is how many data breaches happen.
# An attacker just iterates: /users/1/data, /users/2/data, ...
@app.get("/users/{user_id}/data")
def get_user_data(user_id: int):
    return db.get_user_data(user_id)  # Anyone can access any user's data!

# ✅ Secure: Verify the authenticated user has permission to access this resource.
# ALWAYS check authorization on the server side, never trust the client.
@app.get("/users/{user_id}/data")
def get_user_data(user_id: int, current_user: User = Depends(get_current_user)):
    if current_user.id != user_id and not current_user.is_admin:
        raise HTTPException(403, "Access denied")
    return db.get_user_data(user_id)

2. Cryptographic Failures

Using the wrong cryptographic algorithm is as dangerous as using none at all. MD5 was broken in 2004, SHA-1 in 2017. An attacker with a modern GPU can compute 10 billion MD5 hashes per second — meaning any MD5-hashed password under 8 characters can be cracked in under a minute.
# ❌ Bad: MD5 is NOT a password hashing algorithm -- it was designed to be FAST.
# Fast is exactly what you do NOT want for passwords (helps attackers brute-force).
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()  # Crackable in seconds

# ✅ Good: bcrypt is designed to be SLOW and includes a built-in salt.
# Cost factor 12 = ~250ms per hash. An attacker computing 4 hashes/second
# would need 7 years to brute-force a decent password.
import bcrypt
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(12))

3. Injection

SQL injection has been the #1 or #2 vulnerability for over 20 years, and it is still being discovered in production systems today. The attack is trivial: if user_id is 1; DROP TABLE users; --, your f-string builds a query that deletes your entire users table. Parameterized queries make this attack structurally impossible because the database treats parameters as data, never as executable SQL.
# ❌ SQL Injection vulnerable -- the user input becomes part of the SQL command.
# If user_id = "1 OR 1=1" this returns ALL users. If user_id = "1; DROP TABLE users; --"
# this deletes your table. This is not theoretical -- it happens in production.
query = f"SELECT * FROM users WHERE id = {user_id}"

# ✅ Parameterized query -- the database engine treats the parameter as a VALUE,
# never as executable SQL. Even if user_id = "1; DROP TABLE users; --",
# it searches for a user with that literal string as their ID (finds nothing, safely).
query = "SELECT * FROM users WHERE id = ?"
cursor.execute(query, (user_id,))

4. Insecure Design

Insecure design is a category new to the 2021 OWASP list. It recognizes that some security flaws are architectural — no amount of perfect implementation can fix a fundamentally insecure design. Security questions (“What is your mother’s maiden name?”) are a classic example: the answers are often public knowledge on social media, making them trivially guessable.
# ❌ Bad: Security questions are inherently insecure.
# Answers are often public (social media), guessable, or shared
# across services. Sarah Palin's email was hacked in 2008 using
# publicly available answers to her security questions.
def reset_password(email, mothers_maiden_name):
    pass

# ✅ Good: Cryptographically random token sent to the verified email.
# The token is single-use, time-limited, and impossible to guess.
def reset_password(email):
    token = generate_secure_token()              # Cryptographically random, 256-bit
    send_reset_email(email, token)               # Only the email owner receives it
    store_token_with_expiry(email, token, expiry=30_minutes)  # Auto-expires

5. Security Misconfiguration

The most common misconfiguration is leaving debug mode enabled in production. Debug mode typically exposes stack traces, source code, environment variables, and sometimes an interactive debugger — giving attackers a complete blueprint of your system. Django’s debug page, for instance, shows all settings including database credentials.
# ❌ Bad: Debug mode in production -- exposes stack traces, source code,
# and often database credentials to anyone who triggers an error.
app.run(debug=True)

# ❌ Bad: Default/hardcoded credentials -- attackers try these first.
# "admin/admin123" is literally in every brute-force dictionary.
DATABASE_PASSWORD = "admin123"

# ✅ Good: Configuration from environment variables, never hardcoded.
# Debug mode defaults to False (safe by default).
app.run(debug=os.getenv("FLASK_DEBUG", False))
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")  # Injected at deployment time

6. Vulnerable Components

# Regularly audit dependencies
npm audit
pip-audit
snyk test

# Keep dependencies updated
npm update
pip install --upgrade package_name

7. Authentication Failures

Without rate limiting, an attacker can try millions of password combinations against your login endpoint. Even with bcrypt, a bot making 1,000 requests per second can test common passwords across thousands of accounts. Rate limiting and account lockout are the first line of defense.
# ❌ Bad: No rate limiting -- an attacker can make unlimited login attempts,
# trying every password in the dictionary. This is called "credential stuffing"
# when using credentials leaked from other breached services.
@app.post("/login")
def login(credentials):
    return authenticate(credentials)

# ✅ Good: Rate limiting + account lockout -- limits the blast radius of attacks.
# 5 attempts per minute per IP, plus account-level lockout after 5 failures.
from slowapi import Limiter

limiter = Limiter(key_func=get_remote_address)

@app.post("/login")
@limiter.limit("5/minute")  # IP-level rate limit
def login(credentials):
    if get_failed_attempts(credentials.email) > 5:
        raise HTTPException(429, "Account locked")  # Account-level lockout
    return authenticate(credentials)
Practical tip: Implement both IP-based and account-based rate limits. IP-based alone fails against distributed botnets (thousands of IPs). Account-based alone allows attackers to lock out legitimate users (denial of service). Together they provide layered defense.

Encryption

Symmetric vs Asymmetric

TypeKeySpeedUse Case
Symmetric (AES)Same keyFastData at rest, bulk encryption
Asymmetric (RSA)Public/Private pairSlowKey exchange, digital signatures

HTTPS/TLS

┌──────────┐                          ┌──────────┐
│  Client  │                          │  Server  │
└────┬─────┘                          └────┬─────┘
     │  1. ClientHello (supported ciphers) │
     │─────────────────────────────────────►│
     │                                      │
     │  2. ServerHello + Certificate        │
     │◄─────────────────────────────────────│
     │                                      │
     │  3. Key Exchange (encrypted)         │
     │─────────────────────────────────────►│
     │                                      │
     │  4. Secure connection established    │
     │◄────────────────────────────────────►│
     │     (All traffic encrypted)          │
     └──────────────────────────────────────┘

CORS (Cross-Origin Resource Sharing)

CORS is a browser security mechanism that controls which websites can make requests to your API. Without CORS restrictions, a malicious website could make API requests on behalf of your logged-in users (using their cookies). The allow_origins: ["*"] wildcard effectively disables this protection — any website on the internet can make authenticated requests to your API.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# ❌ Bad: Allow all origins -- any website can make requests to your API.
# If combined with allow_credentials=True, attackers can use your users'
# session cookies to make authenticated requests from malicious sites.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Dangerous! "The whole internet can talk to me"
    allow_methods=["*"],
    allow_headers=["*"],
)

# ✅ Good: Explicitly list allowed origins -- only YOUR frontends can call your API.
# The browser enforces this: requests from unlisted origins are blocked.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://myapp.com", "https://admin.myapp.com"],  # Whitelist
    allow_credentials=True,           # Allow cookies/auth headers
    allow_methods=["GET", "POST", "PUT", "DELETE"],  # Only needed methods
    allow_headers=["Authorization", "Content-Type"],  # Only needed headers
)

Content Security Policy (CSP)

CSP is the most powerful defense against XSS (Cross-Site Scripting). It tells the browser: “Only execute JavaScript from these specific sources. Block everything else.” Even if an attacker manages to inject a script tag into your page, the browser will refuse to execute it because the script source is not in your CSP whitelist. GitHub’s CSP alone prevented thousands of potential XSS attacks in its first year of deployment.
# HTTP Header to prevent XSS -- each directive controls a resource type.
Content-Security-Policy: 
    default-src 'self';                       # Default: only load from same origin
    script-src 'self' https://trusted-cdn.com;  # JS only from self and your CDN
    style-src 'self' 'unsafe-inline';         # CSS from self (unsafe-inline is a trade-off)
    img-src 'self' data: https:;              # Images from self, data URIs, and HTTPS
    connect-src 'self' https://api.myapp.com; # AJAX/WebSocket only to your API
    frame-ancestors 'none';                   # Cannot be embedded in iframes (clickjacking prevention)

API Security Best Practices

Rate Limiting

from fastapi import FastAPI, Request
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app = FastAPI()

@app.get("/api/search")
@limiter.limit("10/minute")  # 10 requests per minute per IP
async def search(request: Request, query: str):
    return {"results": perform_search(query)}

@app.post("/api/login")
@limiter.limit("5/minute")  # Stricter for auth endpoints
async def login(request: Request, credentials: Credentials):
    return authenticate(credentials)

API Key Authentication

from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader

api_key_header = APIKeyHeader(name="X-API-Key")

async def verify_api_key(api_key: str = Security(api_key_header)):
    # Hash the API key before comparing (store hashed in DB)
    hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
    
    if not await db.verify_api_key(hashed_key):
        raise HTTPException(status_code=403, detail="Invalid API key")
    
    return api_key

@app.get("/api/data")
async def get_data(api_key: str = Security(verify_api_key)):
    return {"data": "protected"}

Security Headers

Security headers are cheap insurance — a few lines of configuration that block entire categories of attacks. Adding these headers takes 10 minutes and prevents clickjacking, MIME confusion attacks, protocol downgrade attacks, and more. Use securityheaders.com to scan your site and verify all headers are properly set.
from fastapi import FastAPI
from fastapi.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        
        # Prevent clickjacking -- blocks embedding your site in iframes.
        # Attackers overlay invisible iframes to trick users into clicking.
        response.headers["X-Frame-Options"] = "DENY"
        
        # Prevent MIME sniffing -- stops browsers from interpreting files
        # as a different content type (e.g., treating a text file as JS).
        response.headers["X-Content-Type-Options"] = "nosniff"
        
        # Enable XSS filter -- legacy defense, mostly replaced by CSP,
        # but still worth including for older browsers.
        response.headers["X-XSS-Protection"] = "1; mode=block"
        
        # HSTS: force HTTPS for 1 year -- even if user types http://,
        # browser auto-upgrades to https://. Prevents protocol downgrade attacks.
        response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        
        # Control how much referrer information is sent with requests.
        # "strict-origin-when-cross-origin" sends the origin but not the path
        # when navigating to a different site (prevents URL-based data leaks).
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        
        # Restrict access to browser APIs that your app does not need.
        # If your app does not use geolocation or microphone, block them
        # so that injected scripts cannot abuse these APIs.
        response.headers["Permissions-Policy"] = "geolocation=(), microphone=()"
        
        return response

Secrets Management

Environment Variables (Basic)

The simplest secrets management approach: store secrets in environment variables, never in code. This is the bare minimum — suitable for development and simple deployments, but for production systems with multiple services, consider a dedicated secrets manager (Vault, AWS Secrets Manager) that provides audit logging, automatic rotation, and fine-grained access control.
import os
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    """Pydantic validates that all required secrets are present at startup.
    If DATABASE_URL is missing, the app crashes immediately with a clear
    error message instead of failing on the first database query."""
    database_url: str      # Required -- app will not start without this
    jwt_secret: str        # Required -- missing secret = broken auth
    api_key: str           # Required -- missing key = broken integrations
    
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

# CRITICAL: Never commit .env to version control!
# Add to .gitignore BEFORE creating the file. Check git history --
# if .env was ever committed, those secrets are compromised and must be rotated.

HashiCorp Vault (Production)

Vault solves three problems that environment variables cannot: (1) audit logging — you know exactly which service accessed which secret and when, (2) dynamic secrets — database credentials are generated on-the-fly and automatically expire, so there is no long-lived password to steal, and (3) centralized rotation — change a secret in one place, all services pick up the new value.
import hvac

client = hvac.Client(url='https://vault.example.com')
# Authenticate using Kubernetes service account -- no hardcoded tokens.
# Vault verifies the pod's identity with the Kubernetes API server.
client.auth.kubernetes.login(role='my-app', jwt=service_account_token)

# Read secrets -- Vault logs every access for audit compliance
secret = client.secrets.kv.v2.read_secret_version(path='myapp/config')
db_password = secret['data']['data']['password']

# Dynamic database credentials -- the gold standard for production.
# Vault generates a unique username/password for this specific pod,
# valid for (e.g.) 1 hour. When the lease expires, credentials are revoked.
# If a credential is compromised, the blast radius is one pod for one hour.
creds = client.secrets.database.generate_credentials(name='myapp-db')
# Credentials automatically expire and are never reused

Penetration Testing Checklist

  • Test for default credentials
  • Test password policy enforcement
  • Test account lockout mechanism
  • Test session timeout
  • Test “remember me” functionality
  • Test password reset flow
  • Test MFA bypass attempts
  • Test horizontal privilege escalation (user A accessing user B’s data)
  • Test vertical privilege escalation (user becoming admin)
  • Test IDOR (Insecure Direct Object References)
  • Test missing function level access control
  • Test API endpoint authorization
  • Test SQL injection (all input fields)
  • Test NoSQL injection
  • Test Command injection
  • Test LDAP injection
  • Test XPath injection
  • Test template injection (SSTI)
  • Test Reflected XSS
  • Test Stored XSS
  • Test DOM-based XSS
  • Test CSRF protection
  • Test Clickjacking protection
  • Test WebSocket security

Security Checklist

  • Validate all user inputs server-side
  • Use allowlists, not blocklists
  • Sanitize before output (prevent XSS)
  • Validate file uploads (type, size, content)
  • Use parameterized queries (prevent SQLi)
  • Hash passwords with bcrypt/Argon2 (cost factor ≥12)
  • Implement MFA for sensitive operations
  • Use secure session management (HttpOnly, Secure, SameSite cookies)
  • Rate limit login attempts
  • Implement secure password reset (time-limited tokens)
  • Consider passwordless authentication (WebAuthn)
  • Implement least privilege principle
  • Check permissions server-side (never trust client)
  • Use role-based access control (RBAC)
  • Audit access to sensitive resources
  • Implement proper multi-tenancy isolation
  • Encrypt sensitive data at rest (AES-256)
  • Use HTTPS everywhere (TLS 1.2+)
  • Never log sensitive data (passwords, tokens, PII)
  • Implement proper key management and rotation
  • Use secure random number generation
  • Implement data retention and deletion policies
  • Keep systems and dependencies updated
  • Use WAF (Web Application Firewall)
  • Implement network segmentation
  • Enable audit logging
  • Regular security scanning and penetration testing
  • Implement DDoS protection

Common Vulnerabilities Quick Reference

This table covers the vulnerabilities you will encounter most frequently in real-world applications. For each, the prevention is well-established — the challenge is consistently applying it across every endpoint, every input, every dependency.
VulnerabilityAttack VectorPreventionReal-World Example
SQL InjectionMalicious SQL in inputParameterized queries, ORM2008 Heartland breach: 130M cards stolen via SQLi
XSSMalicious scripts in pagesOutput encoding, CSP2005 MySpace Samy worm: 1M friends in 20 hours
CSRFForged requestsCSRF tokens, SameSite cookies2008 Netflix CSRF: attackers changed user account details
SSRFServer-side request to internalAllowlist URLs, validate input2019 Capital One: SSRF on AWS metadata endpoint leaked 100M records
XXEMalicious XML entitiesDisable DTD processingBillion Laughs attack can cause denial of service
Path Traversal../ in file pathsValidate and sanitize pathsReads /etc/passwd or application config files
Insecure DeserializationMalicious serialized dataAvoid deserializing untrusted data2017 Equifax breach exploited Java deserialization
Security is everyone’s job. Don’t assume “someone else will handle security.” Build it in from the start. Security is not a feature — it is a requirement. The cost of fixing a security vulnerability increases 100x from design to production. A parameterized query takes 5 seconds to write; cleaning up after a SQL injection breach takes months and millions of dollars.
Interview Tip: Be prepared to discuss real security incidents you’ve handled, how you stay updated on security threats, and your experience with security tools and practices. A strong answer sounds like: “We discovered an IDOR vulnerability during a code review where user IDs were sequential integers. We implemented UUIDs for external identifiers, added authorization middleware to all data-access endpoints, and set up automated SAST scanning in our CI pipeline to prevent regression.” Concrete incidents and specific remediation steps beat abstract security knowledge.

Interview Deep-Dive

Strong Answer:
  • This is a Severity 1 incident. The exposed secret means anyone who saw it can forge valid JWT tokens for any user in your system, including admin accounts. The blast radius is total authentication compromise.
  • Step 1 (immediate, within 15 minutes): Rotate the JWT secret. Generate a new secret and deploy it to all services that verify JWTs. This instantly invalidates every existing token — every user gets logged out. This is inconvenient but necessary. Do not delay rotation to “minimize user impact” — every minute the old secret is active, an attacker can forge tokens.
  • Step 2 (within 1 hour): Scrub the secret from Git history. Simply deleting the file and committing does not help — git log preserves it forever. Use BFG Repo-Cleaner or git filter-repo to rewrite history and remove the secret from all commits. Force-push the cleaned history. If the repo is public, assume the secret was already scraped by automated credential harvesting bots (services like GitHub’s secret scanning, TruffleHog, and Gitrob exist specifically for this).
  • Step 3 (within 4 hours): Audit for unauthorized access. Check your access logs for the past 3 days for suspicious patterns: logins from unusual IPs or geolocations, privilege escalation attempts, bulk data access, or API calls that do not match normal user behavior. If any forged tokens were used, determine what data was accessed and prepare for disclosure.
  • Step 4 (within 24 hours): Add guardrails to prevent recurrence. (1) Enable GitHub’s built-in secret scanning (or a pre-commit hook like detect-secrets) that blocks commits containing known secret patterns. (2) Move JWT secrets to a secrets manager (Vault, AWS Secrets Manager) so they are never in code or environment files. (3) Implement JWT key rotation capability — use asymmetric keys (RS256) with a JWKS endpoint so you can rotate keys without redeploying every service.
  • Step 5: Conduct a blameless postmortem. How did the secret end up in the repo? Was it a .env file that should have been in .gitignore? A configuration file that was not excluded from version control? Fix the systemic issue, not just the symptom.
Follow-up: You mentioned switching from HS256 to RS256 for JWT signing. Why is that an improvement, and what are the trade-offs?HS256 (HMAC) uses a single shared secret for both signing and verification. Every service that needs to verify a token must have the secret, which means more copies of the secret exist and the blast radius of a compromise is larger. RS256 (RSA) uses a private key for signing and a public key for verification. Only the auth service holds the private key; all other services use the public key, which is safe to distribute publicly. If a consuming service is compromised, the attacker gets the public key — which is useless for forging tokens. The trade-off: RS256 signatures are computationally more expensive than HS256 (roughly 10-100x slower for signing, 5-10x slower for verification). For most applications processing thousands of requests per second, this is negligible. But for extremely high-throughput systems (millions of verifications per second), the CPU cost matters and you may want ES256 (ECDSA), which offers asymmetric security with faster verification than RSA. The other advantage of RS256: you can publish a JWKS (JSON Web Key Set) endpoint and rotate keys by adding a new key to the set and removing the old one. Services automatically pick up the new key without redeployment.
Strong Answer:
  • IDOR is the most common access control vulnerability (OWASP #1 for a reason), and patching one endpoint is a game of whack-a-mole. The fix must be systemic.
  • Immediate fix for the reported endpoint: Add an authorization check that verifies the authenticated user owns the requested resource. In pseudocode: if order.user_id != current_user.id and not current_user.is_admin: raise 403. This is a 3-line fix.
  • Systemic fix layer 1: Replace sequential integer IDs with UUIDs for all external-facing identifiers. Sequential IDs (/orders/1, /orders/2, /orders/3) invite enumeration — an attacker writes a loop from 1 to 10 million. UUIDs (/orders/a1b2c3d4-...) are unguessable, adding a layer of defense-in-depth. This does not replace authorization checks (security through obscurity is not security), but it eliminates casual enumeration.
  • Systemic fix layer 2: Implement an authorization middleware or decorator that runs on every data-access endpoint. Instead of trusting each developer to remember the authorization check, make it automatic. In a Python/FastAPI application, create a dependency that resolves the resource AND verifies ownership in one step: def get_order(order_id, current_user) -> Order either returns the order (if the user owns it) or raises 403. Every endpoint uses this dependency instead of querying the database directly.
  • Systemic fix layer 3: Automated testing. Add authorization tests to the CI pipeline that attempt to access resources as the wrong user and assert 403 responses. Use a DAST (Dynamic Application Security Testing) tool like OWASP ZAP in the pipeline that automatically tests for IDOR by replaying authenticated requests with different user sessions.
  • Systemic fix layer 4: Default-deny architecture. Instead of each endpoint opting IN to authorization, make authorization the default. Every endpoint requires a policy definition, and if no policy exists, the endpoint returns 403. This ensures new endpoints are secure by default rather than vulnerable by default.
Follow-up: Your authorization middleware works for simple ownership checks. But what about shared resources — an order that involves a buyer AND a seller, both of whom should access it? How do you model this?This is where you need ABAC (Attribute-Based Access Control) rather than simple ownership checks. The policy becomes: “A user can access an order if they are the buyer, the seller, or an admin.” I would model this as a set of access rules per resource type. For the Order resource: can_access(user, order) = user.id == order.buyer_id OR user.id == order.seller_id OR user.role == 'admin'. For more complex scenarios (a support agent who can view orders from their assigned customers), I would use a policy engine like OPA (Open Policy Agent) or Casbin that evaluates policies defined in a declarative language. The policy definitions live outside the application code — product managers can update access rules without a code deployment. The key design principle: never scatter authorization logic across endpoint handlers. Centralize it in a policy layer that is independently testable, auditable, and changeable.
Strong Answer:
  • Session-based auth: the server creates a session record (in Redis or a database) on login, returns a session ID as a cookie. Every subsequent request sends the cookie, the server looks up the session, and retrieves the user context. The session is the source of truth, stored server-side.
  • JWT-based auth: the server creates a signed token containing the user’s identity and claims, returns it to the client. Every subsequent request sends the token, the server verifies the signature and reads the claims. No server-side lookup needed — the token IS the session.
  • Session-based advantages: (1) Revocation is instant — delete the session record and the user is logged out immediately. (2) Smaller payload per request — a 32-byte session ID versus a 500-byte JWT. (3) Server has full control — you can change user permissions and it takes effect on the next request.
  • JWT-based advantages: (1) Stateless — no shared session store needed, which simplifies horizontal scaling and microservices (any service can verify the token independently). (2) Works well for cross-domain and mobile — no cookie dependency. (3) Reduces database load — no session lookup on every request.
  • JWT-based disadvantages that most people underestimate: (1) You cannot revoke a JWT before it expires. If a user’s account is compromised, their token remains valid for its entire lifetime. Workarounds (blocklists, short-lived tokens + refresh tokens) add back the server-side state that JWTs were supposed to eliminate. (2) JWTs grow with claims — adding roles, permissions, and metadata makes them large. (3) Token theft is more dangerous than session theft because the JWT contains everything needed to impersonate the user, while a session ID requires access to the session store.
  • My choice depends on the architecture. For a traditional monolithic web application: session-based auth with Redis. Simpler, more secure, and the “stateless” benefit of JWT is irrelevant when you have one server. For a microservices architecture with multiple domains and mobile clients: short-lived JWTs (15 minutes) + server-side refresh tokens (30 days, revocable, stored in a database). This gives you stateless verification for most requests while maintaining the ability to revoke access.
Follow-up: An engineer on your team wants to store user roles and permissions inside the JWT payload to avoid a database lookup on every request. What are the risks?The risk is stale authorization data. If an admin revokes a user’s admin role, the JWT still contains "role": "admin" until it expires. For a 24-hour token, that is 24 hours of unauthorized admin access. For a 15-minute token, the window is smaller but still exists. The trade-off comes down to your security requirements. For a blog platform, a 15-minute window of stale permissions is acceptable. For a financial system, it is not. My approach: store the minimal claim set in the JWT (user_id, token_id, expiry) and fetch permissions from a fast cache (Redis) on each request. The cache lookup adds ~1ms of latency — negligible — and you can update permissions instantly by updating the cache entry. This is a hybrid approach: you get JWT’s stateless verification for authentication (identity) while keeping authorization (permissions) in a mutable, revocable store.
Strong Answer:
  • Encryption in transit (HTTPS/TLS) protects data while it is moving between two parties — from the user’s browser to your server, or between two services. It prevents eavesdropping and man-in-the-middle attacks. An attacker sniffing network traffic sees encrypted gibberish instead of plaintext passwords and credit card numbers.
  • Encryption at rest protects data where it is stored — on disk, in a database, in backups, in S3 buckets. It prevents an attacker who gains access to the storage medium (stolen hard drive, compromised backup, misconfigured S3 bucket) from reading the data.
  • HTTPS alone is NOT sufficient. Here is why: HTTPS protects data between point A and point B. But data is vulnerable at rest at both endpoints. If your database is not encrypted and an attacker gets access to the database files (through a backup leak, a compromised server, or a misconfigured storage volume), they read everything in plaintext. The 2019 Capital One breach exposed 100 million customer records — the data was encrypted in transit but the attacker accessed the unencrypted data at rest through an SSRF vulnerability that reached the AWS metadata service.
  • Defense in depth requires both: (1) Encryption in transit: TLS 1.2+ everywhere, including internal service-to-service communication (mTLS in a service mesh). Do not assume your internal network is safe — zero-trust networking means encrypting everything. (2) Encryption at rest: enable storage-level encryption (AWS EBS encryption, RDS encryption, S3 default encryption). For sensitive fields (SSN, credit card numbers), add application-level encryption using envelope encryption — encrypt the data with a data key, encrypt the data key with a master key stored in AWS KMS or Vault. This way, even a database admin who can query the table sees ciphertext. (3) Encryption in use: for the most sensitive data, consider technologies like AWS Nitro Enclaves or homomorphic encryption that protect data even while it is being processed. This is emerging technology, not yet mainstream, but it is the frontier.
  • My response to the product manager: “HTTPS is essential but it is one layer. Think of it like locking the door to your house — necessary, but it does not help if someone breaks in through a window. We also need encryption at rest (safe inside the house), strong access controls (who has keys), and monitoring (security cameras). Security is layers, not a single checkbox.”
Follow-up: Your database stores user credit card numbers. The compliance team says you need PCI DSS compliance. How does this change your encryption approach?PCI DSS (Payment Card Industry Data Security Standard) changes everything about how you handle card data. First, the best approach: do not store credit card numbers at all. Use a payment processor like Stripe or Braintree that handles card storage and returns a token. Your system only stores the token, which is useless to an attacker. This dramatically reduces your PCI scope — instead of the full 300+ controls of PCI DSS Level 1, you fall under SAQ A, which is a fraction of the compliance burden. If you must store card numbers (rare, but some businesses require it): (1) Encrypt with AES-256 using unique keys per card (not one key for all cards). (2) Store encryption keys in a Hardware Security Module (HSM), never on the same server as the encrypted data. (3) Mask card numbers in all logs, error messages, and support tools (show only last 4 digits). (4) Segment your network so the card database is in an isolated VPC with strict access controls. (5) Implement key rotation — change encryption keys annually and re-encrypt all data. The cost difference is stark: full PCI DSS compliance for a system that stores cards costs $50K-500K annually in audits and infrastructure. Outsourcing to Stripe costs a percentage per transaction but eliminates nearly all of that compliance burden.