How to check HTTP response headers with curl

Dump the response headers of a real GET and drop the body: curl -s -D - -o /dev/null https://example.com/. -D - writes the headers to stdout. -i keeps headers and body together. -I is shorter but sends HEAD, and a server may answer HEAD with different headers.

Why check this

Caching, compression, cookies and every security policy are carried in headers, so a header read is the cheapest regression check after an infrastructure change. Run it after a CDN configuration edit, after a reverse proxy upgrade, and on every staging sign off. It catches the case where a new proxy strips cache-control and the CDN starts serving a logged in page to anonymous visitors.

Prerequisites

Steps

  1. Step 1.

    Dump every response header from a normal GET and discard the body.

    curl -s -D - -o /dev/null https://www.cloudflare.com/
    
    HTTP/2 103 
    link: </fonts/Kunst%20Grotesk%20Regular.woff2>; as=font; crossorigin; rel=preload; type=font/woff2, …
    
    HTTP/2 200 
    date: Fri, 11 Sep 2026 18:58:46 GMT
    content-type: text/html; charset=utf-8
    link: <https://www.cloudflare.com/.well-known/agents.json>; rel="api-catalog", …
    cf-ray: a398e7e259a45906-WAW
    cf-cache-status: HIT
    accept-ranges: bytes
    age: 1
    cache-control: max-age=10
    last-modified: Fri, 11 Sep 2026 18:58:45 GMT
    server: cloudflare
    vary: accept-encoding
    strict-transport-security: max-age=31536000; includeSubDomains
    content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' …
    …
    permissions-policy: geolocation=(), camera=(), microphone=()
    referrer-policy: strict-origin-when-cross-origin
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    …
    set-cookie: _ga=GA1.1.c5df37da-b28e-45c2-8730-889138c41aed; Expires=Mon, 11 Oct 2027 18:58:46 GMT; Path=/
    …
    alt-svc: h3=":443"; ma=86400

    The first block is a 103 Early Hints response. curl prints every response it received, so a stack that sends hints produces two status lines for one request.

  2. Step 2.

    Keep the headers and the body in one stream when you need to read both.

    curl -si https://example.com/ | head -20
    
    HTTP/2 200 
    date: Fri, 11 Sep 2026 18:58:54 GMT
    content-type: text/html
    server: cloudflare
    last-modified: Thu, 10 Sep 2026 21:00:47 GMT
    allow: GET, HEAD
    accept-ranges: bytes
    age: 6543
    cf-cache-status: HIT
    cf-ray: a398e8155e550202-WAW
    
    <!doctype html><html lang="en"><head><title>Example Domain</title>…
  3. Step 3.

    Narrow the GET to the headers under test.

    curl -s -D - -o /dev/null https://www.cloudflare.com/ | grep -i -E '^(cache-control|cf-cache-status|age|last-modified|accept-ranges)'
    
    cf-cache-status: HIT
    accept-ranges: bytes
    age: 1
    cache-control: max-age=10
    last-modified: Fri, 11 Sep 2026 19:00:40 GMT
  4. Step 4.

    Run the same filter through -I and compare the two lists.

    curl -sI https://www.cloudflare.com/ | grep -i -E '^(cache-control|cf-cache-status|age|last-modified|accept-ranges)'
    
    cache-control: no-store

    One line instead of five, and the caching rule is inverted. -I sends HEAD, and this origin answers HEAD from a different code path. Reading -I output here would report the page as uncacheable.

  5. Step 5.

    Send -I to a route that accepts POST only.

    curl -sI https://httpbin.org/post
    
    HTTP/2 405 
    date: Fri, 11 Sep 2026 19:04:00 GMT
    content-type: text/html
    content-length: 178
    server: gunicorn/19.9.0
    allow: POST, OPTIONS
    access-control-allow-origin: *
    access-control-allow-credentials: true

    The route works. HEAD is what it rejects, and allow names the methods it takes.

  6. Step 6.

    Match header names without case, because HTTP/2 sends them lowercase.

    curl -s -D - -o /dev/null https://example.com/ | grep -i 'content-type'
    
    content-type: text/html

    The same pipeline ending in grep -c 'Content-Type' returned 0 on this host.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Two status lines for one request | The server sent 103 Early Hints before the real response | Read the second block. Assertions belong on the final status line. | | cf-cache-status: HIT with age: 1 | A CDN served a copy that is 1 second old | Confirm the page is safe to cache for everyone before accepting a HIT. | | server: cloudflare or server: gunicorn/19.9.0 | The stack names itself and its version to anyone | Version strings tell an attacker which advisories to try. Trim the header at the proxy. | | allow: POST, OPTIONS next to a 405 | The method was refused, not the route | Repeat with the method the header names before reporting the endpoint down. | | A header you expect is absent | It is absent on this path, not necessarily on the site | Request the exact path the client uses, then the origin directly, to find where it is dropped. |

Common mistakes

Sign: curl -I reports headers the browser never receives.Cause: -I sends HEAD, not GET. Step 4 shows cache-control: no-store on HEAD and cache-control: max-age=10 with a cache HIT on GET for the same URL. Use -D - -o /dev/null, which reads the headers of a real GET.
Sign: curl -I returns 405 and the endpoint is reported as broken.Cause: Routes bound to POST reject HEAD with 405, as httpbin.org/post does in step 5. The allow header lists what the route accepts. Send that method instead of concluding the service is down.
Sign: grep finds nothing although the header is in the dump.Cause: HTTP/2 and HTTP/3 carry header names in lowercase, so grep 'Content-Type' matches zero lines on an HTTP/2 response and the same request over HTTP/1.1 matches. Always pass -i to grep when filtering curl header output.

What to check next

FAQ

How to view HTTP headers in Chrome?

Open DevTools, Network tab, reload the page, click the request, then open the Headers panel. Response Headers is the same list curl prints. Click "Raw" to see the values unformatted, which matters for cookies and long CSP strings that Chrome wraps.

How to check the server header of a site?

curl -sI https://example.com/ | grep -i '^server:'. The value is optional, and a version inside it, like gunicorn/19.9.0 in step 5, tells anyone which known issues apply to that build. Removing the version at the proxy costs nothing.

What is the difference between -i and -I?

-i adds the headers to the body of the request you were already making. -I replaces the request with a HEAD. -i is safe on any route. -I is a different request and can return different headers or a different status.

Can curl show the request headers as well?

Yes. curl -v prints the request headers prefixed with > and the response headers with <. Pipe it through grep -E '^[<>]' to drop the TLS handshake lines and keep only the exchange.

Verified

Verified by Maks Vernycurl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic4 minpublished updated Maks Verny