Your app now has real users and real data. This deck covers the three fundamentals every shipper must own — authentication, password storage, and SQL injection — and how they stack up into defense in depth.
← / → to navigate · EN/VI switch below · big picture first, code where it helps
Once you store users, orders, and money, three things become your job.
Every request must identify itself before touching anything. Sessions, tokens, API keys — authentication answers 'who is this?'
Passwords must be stored so that even you — and even your own database — can't read them back.
Attacker input must stay data. If it becomes part of your SQL, one text box can read or delete everything.
Security is layers, not one wall. Each layer catches what the others miss — get the fundamentals right and you're already ahead.
Sessions, JWT, API keys, OAuth — the four ways a request proves who it is, and when to use each.
Proving identity: logging in with a password, a token, or a key. The server answers 'yes, you are Alice.'
The question: WHO is asking?
Deciding permissions: Alice can view her own orders, but not delete Bob's. Checked after identity is known.
The question: what are you ALLOWED to do?
The internet is full of scripts that try to act as anyone.
Without auth, a script can call your API with any user id. 'I am user 42' is not proof — it's a claim.
Once users have accounts, stealing one means stealing their data, orders, even money. Attackers hit logins first.
HTTP has no memory and no face. The only identity is something you can check: a session, a token, a key.
Everything else — permissions, rate limits, per-user data — only makes sense once 'who' is settled.
POST /login with email + password. The server hashes the password and compares it to the stored hash (more in Part 2).
A random id like sid=abc123 is stored server-side, mapped to 'alice is logged in'. The server keeps the memory.
The response carries Set-Cookie: sid=abc123. The browser stores it automatically.
From now on, the browser attaches Cookie: sid=abc123 to every request to your site.
Each request: check the sid, find the session, know who's asking. Logout = delete the session row.
The cookie is the ticket; the session store is the guest list.
A stateless token the client carries — no session store needed.
After login, the server signs a small JSON blob (user id + expiry) with a secret. The result — the JWT — goes to the client, who sends it in the Authorization header.
The server stores nothing per user — stateless, scales across many servers, ideal for mobile apps and APIs.
Nothing stored means nothing revocable: a stolen JWT stays valid until it expires. Keep expiry short; for the web, sessions are usually simpler.
APIs consumed by apps, microservices calling each other, mobile. For a classic browser site, start with sessions.
A long random string a service shows in every request: X-Api-Key: sk_live_9f2c.... No login form — the key IS the identity.
Machines don't have passwords. Stripe, GitHub, weather APIs — one key per service, easy to create, rotate, or kill.
Keys are powerful — treat them like passwords: never in committed code, never in the browser, least privilege per key.
Your app asks Google: 'this user says they're alice@gmail.com — confirm?' Google logs the person in and says yes. Your app never sees their password.
OAuth also lets a user hand you a limited ticket: 'let this app read my calendar, but nothing else'. The scope is the ticket's size.
'Continue with Google/Apple' buttons, a CI bot pushing to GitHub, Google Drive sync. Same protocol, different scopes.
When identity already lives elsewhere (Google), or when you want to share a resource with limited rights instead of sharing passwords.
Sessions for web logins, JWT for stateless APIs, keys for machines, OAuth when someone else owns the identity.
| Method | Identity lives in | State stored | Typical use |
|---|---|---|---|
| Session + cookie | the cookie (sid) | server (session store) | classic browser login |
| JWT | the token (Authorization) | nowhere — it's signed | mobile, APIs, microservices |
| API key | the key (X-Api-Key) | nowhere — just matched | service-to-service |
| OAuth | the user, at the IdP | at the identity provider | 'login with Google', delegated |
An unauthenticated endpoint is a data breach with extra steps. Every endpoint that touches data needs identity + permission checks.
The browser can change anything. 'I am user 42' from a client is a hint, not a fact — identity comes from a session/token you issued.
Sessions and JWTs need expiry. A forever-valid token leaked in a log is an open door, forever.
Session cookies should be HttpOnly (JS can't read them), Secure (HTTPS only), SameSite (adds CSRF friction).
Hashing, salts, bcrypt: how login works when even you can't read the password back.
Not plaintext, not 'encoded', not reversible. Ever.
SQL injection, misconfigured backups, stolen laptops, careless exports. Assume the password column WILL be read by someone who shouldn't.
One leaked row gives the attacker the real password — usable on your site and every other site it was reused on.
Support staff must not be able to answer 'what's your password?'. The system should not be able to — that's the point.
Store a hash: a value computed from the password that cannot be turned back. The password itself never touches the database.
A function that turns input into a fixed-size fingerprint: same input always gives the same hash; a 1-character change gives a completely different one.
hash('hunter2') → 9f86d081884c7d659a2feaa0c55ad015
Computing the hash is instant. Reversing it isn't done — you'd try passwords until one matches. That's why the hash is safe to store.
A stolen hash ≠ a stolen password. It's a lock with no key.
Without salt, 'password123' hashed by 10,000 users is the same string 10,000 times — an attacker cracks it once, logs in as all of them.
Each user gets a random salt. The stored value is hash(password + salt). Two users with the same password now store two different strings.
Precomputed crack lists assume no salt. With a per-user salt, every password in a leak needs its own slow brute-force attack.
bcrypt/argon2 generate and embed the salt in the stored string automatically — a library handles it; you just call hashpw().
MD5/SHA-256 were built for speed — a GPU tries billions per second. You want the opposite: a hash that is slow for everyone, especially them.
bcrypt/argon2 have a 'cost' setting; each bump makes hashing 2× slower. Your login still feels instant — the attacker's GPU just got poorer.
You never write this yourself: bcrypt/argon2 handle salt, cost, and safe comparison. Pick one — both are the right answer.
Make the right thing easy for you (one function call), and the wrong thing expensive for them (billions of slow tries).
import bcrypt
def register(email, password):
# bcrypt generates a fresh random salt for this user
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
db.execute("INSERT INTO users(email, pw) VALUES(?, ?)", [email, hashed])
def login(email, password):
row = db.execute("SELECT pw FROM users WHERE email = ?", [email]).fetchone()
if row and bcrypt.checkpw(password.encode(), row[0]):
return create_session(email)
return None
The password exists in memory only briefly. What's stored — and what a leak exposes — is the hash.
The most common way web apps get breached — and one of the easiest to prevent.
Your app builds SQL from user input. The database has no idea which part is yours.
The database's job is to execute whatever SQL string it receives. It doesn't know half of it came from a text box.
Your app composes the query: a fixed template + the user's values. If you build it by gluing strings, the user's text can change the whole sentence.
Type text that 'closes' the value and injects a new clause: ' OR 1=1 -- turns a lookup into 'return everything'.
Old (1998), everywhere (any app that builds SQL), and one line can dump or delete the whole database. The fix, though, is one habit.
def login(email, password):
# email & password come from a text box — untrusted input
q = "SELECT * FROM users WHERE email='" + email + "'"
q = q + " AND password='" + password + "'"
row = db.execute(q).fetchone() # the DB runs whatever q says
if row:
return "logged in"
return None
The email field is data — but Python glues it into a SQL sentence before the database ever sees it.
-- the user types into the email field:
' OR 1=1 --
-- your string-building code produces:
SELECT * FROM users WHERE email='' OR 1=1 -- '
AND password='whatever'
-- 1=1 is always true, and -- comments out the rest:
-- the password check is gone. every row comes back.
The payload is just a string to you — but to the database it's instructions: skip the password check.
def login(email, password):
# parameters: the query and the data travel separately
row = db.execute(
"SELECT * FROM users WHERE email = ? AND password = ?",
[email, password], # data stays data — never parsed as SQL
).fetchone()
if row and bcrypt.checkpw(password.encode(), row[0]):
return create_session(email)
return None
The database receives the query and the data as two separate things. The data can never alter the query.
The query template is decided by your code, before any input exists. The user's values are sent as parameters — the database stores them as data.
Typing ' OR 1=1 -- in a parameter does nothing: it's just a weird value no user has.
Never build SQL by concatenating or interpolating user input. No f-strings, no +, no .format() — only placeholders.
If you ever catch yourself gluing user input into a WHERE clause, stop. That is the bug.
Treat every piece of user input as hostile. It may be a name, a comment — or an attack. Your code decides whether it's data or a command.
HTTPS, secrets handling, least privilege — three more lines that close most gaps.
Encrypts everything between browser and server: login forms, cookies, every request. A network eavesdropper sees only noise.
Coffee-shop Wi-Fi, carrier snooping, man-in-the-middle. Without it, a 'hashed' password still ships across the wire in plaintext.
HTTPS only, everywhere — including API endpoints and redirects. Modern hosts give you certificates for free (Let's Encrypt).
# .env — on the server only, NEVER committed to git
DATABASE_URL=sqlite:////data/shop.db
SESSION_SECRET=9f2c41d8e7b3...8812
SMTP_PASSWORD=correct-horse-battery
# .gitignore
.env
Code goes to git (and maybe to a fork, a CI log, a recruiter). Secrets stay on the server. One line in .gitignore keeps them out.
Every user, key, and service account gets exactly the access it needs — nothing more. A blog commenter's session can't open the admin panel.
If one credential leaks, the blast radius is its permissions. Least privilege turns 'everything is gone' into 'one small thing is gone'.
CSRF (forged cross-site requests) and XSS (script injected into pages) are the next chapters — SameSite cookies and output escaping are their first lines of defense.
No single layer is perfect. Together they make an attack expensive at every step.
Six habits that put you ahead of most real-world breaches.
bcrypt or argon2, salted automatically. The plaintext never touches the database.
Placeholders only. No concatenation, no f-strings, no exceptions.
Auth on every endpoint, expiry everywhere, HttpOnly/Secure/SameSite cookies.
Every page, every API, every redirect. No exceptions.
Env vars on the server, .env in .gitignore, rotate anything that was ever committed.
Minimum access per user, key, and service account.
Security isn't a feature you add at the end — it's a set of habits you build in: hash the passwords, parameterize the queries, verify every request.
Next: deployment, CI/CD, and monitoring — the last mile of From Coder to Shipper.