Password hashing: fast is exactly the wrong property
Plenty of breached apps did hash their passwords. The breach report just doesn't usually make the front page distinction between that and hashing them with the right function.
A hash function, used the way it's used for checksums
1def hash_password(password):2 return hashlib.sha256(password.encode()).hexdigest()
SHA-256 does exactly what it was designed to do: hash large amounts of data quickly and deterministically. That's the right property for verifying a file download and the wrong one for a password. A modern GPU computes billions of SHA-256 hashes per second, so a stolen dump of hashes gets checked against a cracking dictionary at a rate where most real users' passwords fall within hours, salt or no salt. The salt stops attackers from precomputing one giant rainbow table for every account at once, it does nothing about the speed of cracking any individual hash.
Fixed: a hash function designed to be slow
1def hash_password(password):2 return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))34def verify_password(password, hashed):5 return bcrypt.checkpw(password.encode(), hashed)
bcrypt (and argon2, its more modern alternative) are deliberately, tunably slow. The rounds parameter is a work factor: checking one password takes a fraction of a second by design, which is invisible to a real login request and brutal to an attacker trying billions of guesses. Raise the work factor as hardware gets faster, that's the whole point of making it a parameter instead of a fixed algorithm.
Secrets at rest: in your database, and in your git history
A SaaS app that stores a customer's Stripe key or Slack token to call their API on their behalf has to keep that key somewhere. Where, and how, decides what a database breach actually costs you.
A table storing a customer's third-party API key as plain text
1class Integration(Base):2 __tablename__ = "integrations"3 id = Column(Integer, primary_key=True)4 provider = Column(String)5 api_key = Column(String) # plain text
If this table is ever exfiltrated, a misconfigured backup left on a public bucket, a SQL injection that reaches this far, an insider with read access they shouldn't have, every customer's API key is immediately usable by whoever has the dump. The database being "secure" was the only thing standing between a breach and every one of your customers' connected accounts.
Fixed: encrypt the field with a key that isn't in the database
1fernet = Fernet(os.environ["FIELD_ENCRYPTION_KEY"])23def store_api_key(integration, raw_key):4 integration.api_key = fernet.encrypt(raw_key.encode())56def get_api_key(integration):7 return fernet.decrypt(integration.api_key).decode()
A dump of this table now contains ciphertext, useless without the encryption key, which should live in a KMS or secrets manager, not in the same place as the data it protects. That's the part worth repeating: encrypting a column does nothing if the key sits right next to it in the same config or the same database.
The same mistake, in git instead of a database
A .env file, tracked in the repo
1STRIPE_SECRET_KEY=sk_live_51H8x...2DATABASE_URL=postgres://admin:hunter2@prod-db.internal:5432/app
Deleting this file in a later commit doesn't remove it from git history, anyone with clone access can still check out the commit that added it. A repo that goes public later, gets forked, or is shared with a contractor for one unrelated task exposes every secret that ever touched it. The fix is boring on purpose: .env in .gitignore from the first commit, secrets loaded from the environment or a secrets manager, never typed into a file that a git add . could pick up.
TLS misconfiguration: the one-line fix that undoes everything
This exact line shows up in real production code more often than any other bug in this article, usually written under deadline pressure to make an annoying certificate error go away.
Calling an internal service
1response = requests.get("https://internal-service.local/api/data", verify=False)
The whole point of TLS is that your client checks the server's certificate against a trusted authority before trusting anything the server sends back. verify=False turns that check off entirely. The connection is still encrypted, technically, but your code will now happily talk to anyone who intercepts the connection and presents any certificate at all, expired, self-signed, issued for a completely different domain, doesn't matter. A man-in-the-middle attack that TLS exists specifically to prevent becomes trivial the moment this line ships.
It almost always starts the same way: an internal service has a self-signed or expired certificate, the request throws SSLCertVerificationError, and verify=False makes the error disappear in about ten seconds. It usually isn't removed before the code reaches production.
Fixed: point verification at the right authority instead of turning it off
1response = requests.get(2 "https://internal-service.local/api/data",3 verify="/etc/ssl/certs/internal-ca-bundle.pem",4)
If the internal service uses your organization's own certificate authority, point verify at that authority's bundle instead of a public one. Verification stays on, it's just checking against the CA that actually issued the certificate. The same instinct applies at the infrastructure level: audit which TLS versions and cipher suites your load balancer still accepts (TLS 1.0 and 1.1 have known weaknesses and no reason to still be enabled), tools like testssl.sh or Qualys SSL Labs will tell you exactly what's exposed.
Key management: the part encryption tutorials skip
Every fix in this article assumed a key exists somewhere safe to encrypt with, sign with, or hash with. That assumption is where a lot of otherwise correct cryptography quietly falls apart.
settings.py, committed to the repository
1SECRET_KEY = "dev-secret-please-change"
Placeholder values like this have a habit of outliving the comment telling you to change them. If this key signs session cookies or JWTs and it ships unchanged to production, anyone who can read the source, which might be more people than you'd guess for a "private" repo, can forge a valid, signed token for any user in the system. There's no bug to exploit beyond reading a file that was never supposed to be secret in the first place.
Fixed: unique per environment, never in source
1SECRET_KEY = os.environ["SECRET_KEY"]
One key doing two jobs is one leak away from two breaches
It's tempting to reuse the same key for signing auth tokens and encrypting sensitive database fields, it's one less secret to manage. It also means a single leak, from either purpose, compromises both systems at once. Separate keys per purpose keep a leak contained to whatever that one key was actually for.
A key that never rotates is a leak with no expiration date
A static key that's been in place for three years is still fully valid today if it leaked in year one, from an old backup, a former employee's laptop, a debug log nobody scrubbed. Rotating keys on a schedule bounds how long a leak that already happened can still be exploited. This is exactly what a real KMS (AWS KMS, GCP KMS, HashiCorp Vault) is built to handle: rotation, per-purpose separation, and access auditing, without your application code ever needing to know the current key's actual value.