Skip to content
Chapter 11Lesson 3

Headers as the metadata channel

The HTTP headers a SaaS engineer touches, organized by who reads them and where in a Next.js app each one is set.

A header is not a free-form key-value pair the server tacks onto a response. It is metadata addressed to a specific audience: the browser, an infrastructure layer between client and server, or the application itself. That audience reads the header and acts on it before anything touches the body. Pick the wrong header or the wrong value, and the audience does something you didn’t intend: a CDN caches a private response and replays it to the next user, a browser refuses your cookie, or a rate-limit gateway lets a forged client through.

This lesson builds the three-audience model as a durable scaffold, then walks the header families a SaaS engineer actually touches: content negotiation, caching, conditional requests, authorization, rate-limit signaling, the security baseline, request context, and custom headers. It closes with a map from each header family to the Next.js file that sets it. By the end you’ll be able to place any header into one of three audiences, pick the senior default for the common cases, and know where in a Next.js app a given header needs to live.

The previous two lessons named the shape of an HTTP message in passing: a method or status line, a block of headers, and a body. The body carries the resource, and the headers carry the metadata. This section answers the question an experienced engineer asks before writing a header value: who is going to read this?

Every header is sent because someone will read it. That someone is one of three audiences:

  • The browser, which enforces the header. It sets a cookie, applies a Content Security Policy, refuses an insecure transport, or denies camera access to a third-party iframe.
  • The infrastructure layer between client and server (CDN, reverse proxy, load balancer), which acts on the header. It caches a response, picks a compression codec, or throttles a noisy client.
  • The application, which reads the header to make a decision: which user is signing this request, what content type to return, or whether this is a retry of an operation it has already done.

The audience is the durable property of the header. Once you know who reads a header, you know where in your stack to set it and what breaks when you get it wrong. The catalog of headers a SaaS engineer touches is wide, and memorizing it tends not to last: a year from now you’ll have lost the list. The audience model is what carries you, because once you’ve internalized the three audiences you can place a new header you meet later by reasoning about who reads it.

A few headers blur the boundary, and that’s fine. Cache-Control is mostly an infrastructure header, since CDNs and shared caches are the design audience, but the browser also caches and reads it. Authorization is application-audience, yet the infrastructure layer might still log it. You don’t need to resolve these edge cases. The mapping is one-to-one in practice for every header you’ll touch, and the few boundary cases are called out where they come up.

Browser The browser enforces this header.
  • Set-Cookie
  • Strict-Transport-Security
  • Content-Security-Policy
  • Permissions-Policy
Infrastructure CDNs and proxies act on this header.
  • Cache-Control
  • Vary
  • Content-Encoding
  • Retry-After
Application Your application code reads this header.
  • Authorization
  • Idempotency-Key
  • If-None-Match
  • Content-Type
The three audiences a header can be addressed to. The audience determines where in the stack the header is set and what breaks when its value is wrong.

The model is only useful if you can apply it without looking. Try the drill below: sort each header into the audience that reads it.

Place each header with the audience that reads it. Drag each item into the bucket it belongs to, then press Check.

Browser Enforces the header
Infrastructure CDNs and proxies act on the header
Application Your code reads the header
Set-Cookie
Content-Security-Policy
Strict-Transport-Security
Cache-Control
Vary
Retry-After
Authorization
Idempotency-Key

The rest of the lesson builds on that strip. We’ll walk the header families one audience at a time, then close with the file-by-file map of where each one lives in a Next.js 16 app.

Content negotiation: what the body is, what the client wants

Section titled “Content negotiation: what the body is, what the client wants”

Content negotiation is how the client and server agree on the format of the body. Three headers do the work, and they all follow one rule: name the format on the request side (“what I’m sending, and what I want back”) and again on the response side (“what you’re actually getting”).

Content-Type describes what the current body is. On a request, Content-Type: application/json says “the bytes after the headers are JSON.” On a response, Content-Type: application/problem+json; charset=utf-8 says “the body is RFC 9457 Problem Details, encoded as UTF-8.” Each direction names its own body, so the request Content-Type and the response Content-Type are independent of each other.

