Security Fundamentals
Prerequisite:
Overview
Security is not a feature you bolt on at the end - it is a set of design decisions made throughout a system. Most breaches are not the result of novel cryptographic attacks; they exploit well-understood vulnerabilities: SQL injection, hardcoded secrets, weak passwords, unvalidated input. Understanding the threat model and the standard defenses covers the vast majority of real-world risk.
- Problem it solves: Applications that handle user data, money, or access to infrastructure are targets. A single exploitable vulnerability can compromise all users.
- Alternatives to doing it right: There are none. Security theater (doing the appearance of security) is worse than acknowledging the gaps, because it creates false confidence.
- Relevant standard: The OWASP Top 10 is the canonical list of the most critical web application security risks and is updated regularly.
The CIA Triad
Every security control maps to one or more of these properties:
- Confidentiality - only authorized parties can read the data (encryption, access control)
- Integrity - data cannot be modified without detection (hashing, signatures, immutable audit logs)
- Availability - the system remains accessible to legitimate users (rate limiting, DDoS protection, redundancy)
Authentication vs Authorization
These are distinct and are frequently confused:
- Authentication (authn): who are you? Verifying identity - username/password, MFA, OAuth token.
- Authorization (authz): what are you allowed to do? Checking permissions - can user 42 edit post 99?
A request that passes authentication can still be denied by authorization. Returning 401 Unauthorized means “not authenticated”; 403 Forbidden means “authenticated but not authorized.”
Password Storage
Never store plaintext passwords. Never store them hashed with MD5 or SHA-256 - these are fast by design, making brute-force attacks cheap.
Use a slow, purpose-built password hashing algorithm:
- bcrypt: industry standard, configurable cost factor, built-in salting.
- Argon2: winner of the Password Hashing Competition; memory-hard, resistant to GPU attacks. Preferred for new systems.
import bcrypt
# Hash a password on registration
password = b"correct horse battery staple"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# store 'hashed' in your database - it includes the salt
# Verify on login
def check_password(input_password: bytes, stored_hash: bytes) -> bool:
return bcrypt.checkpw(input_password, stored_hash)
The salt is embedded in the stored hash ($2b$12$...), so you do not need to store it separately. The rounds parameter (12 is standard) sets the cost: each additional round doubles the computation time.
Encryption
Symmetric encryption (one key, encrypt and decrypt): use AES-256-GCM. GCM mode provides both confidentiality and integrity (authenticated encryption). The challenge is key management - how do you distribute and rotate the key securely? Never hardcode it.
Asymmetric encryption (public/private key pair): use RSA (2048+ bits) or ECC (Curve25519). The public key encrypts or verifies signatures; the private key decrypts or signs. TLS uses asymmetric encryption to establish a shared symmetric key during the handshake, then switches to symmetric encryption for the session - the best of both worlds.
Common Attacks and Defenses
SQL Injection
An attacker injects SQL syntax into user input that gets interpolated into a query:
# VULNERABLE - never do this
user_input = "' OR '1'='1"
query = f"SELECT * FROM users WHERE email = '{user_input}'"
# Executes: SELECT * FROM users WHERE email = '' OR '1'='1'
# Returns every user in the database
# SAFE - always use parameterised queries
cursor.execute(
"SELECT * FROM users WHERE email = %s",
(user_input,) # the driver handles escaping
)
Parameterised queries are the complete fix. ORMs like SQLAlchemy use them by default, but raw string formatting in queries is always dangerous regardless of framework.
Cross-Site Scripting (XSS)
An attacker injects JavaScript into your page that runs in other users' browsers. Defence:
- Escape all user-supplied output in HTML contexts (
<,>,&,",') - Use a Content Security Policy (CSP) header to whitelist script sources
- Use frameworks that auto-escape by default (React, Jinja2 with autoescape)
CSRF (Cross-Site Request Forgery)
A malicious page tricks an authenticated user’s browser into making a state-changing request to your site. Defence:
- CSRF tokens: a hidden form field containing a random token tied to the session. The server verifies it on every state-changing request.
SameSite=StrictorSameSite=Laxon session cookies prevents cross-origin cookie sending in modern browsers.
Other Common Vulnerabilities
- Path traversal:
../../etc/passwdin a filename parameter. Always sanitise and validate file paths against an allowed base directory. - Command injection: user input passed to
subprocess.call(f"convert {filename}"). Use parameterised subprocess calls:subprocess.run(["convert", filename]). - IDOR (Insecure Direct Object Reference):
GET /invoices/1234- does the server verify that the current user owns invoice 1234? Always authorise on every request, not just at login.
Secrets Management
Secrets (API keys, database passwords, JWT signing keys) must never be:
- Hardcoded in source code
- Committed to git (even in a private repo - rotate immediately if this happens)
- Logged
The minimum viable approach: environment variables. Inject secrets at runtime, not build time.
Better: a secrets manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager). These provide audited access, automatic rotation, and fine-grained permissions.
# Wrong
DATABASE_URL = "postgresql://admin:mysecretpassword@prod-db:5432/app"
# Right
import os
DATABASE_URL = os.environ["DATABASE_URL"]
HTTPS and HSTS
HTTPS is mandatory. HTTP transmits credentials, session tokens, and personal data in plaintext - trivially intercepted on any shared network.
HSTS (HTTP Strict Transport Security) tells browsers to always use HTTPS for your domain, even if the user types http://:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Submit to the HSTS preload list to have your domain hardcoded into browsers.
JWT Pitfalls
JWTs are powerful but have well-known implementation traps:
alg: none attack: older libraries accept a token with "alg": "none" and no signature, treating it as valid. Always explicitly specify and enforce the expected algorithm server-side.
Weak or hardcoded secrets: HS256 tokens signed with a weak secret can be brute-forced. Use strong random secrets (32+ bytes) or switch to RS256 (asymmetric) so the private key is never shared.
No expiry: JWTs without an exp claim are valid forever. Always set a short expiry (15 minutes for access tokens) and implement refresh token rotation.
Rate Limiting and Account Lockout
- Rate limiting: limit the number of requests per IP or per user per time window. Return
429 Too Many Requestswith aRetry-Afterheader. - Account lockout: after N failed login attempts, temporarily lock the account (or add increasing delays). This defeats password spraying and credential stuffing attacks.
- Login alerting: notify users of logins from new devices or locations.
Examples
Parameterised query vs SQL injection:
import sqlite3
conn = sqlite3.connect("app.db")
# SAFE
def get_user(email: str):
row = conn.execute(
"SELECT id, name FROM users WHERE email = ?", (email,)
).fetchone()
return row
# What an attacker tries with the vulnerable version:
# email = "' UNION SELECT username, password FROM admin_users--"
Password hashing with bcrypt:
import bcrypt
# Registration
def register(email: str, plaintext_password: str):
hashed = bcrypt.hashpw(plaintext_password.encode(), bcrypt.gensalt(rounds=12))
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)",
(email, hashed.decode()))
# Login
def login(email: str, plaintext_password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?",
(email,)).fetchone()
if not row:
return False # constant-time: don't reveal whether email exists
return bcrypt.checkpw(plaintext_password.encode(), row[0].encode())
JWT verification done right (vs the alg: none bug):
import jwt # PyJWT
SECRET = os.environ["JWT_SECRET"]
# WRONG - vulnerable to alg:none attack
payload = jwt.decode(token, SECRET, algorithms=None) # never do this
# RIGHT - explicitly specify allowed algorithms
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise AuthError("Token expired")
except jwt.InvalidTokenError:
raise AuthError("Invalid token")
Security is ultimately about reducing attack surface and applying defence in depth - no single control is sufficient. Parameterise queries, escape output, manage secrets properly, and use HTTPS: doing these four things well eliminates the majority of common vulnerabilities.
Read Next: