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.
Published Updated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- A POST completes but its response is lost; may the client retry merely because fetch rejected?
- Explain the version and execution boundaries before applying the examples
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 36 sections
Review and execution boundary
RFC 9110 semantics and RFC 9114 HTTP/3; ASP.NET Core 8 reference fragments. HTTP/1.1, HTTP/2 and HTTP/3 are not interchangeable transports. The 9 October 2026 follow-up rechecked browser CORS/cookie and framework contracts against the sources below. Curl transcripts are illustrations, not captured service results; browser, TLS and ASP.NET Core exercises remain unexecuted.
Reviewed on 7 October 2026 against the official source snapshots linked below. The review is bounded editorial correction, not certification of every dependency, security property or cloud deployment. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.
Chapter 2 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
Many cross-boundary application bugs reduce 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.
For HTTPS with HTTP/1.1 or HTTP/2, HTTP runs over TLS over TCP over IP. HTTP/3 instead runs over QUIC over UDP over IP; QUIC integrates TLS 1.3 rather than stacking a TLS record layer over TCP. HTTP is the application-facing layer; TLS failures and TCP/QUIC behavior can also explain connectivity and latency problems. Worth a few hours' deep read on each. The good news: once you understand HTTP, CORS, cookies, TLS, JSON, and REST conventions, you can distinguish protocol failures from application and infrastructure faults.
Shipping-tier means you can read a request and response in DevTools, explain what is wrong, trace a CORS failure through the request, preflight and response, 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 an app you are authorized to inspect, reproduce a safe request with curl, and distinguish a network failure from a failed CORS check using the request origin, credentials mode and response, and explain to a colleague why an HttpOnly cookie defends XSS exfil but not CSRF.
HTTP verbs + idempotency — GET/POST/PUT/PATCH/DELETE — what each promises and the retry semantics.
Status codes that bite — 401 vs 403, 409 vs 422, 502 vs 504, 429 + Retry-After.
Headers as a vocabulary — Cache-Control, ETag, Vary, Set-Cookie, CORS — memorise the dozen that matter.
CORS without panic — Simple vs preflighted, the * + credentials trap, server-side configuration.
Cookies done right — HttpOnly, Secure, SameSite, Domain, Path, __Host- prefix.
TLS, REST, JSON, WS, SSE — What is encrypted, REST as constraints, JSON traps, when to stream.
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/1.1 and HTTP/2 normally rely on TCP reliability; HTTP/3 uses QUIC.
A single TCP connection used to carry one HTTP/1.0 request and close (very expensive). HTTP/1.1 made persistent connections the default and specified 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 reduces cross-stream transport head-of-line blocking, while within-stream loss and QPACK dependencies can still block progress. Enable HTTP/3 only after checking the selected client, CDN and network support; retain an HTTP/2 fallback.
An uncached DNS lookup, TCP establishment and a TLS 1.3 handshake each add latency, but not a universal RTT total: DNS can be cached, connections reused and address attempts raced. A fresh QUIC connection integrates transport and TLS establishment. Resumed 0-RTT data can be replayed; do not assume it is safe for non-idempotent writes.
Good enough to ship
• Distinguish HTTP/1.1 and HTTP/2 over TCP/TLS from HTTP/3 over QUIC • Reuse connections through the client connection pool; do not send HTTP/1.1 connection-specific headers in HTTP/2 or HTTP/3 • Negotiate HTTP/2 where both endpoints support it
Expert tier
• 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 (its requested semantics are read-only; incidental logging may still occur) 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. Specify the key scope, payload fingerprint, atomic claim, expiration and replayed result in your API contract; a header alone prevents nothing.
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.
Good enough to ship
• Pick the right verb; never use GET for writes • Return 201 + Location for POST that creates • Return 204 for DELETE / writes with no body
Expert tier
• Implement Idempotency-Key with 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 requests that created a resource, including POST or a creating PUT — 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 identifies a permanent new URI; bookmark/search-index updates depend on the client. 302 Found and 307 Temporary Redirect identify temporary redirects (307 preserves the method, while 302 permits a POST-to-GET change). 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 the server refuses the request; authorization failure is a common reason, but authentication is not a required precondition for 403. 404 Not Found means no current representation was found or the server will not disclose it. 409 Conflict describes a conflict with resource state; a failed If-Match precondition normally produces 412 Precondition Failed. 422 Unprocessable Content means the content type and syntax were understood but the instructions could not be processed; invalid schema data is one API use. 429 Too Many Requests signals rate limiting. Honor Retry-After when supplied (seconds or an HTTP date); it is not guaranteed to be present.
The 5xx family signals failure to fulfill the request on the server side. 500 Internal Server Error means an unexpected condition prevented fulfillment; an unhandled exception is one cause. 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 "no timely upstream response" (a late response is still possible). Inspect gateway and upstream logs; neither code identifies the root cause by itself.
title: 401 vs 403 — the most-misused pair
A SPA that treats 401 as a login challenge and 403 as refusal can misdirect users if the API swaps those meanings. Neither status automatically performs a login redirect. For an authentication challenge, send 401 with WWW-Authenticate; for refused authorization, use 403 (or deliberately conceal existence with 404). A SPA must follow the API authentication contract, not assume every 403 proves a signed-in user.
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 (JSON uses UTF-8; its media type defines no charset parameter), 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).
Good enough to ship
• Set the correct media type; use a charset parameter where that media type defines it
• Use Cache-Control and ETag for cacheable GETs
• Read Retry-After on 429 / 503
Expert tier
• Tune Vary: for shared caches without exploding the cache key space
• Implement weak ETags for streaming responses
• Distinguish private (browser only) vs public (CDN) cache directives
URL anatomy
- 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 9457 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 common request shapes. A CORS-safelisted request uses GET/HEAD/POST and only safelisted author-controlled headers/values: for Content-Type, the allowed media types are text/plain, application/x-www-form-urlencoded, and multipart/form-data. The safelist also has value/length restrictions; API-specific conditions such as XHR upload listeners can require preflight. A simple request can reach the server before any response-access check. A request with application/json, Authorization, or a non-safelisted method/header normally needs a successful preflight: the browser sends OPTIONS with Access-Control-Request-Method and, when needed, Access-Control-Request-Headers. A cached preflight permission can avoid a new OPTIONS. Both simple and preflighted credentialed responses need the explicit-origin and credentials headers below.
For a request whose credentials mode is include, Access-Control-Allow-Origin: * cannot authorize response access. Return the specific allowlisted origin and Access-Control-Allow-Credentials: true, with Vary: Origin when selecting origins dynamically. 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. Access-Control-Max-Age: 7200 requests a two-hour preflight cache lifetime; the browser can impose a lower limit or evict the permission earlier.
title: The CORS bear-trap
With credentials: "include", wildcard origin is rejected. A manually supplied Authorization header triggers preflight and must be explicitly allowed, but does not itself switch fetch into credentials mode include. Never reflect arbitrary origins.
// ASP.NET Core 8 fragment: services before builder.Build(); middleware after it
builder.Services.AddCors(o => o.AddPolicy("spa", p => p
.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(2))));
// app is the built WebApplication; with explicit routing:
app.UseRouting();
app.UseCors("spa"); // before authorization and endpoint execution
app.UseAuthorization();Good enough to ship
• Configure allowed origins explicitly (no wildcards with credentials)
• Let the framework handle preflight
• Set Access-Control-Max-Age to reduce OPTIONS chatter
Expert tier
• Dynamic origin policies driven by tenant config
• Cross-Origin-Resource-Policy + COEP + COOP for cross-origin isolation
• Access-Control-Expose-Headers for 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(set explicitly; browser defaults differ) — sent on cross-site top-level navigation using safe methods, not general cross-site POSTs, 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). Browser session restore may restore session cookies; do not use browser closure as a server-side logout guarantee.__Host-prefix — requiresSecure+Path=/+ noDomainin supporting browsers. It is host-scoped, not port-isolated, and does not imply HttpOnly; set that separately for 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 this chapter's TLS baseline. 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). In a certificate-authenticated full handshake, the server sends ServerHello, then encrypted handshake messages including EncryptedExtensions, Certificate, CertificateVerify and Finished; the client also sends Finished. The browser validates hostname, validity and the chain to a trusted root, plus its applicable revocation policy. An OCSP staple is not guaranteed on every connection. A typical full TLS 1.3 handshake takes one round trip after transport establishment, but retries and network conditions can add delay; resumed sessions may omit certificate messages, and accepted 0-RTT early data has replay risk.
What HTTPS encrypts between TLS endpoints: request method, path, query, headers (including Authorization and Cookie), request and response bodies, and status. The fragment is not sent in the HTTP request at all. A terminating CDN/proxy can read the traffic it decrypts. What observers can still see: IPs, ports, packet sizes and timing. Without successfully negotiated Encrypted Client Hello (ECH), SNI normally exposes the hostname; ECH support/configuration must be verified rather than assumed, and it does not hide IPs or all DNS metadata.
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 signals consent but does not submit the domain: separately submit an eligible domain at hstspreload.org. Inclusion and removal depend on browser list updates. Audit every subdomain before includeSubDomains or preload. The preload service currently recommends HSTS but not preloading as a default; treat preload as a deliberate long-lived operational commitment, not routine hardening.
For local development, dotnet dev-certs https --trust creates or finds and trusts a self-signed HTTPS development certificate, not a general-purpose local CA. Trust setup depends on the OS, SDK and browser; keep this separate from production certificates. 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 — each request supplies the context needed to understand it rather than relying on stored conversational session state. Resource state and caches may live on the server. Moving a login session to a shared database removes affinity but does not, by itself, satisfy Fielding's stateless constraint.
- Uniform interface — identify resources, manipulate them through representations, use self-descriptive messages, and drive application state with hypermedia. Noun-like URIs and HTTP methods are useful conventions, not the whole constraint; representations need not be JSON.
- 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 not implemented by many APIs colloquially called REST. 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. A tightly coupled SPA may deliberately use a simpler resource-oriented HTTP API without hypermedia; call that a design trade-off, not proof that it meets all REST constraints. Code-on-demand is REST's optional sixth constraint.
JSON — type system, common pitfalls
JSON has six types: object, array, string, number, boolean, null. The big four traps:
title: The four JSON traps
- Numbers lose precision past 2^53. A
bigintID like9007199254740993round-trips to9007199254740992in JavaScript. Serialise large IDs as strings. This bites Twitter, Discord, and every API with snowflake IDs. - No date type. Use ISO 8601 strings (
"2026-06-10T10:30:00Z") with aZfor UTC or explicit+HH:MMoffset. Never timestamps as ambiguous numbers (is it seconds? milliseconds? from when?). - null vs missing key matters in PATCH. In JSON Merge Patch,
{"name": null}removes the member; omitting it leaves it unchanged. It cannot distinguish removal from assigning a literal null member. JSON Merge Patch (RFC 7396) defines this; JSON Patch (RFC 6902) is more explicit with{"op":"remove","path":"/name"}. - No comments. JSON5 and JSONC support them; standard JSON does not. Do not rely on parsers being lenient.
In C#, consider System.Text.Json for new code: it ships with .NET and offers opt-in source generation for AOT-friendly serialization. Performance and migration compatibility depend on the workload; using it does not automatically enable a source generator. 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. With uniformly distributed event arrival within a fixed polling interval, expected detection delay is half that interval, before network/processing latency. 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. Useful when streaming is unavailable; compare connection churn and latency against SSE for the actual deployment.
- Server-Sent Events (SSE) — one-way, server → client. HTTP-based; requires proxy buffering and idle-timeout settings compatible with streaming. 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 messages, typically established by an HTTP/1.1 Upgrade; HTTP/2 uses extended CONNECT instead. 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.
Good enough to ship
• Use SSE for one-way push (progress, notifications) • Use SignalR for bidirectional features • Default to polling if the data changes < once / minute
Expert tier
• 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)"Illustrative output (annotated; curl presents HTTP/2 frames in a readable header-like form, not as literal HTTP/1.1 wire text):
* 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:
Tryingshows an address selected after resolution, not a DNS handshake trace.Connected toreports transport connection establishment; TLS lines show handshake events.ALPN: server accepted h2confirms HTTP/2 over TLS.- The certificate summary and
verify okreport successful verification under curl's configured trust policy; this abbreviated transcript does not show every certificate in the chain. >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 using credentials: "include", POST and Authorization/JSON headers. The browser reports a preflight failure. Curl below inspects the response; it does not enforce browser CORS.
# 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: trueWhat to notice:
- For this
includerequest, wildcard origin cannot authorize response access; this response also lacks the requested method/header permissions. - Fix: return the allowlisted origin, credentials permission, POST, and explicit
authorization,content-typepermissions as in the expected response; includeVary: Originfor dynamic origin selection. Access-Control-Request-Headers: authorization,content-typemeans the SPA sent custom headers, triggering preflight.- If OPTIONS fails, inspect network/TLS errors, proxy handling, routing and CORS middleware order. A missing handler is one possibility, not the only diagnosis.
3. ETag-based caching round-trip
ETag caching saves bandwidth and CPU when responses rarely change.
Reference algorithm for an authorized GET (not a runnable C# program):
1. Find the article and authorize access; otherwise return 404/403.
2. Encode its representation version as a stable ETag, e.g. W/"5".
3. Set ETag and Cache-Control: private, no-cache on this response.
4. Parse If-None-Match as a list (including *), not string equality.
5. If any validator weakly matches, return 304 without a body.
6. Otherwise return 200 with the selected representation.What to notice:
- First request: 200 with the body, ETag and private, no-cache.
- A private cache may store this response but must revalidate before reusing it; no-cache does not mean no-store.
- On reuse: the 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 // framework consent-policy flag, not a legal conclusion
});What to notice:
- In supporting browsers,
__Host-requiresSecure+Path=/+ noDomain; HttpOnly is still separate. - Explicit
SameSite=Laxpermits cross-site top-level safe-method navigations, not a cross-site OAuth/OIDC POST callback. Same-site cross-origin requests are different from cross-site requests; Lax alone is not a complete CSRF defense. HttpOnlymeans JS cannot read it; an XSS bug cannot exfiltrate the token throughdocument.cookie.IsEssential = truebypasses the framework cookie-consent check; whether a cookie is legally necessary depends on purpose and jurisdiction.- For MVC cookie-authenticated state-changing endpoints, validate antiforgery tokens (
[ValidateAntiForgeryToken]); Minimal APIs need their supported antiforgery integration. SameSite alone is not a complete CSRF policy.
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 -vagainst an isolated self-signed test endpoint and note the verification failure.-kdisables verification; it does not demonstrate verification failure and must not be the production fix. - 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, select a read-only GET to an endpoint you own using a disposable account.
- Right-click → Copy → Copy as cURL.
- Use only a disposable account and read-only request against an endpoint you own. Copy-as-cURL includes credentials; do not paste it into shared terminals, logs or chat, and do not replay production writes.
- Strip one header at a time until the server rejects (find which headers actually matter).
- Done when: you can reproduce the authorized read without leaking its copied credentials.
-
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 distinguish a redirect from cached HSTS enforcement.
- Run
dotnet dev-certs https --trustanddotnet new webapi. - Run the HTTPS launch profile and use its printed loopback URL/port, rather than assuming 5001; inspect certificate trust in browser security details.
- Distinguish HSTS from
UseHttpsRedirection(): ASP.NET Core HSTS excludes localhost by default, and development normally omits HSTS. - With
UseHttpsRedirection()configured and the HTTPS port discoverable, visit the printed HTTP loopback URL and inspect the redirect. This does not demonstrate HSTS on localhost. - Done when: you can explain HSTS preload, separate submission and delayed removal.
- Run
-
Idempotency-Key for POST. Goal: make a non-idempotent endpoint safe to retry.
- Build
POST /api/ordersthat inserts a row. - Scope the key to the authenticated caller/operation, fingerprint the payload and atomically claim the key with the database mutation; define TTL and in-progress behavior.
- On duplicate key, return the original response (cached).
- Test sequential and concurrent identical keys, changed payload reuse and a lost response after commit. Confirm one row and consistent replay; an expired key is outside the deduplication window.
- 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: revalidation produces 304 with no body and the relevant cache metadata, while a changed representation produces 200. A local cache hit alone does not prove server-side validator handling.
- 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?
Self-check answers
- 401/403: 401 challenges missing/invalid authentication and needs
WWW-Authenticate; 403 refuses the request and does not prove prior authentication. Swapping them can trigger pointless login attempts or hide an available challenge. - Wildcard: response sharing for credentials mode
includerequires a specific allowed origin andAccess-Control-Allow-Credentials: true; a wildcard does not establish that permission. - Cookie baseline: use HttpOnly, Secure and an explicit SameSite policy.
__Host-adds host-only scope plusPath=/and Secure in supporting browsers, not HttpOnly or isolation by port. - Gateway failures: 502 reports an invalid upstream response; 504 reports no timely one. Inspect both sides and the network; neither proves that an upstream write did not commit.
- Large IDs: JavaScript Number cannot represent every integer above
2^53 - 1.9007199254740993parses as9007199254740992; use a decimal string end to end. JSON itself does not require binary64 arithmetic. - Vary:
Vary: Accept-Encodingmakes cache reuse depend on the request's encoding preference. Omitting it can reuse a representation for an incompatible request; Vary is not an authorization boundary. - Preflight: non-safelisted methods or author-controlled headers/values require permission, often via OPTIONS. Authorization and JSON Content-Type trigger it; a valid preflight cache entry may eliminate a new OPTIONS.
- Encryption: path/query and HTTP headers/body are encrypted between TLS endpoints. IPs, ports, sizes and timing remain observable; ordinary SNI exposes the host unless ECH is negotiated. A fragment is not part of the HTTP request.
- Streaming: SSE suits server-to-client text events with reconnect handling; WebSockets suit bidirectional messages and binary payloads. Either needs authentication, disconnect handling and bounded buffering.
- Retry keys: the server must atomically bind a scoped key and payload to one effect/result. PUT uses a client-selected resource URI and replacement semantics; neither is made safe merely by naming a header or method.
- XSS/CSRF: HttpOnly blocks cookie reads by script, not authenticated actions by injected script. SameSite restricts cross-site cookie sending, not same-origin XSS or every CSRF scenario. Use antiforgery protection where the flow needs it.
- Validators: a weak ETag denotes semantic equivalence rather than byte identity. Weak comparison works for GET/HEAD If-None-Match revalidation; If-Match needs strong comparison for write preconditions.
Exercise solution checkpoints
- Trace: distinguish address resolution, connection establishment, certificate verification and ALPN. Expect a verification failure without
-kfor an untrusted isolated certificate; an HTTP/3 attempt also needs a capable curl build and endpoint. - Replay: the acceptable result is the same authorized read with the minimum required headers, with credentials kept private. Curl success says nothing about whether browser CORS will permit the response.
- CORS: return the exact allowlisted page origin, credentials permission and the requested method/headers; place middleware between routing and authorization. Negative controls are a disallowed origin and a missing Authorization permission.
- HTTPS: a trusted development certificate permits local TLS; HTTP redirection is a server response, whereas HSTS upgrades future requests in the client. Loopback exclusion means this exercise does not prove an HSTS preload deployment.
- Deduplication: enforce a unique scoped key and coordinate its result with the write transaction. Two simultaneous attempts must not both mutate; a changed payload under the same key must be rejected according to the API contract.
- Caching: unchanged validators produce authorized 304/no body; changed validators produce 200/new body. Test a list,
*, weak tags and failed strong write preconditions;no-cacheallows storage but requires revalidation.
High-signal resources
Official docs
- MDN — HTTP — canonical free reference. Bookmark it.
- RFC 9110 — HTTP Semantics — the current authoritative spec.
- HTTP cookies revision (6265bis draft tracker) — the modern cookie spec.
- RFC 7396 — JSON Merge Patch + RFC 6902 — JSON Patch.
- RFC 9457 — 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. Use its TCP, TLS and HTTP/2 material; use RFC 9114 for HTTP/3.
- 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 reference capstone API design exposes REST endpoints (GET/POST/PUT/DELETE /api/queries), sets __Host-app.auth cookies with SameSite=Lax, Secure, HttpOnly via the cookie auth handler, aims to return application/problem+json (RFC 9457) for application-generated errors; proxy/authentication failures need separate handling, and HEAD responses have no body. It 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 requested two-hour preflight cache lifetime, subject to browser limits.
Status codes are deliberate: 201 + Location: for POST, 204 for DELETE, 412 for a failed If-Match precondition, or 409 for a separately detected domain-state conflict, 422 for validation failures with per-field errors, and 429 + Retry-After from the rate-limiter middleware. The SPA may use exposed rate-limit hints for feedback, but the server remains authoritative.
When you can open the Network panel on the running capstone, see :status 200, cache-control: private, no-cache, 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
Corrected contracts and failure analysis
QUIC isolates loss recovery between independent streams; it does not abolish every form of head-of-line blocking. Bytes within an ordered stream still wait for missing bytes, and QPACK dependencies can delay header decoding. TLS is integrated into QUIC rather than being a TCP layer beneath HTTP/3. DNS caching, connection reuse and resumption mean there is no universal setup RTT count.
Idempotency describes the intended effect of repeated requests, not identical response codes. A second DELETE may return 404 after the first returned 204. CORS governs what a browser can read; it is neither API authentication nor a general CSRF defense. A simple cross-origin form request may reach a server even if the browser cannot read its response. HttpOnly hides a cookie from script access but does not stop injected JavaScript from issuing authenticated requests.
Boundary exercise with solution
A POST completes but its response is lost; may the client retry merely because fetch rejected?
Solution and reasoning
No. A network error does not establish that the server did no work. Use a server-enforced idempotency contract or a status lookup. A 404 response, in contrast, resolves fetch normally; check response.ok and classify it separately.
Source-backed review notes
- RFC 9114: HTTP/3 — accessed 2026-10-07. Exact supporting passage: “[QPACK] describes a variation of HPACK that gives an encoder some control over how much head-of-line blocking can be caused by compression. This allows an encoder to balance compression efficiency with latency. HTTP/3 uses QPACK to compress header and trailer sections, including the control data present in the header section.¶”
- Using the Fetch API - Web APIs | MDN — accessed 2026-10-07. Exact supporting passage: “The fetch() function will reject the promise on some errors, but not if the server responds with an error status like 404: so we also check the response status and throw if it is not OK.”
Conditional request exercise: validators and private responses
Reference design, not a deployed endpoint: retrieve and authorize the representation before evaluating its validator. A personalized query result uses Cache-Control: private, no-cache (or no-store when storage is inappropriate), not public. Build a stable ETag from the representation version, not byte[].ToString(). Set ETag and the relevant cache metadata on both 200 and 304. Parse If-None-Match as a list including *; use weak comparison for GET/HEAD revalidation. If-Match uses strong comparison for write preconditions.
Exercise. Version 7 has strong ETag "7"; a GET sends If-None-Match: "4", W/"7". A PUT sends If-Match: W/"7". What happens?
Solution. The GET can return 304 after authorization because weak comparison matches the current representation. The PUT precondition fails with 412 because a weak tag cannot satisfy strong If-Match. With If-Match: "7", compare-and-update must be atomic; otherwise two writers can both pass a separate read and overwrite each other. Neither an ETag nor CORS is an authorization mechanism.
Current contract references
Rechecked 9 October 2026: Fetch Standard, CORS and preflight, ASP.NET Core 8 CORS middleware, development certificates, ASP.NET Core 8 HTTPS/HSTS, and Fielding's REST constraints. These support the conceptual contracts, not a browser or service execution claim.
Recovery source checks
- RFC 9110: HTTP Semantics: If-Match needs strong matching; weak If-None-Match supports GET revalidation.
- RFC 9110: HTTP Semantics: 401 challenges authentication with WWW-Authenticate; 403 is refusal, not proof of authentication.
- json-merge: JSON Merge Patch null removes an existing member.
- HSTS Preload List Submission: HSTS preload directive and list submission are distinct.
- Set-Cookie header - HTTP | MDN: Explicit SameSite=Lax cross-site navigation needs a safe method.
- Set-Cookie header - HTTP | MDN: Session restoration can restore session cookies.
- Cross-Origin Resource Sharing (CORS) - HTTP | MDN: Credentialed CORS responses require explicit origin and allowed headers.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.