MS Stack Ch 2 — Web fundamentals
HTTP, headers, cookies, TLS, REST, CORS, JSON — the wire-level protocol every backend and frontend bug eventually reduces to. Buy yourself fluency in the protocol and stop guessing.
Chapter 2 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
Every backend and frontend bug you will ever hit eventually reduces to "what is the browser actually sending, what is the server actually replying, and why does it not match what I wrote." If HTTP feels like magic, every debugging session feels like guessing. If HTTP feels like a transcript of a conversation, the bugs become obvious. This chapter buys you fluency in the wire-level protocol so you stop guessing and start reading.
The web stack layers like this: HTTP (verbs, status, headers, body) sits on TLS 1.3 (cert chain, ALPN, SNI), which sits on TCP (or QUIC for HTTP/3), which sits on IP, which sits on the link layer. You spend 95% of your career in the top layer. But TLS is where most production weirdness lives, and TCP/QUIC is where surprising latency comes from. Worth a few hours' deep read on each. The good news: once you understand HTTP, CORS, cookies, TLS, JSON, and REST conventions, almost every API bug becomes a 5-minute fix instead of a 2-day mystery.
Shipping-tier means you can read a request and response in DevTools, explain what is wrong, debug a CORS error in under 5 minutes, set SameSite cookies correctly, know when to return 409 vs 422, and hand-craft a request with curl -v. Expert-tier means you have debugged an HTTP/2 stream-multiplexing bug, explained TLS 1.3 0-RTT to a sceptic, written a CORS preflight handler from scratch, and tuned HTTP/3 with ECH (Encrypted Client Hello).
You finish this chapter when you can open any web app's Network panel, reproduce any request with curl, diagnose a CORS failure from the response headers alone, and explain to a colleague why an HttpOnly cookie defends XSS exfil but not CSRF.
Concepts and depth
Client/server model, TCP vs HTTP
The web's mental model is request/response over a reliable byte stream. The client opens a TCP connection (or reuses one) to the server's IP on port 443, the TLS handshake negotiates a shared secret and proves the server's identity, and then HTTP runs on top — text or binary frames depending on the version. TCP guarantees ordered, reliable delivery of bytes; HTTP gives those bytes structure (request line, headers, body). The two are often confused but live at different layers: TCP knows nothing about verbs or headers, and HTTP relies on TCP for the reliability.
A single TCP connection used to carry one HTTP/1.0 request and close (very expensive). HTTP/1.1 added persistent connections and pipelining (rarely used in practice). HTTP/2 multiplexes many parallel requests on a single TCP connection using binary frames, eliminating head-of-line blocking at the application layer but not at the transport layer (a lost TCP packet stalls all streams). HTTP/3 replaces TCP with QUIC over UDP, which moves the reliability and ordering into user space and eliminates head-of-line blocking entirely. As of 2026, HTTP/3 is supported by every major CDN and browser; you should serve it if you can.
The first request to a new origin pays the full setup cost: roughly 3–4 RTTs (one for DNS, one or two for TCP, one for TLS). On a mobile network at 100 ms RTT that is 300–400 ms of latency before your server is even contacted. Subsequent requests on the same connection skip all of that. HTTP/3 collapses the TCP and TLS handshake into a single round trip, and 0-RTT resumption lets returning clients send data with the very first packet.
- • Know that HTTP is text/binary on top of TCP/TLS
- • Reuse connections by keeping
Connection: keep-alivedefaults - • Use HTTP/2 (any modern server gives it for free over TLS)
- • Diagnose HTTP/2 head-of-line blocking vs application-layer bugs
- • Enable HTTP/3 and ECH at the CDN edge
- • Tune TCP BBR / QUIC congestion control parameters
HTTP verbs and idempotency
A verb is a promise about side effects and safety. GET is safe (reads, never modifies state) and idempotent (calling it 5 times = calling it once). HEAD is GET without the body. POST is the workhorse for "create" and "do an action" and "I do not fit other verbs" — it is not idempotent by default, which is why retrying a failed POST can create duplicates. PUT is idempotent replace ("the resource at this URI is now exactly this body"); calling it 5 times leaves the same final state. PATCH is partial update — usually not idempotent (depends on the patch document semantics). DELETE is idempotent (calling it 5 times leaves the resource gone; subsequent calls return 404 or 204 depending on your style).
Idempotency matters because real networks drop packets and clients retry. If a write is not idempotent, every retry risks a duplicate. Two ways to add idempotency to POST: (a) use an Idempotency-Key header (the client generates a UUID; the server records it and short-circuits duplicates within a window), or (b) restructure as PUT with the client choosing the resource ID. Stripe pioneered Idempotency-Key; it is now a de-facto standard for any API that takes money.
OPTIONS is the verb the browser uses for CORS preflight (covered below) and for service discovery. CONNECT is for HTTP tunnels (proxies). TRACE is a debugging verb that almost every server disables for security reasons. You will write GET / POST / PUT / PATCH / DELETE 99% of the time; know what they promise.
- • Pick the right verb; never use GET for writes
- • Return 201 + Location for POST that creates
- • Return 204 for DELETE / writes with no body
- • Implement
Idempotency-Keywith a dedup table + TTL - • Pick JSON Merge Patch (RFC 7396) vs JSON Patch (RFC 6902) deliberately
- • Return 405 Method Not Allowed with
Allow:listing supported verbs
Status codes — the four bands
The 2xx family signals success. 200 OK is the default for reads. 201 Created is for POSTs that minted a resource — include a Location: header pointing at the new resource. 204 No Content is for successful writes with no body (DELETE, PUT updates, OPTIONS preflight). 206 Partial Content is the response to Range: requests for resumable downloads.
The 3xx family signals "the resource is elsewhere or unchanged". 301 Moved Permanently updates search engines and browser bookmarks; 302 Found and 307 Temporary Redirect are short-lived (307 preserves the verb, 302 historically did not). 304 Not Modified is the response to a conditional GET (If-None-Match or If-Modified-Since matched) — body is empty, the client serves from cache. 308 Permanent Redirect is 301 that preserves the verb. Use 307/308 for API redirects to avoid GET being substituted for POST.
The 4xx family is "you sent something wrong". The pair that gets misused most: 401 Unauthorized means "you did not authenticate" (no token, invalid signature) — the client should re-authenticate. 403 Forbidden means "you authenticated but you cannot access this" — re-authenticating will not help. 404 Not Found is the resource. 409 Conflict is for concurrent updates / version mismatches (use with If-Match + ETag). 422 Unprocessable Entity is "well-formed JSON but semantically invalid" (failed validation on a known schema). 429 Too Many Requests is rate limited — always read the Retry-After: header (seconds or HTTP date).
The 5xx family is "the server is broken". 500 Internal Server Error is an unhandled exception. 502 Bad Gateway means a proxy got an invalid response from upstream. 503 Service Unavailable is overload or maintenance — include Retry-After:. 504 Gateway Timeout means a proxy gave up waiting for upstream. The 502 vs 504 distinction matters operationally: 502 says "upstream replied with garbage", 504 says "upstream never replied". Different fixes.
Headers — standard request / response / custom
Headers are the conversation metadata. Request headers you will send constantly: Accept: application/json (what content types you accept back), Accept-Encoding: gzip, br, zstd (what compressions you support), Authorization: Bearer eyJ... (your token), Cookie: session=abc (sent automatically by the browser for same-origin requests), Content-Type: application/json (what you are sending in the body), Origin: https://app.example.com (initiating origin — used for CORS), User-Agent (client identification), If-None-Match: W/"abc" (conditional GET).
Response headers worth memorising: Content-Type: application/json; charset=utf-8 (always include charset for text), Content-Encoding: gzip (body is compressed), Cache-Control: max-age=300, public (caching directives — private/public/no-store/no-cache/max-age/s-maxage), ETag: W/"abc" (opaque version tag), Set-Cookie: session=xyz; HttpOnly; Secure; SameSite=Lax, Location: /api/users/42, Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (HSTS), Vary: Accept-Encoding, Accept-Language (which request headers affect the response — critical for caching).
Custom headers are prefixed with X- historically, though RFC 6648 deprecated the prefix in 2012 (the modern convention is just a descriptive name, often a domain-scoped prefix like Anthropic- or Stripe-). Common ones: X-Request-ID (trace correlation), X-Forwarded-For / X-Forwarded-Proto (set by load balancers — never trust them from the public internet without sanitisation), X-RateLimit-Remaining / X-RateLimit-Reset (rate limit hints).
- • Always set
Content-Typewith charset - • Use
Cache-ControlandETagfor cacheable GETs - • Read
Retry-Afteron 429 / 503
- • Tune
Vary:for shared caches without exploding the cache key space - • Implement weak ETags for streaming responses
- • Distinguish
private(browser only) vspublic(CDN) cache directives
URL anatomy
https://maria:secret@api.example.com:8443/v1/users?role=admin&page=2#section
└─┬─┘ └────┬────┘ └──────┬──────┘ └┬─┘ └──┬─┘ └──────┬─────────┘ └──┬───┘
scheme userinfo* host port path query fragment
(client only!)
- Scheme —
http,https,ws,wss,file,mailto:, etc. - Userinfo —
maria:secret@; deprecated in HTTP URLs. Never send credentials this way. - Host — domain or IP. IPv6 must be bracketed:
https://[::1]:8443/. - Port — default 80 (http), 443 (https). Specify only if non-default.
- Path — hierarchical; case-sensitive on most servers.
- Query —
?k=v&k2=v2; percent-encoded; order matters for some servers' cache keys. - Fragment —
#section; never sent to the server; client-side only (anchor scroll, hash routing in old SPAs).
Percent-encoding turns reserved characters into %XX byte sequences (UTF-8). Space → %20, / → %2F, é → %C3%A9. Use encodeURIComponent in JS for query values, never encodeURI. In .NET use Uri.EscapeDataString for the same job. Forgetting to percent-encode is the easiest way to ship a URL injection bug.
MIME types and content negotiation
A MIME type (also called media type) describes a body's format: application/json, text/html; charset=utf-8, image/webp, multipart/form-data; boundary=---xyz, application/octet-stream (binary blob). The Content-Type response header tells the client how to interpret the body; the Accept request header tells the server what the client wants back. Content negotiation is the server picking from the client's Accept list (or returning 406 Not Acceptable if no overlap).
In practice, REST APIs return JSON by default and ignore content negotiation. The places it matters: image servers (negotiating WebP vs JPEG via Accept: image/webp,image/*), API gateways serving both JSON and Protobuf, and content APIs serving multiple representations of the same resource (HTML + RSS + JSON Feed). ASP.NET Core can wire negotiation via [Produces] attributes on controllers; for SPAs it is usually overkill.
The +suffix convention extends a base type with semantics: application/problem+json (RFC 7807 error responses), application/vnd.api+json (JSON:API), application/hal+json (HAL hypermedia). Use them when they exist — clients can dispatch on them without sniffing the body.
CORS — same-origin policy, preflight, credentials
The browser's same-origin policy says JavaScript on https://app.example.com cannot read responses from https://api.example.com (different origin = different scheme/host/port). That is secure by default — it stops a malicious page from silently reading your authenticated bank API. CORS (Cross-Origin Resource Sharing) is the opt-in mechanism for the server to say "yes, this specific other origin is allowed."
Two request shapes. Simple requests (GET/HEAD/POST with a Content-Type of text/plain, application/x-www-form-urlencoded, or multipart/form-data, and no custom headers) skip preflight — the browser sends directly and the server response just needs Access-Control-Allow-Origin. Preflighted requests (any other method, any custom Content-Type including application/json, any custom header including Authorization) require the browser to first send OPTIONS with Access-Control-Request-Method and Access-Control-Request-Headers. The server must respond with matching Access-Control-Allow-* headers before the browser sends the real request.
The most-bitten trap: Access-Control-Allow-Origin: * is forbidden when the response also has Access-Control-Allow-Credentials: true. You must echo the specific origin back. Other common mistakes: forgetting Access-Control-Allow-Headers for the headers your client sends, proxies stripping OPTIONS unless explicitly allowed, and using credentials: 'include' in fetch without server-side credentials support. Set Access-Control-Max-Age: 7200 to cache the preflight for 2 hours and reduce noise.
// ASP.NET Core CORS, one block
builder.Services.AddCors(o => o.AddPolicy("spa", p => p
.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(2))));
app.UseCors("spa");
- • Configure allowed origins explicitly (no wildcards with credentials)
- • Let the framework handle preflight
- • Set
Access-Control-Max-Ageto reduce OPTIONS chatter
- • Dynamic origin policies driven by tenant config
- • Cross-Origin-Resource-Policy + COEP + COOP for cross-origin isolation
- •
Access-Control-Expose-Headersfor surfacing custom response headers to JS
Cookies — HttpOnly, Secure, SameSite, Domain, Path, Expires/Max-Age
Cookies are the most-misconfigured artefact in web security. A modern auth cookie needs:
HttpOnly— JavaScript cannot read it viadocument.cookie. Defends XSS exfiltration of the token.Secure— only sent over HTTPS. Mandatory ifSameSite=None; set it anyway.SameSite=Lax(default in modern browsers) — sent on top-level navigation, not on cross-site iframes or fetches. Decent CSRF defence. UseSameSite=Strictfor high-security flows that never need cross-site navigation. UseSameSite=Noneonly when you genuinely need third-party cookie behaviour (almost never for first-party apps).Domain+Path— scope the cookie minimally. Never setDomain=.example.comunless you actually need subdomain sharing; the wider the scope, the larger the blast radius if leaked.Max-AgeorExpires— set both for legacy compatibility; preferMax-Age(seconds). Session cookies (neither set) die when the browser closes.__Host-prefix — impliesSecure+Path=/+ noDomain. The strictest, browser-enforced. Use it for new auth cookies.
HttpOnly does not defend against CSRF — the browser still attaches the cookie automatically on cross-site requests. That is what SameSite is for. Conversely, SameSite=Lax does not defend against XSS — JS on the same origin can still read non-HttpOnly cookies. You need both.
builder.Services.ConfigureApplicationCookie(o =>
{
o.Cookie.Name = "__Host-app.auth";
o.Cookie.HttpOnly = true;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.Cookie.SameSite = SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromDays(7);
o.SlidingExpiration = true;
});
TLS / HTTPS — cert chains, HSTS
TLS 1.3 (RFC 8446) is the current standard. The handshake: client sends ClientHello with supported cipher suites and ALPN (which application protocol — h2 for HTTP/2, h3 for HTTP/3) and SNI (which hostname is being requested, needed for shared hosting). Server replies with ServerHello, its leaf cert, intermediate certs, and Finished. Browser validates: hostname in the cert's SAN list? Not expired? Signed by a chain that ends at a trusted root in the browser's trust store? OCSP staple says not revoked? If all pass, the encrypted session is established (TLS 1.3 handshake is 1 RTT; 0-RTT resumption is possible for repeat visits).
What is encrypted: request method, path, query, fragment (well, fragment never leaves the browser), all headers (including Authorization and Cookie), request body, response body, status code. What is NOT encrypted: source and destination IPs, destination port (443), SNI hostname (fixed by Encrypted Client Hello, still rolling out across CDNs in 2026), packet sizes, packet timing (traffic analysis can still reveal a lot).
HSTS (Strict-Transport-Security: max-age=31536000; includeSubDomains; preload) tells the browser "always use HTTPS for this domain for N seconds, even if the user types http://." preload submits the domain to the browser's hard-coded HSTS list at hstspreload.org — once you are in, every browser worldwide forces HTTPS on your domain. Be sure you really want this; removal takes weeks.
For local development use dotnet dev-certs https --trust to install ASP.NET Core's local CA into your trust store. For production use Let's Encrypt with certbot or your CDN's free cert option (Cloudflare, Azure Front Door). Cert rotation should be automated; manual renewal is a recipe for 2 AM pages.
REST style — resources, statelessness, idempotency, HATEOAS
REST is a set of architectural constraints from Roy Fielding's 2000 dissertation, not an OpenAPI version. The five used in practice:
- Client-server separation — the client does not need to know how the server stores data; the server does not need to know how the client renders it.
- Statelessness — every request carries its own auth and context. No server-side session affinity required. (You can still cache server-side; you just cannot require clients to hit a specific server.)
- Uniform interface — resources are nouns (
/users/42), verbs are HTTP methods. JSON in, JSON out. - Cacheability — responses say what is cacheable for how long (
Cache-Control,ETag,Last-Modified). Use it. - Layered system — proxies, CDNs, and gateways can sit between client and origin without either side knowing.
HATEOAS (Hypermedia as the Engine of Application State) is in the original thesis but ignored by ~95% of real REST APIs. The idea: responses include links to the next valid actions ({ "id": 42, "links": [{"rel":"cancel","href":"/orders/42/cancel"}] }), so the client never hard-codes URLs. In practice, clients hard-code URI templates anyway, and the extra payload size and complexity rarely pays off. Use it if you have a long-lived public API with diverse clients; skip it for SPAs you control.
JSON — type system, common pitfalls
JSON has six types: object, array, string, number, boolean, null. The big four traps:
In C#, prefer System.Text.Json over Newtonsoft.Json for new code — it is in-box, faster, and uses source generators for AOT-friendly serialisation. Configure it with PropertyNamingPolicy = JsonNamingPolicy.CamelCase and consider DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to omit null fields.
WebSockets and SSE — high-level awareness
Sometimes plain polling is not enough. Four options, in order of complexity:
- Plain polling — client asks every N seconds. Simplest; reuses HTTP infra. Wastes bandwidth and battery. Latency = polling interval / 2 on average. Fine for slow-changing data (minutes).
- Long polling — client opens a request; server holds it open until an event. Closer to real-time but connection churn. Increasingly rare; SSE is better in almost every case.
- Server-Sent Events (SSE) — one-way, server → client. HTTP-based; works through every proxy. Auto-reconnect built into the browser
EventSourceAPI. Text-only (no binary). Perfect for notifications, live progress bars, server-pushed updates, AI streaming responses. - WebSockets — full-duplex, persistent TCP connection upgraded from HTTP. Binary + text frames. Does not get caching/auth/proxies for free — you have to build them. Use for chat, collaborative editing, real-time games.
For ASP.NET Core, SignalR wraps both WebSockets and SSE behind a single high-level API with auto-fallback, server-to-client streaming, and connection groups. Reach for SignalR before raw WebSockets unless you have a specific reason.
- • Use SSE for one-way push (progress, notifications)
- • Use SignalR for bidirectional features
- • Default to polling if the data changes < once / minute
- • Backpressure for slow consumers on WebSockets
- • Sticky sessions vs Redis backplane for SignalR scale-out
- • HTTP/2 streaming for SSE to avoid connection limits
Worked examples
1. curl -v of a real HTTPS request
curl -v is the single best HTTP teaching tool. Run it against any API and read the output line by line.
curl -v https://httpbin.org/get \
-H "Accept: application/json" \
-H "X-Request-ID: $(uuidgen)"
Sample output (annotated):
* Trying 54.236.130.143:443... # DNS resolved, TCP SYN
* Connected to httpbin.org (54.236.130.143) port 443
* ALPN: offers h2,http/1.1 # client offers HTTP/2
* TLSv1.3 (OUT), TLS handshake, Client hello
* TLSv1.3 (IN), TLS handshake, Server hello
* TLSv1.3 (IN), TLS handshake, Encrypted extensions
* TLSv1.3 (IN), TLS handshake, Certificate
* TLSv1.3 (IN), TLS handshake, CERT verify
* TLSv1.3 (IN), TLS handshake, Finished
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec
* TLSv1.3 (OUT), TLS handshake, Finished
* ALPN: server accepted h2 # negotiated HTTP/2
* Server certificate: # cert details
* subject: CN=httpbin.org
* start date: ...
* expire date: ...
* issuer: CN=Amazon RSA 2048 M02; O=Amazon
* SSL certificate verify ok.
> GET /get HTTP/2 # request line + headers
> Host: httpbin.org
> User-Agent: curl/8.4.0
> Accept: application/json
> X-Request-ID: 1d6e...
< HTTP/2 200 # response status + headers
< date: Fri, 13 Jun 2026 08:14:55 GMT
< content-type: application/json
< content-length: 312
< server: gunicorn/19.9.0
< access-control-allow-origin: *
What to notice:
- Three handshakes are visible: DNS, TCP (
Connected to), TLS (TLS handshake). ALPN: server accepted h2confirms HTTP/2 over TLS.- The certificate chain is printed;
verify okmeans it chained to a trusted root. >lines are sent,<lines are received — this is your contract with the server.- Re-run with
--http3to test HTTP/3 if your curl supports it.
2. A CORS bug, diagnosed in 90 seconds
A SPA on https://app.example.com calls an API on https://api.example.com. Console shows "CORS preflight did not succeed."
# Step 1: replay the preflight
curl -v -X OPTIONS https://api.example.com/v1/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
# What the response *should* look like:
< HTTP/2 204
< access-control-allow-origin: https://app.example.com
< access-control-allow-methods: POST
< access-control-allow-headers: authorization,content-type
< access-control-allow-credentials: true
< access-control-max-age: 7200
# What it *actually* returns (the bug):
< HTTP/2 204
< access-control-allow-origin: *
< access-control-allow-credentials: true
What to notice:
- Wildcard origin + credentials is illegal; the browser rejects.
- Fix: server must echo
https://app.example.comspecifically. Access-Control-Request-Headers: authorization,content-typemeans the SPA sent custom headers, triggering preflight.- If the OPTIONS itself fails, the API is missing a handler — many minimal API setups need explicit CORS middleware before
app.MapControllers().
3. ETag-based caching round-trip
ETag caching saves bandwidth and CPU when responses rarely change.
app.MapGet("/api/articles/{id}", (int id, HttpContext ctx) =>
{
var article = db.Articles.Find(id);
if (article is null) return Results.NotFound();
var etag = $"W/\"{article.RowVersion}\""; // weak ETag
var ifNoneMatch = ctx.Request.Headers.IfNoneMatch.ToString();
if (ifNoneMatch == etag)
return Results.StatusCode(StatusCodes.Status304NotModified);
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "public, max-age=60";
return Results.Ok(article);
});
What to notice:
- First request:
200 OKwith body +ETag: W/"5"+Cache-Control: max-age=60. - Within 60 seconds: browser serves from cache, no network request at all.
- After 60 seconds: browser revalidates with
If-None-Match: W/"5"— server returns304 Not Modifiedwith no body. - The
W/prefix marks a weak ETag (byte-for-byte equality not guaranteed); fine for most cases. - For static assets use content-hashed filenames +
max-age=31536000, immutableinstead of ETags.
4. Setting a hardened auth cookie
ctx.Response.Cookies.Append("__Host-app.auth", token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/",
// No Domain (the __Host- prefix forbids it)
MaxAge = TimeSpan.FromDays(7),
IsEssential = true // not subject to GDPR consent for the auth cookie itself
});
What to notice:
__Host-prefix means the browser enforcesSecure+Path=/+ noDomain. The strictest scope.SameSite=Laxallows top-level navigation (so a redirect back from OAuth still carries the cookie) but blocks cross-site fetches — solid CSRF baseline.HttpOnlymeans JS cannot read it; an XSS bug cannot exfiltrate the token throughdocument.cookie.IsEssential = trueopts out of ASP.NET Core's GDPR consent gate — auth cookies are functionally required.- Pair with
[ValidateAntiForgeryToken]on state-changing endpoints for full CSRF defence in browser flows.
Hands-on exercises
-
curl -vannotation. Goal: read TLS + HTTP/2 traces fluently.- Run
curl -v https://example.comand identify each handshake stage in the output. - Add
--http2and--http3flags; observe the ALPN negotiation differ. - Try
curl -v -kagainst a self-signed cert and note the verification failure. - Repeat against your own staging API.
- Done when: you can explain every line of
curl -voutput to a junior.
- Run
-
Reproduce a real request with curl. Goal: become DevTools → curl fluent.
- Open DevTools → Network on a logged-in app, find a POST/PUT request.
- Right-click → Copy → Copy as cURL.
- Paste into a shell, run it, get the same response.
- Strip one header at a time until the server rejects (find which headers actually matter).
- Done when: you can reproduce any browser request via curl in under a minute.
-
CORS debugging round-trip. Goal: fix one CORS bug end-to-end.
- Scaffold a minimal ASP.NET Core API and a static HTML page on a different port.
- Have the HTML page
fetch()the API withcredentials: 'include'andAuthorization. - Observe the preflight failure in DevTools; identify the missing header.
- Add
AddCorsto the API with the correct origin and credentials. - Done when: the fetch succeeds and you can explain why each Access-Control-Allow-* header is needed.
-
Self-signed cert + HSTS. Goal: serve local HTTPS and observe HSTS upgrade.
- Run
dotnet dev-certs https --trustanddotnet new webapi. - Run the API and visit
https://localhost:5001— confirm the lock icon. - Add
app.UseHsts()andapp.UseHttpsRedirection(). - Visit
http://localhost:5000— observe the 307 redirect to HTTPS. - Done when: you can explain HSTS preload and why it is a one-way ticket.
- Run
-
Idempotency-Key for POST. Goal: make a non-idempotent endpoint safe to retry.
- Build
POST /api/ordersthat inserts a row. - Add an
Idempotency-Keyheader check that records the key in aprocessed_keystable with TTL. - On duplicate key, return the original response (cached).
- Test by issuing the same key twice and confirming only one row is created.
- Done when: retries are safe and you understand why this is harder than it looks (concurrent retries with the same key).
- Build
-
ETag + 304 round-trip. Goal: bandwidth and CPU savings on a GET.
- Add
ETag+Cache-Controlto a GET endpoint. - Hit it from
curl -ionce; copy the ETag. - Re-hit with
-H "If-None-Match: <etag>"; verify 304 + empty body. - Modify the resource; verify the ETag changes and 200 returns the new body.
- Done when: DevTools shows "(disk cache)" or "304" for repeat fetches.
- Add
Self-check questions
- Explain the difference between
401 Unauthorizedand403 Forbidden, and the failure mode if you swap them. - Why does
Access-Control-Allow-Origin: *not work withcredentials: 'include'? - What three cookie attributes does a modern auth cookie need at minimum? What does the
__Host-prefix add? - What is the difference between
502 Bad Gatewayand504 Gateway Timeout, and which one tells you which side to debug? - Why are HTTP IDs over
2^53problematic when serialised as JSON numbers, and what is the fix? - What does
Vary: Accept-Encodingmean for a caching proxy, and what happens if you omit it? - What is the difference between a "simple" and a "preflighted" CORS request, and what triggers preflight?
- Which parts of an HTTPS URL are encrypted on the wire, and which are not?
- When would you reach for SSE over WebSockets, and when the reverse?
- What is
Idempotency-Keyand how does it differ from making the endpoint a PUT? - Why does
HttpOnlydefend XSS but not CSRF? Why doesSameSite=Laxdefend CSRF but not XSS? - What does a weak ETag (
W/"...") mean and when would you use it over a strong ETag?
High-signal resources
Official docs
- MDN — HTTP — canonical free reference. Bookmark it.
- RFC 9110 — HTTP Semantics — the current authoritative spec.
- RFC 6265bis — Cookies — the modern cookie spec.
- RFC 7396 — JSON Merge Patch + RFC 6902 — JSON Patch.
- RFC 7807 — Problem Details for HTTP APIs — the right error body shape.
- MDN — CORS — every gotcha explained.
Books or courses
- High Performance Browser Networking — Ilya Grigorik. Free at hpbn.co. The TCP, TLS, HTTP/2, and HTTP/3 chapters are essential.
- HTTP: The Definitive Guide — Gourley & Totty. Older but still the best whole-protocol book.
Practitioner posts
- web.dev — security articles — short, modern, accurate. Read the cookie and CSP guides.
- Jake Archibald's blog on caching — the canonical immutable-vs-revalidate writeup.
- Cloudflare blog — HTTP/3 and QUIC explainer — clear, current.
- Troy Hunt on HSTS preload — pragmatic, with war stories.
Weekly milestones
- Day 1 — Verbs + status codes into muscle memory. Print the tables, pin them to the wall. Run
curl -von 5 sites. - Day 2 — Headers + URL anatomy + MIME. Do exercise 1 and exercise 2.
- Day 3 — CORS deep dive: read MDN end-to-end, do exercise 3. Answer self-check questions 2 and 7.
- Day 4-5 — Cookies + TLS + HSTS. Do exercise 4 and exercise 5. Read web.dev security guides.
- Day 6-7 — REST + JSON + WebSockets/SSE. Do exercise 6. Write a personal HTTP cheatsheet you would hand a new hire; re-attempt every self-check question without notes.
How it shows up in the capstone
The capstone API exposes REST endpoints (GET/POST/PUT/DELETE /api/queries), sets __Host-app.auth cookies with SameSite=Lax, Secure, HttpOnly via the cookie auth handler, returns application/problem+json (RFC 7807) error bodies for every 4xx/5xx, respects If-None-Match for GET /api/queries/{id} returning 304 when unchanged, and configures CORS to allow only the SPA's origin with credentials and a 2-hour preflight cache.
Status codes are deliberate: 201 + Location: for POST, 204 for DELETE, 409 + an explanation in the problem-details body for optimistic-concurrency conflicts (If-Match mismatch), 422 for validation failures with per-field errors, and 429 + Retry-After from the rate-limiter middleware. The SPA reads X-RateLimit-Remaining to disable the submit button preemptively.
When you can open the Network panel on the running capstone, see :status 200, cache-control: public, max-age=60, etag: W/"abc", and set-cookie: __Host-app.auth=...; HttpOnly; Secure; SameSite=Lax, you will know this chapter paid off.
Previous chapter ← Ch 1 — Developer tooling Next chapter → Ch 3 — C# language