Accept is the request-side header that says what the client wants in the response. A SaaS API client sends Accept: application/json. A browser navigating to an HTML page sends a longer list, Accept: text/html,application/xhtml+xml,..., telling the server which formats it can render. Content-Type and Accept are not the same axis. A POST request can carry Content-Type: application/json and Accept: application/problem+json at the same time, meaning “I’m sending JSON; if you reject me, send the error as Problem Details.” Both can be present, because they answer different questions.

Content-Encoding declares how the body was compressed before being put on the wire. The client advertises which codecs it supports via Accept-Encoding, then the server picks one and stamps the response with Content-Encoding. The 2026 senior reach for new traffic is zstd. Chrome, Edge, Firefox, and Safari all support it now, Cloudflare and the other major CDNs negotiate it by default, and for the same output quality it compresses more per unit of CPU than Brotli. Brotli stays the default where you need to support older clients, and gzip is the legacy fallback. You almost never set Content-Encoding by hand, because the platform (Next.js plus the deploy target) negotiates the codec and stamps the encoding for you. If you set it manually without actually compressing the body, the declared encoding won’t match the bytes, and downstream tooling chokes on the mismatch.

Vary extends the cache key. By default a shared cache keys responses by URL alone. Vary: Accept-Encoding, Cookie tells it that this response also depends on the values of those two request headers, so it should fold them into the key. Without Vary: Cookie on a per-user response, the CDN serves user A’s response to user B from cache. Without Vary: Accept-Encoding, the Brotli body gets handed to a client that only asked for gzip and can’t decompress it. So the rule is simple: any response that genuinely differs by a request header needs a Vary that names that header. The senior default for authenticated HTML (covered in the next section) is private, no-store, which makes Vary: Cookie moot because the response isn’t cached at all. On cacheable per-user surfaces, though, Vary is mandatory.

Two more headers round out the set. Content-Length declares the body’s size in bytes, which the infrastructure layer reads to frame the message, allocate buffers, and decide when the body has finished arriving. The platform sets it for you when it serializes the response, so it almost never appears in your code. Accept-Language carries the client’s preferred locales, and Unit 17’s next-intl reads it for locale negotiation.

Here’s what content negotiation looks like on the wire. A request asks for JSON with any of three encodings, plus a cookie for identity. The response answers with JSON, picks zstd, and varies by both encoding and cookie so the CDN keys correctly.

GET /api/invoices HTTP/3
Accept: application/json
Accept-Encoding: zstd, br, gzip
Cookie: session=abc...
HTTP/3 200 OK
Content-Type: application/json
Content-Encoding: zstd
Vary: Accept-Encoding, Cookie

Cache-Control is the header an infrastructure layer looks at first. Its value is a comma-separated list of directives, each one a knob on what caches are allowed to do with the response. Only six or seven directives are worth knowing, and four senior defaults will cover almost every response you ever ship.

The directives a SaaS engineer reaches for are these:

  • private vs. public: private means only the client’s own cache (the browser) may store the response, while public lets shared caches (CDNs, corporate proxies) store it too. Authenticated responses are private; anonymous responses can be public.
  • max-age=N: how long, in seconds, the response stays fresh and may be served without revalidation.
  • s-maxage=N: same as max-age but applies only to shared caches. Useful when you want the CDN to keep the response longer than the browser does.
  • no-store vs. no-cache: not synonyms. no-store forbids any cache from storing the response at all. no-cache allows storage but forces revalidation (a conditional request, covered in the next section) on every read. Confusing these two is the most common cache bug in production.
  • must-revalidate: once a response is stale, it must be revalidated, and intermediaries may not serve it stale as a fallback.
  • stale-while-revalidate=N: once stale, the cache may serve the stale response and asynchronously fetch a fresh one. Keeps p99 latency low at the cost of one window of stale content.
  • immutable: the response will never change at this URL, so browsers may skip revalidation entirely, even on reload. This is the marker for hashed static assets.

