Short chapters you can poke, each readable in plain words or technical detail, with a live feed of what is being reported today. Everything runs in the browser and progress stays on your device. This page starts with a hands-on lab on a question security testers meet constantly: why a plain scan can say all is well when the running page is not.
Static probe vs. deterministic browser capture
A cURL request or a static scanner reads the first response a server sends. Many modern web apps send a small HTML shell and build the real page in the browser: state is created, tokens are attached to requests and the page is rewritten after the scripts run. A deterministic real-browser capture waits for defined conditions, records every request, DOM change and console message, and then asserts on what it recorded, the same way every time. Pick a scenario, then run the comparison.
How a deterministic capture works
Pin the environment. Fix the viewport, locale, time zone and cache, and block service workers, so two runs differ only if the app does.
Intercept the requests. Route every request through the test so each one is recorded, and so slow, failing or expired responses can be forced on demand.
Wait for networkidle and hydration. Wait for the network to go quiet and for an explicit hydration-complete marker, so the audit never measures a half-built page.
Observe the DOM. Install a MutationObserver before any page script runs, so every insertion and rewrite is seen in order.
Assert on the HAR and the trace. Check the recorded requests and DOM events against rules. A failure carries its own proof: the exact request, the timing and the trace.
Scenario 1: Hydration race and a leaked bearer token
The server sends an empty shell, so there is nothing to find in it. After the scripts run, an analytics client starts before the auth store has finished loading and copies the access token into a request URL, while the same token sits in a global object in memory.
Where the defect lives on the timeline, and what each kind of probe can see of it.
Result: 0 findings. The token does not exist yet in the first response. It appears only after JavaScript runs and the auth store hydrates, so a probe that never runs the page can never see it, whatever signatures it carries.
Deterministic Playwright audit
Pin the environmentContext pinned: 1280x800, UTC, cache off, service workers blocked, HAR recording on.
Intercept the requestsEvery request is recorded, including third-party hosts; nothing is stubbed away.
Wait for networkidle and hydrationnetworkidle reached at 1.9 s, then the page's own data-hydrated marker flips to true at 1.6 s.
Observe the DOMThe observer shows window.__APP_STATE__ gaining an accessToken key while the shell is still being built.
Assert on the HAR and the traceHAR assertion: request 14, a GET to telemetry.example.test, carries ?t=<token>, sent 410 ms before the hydration barrier.
Finding
A bearer token is sent in a URL to a third-party host before hydration completes, because the analytics client reads the auth store before it is ready. The same token is reachable from a global in memory.
Proof: an illustrative Playwright test
import { test, expect } from '@playwright/test';
test('no bearer token leaves the app before hydration finishes', async ({ page }) => {
const leaks: string[] = [];
page.on('request', (req) => {
const url = req.url();
const auth = req.headers()['authorization'] ?? '';
const thirdParty = !url.startsWith('https://app.example.test');
if (/[?&](t|token|access_token)=/.test(url) || (thirdParty && auth.startsWith('Bearer '))) {
leaks.push(req.method() + ' ' + url);
}
});
await page.goto('https://app.example.test/dashboard');
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => document.documentElement.dataset.hydrated === 'true');
expect(leaks, 'tokens sent in a URL or to a third party').toEqual([]);
const keys = await page.evaluate(() => Object.keys((window as any).__APP_STATE__ ?? {}));
expect(keys).not.toContain('accessToken');
});
How to fix it
Send the token only in an Authorization header, and only to the first-party API.
Start analytics after the auth store reports ready, not on a timer.
Keep credentials out of globals; hold them in a closure or an HttpOnly cookie.
Scenario 2: An authenticated API waterfall and an unbounded retry storm
A dashboard makes six dependent calls one after another. When the access token expires, every call gets a 401 and retries at once with no back-off, and each retry triggers its own token refresh, so one page view turns into a burst of requests.
Where the defect lives on the timeline, and what each kind of probe can see of it.
Result: 0 findings. One request with a valid token looks healthy and fast. The defect is in the sequence and the retry behaviour across many requests, which only exists inside a running page with an expiring session.
Deterministic Playwright audit
Pin the environmentContext pinned, with the session starting one second from expiry.
Intercept the requestsAPI calls are routed through the test: the first 401 is forced, every call is timestamped.
Wait for networkidle and hydrationnetworkidle only arrives at 3.4 s; a naive audit would have stopped at the first quiet moment.
Observe the DOMThe observer shows the loading spinner re-rendering nine times as retries land.
Assert on the HAR and the traceHAR assertion: 47 API requests for one page view, and the refresh endpoint called 9 times at once; the budget was 12 and 1.
Finding
Expiry triggers a retry storm: immediate retries with no exponential back-off or jitter, and no single-flight guard on the token refresh. The sequential waterfall also doubles the time to a usable page.
Proof: an illustrative Playwright test
import { test, expect } from '@playwright/test';
test('an expired token causes one refresh and a bounded number of calls', async ({ page }) => {
let expired = true;
const calls: string[] = [];
await page.route('https://app.example.test/api/**', (route) => {
const url = route.request().url();
calls.push(url);
if (url.endsWith('/api/auth/refresh')) { expired = false; return route.fulfill({ status: 200, json: { ok: true } }); }
return route.fulfill({ status: expired ? 401 : 200, json: {} });
});
await page.goto('https://app.example.test/dashboard');
await page.waitForLoadState('networkidle');
const refreshes = calls.filter((u) => u.endsWith('/api/auth/refresh')).length;
expect(refreshes, 'refresh calls').toBeLessThanOrEqual(1);
expect(calls.length, 'API calls for one page view').toBeLessThanOrEqual(12);
});
How to fix it
Make the token refresh single-flight: one in-flight refresh that every waiting call shares.
Retry with exponential back-off and jitter, with a hard cap.
Fetch independent data in parallel instead of in a chain.
Scenario 3: DOM-based XSS through a postMessage origin bypass
The page listens for messages and checks the sender with a substring test on the origin. A look-alike origin passes the check, the payload is stored in state, and a later asynchronous render writes it into the page as HTML.
Where the defect lives on the timeline, and what each kind of probe can see of it.
Standard static or cURL scanner
$ curl -s https://app.example.test/dashboard | grep -c "message"
0
scanner: no listener, no sink, 0 findings
Result: 0 findings. The listener, the weak origin check and the HTML sink all live in the script bundle, and they only connect at runtime: a message arrives, state changes, an async render fires. A probe that fetches the shell sees none of it.
Deterministic Playwright audit
Pin the environmentContext pinned; the look-alike origin app.example.test.attacker.example is served from the test itself.
Intercept the requestsThe popup request is intercepted and answered with a page that posts a crafted message back to its opener.
Wait for networkidle and hydrationHydration barrier first, then two animation frames after the message, so the async state flush has run.
Observe the DOMThe observer records an <img> with an onerror attribute inserted into the banner element.
Assert on the HAR and the traceTrace assertion: window.__pwned is set, so attacker-controlled markup executed in the page's origin.
Finding
The origin check uses includes() on the sender's origin, so any host whose name contains the real one is accepted. The message body is later assigned with innerHTML after an async state update, which turns a messaging bug into DOM-based XSS.
Proof: an illustrative Playwright test
import { test, expect } from '@playwright/test';
test('a look-alike origin cannot push markup into the page', async ({ page, context }) => {
await context.route('https://app.example.test.attacker.example/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<script>opener.postMessage({ type: "banner", html: "<img src=x onerror=window.__pwned=1>" }, "*")</script>',
}));
await page.goto('https://app.example.test/dashboard');
await page.waitForFunction(() => document.documentElement.dataset.hydrated === 'true');
const popup = page.waitForEvent('popup');
await page.evaluate(() => window.open('https://app.example.test.attacker.example/'));
await popup;
// two animation frames: let the asynchronous state update and render run
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
How to fix it
Compare the origin for exact equality against an allow-list, never with includes() or a regular expression.
Render message data with textContent, or sanitise it, and never assign it with innerHTML.
Enforce Trusted Types so an unsafe HTML sink throws instead of executing.
Every host, request count and timing in this lab is invented for teaching. Nothing here comes from a real system or engagement, and the code is an illustrative sketch rather than a tested tool.
Start here: one laptop, a whole school Imagine a school with 24 computers.
What are we actually protecting? Think of three promises a safe system makes: only the right people can read your things, nobody can secretly change them, and they are there when you need them.
How breaches actually unfold Break-ins usually happen in steps: a criminal looks around, gets in through one weak spot (a trick email or a stolen password), settles in, then moves from computer to computer.
Defence in depth: why one lock is never enough No lock is perfect, so we use several: a door lock, a window latch, an alarm.
People, passwords and phishing Most break-ins start with a person, not clever hacking: a reused password, or a believable email.
Detecting what got through Even good protection sometimes fails, so people watch for warning signs: strange logins, or a computer talking to somewhere new.
Seeing the shift: Doppler and faint signals You know the sound of an ambulance: the pitch is higher as it comes toward you and lower as it drives away.
Profiling an intruder: odds, shuffles and spread When something suspicious happens online, defenders ask two questions: is this a robot or a person, and how worried should we be?
The half-second that caught a backdoor (xz, 2024) In March 2024 a developer was testing how fast computers log in over SSH, the standard way to control servers remotely.
When the fix is the failure (CrowdStrike, 2024) On 19 July 2024 a faulty update to a security product crashed computers around the world at once.
Public keys and the padlock How can your browser agree a secret with a website it has never met, when anyone could be listening?
When typed text becomes a command (injection) Many serious break-ins use one trick.
Leaks nobody designed: side channels A lock can be perfect and still give things away.
A long name on a short form: memory safety A program keeps its information in memory, which is like a long row of numbered boxes.
Zero trust: when the thief signs in The old way to protect a company was a castle: strong walls, and once you were inside you were trusted with everything.
Recorded today, readable tomorrow: post-quantum cryptography Most secure connections today rely on locks (chapter 10) that would be easy to pick for a large enough quantum computer.
Is what you installed what they reviewed? Reproducible builds and SBOMs When you install a program, you are trusting that the file you received was made from the source code that people reviewed.
How the internet finds things: false routes and broken directories To visit a website your device asks a directory (called DNS) for its address.
A crowd at the door: DDoS and amplification A denial-of-service attack does not break in.
The first hour: incident response and forensics When something goes wrong, the first hour decides how bad it gets.
An assistant that reads hostile text: AI and prompt injection AI assistants follow instructions written in ordinary language.
When a hacked computer can break something: industrial security Factories, power stations, water plants and robots are run by small computers that open valves and start pumps.
Missions
Ten short, practical checklists for ordinary people.
Give your email its own strong passphrase
Turn on two-step login for email and banking
Start a password manager
Check whether your email has appeared in a breach
Turn on automatic updates
Lock down your phone
Make a backup you can actually restore
Practise the phishing habit
Secure your home router
Check your account recovery options
There is also a beginner capture-the-flag with eleven challenges, and a Live tab that shows a daily digest of known-exploited vulnerabilities, advisories and security news, each linked to the original publisher.