Web Security Cheatsheet
Secrets and Cryptography
Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Where secrets belong
A secret is anything whose disclosure grants access: API keys, database passwords, signing keys, OAuth client secrets, and private keys. The failure mode is almost never a broken cipher, it is a secret in the wrong place. Cryptographic Failures is A04:2025.
| Location | Verdict |
|---|---|
| Committed to Git | Never. History is forever, and a private repo becomes public or gets forked. |
| Hardcoded in source | Never. It ships in every artifact and every backup. |
| In a client bundle or mobile app | Never. Anything shipped to a device is public, minification is not protection. |
| In a container image layer | No. Layers are extractable, including deleted files. |
| In a CI log or an error message | No. Redact before logging, and treat a logged secret as compromised. |
| In a URL query string | No. It lands in proxy logs, Referer, and browser history. |
| An env var from a secret manager at start | Acceptable, and the common baseline |
| A secret manager fetched at runtime | Better, supports rotation without a redeploy |
| KMS or HSM, key never leaves | Best for signing and encryption keys |
# .gitignore is a reminder, not a control
.env
.env.*
!.env.example
*.pem
*.keyShip a committed .env.example with placeholder values so the required names are
documented without the values.
Preventing and handling leaks
Detection has to be automatic, because humans miss it.
# Scan history, not just the working tree gitleaks detect --source . --redact trufflehog git file://. --only-verified # Block commits before they happen pre-commit install # with a gitleaks or detect-secrets hook configured
| Practice | Detail |
|---|---|
| Pre-commit scanning | Cheapest place to catch it |
| CI scanning on every PR | Catches what a developer bypassed locally |
| Provider push protection | GitHub secret scanning blocks known key formats at push |
| Short-lived credentials | OIDC federation from CI to cloud beats a long-lived key entirely |
| Least privilege per credential | One scope, one purpose, so a leak has a bounded blast radius |
| Scheduled rotation | And a tested runbook, not a wiki page nobody has run |
A leaked secret is compromised, permanently. Rotate it. Do not "remove it in the next commit" and move on. Rewriting history does not help, because the object may already be cloned, cached by the host, or indexed. The order is: rotate, revoke the old value, then check access logs for use of it, then clean history.
Crypto do and do not
Use a vetted high-level library. The list below is the short version of current guidance, and every "do not" row is something that has caused real breaches.
| Do | Do not | Why |
|---|---|---|
| AES-256-GCM, ChaCha20-Poly1305 | AES-ECB | ECB encrypts identical blocks identically, so it leaks structure |
| AEAD (GCM, Poly1305, SIV) | CBC without a separate MAC | Unauthenticated ciphertext is malleable, and padding behavior leaks |
| A random nonce per message, never reused | A fixed or counter-reset nonce | Reusing a GCM nonce with the same key breaks confidentiality and forgeability |
| HMAC-SHA-256 for integrity | A bare hash of key + message | Naive constructions are vulnerable to length extension |
| SHA-256, SHA-384, SHA-512, SHA-3 | MD5, SHA-1 | Both are broken for collision resistance |
| Argon2id, scrypt, bcrypt, PBKDF2 for passwords | SHA-256 for passwords | Fast hashes are the wrong tool, see the authentication topic |
| HKDF to derive keys from a master secret | Using one key for everything | Key separation limits the damage of any single exposure |
secrets / crypto.randomBytes / SecureRandom | random, Math.random, rand() | Non-cryptographic PRNGs are predictable from a few outputs |
| Constant-time comparison | == on a secret or a MAC | Byte-by-byte comparison leaks position through timing |
| Ed25519 or ECDSA P-256 for signatures, RSA ≥ 3072 | RSA-1024, DSA | Below current strength expectations |
| TLS 1.2 minimum, prefer TLS 1.3 | SSLv3, TLS 1.0, TLS 1.1 | Deprecated and vulnerable |
A maintained library (libsodium, Tink, cryptography, WebCrypto) | Your own construction | Rolling your own is the single most reliable way to get this wrong |
# Python, cryptography: AES-GCM done correctly import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM key = AESGCM.generate_key(bit_length=256) # store in a KMS, not in code aead = AESGCM(key) nonce = os.urandom(12) # fresh 96-bit nonce PER message ct = aead.encrypt(nonce, plaintext, associated_data=b"v1:user:42") pt = aead.decrypt(nonce, ct, associated_data=b"v1:user:42")
// Node, AES-256-GCM with the auth tag kept import crypto from 'node:crypto' const iv = crypto.randomBytes(12) const c = crypto.createCipheriv('aes-256-gcm', key, iv) const ct = Buffer.concat([c.update(pt), c.final()]) const tag = c.getAuthTag() // store iv + tag + ct together
The nonce is not a secret, so store it alongside the ciphertext. It must be unique per key, which is a different requirement from being secret.
Constant-time comparison
Comparing a submitted token or MAC with == returns as soon as bytes differ, which
leaks how many leading bytes matched. Use the platform's constant-time function.
import hmac hmac.compare_digest(provided, expected) # Python
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) // Node, equal lengths
subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 // Go
java.security.MessageDigest.isEqual(a, b); // Java
timingSafeEqual throws when the lengths differ, so compare fixed-length digests,
or hash both sides first.
Data in transit and at rest
In transit. TLS everywhere, including internal service-to-service calls.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload- Redirect all HTTP to HTTPS, then let HSTS remove the redirect from the picture.
- Verify certificates. Disabling verification, in any client, is not a workaround.
- TLS 1.2 as the floor, TLS 1.3 preferred, and no compression.
- preload submits to the browser preload list. It is effectively hard to reverse,
so do not add it until every subdomain is HTTPS-only.
At rest. Encrypt with a managed key where the platform offers it, and be honest about what it protects. Full-disk and volume encryption defends against a stolen disk, not against a SQL injection that reads the table.
| Data | Handling |
|---|---|
| Passwords | Argon2id or equivalent, never encrypted (you never need to recover them) |
| Payment card data | Do not store it. Use a PCI-compliant provider's token. |
| API keys you issue | Store a hash, show the value once, and display a prefix for identification |
| PII | Field-level encryption or tokenization where feasible, and minimize what you keep |
| Backups | Encrypted, with the key stored separately, and restores tested |
Hashing something is not encrypting it and encrypting something is not hashing it. Use a hash when you never need the original back, and an AEAD cipher when you do.
Key management
The key is now the secret, so the same rules apply to it, with extra care.
| Rule | Detail |
|---|---|
| Never in source or a config file in the repo | KMS, HSM, or a secret manager |
| One key per purpose | Separate encryption, signing, and session keys |
| Support two keys at once | Decrypt with old and new during rotation, encrypt with new |
| Version your ciphertext | A prefix such as v2: makes migration and rotation possible |
| Rotate on a schedule and on suspicion | And test the rotation path before you need it |
| Audit key access | Who read which key, when |
| Envelope encryption for bulk data | A KMS key wraps a per-record data key |
stored value: v2:<key-id>:<base64 nonce>:<base64 ciphertext+tag>
Encoding the version and the key id in the stored value is what makes rotation a background job rather than an outage.
Recognizing it in review
grep -rniE "aws_secret|api[_-]?key\s*=\s*[\"'][A-Za-z0-9/+]{16,}" . grep -rniE "BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY" . grep -rniE "\bMD5\b|\bSHA1\b|ECB|DES\b|RC4" src/ grep -rniE "Math\.random|random\.randint.*token|rand\(\)" src/ grep -rniE "verify=False|rejectUnauthorized: *false|InsecureSkipVerify" src/
Checklist:
- No secret is in the repository, in a client bundle, in an image layer, or in a
log. Secret scanning runs in CI.
- Every credential is least privilege, and short-lived where the platform allows it.
- Encryption is AEAD, and the nonce is fresh per message.
- No MD5, SHA-1, ECB, DES, or RC4 in any security-relevant path.
- Randomness for tokens, ids, nonces, and salts comes from a CSPRNG.
- Every secret or MAC comparison is constant time.
- Certificate verification is enabled in every client, with no verify=False.
- HSTS is set with a long max-age, and preload only when every subdomain is
ready.
- Keys live in a KMS or secret manager, are separated by purpose, are versioned in
the stored format, and have a tested rotation runbook.
- No custom cryptographic construction exists anywhere in the codebase.
Further reading: the OWASP Cryptographic Storage Cheat Sheet, Secrets Management Cheat Sheet, and Transport Layer Security Cheat Sheet.