Four defaults cover almost every response. Each one below names its trigger and the exact directive string.

  • Authenticated HTML (any page that varies by signed-in user): Cache-Control: private, no-store. No shared cache may touch it, and the browser must not store it either, so the back button doesn’t flash signed-in content after sign-out.
  • Hashed static assets (filename includes a content hash, like /_next/static/chunks/main.a1b2c3.js): Cache-Control: public, max-age=31536000, immutable. That max-age is one year. New deploys ship new hashes, so the URL itself acts as the version, and the old URL never needs to change.
  • Cacheable HTML on the CDN edge (marketing pages, blog posts, anything anonymous): Cache-Control: s-maxage=300, stale-while-revalidate=86400. The response stays fresh on the CDN for five minutes, with a day of stale-while-revalidate behind that. The user always sees a fast response, and the CDN refreshes in the background.
  • API responses that change rapidly on a logged-in surface: Cache-Control: private, no-store. The same default as authenticated HTML, because the server can’t guarantee the response won’t change between two reads.

Next.js 16 wraps all of this. The framework-level abstraction is the 'use cache' directive paired with cacheLife(...) and cacheTag(...), so you don’t usually write Cache-Control strings by hand. The directives in this section are the underlying HTTP contract, and 'use cache' is the wrapper that emits them. The detail lands in Unit 4. For now, recognize the wire shape so you can read it in DevTools and know what the framework produced.

The two directives that cause this confusion most often are no-store and no-cache. They sound interchangeable, but they aren’t. Here’s the difference on the wire.

HTTP/3 200 OK
Cache-Control: private, no-store

Nothing stores this response: not the browser, not the CDN, not a corporate proxy. The next request re-runs the server work. This is the senior default for anything user-specific.

Now apply the defaults. The scenario below offers four candidate values; only one is right, and the other three each leak something different.

A Server Component renders an invoice list for the signed-in user at /invoices. The page is server-rendered, varies by the user’s organization, and the client navigates here from a sidebar link. What value should Cache-Control have on the response?

public, max-age=300
private, max-age=300
private, no-store
no-cache

Conditional requests: ETag and If-None-Match

Section titled “Conditional requests: ETag and If-None-Match”

The conditional-request pair completes the caching story. It answers the question a cached copy raises: the client already holds a version of this resource, but has the resource changed since then?

The pattern is small. The server stamps the response with ETag: "v1", an opaque token identifying this version of the resource. On the next read, the client sends If-None-Match: "v1" in the request. If the resource hasn’t changed, the server replies with 304 Not Modified and no body, and the client renders from its cached copy. What you save is the body: the round-trip still happens, but the payload doesn’t travel.

Reach for this on read-mostly resources the client revisits: list endpoints, large JSON payloads, image metadata, anything that doesn’t change often enough to justify re-shipping the body. It pairs naturally with Cache-Control: no-cache, where every read is conditional by design.

Two related headers earn one mention each. If-Match on a write request implements optimistic concurrency: “apply this mutation only if the resource is still at version v1; otherwise return 412 Precondition Failed and let me reconcile.” It’s useful for collaborative-edit surfaces, but you won’t reach for it daily. Last-Modified and If-Modified-Since are the timestamp version of the same pattern. The 2026 senior default is ETag, because timestamps have only second precision and so can’t tell apart two writes that land in the same second. An ETag value is opaque to the client, which means the server is free to derive it however it likes: a row version, a content hash, or a monotonic counter.

The exchange looks like this on the wire.

GET /api/invoices HTTP/3
If-None-Match: "v1"
HTTP/3 304 Not Modified
ETag: "v1"

No body on the 304. The client renders from its existing cache.

Authorization: cookies for browsers, Bearer for machines

Section titled “Authorization: cookies for browsers, Bearer for machines”

The first decision an application makes on every incoming request is “who is this?”, and the answer rides on either a cookie or an Authorization header. The two are not interchangeable, and which one to use is shaped entirely by who’s calling.

For first-party browser traffic, the answer is cookies. The browser sets a cookie at sign-in via Set-Cookie: on the response, sends it back automatically via Cookie: on every same-origin request, and clears it at sign-out. The browser also enforces protections that cookies have accumulated over thirty years: HttpOnly keeps JavaScript from reading the cookie value, Secure requires HTTPS, SameSite=Lax defangs CSRF on top-level navigations, and the __Host- prefix locks the cookie to one origin. Those mitigations are why first-party browser sessions live in cookies and not in a header your JavaScript writes by hand.

