Skip to content

Headers are a fundamental part of HTTP requests and responses, carrying metadata about the message body, client capabilities, server information, and more. sillo provides comprehensive tools for working with headers in both requests and responses.

Access incoming request headers through the request.headers property, which provides a case-insensitive dictionary-like interface:

from sillo import silloApp
app = silloApp()
@app.get("/")
async def show_headers(request, response):
user_agent = request.headers.get("user-agent")
accept_language = request.headers.get("accept-language")
return {
"user_agent": user_agent,
"accept_language": accept_language
}
HeaderDescriptionExample
AcceptContent types the client can processapplication/json, text/html
AuthorizationCredentials for authenticationBearer xyz123
Content-TypeMedia type of the request bodyapplication/json
CookieCookies sent by the clientsession_id=abc123
User-AgentClient application informationMozilla/5.0
X-Requested-WithIndicates AJAX requestXMLHttpRequest

Set response headers using the response.set_header() method or by passing a headers dictionary:

from sillo import silloApp
app = silloApp()
@app.get("/")
async def set_headers(request, response):
response.set_header("X-Custom-Header", "Custom Value")
response.set_header("Cache-Control", "no-store")
return response.text("Hello, World!")
HeaderDescriptionExample
Content-TypeMedia type of the responsetext/html; charset=utf-8
Cache-ControlCaching directivesmax-age=3600
Set-CookieSets cookies on clientsession_id=abc123; Path=/
LocationURL for redirectshttps://example.com/new
X-Frame-OptionsClickjacking protectionDENY
Content-Security-PolicySecurity policydefault-src 'self'

In middleware, you need to modify headers after the response has been created by the route handler.

Always set headers after calling await call_next():

