Security is not a final penetration-test phase. It is the everyday practice of deciding what to trust, what to expose, who may act and how the system fails.
Before we begin: sit with me for a moment
If you are new to web security, you may already be thinking, “Faz, this subject is too large. There are hundreds of attacks, strange acronyms and frightening stories. How am I supposed to remember all of it?”
I understand that feeling. I have written these notes for myself as much as for you. I do not want us to memorize a catalogue of attacks. I want us to learn a way of thinking that remains useful when the framework, cloud platform or attack name changes.
So imagine we are reviewing a small learning application together. You are the junior developer and I am sitting beside you. You can interrupt at any point. In fact, each section begins with the sort of question I would want you to ask. We will not laugh at simple questions; simple questions often expose dangerous assumptions.
Our central question will be:
“What is this part of the system trusting, and what evidence makes that trust reasonable?”When a request contains
userId: 7, should we trust that the caller is user 7? No. When a signed-in user asks for course 42, should we trust that they own it? No. When a file ends in .jpg, should we trust that it is a safe image? No. This does not mean we distrust people personally. It means software receives claims, and claims need verification at the correct boundary.
I will sometimes show unsafe code. That code is there so you can recognize the mistaken assumption; do not copy it into a real application. Immediately after it, we will build the safer version and discuss what the safer version still does not solve.
One more promise: I will not tell you that one header, library or scanner “makes the application secure.” Security is a system property. A strong lock on the front door does not help if every apartment key opens every room. We will connect identity, permission, input handling, browser behavior, infrastructure, failure and monitoring as parts of one story.
1. “Faz, where do I even begin with security?”
An asset is something worth protecting: accounts, personal data, money, source code, availability or reputation. A threat actor may be an external attacker, malicious user, compromised dependency or accidental insider. A trust boundary is where data or authority moves between contexts.
browser → CDN → API → service → database
↘ identity provider
Every arrow deserves validation, authentication, authorization, encryption and logging decisions appropriate to its risk.
2. “If the user is logged in, why is that not enough?”
Because login answers only the first half of the conversation.
When a receptionist checks my passport, they have evidence of who I am. That does not allow me to enter every hotel room. The room number, my booking, the current time and the action I am attempting all affect permission. Authentication establishes identity; authorization evaluates authority in context.
This distinction sounds simple, yet it is the source of serious breaches. Developers often protect a route with requireLogin and feel the work is finished. The server now knows *who* sent the request, but it has not answered, “May this person update this particular course belonging to this particular organization?” Keep those as two visible steps in your code and your tests.
async function updateCourse(userId: string, courseId: string, input: CourseInput) {
const membership = await db.membership.findUnique({ userId, courseId });
if (!membership || !["owner", "editor"].includes(membership.role)) {
throw new ForbiddenError();
}
return db.course.update(courseId, input);
}
A valid session identifies the caller. It does not grant access to every object. Broken access control remains the leading OWASP Top 10:2025 risk. Check ownership, tenant and action for each server operation. Never rely on a hidden button.
3. “What exactly counts as untrusted input?”
URL parameters, headers, cookies, forms, JSON, files, queue messages and database values originating elsewhere are untrusted.
const CreateCourse = z.object({
title: z.string().trim().min(1).max(120),
visibility: z.enum(["private", "team", "public"]),
});
const input = CreateCourse.parse(req.body);
Use allowlists and size limits. Validation confirms shape; business rules and authorization remain separate.
4. “How does ordinary text become an injection attack?”
Injection occurs when data is interpreted as commands in SQL, shell, templates or another language.
// Unsafe
await db.execute(`SELECT * FROM users WHERE email = '${email}'`);
// Parameterised
await db.execute("SELECT * FROM users WHERE email = $1", [email]);
Avoid shell construction with user data. Use parameter APIs, safe query builders and context-aware output encoding.
5. “What is XSS actually doing inside the browser?”
XSS executes attacker-controlled script in another user's browser. Framework escaping helps only when used correctly.
output.textContent = untrustedComment; // text
// output.innerHTML = untrustedComment; // interprets markup: dangerous
If rich HTML is required, sanitise with a maintained purpose-built library, restrict allowed content and add Content Security Policy as defence in depth.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'
6. “Why would the browser send credentials for an attacker?”
Browsers automatically send matching cookies. A malicious site may cause a victim's browser to submit a request. Use SameSite cookies, CSRF tokens for relevant state changes, origin checks and safe method semantics.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly prevents ordinary JavaScript reading the cookie; Secure limits it to HTTPS. Cookie scope and lifetime should be minimal.
7. “Are passwords, sessions and OAuth three versions of the same thing?”
Use established identity libraries. Store passwords with a purpose-designed slow password hashing algorithm and unique salts. Rate-limit login and recovery. Rotate sessions after privilege changes. Provide secure revocation and recovery.
OAuth delegates authorisation; OpenID Connect adds identity. Validate state, nonce, PKCE, issuer, audience, redirect URI and token signatures according to the flow and library. Do not invent tokens.
8. “Can we make data safe simply by encrypting it?”
Secrets do not belong in source control, frontend bundles, logs or Docker layers. Use managed secret storage and rotate credentials. TLS protects data in transit; encryption at rest does not replace access control.
Use platform cryptography for specific requirements. Do not design an encryption algorithm or compare secrets with timing-sensitive custom loops.
9. “Why should I worry about code my team did not write?”
OWASP Top 10:2025 includes software supply-chain failures near the top. Pin dependencies with a lockfile, review install scripts, minimise packages, update deliberately, protect CI credentials and verify build provenance where risk justifies it.
permissions:
contents: read
jobs:
build:
steps:
- uses: actions/checkout@<pinned-commit>
- run: npm ci
Do not run untrusted pull-request code with deployment secrets.
10. “Which browser security headers genuinely help us?”
Use HTTPS, HSTS after confirming HTTPS readiness, CSP, frame restrictions, referrer policy and MIME sniffing protection. Configure trusted proxies correctly so IP and secure-cookie decisions cannot be spoofed.
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
11. “What should the application do when a security dependency fails?”
Log authentication and high-value authorization decisions with request IDs, but never passwords, raw session tokens or unnecessary personal data. Alert on actionable patterns. The 2025 list explicitly includes mishandling exceptional conditions: fail closed when an authorization dependency fails, bound retries and transactions, and avoid leaking internals.
12. “Can we practise this on one small feature?”
Take StudyDesk's “invite learner” feature. List assets, entry points and threats. Test whether a viewer can invite, whether a user can change the course ID, whether an invitation can be reused, whether errors reveal private courses, whether logs expose email addresses and whether a compromised dependency can access secrets.
Mastery statement: secure software treats client input as untrusted, verifies permission at the resource, uses proven security mechanisms, minimises authority and fails observably without leaking sensitive information.
13. “Can you give me one mental model I will remember?”
Imagine an apartment building. The street door is authentication: it asks, “Who are you?” The key for apartment 24 is authorization: it asks, “May this person enter this particular room?” A visitor log is auditing. A fire door is defence in depth. None replaces the others.
This gives us three useful security goals:
- Confidentiality: only permitted people can read the data.
- Integrity: data cannot be changed without permission.
- Availability: legitimate users can still use the system.
14. “Do we really threat-model before writing code?”
Threat modelling is structured curiosity. Draw the system, mark trust boundaries, list valuable assets, then ask how each entry point could be abused.
For StudyDesk, assets include accounts, private notes, payments and instructor permissions. Entry points include forms, APIs, uploaded files, webhooks and administrator tools. A tiny worksheet is enough:
| Asset | Threat | Existing control | Next test |
|---|---|---|---|
| Private note | Another tenant guesses its ID | Ownership query | Request it as a different user |
| Session | Token stolen from script | HttpOnly cookie | Verify no token reaches JS or logs |
| Upload | File contains active content | Type/size checks | Upload renamed HTML and oversized data |
15. “Why is changing an ID in the URL so dangerous?”
The most important rule is: authenticate the caller, then authorize the requested action on the requested resource.
// Unsafe: existence is checked, ownership is not.
const course = await db.course.findUnique({ where: { id: req.params.id } });
// Safer: permission becomes part of the data query.
const course = await db.course.findFirst({
where: {
id: req.params.id,
organisationId: req.user.organisationId,
},
});
if (!course) return res.sendStatus(404);
This prevents broken object-level authorization: changing /courses/41 to /courses/42 must not cross a tenant boundary. A hidden button is not a security control; an attacker can call the endpoint directly.
Centralize rules without making them vague:
type Action = "course:read" | "course:update" | "course:delete";
function can(user: User, action: Action, course: Course): boolean {
if (user.organisationId !== course.organisationId) return false;
if (action === "course:read") return true;
return user.role === "instructor" || user.role === "admin";
}
Test both positive and negative paths. “An instructor can update” is incomplete without “a learner, outsider and anonymous caller cannot.”
16. “What if the request sends fields our form never showed?”
Suppose a profile form should change only displayName, but the server spreads the whole body:
// Dangerous: the caller may send { role: "admin" }.
await db.user.update({ where: { id: user.id }, data: req.body });
Allowlist fields and validate their shapes:
const input = updateProfileSchema.parse(req.body);
await db.user.update({
where: { id: req.user.id },
data: { displayName: input.displayName, bio: input.bio },
});
Think of a hotel registration card: the guest may write their contact details, not assign themselves the master key.
17. “Aren’t validation, sanitisation and encoding the same cleanup?”
No, and I want us to pause here because “clean the input” is one of those phrases that sounds responsible while hiding the real decision.
If I ask for a number of seats, validation checks that I received an integer within the allowed range. If I intentionally support a restricted rich-text editor, sanitisation may remove markup outside that policy. When I later place an ordinary course title into HTML, output encoding ensures its characters are treated as text rather than markup. These controls happen for different reasons and often at different times.
Please do not build a function called makeSafe(value) and use it for HTML, SQL, URLs and shell commands. Safety belongs to a context. We validate a business value; we keep data separate from an interpreter's instructions; and we encode for the destination that will interpret the output.
These words solve different problems:
- Validation rejects data outside the business contract.
- Sanitisation deliberately removes or normalises permitted content.
- Encoding represents data safely in its output context.
const CourseInput = z.object({
title: z.string().trim().min(1).max(120),
seats: z.number().int().min(1).max(500),
});
Do not “sanitize” a password by removing punctuation; that silently changes the secret. Do not HTML-encode data before storing it in a general-purpose database; encode for the destination when rendering. HTML text, HTML attributes, URLs, CSS and JavaScript strings have different contexts.
18. “What does it mean to keep instructions separate from data?”
Injection happens when data is accidentally interpreted as instructions. Parameterised SQL keeps code separate from values:
// Unsafe
const sql = `SELECT * FROM users WHERE email = '${email}'`;
// Safer with a query builder or parameterized driver
const result = await pool.query(
"SELECT id, email FROM users WHERE email = $1",
[email],
);
The same principle applies to shell commands, LDAP, templates and NoSQL filters. Avoid constructing commands from request values.
// Prefer an API with separated arguments.
spawn("ffmpeg", ["-i", validatedInputPath, validatedOutputPath], {
shell: false,
});
An allowlist is often stronger than a blacklist. If the only valid image sizes are small, medium and large, accept only those values.
19. “Does React escaping mean XSS is solved?”
React gives us a very useful safe default: ordinary string expressions are rendered as text. That removes a large class of accidental HTML injection. But a safe default is not the same as a proof that the entire application cannot execute attacker-controlled code.
Ask where the application leaves the safe path. Does it use dangerouslySetInnerHTML for article content? Does it place an unchecked value into a URL? Does a third-party analytics script have full origin access? Does legacy code call innerHTML? XSS lives at these interpretation boundaries.
My mentoring advice is gentle but firm: do not abandon React's safe text rendering unless the product truly needs rich HTML. If it does, make sanitisation one narrow, reviewed boundary and add CSP as another layer. The unusual operation should look unusual in code.
React escapes text expressions, but escape hatches still deserve scrutiny:
// Good for ordinary user text
<p>{comment.body}</p>
// Dangerous unless html came through a robust sanitizer
<article dangerouslySetInnerHTML={{ __html: html }} />
URLs also need policy. Reject dangerous schemes rather than placing arbitrary strings in href. A strict Content Security Policy reduces the damage of some injection mistakes, but it does not make unsafe HTML safe.
Start CSP in report-only mode, review violations, then enforce it. Nonces or hashes are preferable to broadly allowing inline scripts.
20. “Can you walk me through a CSRF attack slowly?”
Certainly. First, remove the idea that the attacker steals the cookie. They may never see it.
Alice signs into StudyDesk, and the browser stores a session cookie. In another tab she opens a malicious site. That site creates a request to StudyDesk—perhaps by submitting a form. The browser recognizes the StudyDesk destination and may attach Alice's matching cookie, just as it would for a legitimate StudyDesk page. StudyDesk sees a valid session unless it also checks whether the state-changing request came from an intended application interaction.
That is why CSRF is sometimes described as the browser being *too helpful*. The defence is not “hide the endpoint.” We use safe method semantics, suitable SameSite cookies, anti-CSRF tokens and origin checks according to the application. Then we still authorize the action. Each control answers a separate question.
A browser automatically includes cookies for matching requests. An attacker cannot necessarily read the response, but may cause a logged-in browser to send a state-changing request.
Protect cookie-authenticated mutations with suitable SameSite settings and anti-CSRF tokens or origin verification. GET should not delete, pay or change an email address.
app.post("/api/email", requireSession, verifyCsrfToken, async (req, res) => {
// validate, authorize, update
res.sendStatus(204);
});
CORS is not CSRF protection. CORS governs whether frontend JavaScript may read cross-origin responses; many cross-origin requests can still be sent.
21. “What is a session, and what should logout really do?”
A session identifier is like a cloakroom ticket: it should contain little meaning and point to server-side state. Store it in a Secure, HttpOnly, appropriately SameSite cookie. Rotate it after login and privilege changes to prevent session fixation.
JWTs are signed containers, not automatic security. Validate algorithm, signature, issuer, audience, expiry and expected claims. Plan revocation and key rotation. Never put secrets in a token merely because it is encoded; ordinary JWT payloads are readable.
Logout should invalidate the session server-side when immediate revocation matters, then expire the browser cookie.
22. “Why can’t a secure site send me my old password?”
Store password hashes made with a current password-hashing function such as Argon2id, configured using current platform guidance. A unique salt prevents equal passwords producing equal stored hashes. A server-held pepper can add separation but complicates rotation.
Recovery is another authentication route and must be protected accordingly:
- return the same public response whether an account exists;
- issue random, single-use, short-lived tokens;
- store a hash of the recovery token;
- rate-limit attempts;
- notify the account owner after a change;
- revoke or review existing sessions.
23. “What problem do OAuth and OpenID Connect each solve?”
Let us begin with what OAuth is not: it is not a password-sharing protocol and it is not, by itself, a statement that a human has logged into your application.
OAuth lets a client receive limited authority to call a resource server. OpenID Connect adds an identity layer so the client can learn about an authenticated user through defined claims and checks. In everyday language, OAuth says, “This application may access this scope,” while OpenID Connect helps say, “This is the authenticated subject.”
The distinction matters because developers sometimes accept any access token as proof of login without verifying its intended audience or meaning. Tokens are issued for a purpose. Validate that purpose rather than trusting the fact that the value has dots and a signature.
OAuth answers delegated authorization: “May this application call an API with this scope?” OpenID Connect adds an identity layer: “Who authenticated?”
For browser-based public clients, Authorization Code with PKCE binds the authorization request to the token exchange. state helps bind request and callback; nonce binds an ID token to the authentication request. Use a mature library and validate redirect URIs exactly.
Do not treat every access token as a login token. Check the intended audience and token type.
24. “Why are file uploads and URL importers security-sensitive?”
An uploaded filename, extension and declared MIME type are all attacker-controlled. Generate storage names, enforce size limits, inspect content when necessary, keep uploads outside executable web roots and serve risky formats as downloads from a separate origin.
Server-side request forgery occurs when an attacker persuades your server to fetch an unintended address, perhaps cloud metadata or an internal admin service.
const allowedHosts = new Set(["images.example.com"]);
const candidate = new URL(inputUrl);
if (candidate.protocol !== "https:" || !allowedHosts.has(candidate.hostname)) {
throw new Error("Unsupported image source");
}
Real protection must also consider redirects, DNS rebinding and resolved private addresses. Prefer purpose-specific integrations over an unrestricted URL fetcher.
25. “Where should secrets live, and can our build pipeline leak them?”
Configuration that reaches a browser bundle is public. Keep database passwords and signing keys server-side in a managed secret system. Give each workload only the secrets and permissions it needs.
For dependencies:
- commit and honour a lockfile;
- use
npm ciin CI; - remove unused packages;
- review surprising install scripts and ownership changes;
- automate vulnerability alerts, then assess reachability and impact;
- pin third-party CI actions to trusted immutable revisions where appropriate.
26. “How much should an error reveal, and what should we log?”
Fail closed on authorization uncertainty. Return a stable public error while retaining safe internal diagnostics.
try {
await changeRole(command);
res.sendStatus(204);
} catch (error) {
logger.error({ err: error, requestId, actorId: req.user?.id }, "role change failed");
res.status(500).json({ type: "internal-error", requestId });
}
Never log passwords, recovery tokens, full cookies or payment data. Protect logs because they contain a map of system activity. Define who receives an alert, how credentials are revoked, how evidence is preserved and how users are informed. A backup is useful only if restore is tested.
27. “What questions should I ask during every endpoint review?”
For every endpoint, ask:
- Who is the caller?
- What precise action are they requesting?
- Which record and tenant does it affect?
- Is input validated before expensive work?
- Is data passed to an interpreter safely?
- Could the response disclose another user's information?
- Can retries duplicate money, email or state changes?
- Are failures rate-limited, logged and safe?
28. “How can I revise this without becoming overwhelmed?”
- Day 1: draw one application's assets and trust boundaries.
- Day 2: write authorization tests for one resource.
- Day 3: practise validation, parameterised queries and safe rendering.
- Day 4: inspect cookies, CSRF controls and OAuth callback checks.
- Day 5: review secrets, dependencies and CI permissions.
- Day 6: test uploads, rate limits, error handling and logs.
- Day 7: explain one realistic attack from entry point to prevention without jargon.
29. “What are we truly trying to protect?”
Security can feel frightening because examples involve attackers, stolen accounts and broken systems. Let us remove the drama and keep the useful reasoning. I do not want you coding in fear; I want you noticing authority and consequences.
Every feature answers four questions:
- What are we protecting?
- Who or what is interacting with it?
- What are they allowed to do?
- What happens when assumptions fail?
In software, we aim to preserve:
- confidentiality: information is seen only by authorized parties;
- integrity: information and behavior change only in authorized ways;
- availability: legitimate users can use the system when needed.
30. “Should I memorize the OWASP Top 10:2025?”
No. Learn what each category helps you notice, then return to the source when you need precision.
If you memorize “A01 is Broken Access Control” but still check permission only in the frontend, the memorization did nothing. If you understand that every action needs a server-side decision about the caller, operation and resource, the category has improved your engineering. OWASP gives us shared language; our design and evidence provide the control.
The current OWASP Top 10:2025 categories are:
- Broken Access Control
- Security Misconfiguration
- Software Supply Chain Failures
- Cryptographic Failures
- Injection
- Insecure Design
- Authentication Failures
- Software or Data Integrity Failures
- Security Logging and Alerting Failures
- Mishandling of Exceptional Conditions
Notice the emphasis on root causes. An exposed admin console may come from misconfiguration. Another tenant's invoice may come from broken access control. A package installer that steals CI credentials is a supply-chain failure. The symptom “data leaked” does not tell us which engineering control failed.
Use the list during design, review, testing and operations—not only before release.
31. “How do I draw the security boundary of a real application?”
For StudyDesk, write four short lists.
Assets: learner identity, private notes, course ownership, payment records, certificates, administrator actions, availability and source/build integrity.
Actors: anonymous visitor, learner, instructor, organization administrator, support agent, service account, identity provider, compromised dependency and malicious insider.
Entry points: pages, API routes, file uploads, OAuth callback, webhooks, admin tools, background jobs, import files and CI pipeline.
Trust boundaries: browser to API, API to database, organization A to B, application to identity provider, CI to production and employee support tooling to customer data.
Draw the flow:
[Browser]
│ HTTPS + cookie
▼
[CDN / Gateway] ──► [Identity provider]
│ trusted forwarded identity? verify boundary
▼
[StudyDesk API] ──► [PostgreSQL]
│
├─────────────► [Object storage]
└─────────────► [Job queue / email provider]
Every arrow carries data and authority. Ask whether the receiver should trust the sender, how identity is established, what is validated, how requests are limited and what evidence remains.
32. “What is STRIDE, and how does it help me ask better questions?”
STRIDE is one prompt set, not a magical formula:
- Spoofing: pretending to be another identity.
- Tampering: changing data or code without permission.
- Repudiation: denying an action where reliable evidence is absent.
- Information disclosure: exposing protected data.
- Denial of service: preventing legitimate use.
- Elevation of privilege: gaining greater authority.
| Prompt | Example question | Possible control |
|---|---|---|
| Spoofing | Can a stolen session invite? | Strong session protection, reauthentication for high-risk actions |
| Tampering | Can organization ID be changed? | Derive tenant from authorized context, not body |
| Repudiation | Can an admin deny the invitation? | Tamper-resistant audit event |
| Disclosure | Does error reveal private organization members? | Minimal, consistent responses |
| Denial | Can invitations flood email? | Rate limits and quotas |
| Elevation | Can a learner choose role owner? | Server-side role allowlist and authorization |
33. “What would an attacker’s version of our user story look like?”
User story:
As an instructor, I can publish my course so learners can enroll.Abuse stories:
- As a learner, I try to publish a course by calling the API directly.
- As an instructor in organization A, I change the course ID to one in organization B.
- I replay the publish request repeatedly.
- I send extra fields such as
ownerId. - I publish a course with no lessons through a race condition.
- I cause downstream email failure and test whether the course becomes half-published.
34. “Why should access be denied by default?”
Because new code and new data should not accidentally inherit authority that nobody explicitly considered.
Imagine a building where every new room begins unlocked and somebody is expected to remember the lock later. “Allow unless forbidden” creates that building. Deny by default asks us to state the grant: which actor, capability, resource and condition justify entry. This may feel slightly slower during implementation, but it makes omissions safer.
Deny by default means access exists only when a rule grants it. Least privilege means grant only the minimum capability, scope and duration required.
type Permission =
| "course:read"
| "course:update"
| "course:publish"
| "course:delete";
const rolePermissions: Record<Role, ReadonlySet<Permission>> = {
learner: new Set(["course:read"]),
instructor: new Set(["course:read", "course:update", "course:publish"]),
administrator: new Set(["course:read", "course:update", "course:publish", "course:delete"]),
};
This role table is only one input. Resource ownership, tenant, status and risk also matter.
function mayPublish(actor: Actor, course: Course): boolean {
return actor.organisationId === course.organisationId &&
rolePermissions[actor.role].has("course:publish") &&
course.status === "draft";
}
Avoid a universal isAdmin escape hatch unless its scope is clearly designed and audited. Support engineers often need temporary, purpose-limited access rather than permanent global authority.
35. “How do I authorize the exact record, not just the route?”
This is where many junior developers make an understandable leap: “The /notes/:id route requires login, therefore notes are protected.” Stop one step earlier. Protected from whom, and which note?
The route-level check tells us an authenticated person arrived. The record-level check must bind the note to that person's ownership, tenant or granted capability. Put this scope into the query where practical so an unauthorized row is never loaded casually and returned later.
An API receives /api/notes/note_456. Authentication proves that Alice is signed in. It does not prove that Alice owns note_456.
Unsafe:
const note = await db.note.findUnique({ where: { id: req.params.id } });
return res.json(note);
Safer query with tenant and owner scope:
const note = await db.note.findFirst({
where: {
id: req.params.id,
ownerId: req.user.id,
organisationId: req.user.organisationId,
},
});
if (!note) return res.sendStatus(404);
return res.json(toNoteResponse(note));
Using a UUID rather than an integer makes guessing harder but does not grant permission. Authorization must hold even when an identifier is known.
Returning 404 can conceal whether another user's resource exists. This is a product/security policy choice; use it consistently and still log authorized internal detail safely.
36. “Can permission differ by operation and even by response field?”
Protect each operation, not just routes called “admin.”
router.delete("/courses/:id", requirePermission("course:delete"), deleteCourse);
Then verify resource context inside the use case. Middleware that checks a generic permission cannot know every tenant or record rule.
Field-level authorization matters too. A support agent might read account email but not password hashes, payment tokens or private notes.
function toUserResponse(user: User, viewer: Actor) {
return {
id: user.id,
displayName: user.displayName,
...(viewer.id === user.id ? { email: user.email } : {}),
};
}
Prefer deliberate response models. Serializing ORM entities wholesale risks accidental disclosure when new columns appear.
37. “Where can one customer’s data accidentally reach another?”
Tenant isolation must appear in every relevant query, uniqueness rule, cache key, background job and object-storage path.
type TenantContext = { organisationId: string; actorId: string };
class CourseRepository {
constructor(private readonly tenant: TenantContext) {}
find(id: string) {
return db.course.findFirst({
where: { id, organisationId: this.tenant.organisationId },
});
}
}
This makes accidentally forgetting tenant scope harder. Database row-level security can add defence in depth when designed and tested correctly, but application authorization still matters.
Include tenant ID in cache keys:
const key = `org:${organisationId}:course:${courseId}`;
A cache entry keyed only by course ID can leak across tenants even if the database query is correct.
38. “What makes one login stronger than another?”
Authentication answers who controls the current session with some level of confidence. Factors are commonly grouped as:
- something you know, such as a password;
- something you have, such as a security key or authenticator;
- something you are, such as a biometric characteristic.
Use stronger reauthentication for sensitive actions such as changing MFA, viewing recovery codes or initiating large payments. Preserve context so attackers cannot downgrade to a weaker recovery route.
39. “What should we actually store instead of a password?”
Never store plaintext or reversibly encrypted passwords. Store output from a purpose-designed password hashing function using a unique salt and current parameters.
const passwordHash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: configuredMemoryCost,
timeCost: configuredTimeCost,
parallelism: configuredParallelism,
});
const valid = await argon2.verify(passwordHash, candidate);
Tune parameters on your production class of hardware using current guidance. Expensive hashing slows offline guessing but also consumes server resources, so rate-limit authentication.
A salt need not be secret. A pepper is an optional server-held secret added according to a carefully designed scheme; it adds operational rotation complexity. Use a mature identity implementation rather than inventing one.
Password policy should permit long passwords and password managers. Reject known-compromised values where feasible. Arbitrary periodic rotation can encourage predictable choices unless there is evidence of compromise.
40. “Can a login form leak whether an account exists?”
Do not reveal whether an account exists through obvious response differences:
{"message":"If the credentials are valid, you will be signed in."}
Exact timing equality is difficult, but avoid gross differences such as skipping password hashing entirely for unknown users. Use a dummy hash path where appropriate.
Rate limits should consider account and network signals without permanently locking an account through attacker activity. Add progressive delays, alerting and risk-based controls. CAPTCHA can add friction but is not your only defense.
Log successful and failed authentication with safe identifiers, source context and reason categories—never raw passwords.
41. “What happens to a session between login and logout?”
A session is more than a cookie value. Design creation, rotation, idle expiry, absolute expiry, privilege change, revocation and logout.
Set-Cookie: __Host-session=random-opaque-id; Path=/; Secure; HttpOnly; SameSite=Lax
The __Host- prefix requires secure, host-only behavior with Path=/ in supporting browsers. The server stores a hash or protected form of the identifier plus session metadata.
Rotate the identifier after login to prevent session fixation and after privilege elevation to limit stolen older context. On password reset or suspected compromise, offer to revoke other sessions.
await db.transaction(async tx => {
await tx.sessions.revokeAllForUser(userId);
await tx.passwords.updateHash(userId, newHash);
await tx.audit.record({ actorId: userId, action: "password.changed" });
});
Logout should revoke server state when immediate invalidation is required, not merely delete a browser cookie.
42. “What does each cookie attribute protect me from?”
Think of cookie attributes as delivery instructions on an envelope. Secure says use the protected transport. HttpOnly says ordinary page JavaScript must not read the value. SameSite limits selected cross-site delivery. Path and Domain narrow where the envelope is sent. Expiry controls how long it remains available.
None says, “Everything inside this application is now secure.” An XSS payload may not read an HttpOnly session cookie, but it can still ask the browser to make same-origin authenticated requests. A SameSite cookie reduces CSRF exposure but does not authorize a course deletion. Keep each control attached to the problem it actually solves.
Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=1800
Secure: send only over HTTPS.HttpOnly: hide from ordinary JavaScript reads.SameSite: control cross-site attachment behavior.Path/Domain: sending scope.Max-Age/Expires: persistence.
HttpOnly reduces direct session theft through XSS, but injected script can still make authenticated same-origin requests. SameSite reduces some CSRF paths, but application requirements may still need tokens and origin checks.
Do not put sensitive profile data in a readable cookie. Cookie bytes accompany matching requests and can leak through logs or overly broad scope.
43. “Can we replay the CSRF story one request at a time?”
Alice is signed into StudyDesk with a cookie. She visits an attacker-controlled page. That page submits a form to StudyDesk. The browser may attach Alice's matching cookie automatically. If StudyDesk trusts only the cookie and accepts the mutation, the attacker has caused an action under Alice's session.
Controls include:
- safe methods never change state;
- appropriate
SameSitecookies; - synchronizer or double-submit token patterns implemented correctly;
- checking
Originfor state-changing requests; - requiring a custom header in API designs where CORS policy is strict;
- reauthentication for especially sensitive actions.
app.post("/api/email-change", requireSession, verifyOrigin, verifyCsrf, async (req, res) => {
// authorization and validation still follow
});
CORS and CSRF are different. CORS decides whether script can read a cross-origin response. CSRF concerns causing an authenticated browser request. A request can be sent even when its response cannot be read.
44. “Who are all the people in an OAuth flow?”
OAuth delegates API authorization. OpenID Connect builds an identity layer on top.
Imagine StudyDesk wants permission to read a learner's calendar:
- the resource owner is the learner;
- the client is StudyDesk;
- the authorization server obtains consent and issues tokens;
- the resource server hosts calendar data;
- an access token carries delegated authority.
45. “How does PKCE protect the authorization code?”
The client creates a random verifier and derived challenge:
const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64Url(await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
));
It sends code_challenge with method S256 in the authorization request. The callback receives a short-lived code. The token request includes the original verifier. The authorization server checks that it matches the challenge bound to that code.
PKCE protects against authorization-code interception/injection classes. Current OAuth security best practice requires it for public clients and recommends it for confidential clients. Use exact registered redirect URI matching, protect callback state, validate issuer and avoid open redirectors.
Use a maintained standards library. The snippet teaches the relationship, not a complete OAuth client.
46. “If a JWT is signed, is everything inside it trustworthy and secret?”
Signed does not mean encrypted, and valid does not mean suitable for every API.
A signature can show that the protected token content was issued by a trusted signer and has not been altered—*after* we verify with the expected algorithm and key. We must still check issuer, audience, expiry and application claims. A token issued for the photo API should not automatically unlock the payment API. Ordinary JWT payloads can be decoded and read, so never place a secret there because the text looks scrambled.
A JWT is a token format containing encoded claims and a signature or other protection; ordinary signed JWT payloads are readable. Never place secrets in one because it “looks encrypted.”
Resource-server validation should constrain algorithms and keys rather than trust token-selected surprises:
const claims = await jwtVerify(token, trustedKeySet, {
issuer: "https://identity.example.com",
audience: "studydesk-api",
algorithms: ["RS256"],
});
Then enforce scopes and resource-level authorization. Plan signing-key rotation, token lifetime, clock skew and revocation. Short access-token lifetimes reduce exposure but do not remove the need to protect refresh tokens.
Opaque tokens can be introspected or looked up server-side. Choose format based on trust boundaries and operational needs, not fashion.
47. “Why is password recovery part of the login system?”
Recovery can bypass every strong login control, so treat it as a high-risk authentication route.
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");
await recoveryStore.save({
userId,
tokenHash,
expiresAt: addMinutes(new Date(), 20),
usedAt: null,
});
Email the raw token, store its hash, make it single-use and short-lived, and rate-limit requests/attempts. Return the same public response whether an email exists. After success, revoke or review sessions and notify the user through an established channel.
Never send the old password. A secure password store cannot recover it.
48. “Does untrusted input only mean form fields?”
Input includes more than form fields:
- path and query parameters;
- headers and cookies;
- JSON, multipart data and file content;
- database rows written by another service;
- queue messages and webhook events;
- environment variables and configuration;
- decoded tokens;
- data read from client storage.
const CreateCourse = z.object({
title: z.string().trim().min(1).max(120),
visibility: z.enum(["private", "organisation", "public"]),
pricePence: z.number().int().min(0).max(1_000_000),
}).strict();
.strict() rejects unexpected fields in this example. Whether to reject or ignore extras is a compatibility decision, but never spread them into privileged models.
49. “Which kind of input handling do I need at each moment?”
These terms solve different problems.
Validation decides whether input fits a contract. Normalization creates a canonical form, such as trimming permitted surrounding whitespace. Sanitization removes or transforms disallowed content while preserving an intended rich format. Output encoding safely represents data in a destination context.
Examples:
- Validate that age is an integer in an allowed range.
- Normalize an email domain according to your identity rules.
- Sanitize intentionally supported rich HTML with a maintained parser/policy.
- Encode ordinary text through framework text rendering.
50. “Can we trace injection from the mistake to the defence?”
Yes. The mistake begins before the famous malicious string appears. It begins when our program constructs instructions and values through the same channel.
Suppose we build SQL by joining strings. The database cannot know which characters came from our developer and which came from a form; it receives one instruction. Parameterization changes the shape of the conversation: the statement is compiled as a statement, and the email is supplied as a value. The attack payload loses its chance to become grammar.
Carry this idea beyond SQL. Shells, template engines, regular-expression constructors and query languages are interpreters. First ask whether you need the interpreter. Then use its structured API, allowlist dynamic identifiers and keep untrusted content in value positions.
SQL injection:
// Unsafe: input becomes SQL syntax.
const query = `SELECT * FROM users WHERE email = '${email}'`;
Parameterization separates statement from value:
const result = await pool.query(
"SELECT id, email FROM users WHERE email = $1",
[email],
);
An ORM/query builder reduces many risks but escape hatches, raw queries and dynamic identifiers remain dangerous. Allowlist column names and sort direction:
const sortMap = {
title: sql.identifier("title"),
createdAt: sql.identifier("created_at"),
} as const;
const sort = sortMap[input.sort];
if (!sort) throw new ValidationError("Unsupported sort");
Command injection
Unsafe:
exec(`convert ${uploadedName} ${outputName}`);
Safer separation plus validated generated paths:
spawn("convert", [trustedInputPath, trustedOutputPath], {
shell: false,
timeout: 15_000,
});
Prefer dedicated libraries over shell commands. Run processing with low privileges and resource limits. Argument separation helps, but the invoked program may have its own option/file parsing risks.
Template and expression injection
Do not evaluate user expressions or treat them as template source. If learners can create formulas, design a small parser with explicit permitted operations rather than eval.
The durable principle is: identify every interpreter, then keep untrusted data in the interpreter's data channel.
51. “How do I reason about XSS without memorizing payloads?”
XSS occurs when attacker-controlled content executes in another user's browser under your origin.
Ask:
- Where did the data come from?
- Into which output context is it placed?
- Which API interprets that context?
function Comment({ body }: { body: string }) {
return <p>{body}</p>; // escaped as text
}
Unsafe escape hatch:
<article dangerouslySetInnerHTML={{ __html: untrustedHtml }} />
Safe DOM text:
message.textContent = untrustedText;
Dangerous HTML interpretation:
message.innerHTML = untrustedText;
Framework escaping protects normal text interpolation, not unsafe DOM APIs, dangerous URL schemes, third-party widgets or compromised code.
52. “Why can’t one escape function protect every output?”
HTML text, attributes, URLs, CSS and JavaScript strings have different grammars. One generic “escape” function is not correct everywhere.
For links, parse and allow protocols:
function safeExternalUrl(value: string): string | null {
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
Then use framework attribute binding. Avoid building executable JavaScript strings. If user-authored rich text is a real requirement, sanitize with a maintained HTML sanitizer configured to an explicit allowlist, then keep that trusted type narrow and auditable.
Rich-text sanitization must cover mutations and URL attributes and should be tested with adversarial fixtures. A blacklist of