For programmatic clients (a mobile app, a server-to-server SDK, a public API consumer), the answer is Authorization: Bearer <token>. A bearer token is a raw credential carried in plaintext on every request. It has none of the browser-managed mitigations that cookies have. That’s why it’s the wrong choice for a browser session, and the right one for a client that manages its own token lifecycle, stores credentials in a vault or a keychain, and has no cookie store at all.

The senior 2026 default in this course names the split directly: Better Auth (Unit 8) ships the project’s session as a __Host--prefixed HttpOnly; Secure; SameSite=Lax; Path=/ cookie. Public route handlers called by non-browser clients read Authorization: Bearer <token> instead. The two coexist inside a single SaaS, and you pick by who’s calling the endpoint, not by personal preference.

Two cousins are worth a sentence each. Authorization: Basic is Base64-encoded username:password. It’s only safe over HTTPS, and in 2026 it’s only used for internal tooling, admin scripts, and the occasional CI/CD job, never anything user-facing. WWW-Authenticate is the companion header on a 401 response that tells the client which authentication scheme the server expects (WWW-Authenticate: Bearer). The authedRoute wrapper in Chapter 57 sets it on every 401 from a route handler.

The cookie deep dive, covering HttpOnly, Secure, SameSite, __Host-, Partitioned, and the full Set-Cookie attribute surface, lives in Chapter 13. The two channels look like this side by side on the wire.

GET /invoices HTTP/3
Cookie: __Host-session=abc...

Set once at sign-in by Set-Cookie, then sent automatically on every same-origin request. The browser manages the lifecycle, the __Host- prefix locks it to this origin, and HttpOnly keeps JavaScript out of it.

When a server tells the client to slow down, it does so in two ways: by returning the 429 Too Many Requests status code from the previous lesson’s subset, and by spelling out how much to slow down with two headers.

The pair is RateLimit (the current state) and RateLimit-Policy (the policy that produced it). These are the response headers the IETF httpapi working group has been standardizing as draft-ietf-httpapi-ratelimit-headers, currently at draft 11 and intended for the Standards Track. Their values are structured fields per RFC 9651: lists of items with parameters that a defined grammar parses, rather than the loose X-RateLimit-* set that the older Heroku and Twitter shape used. The 2026 senior reach is the IETF draft shape, and Upstash, Cloudflare, and most modern gateways emit it.

Retry-After is the back-off signal the HTTP client is required to honor. The value is either a number of seconds or an HTTP-date. It appears on 429 Too Many Requests from a rate limiter and on 503 Service Unavailable from a load balancer telling you it’s overloaded and to come back later.

On the client side, the senior reflex on a 429 is to read Retry-After and schedule a retry. The retry must be idempotency-aware, since retrying a POST without an Idempotency-Key is the bug the methods lesson closed. On a 503, read Retry-After and either retry transparently or surface a “service unavailable” message, depending on what the UX needs. Either way, you don’t pick the wait time yourself; the server told you.

A 429 with all three headers looks like this on the wire.

HTTP/3 429 Too Many Requests
RateLimit: "auth-sign-in";r=0;t=27
RateLimit-Policy: "auth-sign-in";q=10;w=60
Retry-After: 27
Content-Type: application/problem+json

Read the structured-field values. The policy named auth-sign-in allows 10 requests (q=10) per 60-second window (w=60), and the current state on this policy is zero remaining (r=0) with 27 seconds to reset (t=27). Retry-After: 27 says the same thing in the form every HTTP client library already reads. The application/problem+json body explains why in human terms, using RFC 9457 Problem Details from the previous lesson.

The helper that emits these headers on every limited response in your app, rate-limit-headers.ts, lands in Chapter 75. This lesson names the wire shape so you can recognize it.

The security baseline: the irreducible six

Section titled “The security baseline: the irreducible six”

