Auth & application security
This is the practical, day-to-day companion to the interview Security basics lesson. Security is not a feature you bolt on at the end and it is not one big "make it secure" task — it is a set of small habits you apply on every single endpoint you write. An endpoint is just one URL your server answers (for example POST /login or GET /orders/42). Most real-world breaches are not exotic; they happen because one endpoint skipped one habit under deadline pressure. By the end of this page you will know the vocabulary, the mechanisms, and the cheap defenses for each of the classic risks. Three ideas sit underneath everything that follows, and we will keep returning to them: never trust client input (anything the browser sends you can be faked), defense in depth (assume any one protective layer can fail, so add another behind it), and least privilege (give every user, key, and service the minimum access it needs and nothing more).
Authentication vs authorization
These two words sound alike and are constantly confused, so we start here and keep the distinction front and center the whole lesson. Authentication — abbreviated authn — answers the question "who are you?" It is the act of proving identity: typing a password, tapping a passkey, scanning a fingerprint, or completing a login. When authentication succeeds, the system is confident you are a specific account, say the user Alice.
Authorization — abbreviated authz — answers a completely different question: "what are you allowed to do?" It is the act of checking permissions, and it only makes sense after identity is known. Authorization decides whether Alice may read a file, edit a setting, or delete a record.
Here is the concrete example to lock it in. Logging in to a forum is authentication — you prove you are Alice. Being allowed to delete a post that you wrote is authorization — the system already knows you are Alice and now checks that you own that post. If Alice tries to delete another user's post, the system can authenticate her perfectly (it is certainly Alice) and still correctly refuse the action, because authorization fails. The key insight: a system can know exactly who you are and still rightly say no. Authn answered "yes, you are Alice"; authz answered "no, Alice may not do that."
One-sentence memory hook: authentication is the bouncer checking your ID at the door; authorization is the velvet rope deciding which rooms your ticket lets you into. You can be a verified, real person and still not be on the list for the VIP room.
How login state is remembered: sessions vs JWTs
HTTP — the protocol browsers use to talk to servers — is stateless, meaning each request arrives with no memory of the one before it. The server does not automatically "remember" that you logged in two clicks ago; as far as a raw HTTP request is concerned, every visitor is a stranger. So after you authenticate once, the server needs some way to recognize you on the next request without making you re-type your password every time. There are two dominant approaches, and the difference between them is a classic interview topic.
Approach 1 — the session id (server-side state)
First, a definition you need: a cookie is a small piece of text the server tells the browser to store and then automatically send back on every future request to that site. You never see it; the browser attaches it silently. Cookies are the standard plumbing for "remember me across requests."
With the session approach, when you log in the server creates a record of your session on its own side (in memory or a database) and generates a long random string called a session id — something like a3f9c1... that means nothing on its own. It hands that session id to your browser inside a cookie. On every later request the browser sends the cookie back, and the server looks the id up in its store to find out "ah, this id belongs to Alice's session." The session id is just a claim ticket; all the real information lives on the server.
# Login: create a server-side session, return only an opaque id
session_id = random_token()
sessions[session_id] = {"user": "alice", "role": "member"} # stored on the SERVER
set_cookie("sid", session_id) # browser keeps the id
# Later request: look the id up to recover who they are
sid = read_cookie("sid")
user = sessions.get(sid) # None if expired or revoked -> not logged in
Because the truth lives on the server, logging someone out is trivial: delete the record (del sessions[sid]) and the very next request fails the lookup. The cost is that every request needs that lookup, and if you run many servers they must share access to the session store.
Approach 2 — the JWT (self-contained token)
A JWT (JSON Web Token, pronounced "jot") flips the model: instead of storing your identity on the server and giving you a meaningless id, the server gives you a self-contained, signed token that carries your identity inside it. The server can verify the token on each request without any database lookup, because the token itself is the proof. A JWT has three dot-separated parts:
- Header — a tiny bit of JSON saying which signing algorithm is used (e.g.
{"alg":"HS256"}). - Payload — the actual claims, also JSON: who you are and metadata, e.g.
{"user":"alice","role":"member","exp":1735689600}. Theexpfield is an expiry timestamp. - Signature — a cryptographic stamp computed over the header + payload using a secret key only the server knows. If anyone changes even one character of the payload, the signature no longer matches and the token is rejected.
Crucially, the payload is only encoded (base64), not encrypted — anyone can read it. The signature does not hide the data; it guarantees the data was not tampered with. So you must never put secrets in a JWT payload, but you can trust that the claims came from your server unmodified.
# A JWT is three base64 chunks joined by dots: header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UiLCJleHAiOjE3MzU2ODk2MDB9.Kx8s...sig
# Verify on each request — no DB lookup, just a signature check
claims = jwt.verify(token, SECRET_KEY) # throws if signature bad OR expired
user = claims["user"] # trusted because the signature held
The tradeoff in one breath: sessions are easy to revoke — delete the server-side record and the user is logged out on their next request. JWTs scale statelessly — any server can verify a token with just the secret key and no shared store — but they are hard to revoke before they expire, because every server will happily accept any validly-signed, not-yet-expired token. That is why JWTs are issued with short lifetimes (minutes) plus a refresh token flow to mint fresh ones, so a stolen token is only dangerous for a small window.
Concrete examples of each: a traditional server-rendered web app (think a bank's internal admin tool) typically uses sessions — there is one server tier, instant logout matters for security, and the lookup is cheap. A large API serving many independent microservices (think a streaming platform where dozens of services each need to know who the caller is) often uses JWTs — no service wants to call a central session store on every request, so a self-verifying token wins.
OAuth: "Sign in with Google"
OAuth (specifically OAuth 2.0 / OpenID Connect, the technology behind every "Sign in with Google / Apple / GitHub" button) lets your app delegate authentication to a trusted provider so you never touch the user's password at all. The rough flow: a user clicks "Sign in with Google"; your app redirects them to Google; the user authenticates with Google (entering their password on Google's own page, never yours); Google asks "do you allow this app to see your name and email?"; on consent, Google redirects back to your app with a short-lived authorization code; your server exchanges that code (server-to-server, using your app's secret) for a token that vouches for the user's identity. Your app reads the verified email from that token and treats the user as logged in. The win is that your app never sees, stores, or could ever leak the user's password — one fewer secret to protect, password-reset flows and breach risk handled by Google. (Note this is delegated authentication; the user still authorizes what your app may access.)
Password handling: salted, slow hashes
If your app handles passwords directly, the cardinal rule is: never store the plaintext password. "Plaintext" means the raw, readable password as the user typed it. Instead you store a salted hash. Let us define each piece:
- A hash is a one-way function: it turns input into a fixed-length scrambled string, and there is no practical way to run it backward. You can verify a guess (hash the guess and compare) but you cannot reverse the stored hash into the original password.
- A salt is random data, unique per user, mixed into the password before hashing. Without salts, two users who both chose
password123would store the identical hash — and attackers could precompute a giant table of (common password → hash) once and look up everyone (a "rainbow table" attack). A unique salt per user makes those identical passwords produce different hashes, so precomputed tables are useless. - Slowness is a feature. Algorithms like bcrypt and argon2 are deliberately expensive to compute (and tunable to get slower as hardware improves). A login checking one password barely notices a few milliseconds, but an attacker who stole your hash database and wants to try billions of guesses is throttled to a crawl. A fast hash like raw SHA-256 is the wrong tool here precisely because it is fast.
# Sign up: store a salted, slow hash — never the password itself
hashed = bcrypt.hash(password) # a random salt is generated and baked into the output
db.save(user, hashed) # e.g. "$2b$12$Nf...salt...andhash"
# Log in: hash the guess and compare — you never un-hash anything
if bcrypt.verify(attempt, hashed):
grant_session()
else:
reject()
The database-leak scenario that makes this concrete. Imagine an attacker steals a copy of your users table. If you stored plaintext, the catastrophe is total and immediate: every password is exposed, and because people reuse passwords, the attacker now also has the keys to those users' email, bank, and everything else — your breach becomes their breach everywhere. If instead you stored salted bcrypt hashes, the attacker holds a pile of irreversible scrambles: there is no plaintext to read, the per-user salts defeat precomputed tables, and the deliberate slowness makes brute-forcing each one impractical. Same theft, vastly different blast radius. This is defense in depth in action — you assume the database will someday leak and make the leak as worthless as possible.
The top risks, each with mechanism, attack, and defense
These are the OWASP-flavored classics — OWASP (the Open Worldwide Application Security Project) publishes the canonical free list of the most common web vulnerabilities. Each one has a known, cheap defense; the bugs appear when someone skips that defense under deadline. We will give each a one-line mechanism, a concrete attack, and the fix.
Injection (SQL injection)
Mechanism: user input gets glued into a command (an SQL query, a shell command) and the database/shell parses part of the input as code instead of data. Attack: suppose a login query is built by string concatenation and a user types a crafted username:
# VULNERABLE — input is concatenated straight into the SQL string
query = "SELECT * FROM users WHERE name = '" + name + "'"
# Attacker types this as the "name": ' OR '1'='1
# The query the DB actually runs becomes:
# SELECT * FROM users WHERE name = '' OR '1'='1'
# '1'='1' is always true, so it returns EVERY row — login bypassed.
Defense: use parameterized queries (also called prepared statements). You send the SQL skeleton and the values separately; the driver guarantees the values are treated as pure data and can never be parsed as SQL commands, no matter what characters they contain.
# SAFE — the ? is a placeholder; name is bound as a value, never as code
query = "SELECT * FROM users WHERE name = ?"
db.execute(query, [name]) # even "' OR '1'='1" is just a (nonexistent) name
Forward-link: the SQL lesson covers query construction in depth, and the API design lesson covers validating inputs at the boundary.
Broken access control (IDOR)
Mechanism: the server authenticates the user but forgets to check authz on the specific object being accessed — it trusts a client-supplied id. The classic form is IDOR (Insecure Direct Object Reference). Attack: Alice is logged in and views her order at GET /orders/42. She simply edits the URL to GET /orders/43 — Bob's order — and if the server returns it just because she is logged in, she has read someone else's data. Being authenticated is not the same as being authorized for that row.
# VULNERABLE — trusts the id from the URL; checks authn but not object authz
order = db.get_order(request.id)
return order
# SAFE — verify the current user actually owns this object, every time
order = db.get_order(request.id)
if order.owner != current_user.id:
return forbidden() # 403 — authenticated, but not authorized for this row
return order
Defense: check authorization on every object access, server-side, against the logged-in user — never assume that holding an id means you may see it. (This is exactly the authn-vs-authz distinction from the top of the lesson, now as a concrete bug.)
XSS (cross-site scripting)
Mechanism: user-submitted text is written into a web page without being escaped, so the browser executes it as code. Attack: a user sets their display name to <script>steal(document.cookie)</script>. When another victim views a page showing that name, their browser runs the script — and since the browser is inside the victim's logged-in session, the script can read their session cookie and send it to the attacker (session hijacking). Defense: escape output — convert dangerous characters so user text renders as text, not markup. The character < becomes the harmless entity <, so the browser displays the literal string instead of treating it as a tag.
# Stored: <script>steal(document.cookie)</script>
# UNSAFE: written raw into HTML -> the browser RUNS the script
# SAFE: escaped on output -> displayed as literal text:
# <script>steal(document.cookie)</script>
CSRF (cross-site request forgery)
Mechanism: because the browser automatically attaches your cookies to any request to a site, a malicious third-party page can trigger a state-changing request to a site where you are already logged in, riding your session without your knowledge. Attack: while logged into your bank, you visit an attacker's page that silently submits a hidden form to POST yourbank.com/transfer?to=attacker&amount=1000. Your browser dutifully sends your bank cookie along, and the bank — seeing a valid session — processes the transfer. You never clicked anything meaningful. Defense: two layers. First, mark session cookies SameSite, which tells the browser not to send them on cross-site requests, so the forged request arrives with no cookie. Second, require an anti-CSRF token: a random value your own page embeds in forms and the server checks — the attacker's page cannot know it, so its forged request is rejected.
Secrets management
Mechanism: credentials (API keys, database passwords, signing keys) get hard-coded into source files and committed to git. Attack: the moment that code is pushed to a public repo — or a private repo later leaks — every key in it is compromised; bots scan GitHub for exactly this and exploit found keys within minutes. And deleting the line later does not help: git history keeps the old commit forever, so the secret is still recoverable. Defense: no secrets in source or git, ever. Load them from environment variables or a dedicated secrets manager, and prefer cloud IAM roles (Identity and Access Management — the cloud's authz system) so a running service gets short-lived, automatically-rotated credentials instead of a long-lived key sitting in a file. See the Cloud lesson for IAM, and note this is least privilege again: each key should grant only what its service needs.
# WRONG — secret committed to source control, leaked forever in git history
API_KEY = "sk_live_8f3a9c2b1d..."
# RIGHT — read from the environment / secrets manager at runtime
API_KEY = os.environ["API_KEY"] # never in the repo; injected by the platform
The unifying themes
Notice the through-line under every defense above. Never trust client input: not its data (injection, XSS), not its claimed permissions (broken access control / IDOR), not its forged requests (CSRF) — validate and re-check on the server, always. Defense in depth: salt and slow-hash passwords, SameSite cookies and CSRF tokens, short JWT lifetimes and refresh flows — assume one layer fails and have the next ready. Least privilege: a user may touch only their own rows, a key may do only its one job, a service holds only short-lived scoped credentials. When in doubt: validate on the server, and check authz again.
Go deeper (optional): the OWASP Top Ten is the canonical, free checklist of these risks, with code-level examples in every major language — the one security reference worth reading end to end at least once.