async def cors_middleware(request, response, call_next):
response = await call_next()
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
response.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
return response
async def cors_middleware(request, response, call_next):
response = await call_next()
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
response.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
response.set_header("Access-Control-Max-Age", "86400")
return response
async def security_middleware(request, response, call_next):
response = await call_next()
response.set_header("X-Content-Type-Options", "nosniff")
response.set_header("X-Frame-Options", "DENY")
response.set_header("X-XSS-Protection", "1; mode=block")
response.set_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return response
async def request_id_middleware(request, response, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
response = await call_next()
response.set_header("X-Request-ID", request_id)
return response

sillo provides several methods for working with headers:

  • request.headers.get(key, default=None) - Get a header value
  • request.headers.items() - Get all headers as key-value pairs
  • request.headers.keys() - Get all header names
  • request.headers.values() - Get all header values
  • response.set_header(key, value, override=False) - Set a header
  • response.remove_header(key) - Remove a header
  • response.has_header(key) - Check if header exists
  • response.set_headers(headers_dict, override_all=False) - Set multiple headers

For enhanced security, consider these recommended headers:

async def add_security_headers(request, response, call_next):
response.set_headers({
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'; script-src 'self'",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload"
})
return await call_next()
app.use(add_security_headers)

Optimize client-side caching and resource loading:

async def add_performance_headers(request, response, call_next):
if request.path.endswith(('.js', '.css', '.png', '.jpg')):
response.set_header("Cache-Control", "public, max-age=31536000, immutable")
return await call_next()
app.use(add_performance_headers)

Cookies are set via special Set-Cookie headers:

@app.get("/login")
async def login(request, response):
response.set_cookie(
key="session_id",
value="abc123",
max_age=3600,
secure=True,
httponly=True,
samesite="strict"
)
return response.redirect("/dashboard")

Handle conditional requests with these headers:

HeaderPurposeExample
If-Modified-SinceCheck if resource changedSat, 01 Jan 2022 00:00:00 GMT
If-None-MatchCheck ETag match"abc123"
ETagResource version identifierW/"xyz456"
Last-ModifiedResource modification timeSat, 01 Jan 2022 00:00:00 GMT

Some headers are restricted and cannot be modified:

  • Content-Length (automatically calculated)
  • Connection
  • Transfer-Encoding
  • Host

Headers are case-insensitive and repeatable

Section titled “Headers are case-insensitive and repeatable”

Two properties of HTTP headers cause most of the surprises.

Case does not matter. Content-Type, content-type, and CONTENT-TYPE are the same header. sillo’s header mapping handles this, so request.headers["content-type"] works regardless of what the client sent. Code that iterates raw ASGI headers and compares with == does not.

A header may appear more than once. Set-Cookie, Accept, Via, and X-Forwarded-For all legitimately repeat, and a mapping lookup gives you one of them. Where repetition matters, read the multi-value form rather than indexing.

The one that bites hardest is X-Forwarded-For, which is comma-separated and repeatable — a chain through three proxies can arrive as one header with three values, three headers with one each, or any combination. Any parser that assumes one shape is wrong behind some proxy.

These are cheap, apply globally, and each closes a real class of attack.

Strict-Transport-Security tells browsers to use HTTPS for your domain for a period, closing the downgrade window on the second visit. Start with a short max-age; the value is hard to shorten once cached.

X-Content-Type-Options: nosniff stops browsers second-guessing your Content-Type. Without it, a mislabeled upload can be interpreted as script.

Content-Security-Policy is the strongest control available against XSS and the hardest to adopt, because a strict policy breaks inline scripts and styles. Deploy it in report-only mode first and read the reports.

Referrer-Policy: strict-origin-when-cross-origin stops full URLs — including path segments and query strings, which may carry tokens — leaking to third parties.

X-Frame-Options or CSP frame-ancestors prevents your pages being framed, which is what clickjacking requires.

Set them in middleware so they apply to every response, including errors and static files. A security header applied by a decorator is a security header missing from the routes somebody forgot to decorate.

Every header is client-controlled, so the same rules apply as to any other input: validate before use, bound the length, and never interpolate into a log line, a filename, or a downstream request without escaping.

Header injection is the specific hazard when you write a header from user input. A newline in a value lets the caller append arbitrary headers, including a second Set-Cookie. Strip control characters from anything user-supplied before it becomes part of a response.

Accept, Accept-Language, and Accept-Encoding are weighted preference lists, not single values:

Accept: text/html;q=0.9, application/json;q=1.0, */*;q=0.1

Parsing by substring match or by taking the first entry gives wrong answers for any client that sends real quality values. Where it matters, parse properly — and where it does not, ignore the header and return one format consistently, which is more honest than negotiating badly.

Anything whose response varies by one of these must set Vary naming it. See Content Negotiation.

Cache-Control is the one that decides whether anything between you and the client keeps a copy. Its absence does not mean “do not cache” — intermediaries apply heuristics — so say what you mean.

no-store on anything authenticated. public, max-age=31536000, immutable on content-hashed static assets. private, max-age=0, must-revalidate on per-user pages that should always be checked.

ETag with If-None-Match turns an unchanged large response into a 304 with no body, which is the cheapest win available on any endpoint that is polled.

The failure mode worth avoiding: a public cache directive on a response that varies by user. A shared cache stores one user’s data and serves it to the next, and neither you nor they will notice quickly.

per response
resp = response.json({"ok": True})
resp.headers["X-Request-ID"] = request_id
resp.headers["Cache-Control"] = "no-store"
return resp

For anything that should apply to every response — security headers, correlation ids, timing — set it in middleware instead. A header applied by a decorator is a header missing from the routes nobody decorated, and the missing ones are never the routes you would have chosen.

Two values need care when they come from user input. Newlines and carriage returns must be stripped, because a value containing them can inject additional headers. And length should be bounded, since headers travel on every response and some intermediaries reject oversized ones outright.

the common reads
content_type = request.headers.get("content-type", "")
auth = request.headers.get("authorization")
accepts_json = "application/json" in request.headers.get("accept", "")

Lookups are case-insensitive, and a missing header returns None rather than raising — which means a typo in a header name is indistinguishable from the header being absent. Where a header is required, check explicitly and return a clear 400 rather than proceeding with None.

For headers that legitimately repeat, index the multi-value view rather than the mapping. X-Forwarded-For is the case that matters, and Network helpers covers the parsing it needs.

Prefix conventions have changed: X- prefixes were deprecated by RFC 6648 in 2012, and the modern advice is to use an unprefixed name scoped to your organisation. In practice X-Request-ID and friends are so entrenched that consistency within your own API matters more than the RFC.

What does matter: document every custom header you require or emit, treat them as part of your API contract, and remember that a header a client must send is a header that will be forgotten. Prefer a body field where the choice is genuinely available.

Servers cap total header size — typically 8 KB to 16 KB across all headers — and exceeding it produces a 431 or a connection reset rather than a helpful error.

Two things cause it in practice: cookies that have grown, since every cookie for the domain is sent on every request; and a JWT with too many claims sitting in an Authorization header. Both are worth measuring rather than assuming, because the failure appears only for the users who have accumulated the most state.

Header names are case-insensitive and some headers legitimately repeat — index the multi-value view where that matters. Set the security header set in middleware so it covers errors and static files too. Treat every inbound header as untrusted input, strip control characters from anything user-supplied before it becomes part of a response, and remember that cookies and oversized tokens are what push you into server header limits.

Two assertions worth having in a suite. That the security headers appear on every response including a 404 and a 500 — a middleware that runs inside the error handler will silently stop applying them. And that a header carrying user input has control characters stripped, since header injection is invisible until someone tries it.

Both are one-line assertions and both catch regressions that no functional test would notice.