Six headers form the security baseline every SaaS app sends. They are all browser-audience: the browser enforces them, the infrastructure layer doesn’t touch them, and the application produces them at response time. This lesson names the six and the role each plays; Chapter 81 wires them in production with the CSP nonce, the report-only rollout, and the Reporting-Endpoints companion. The payoff here is recognition: see one of these in DevTools and know what it’s there for.

  • Strict-Transport-Security (HSTS): “always use HTTPS for this host, for the next N seconds, including subdomains.” Closes the downgrade-to-HTTP window an attacker uses to strip TLS in a man-in-the-middle attack.
  • Content-Security-Policy (CSP): “only execute scripts from these sources.” The 2026 senior shape is nonce-based with 'strict-dynamic': every request gets a fresh nonce , the inline <script> tags carry it, and other scripts inherit trust from nonced scripts. Closes XSS as a class.
  • X-Content-Type-Options: nosniff: “do not sniff the body to guess a Content-Type; trust the header.” Closes the MIME-confusion attack where a .txt upload is sniffed as text/html and executed in a browser context.
  • Referrer-Policy: “what to put in Referer on outgoing navigations.” The 2026 senior default is strict-origin-when-cross-origin: full URL on same-origin, origin only when cross-origin, nothing on HTTPS-to-HTTP. Closes the credential-in-URL leak through referer headers.
  • Permissions-Policy: “which browser features (camera, microphone, geolocation, USB, payment) this origin and its iframes may use.” The senior default is deny-by-default with per-feature grants only for the features the app actually needs.
  • frame-ancestors: a CSP directive rather than a separate header, answering “who may embed this page in an iframe.” In 2026 this replaces the old X-Frame-Options header. X-Frame-Options is retired because the CSP directive composes with the rest of CSP and X-Frame-Options doesn’t.

Alongside the six sits Reporting-Endpoints, a companion response header that names URLs the browser can POST CSP and Permissions-Policy violation reports to. This lesson only names it; Chapter 81 wires the report-only rollout and the endpoint that receives the reports.

All six are browser-audience and almost entirely static across requests, so most of them live in next.config.ts, the build-time setter. The exception is CSP, because the nonce has to be fresh on every request. The closing section ties this back to where each header is set in the file system.

Request-context headers and the trust-the-edge rule

Section titled “Request-context headers and the trust-the-edge rule”

Every incoming request carries a small set of headers the application reads to figure out the context: who’s calling, from where, and through what. Five are worth naming.

  • Cookie:: the value the browser sends. Better Auth’s session middleware (Unit 8) reads it. The application reads it; the infrastructure layer doesn’t.
  • User-Agent:: what client is calling. This is a forensic and analytics signal only; never trust the value for an authorization decision, because the client controls it. It’s useful in logs for segmenting “is this a bot, a mobile browser, a server-to-server SDK.”
  • Referer:: the previous URL the user navigated from. (The header has been misspelled since RFC 1945 in 1996, and that field name is canonical now. The policy header that limits it, Referrer-Policy, uses the correct spelling. The spec uses both, depending on which one you’re reading.) The application reads this for analytics.
  • Origin:: the load-bearing header for CSRF defense on Server Actions. On any cross-origin request that includes credentials, the browser sets Origin: to the calling origin. Server Actions check this against the app’s own origin and reject mismatches. Chapter 54 owns the check.
  • Host: (and the equivalent :authority pseudo-header under HTTP/2 and HTTP/3): which hostname the client meant. The application reads it for multi-tenant subdomain routing (org A on acme.app.com, org B on globex.app.com), and the infrastructure layer routes on it.

That’s the easy half. The hard half is recovering the real client IP through a chain of proxies without trusting a value the client could have forged.

A request typically passes through several hops before it reaches your application: CDN, then load balancer, then application. Your application sees only the load balancer’s IP as the source IP. To recover the real client IP, each proxy appends its known-good upstream IP to X-Forwarded-For (or to the structured Forwarded header standardized in RFC 7239), and the application then walks the chain.

The catch is which entries you can believe. Only the rightmost entry, the one appended by the immediate edge your application talks to, is trustworthy. Every entry to the left of that hop could have been forged by the original client, which is free to send a fake X-Forwarded-For header in its request. So if you rate-limit by the leftmost entry, any attacker can spoof an IP and slip past your limiter.

The fix is to count proxies from the right and stop at the first untrusted hop. On Vercel, that means reading request.headers.get('x-vercel-forwarded-for'): Vercel’s edge appends a value it controls, and that’s the entry you trust. On a custom edge, configure the equivalent.

Forwarded (RFC 7239) is the standardized replacement for the older X-Forwarded-* set. It folds three separate headers, X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host, into one structured header: Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43. The senior 2026 default is to emit Forwarded from your edge, though the legacy X-Forwarded-* set persists because most upstream tooling still reads it. Read whichever header your edge actually sets, and configure the edge to set the modern one.

When you invent a custom header, whether for a webhook signature, a request ID, or an internal application contract, what do you call it?

The instinct is to prefix it with X-, but that convention is retired. RFC 6648 deprecated the X- prefix in 2012, for a practical reason: when a custom header eventually became a standard, stripping the prefix broke every consumer that had hard-coded the prefixed name. The gzip / x-gzip rename is the textbook case. The senior 2026 reach is no prefix for plain application headers (Request-Id, Idempotency-Key) and a vendor token for third-party-defined headers: Stripe-Signature on webhook callbacks from Stripe, and Svix-Id / Svix-Timestamp / Svix-Signature on Svix-delivered webhooks. Both shapes communicate ownership without colliding with the IANA registry.

GitHub still emits X-GitHub-Event as a legacy holdout, named once here as the exception. The rule for new headers is to pick the IETF draft shape when one exists (Idempotency-Key, RateLimit), a vendor token for proprietary semantics, and no prefix for everything else.

The three audiences map cleanly onto the three places a Next.js app sets headers, and this map is what you’ll reach for in practice when shipping a new feature.

  • next.config.ts headers(): for headers that are static across requests, or static per route prefix. HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, frame-ancestors-via-CSP (when no nonce is needed), and Cache-Control on /_next/static. This is the build-time setter; the value never depends on the request, so it gets configured once.
  • proxy.ts (the Next.js 16 rename of what used to be middleware.ts): for headers that need per-request data. The flagship case is the CSP nonce, a fresh random value per request, injected into both the Content-Security-Policy header and the <script> tags that carry it. This is also where the request-id header, the rate-limit headers, and per-request auth headers live. It runs on the Node.js runtime in Next.js 16.
  • The response itself, meaning the route handler (route.ts), the Server Action’s response, or the page’s metadata export: for headers that are response-specific. Cache-Control per route, the ETag per resource, WWW-Authenticate: Bearer on the 401 from authedRoute, and the Problem Details Content-Type: application/problem+json on a 422.
next.config.ts headers() Static across requests.
  • Strict-Transport-Security
  • X-Content-Type-Options
  • Referrer-Policy
  • Permissions-Policy
proxy.ts Per-request data (CSP nonce, request-id).
  • Content-Security-Policy
  • X-Request-Id
  • RateLimit
route.ts / Server Action Per-response (per-route cache, per-resource ETag).
  • Cache-Control
  • ETag
  • WWW-Authenticate
  • Content-Type
Three setters, three triggers. The audience model decides which file owns the header.

Three short snippets anchor the diagram, one per setter. These are read-only: you won’t write a next.config.ts until Unit 3, a proxy.ts until Unit 4 onward, or a route.ts until Unit 6. The point is the shape: which file, which export, and where the header strings actually live.

Start with the build-time setter, next.config.ts. The headers() async function returns an array of rules, each pairing a source path matcher with a list of (key, value) pairs. This is the same shape across every Next.js project.

next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
];
},
};
export default nextConfig;

It’s static, runs at build time, and applies to every matching request.

Next is the per-request setter, proxy.ts at the project root. The proxy function runs on the Node.js runtime before the route resolves. Here it generates a per-request nonce and sets it on the response’s Content-Security-Policy and X-Request-Id headers.

proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const response = NextResponse.next();
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
);
response.headers.set('X-Request-Id', nonce);
return response;
}

This runs on every request that matches the proxy’s config, and the nonce is fresh each time. The snippet uses crypto.randomUUID() for parity with the Idempotency-Key reach in Lesson 1, but the CSP spec actually calls for a fresh random value encoded as base64. Chapter 81 swaps to the canonical crypto.getRandomValues(...) plus base64 pattern when it wires CSP in production.

Last is the per-response setter, the route handler at app/api/<route>/route.ts. Each method is a named export (GET, POST, PATCH, and so on). Headers go on the Response object, so they ride with the body the handler returns.

app/api/invoices/route.ts
export async function GET() {
return new Response(JSON.stringify({ invoices: [] }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'private, no-store',
ETag: '"v1"',
},
});
}

These are per-response, set on the Response object so they ride with this specific body.

That completes the map from audience to setter to file. Every header in the lesson lands in one of those three slots.

Here are six claims that apply the lesson’s core reasoning to specific cases: the audience model, the senior defaults, and the request-context pitfalls. Mark each one true or false.

Each claim is about a header the application sends or reads. Mark each statement True or False.

Cache-Control: no-cache forbids the cache from storing the response, so it’s the senior default for authenticated HTML.

no-cache allows storage but forces revalidation on every read. The directive that forbids storage entirely is no-store. The senior default for authenticated HTML is private, no-store.

A first-party browser session uses cookies; a server-to-server API call uses Authorization: Bearer.

Browser sessions live in cookies because the browser enforces HttpOnly, Secure, SameSite, and the __Host- prefix — mitigations a bearer token doesn’t have. Programmatic clients use bearer because they manage their own credential lifecycle and shouldn’t store credentials in a cookie store.

Rate-limiting by the leftmost IP in X-Forwarded-For is safe because the proxies append in order.

The only hop you can trust is the immediate edge. Every value to the left of that hop could have been forged by the original client. Count from the right and stop at the first untrusted hop, or read the value your trusted edge appended (e.g. Vercel’s x-vercel-forwarded-for).

Vary: Cookie on a per-user response tells a shared cache to key the cache by the request’s Cookie value, so user A’s response doesn’t get served to user B.

Without Vary: Cookie, the cache keys by URL only and serves the first response it cached to every user that hits that URL. The auth-default private, no-store makes the question moot — the response isn’t cached at all — but on cacheable per-user surfaces, Vary is mandatory.

Custom application headers should be prefixed with X- to mark them as non-standard.

RFC 6648 deprecated the X- prefix in 2012. The senior reach is no prefix (e.g. Request-Id, Idempotency-Key) or a vendor token (Stripe-Signature, Svix-Id).

The CSP nonce is set in proxy.ts rather than next.config.ts because each request needs a fresh nonce.

next.config.ts headers() is for static-across-requests headers. CSP with a per-request nonce needs request-time data, which means the proxy. The same rule applies to any request-id, rate-limit, or per-tenant header.

This lesson named six topics in passing and deliberately didn’t expand them. Each one lands properly elsewhere in the course, so here’s the map of where to look.

  • Cookie attributes (HttpOnly, Secure, SameSite, __Host-, Partitioned) and the full Set-Cookie deep dive: Chapter 13.
  • CORS response headers (Access-Control-Allow-*) and the preflight dance: Chapter 12.
  • Next.js 16’s 'use cache' directive and cacheLife: Unit 4.
  • The full security-baseline implementation with the CSP nonce wiring and the report-only rollout: Chapter 81.
  • The rate-limit-headers.ts helper that emits RateLimit and Retry-After on every limited response: Chapter 75.
  • The Server Action Origin header check for CSRF: Chapter 54.

If you want to read further before those land, the references below are worth the click. MDN’s HTTP headers index is the canonical reference for any individual header’s syntax and history. MDN’s HTTP caching guide is the long-form companion for the Cache-Control and ETag sections. The OWASP Secure Headers Project is the canonical defensive-headers checklist. The IETF httpapi RateLimit draft tracker is where the standardization state of RateLimit and RateLimit-Policy lives.