skip to content

How do you safely read a tenant id from r.Context() inside an http.Handler?

level: middleimportance: should knowfreq 52%

answer

  1. the lookup is untyped in both directions
  2. absent key yields nil, not zero
  3. one assertion form panics, one does not
  4. wrap the read in a typed accessor
  5. missing means misconfigured, so 500

basics

~20 s

Use a comma-ok type assertion: id, ok := r.Context().Value(tenantKey{}).(string). The lookup returns an untyped value, so a bare assertion panics when nothing attached the key. Treat a missing value as a server misconfiguration, not a default.

solid answer

~40 s

`Value` on a `context.Context` returns `any`, and returns nil when no context in the chain holds the key, so the read needs a comma-ok assertion: `id, ok := r.Context().Value(tenantKey{}).(string)`. A bare `.(string)` panics with an interface-conversion error the day the middleware is not wired into a route, which turns a configuration mistake into a 500 with a stack trace. I put the key and a typed accessor — `func TenantFrom(ctx context.Context) (string, bool)` — in the package that attaches the value, so handlers never touch `Value` directly and cannot get the type wrong. When `ok` is false I return 500, not 400 and never a silent default: an absent tenant means the chain is misconfigured, and defaulting to an empty or shared tenant is how cross-tenant reads happen.

code

go · 7 lines
go
type tenantKey struct{}

// TenantFrom reports the tenant id an earlier middleware attached.
func TenantFrom(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(tenantKey{}).(string)
	return id, ok
}

go deeper

for a junior

Remember the comma-ok shape of the read and that the lookup hands back an untyped value, so you must assert the type you expect rather than using the result directly.

for a middle

Explain both failure modes — key absent and key present with another type — say what the single-result assertion does at runtime, and show the typed accessor that keeps the type in one place.

for a senior

Argue the response code for a missing value, name the cross-tenant risk of defaulting it, and describe how you would catch a route that skips the middleware before it reaches production.

for a principal

Own the convention: which package holds the key and accessor, what handlers are allowed to read directly, and how you stop request-scoped data being copied into longer-lived shared state across the codebase.

## What the read actually returns `context.Context`'s lookup method has this shape: ```go Value(key any) any ``` It is untyped in both directions. Asking a context for a key walks the chain of derived contexts toward the root and hands back whatever was stored, as `any`. If no context in the chain holds that key, the result is nil — not an error, not a zero string, not a panic. That means every read has two failure modes a handler must survive: the key was never attached, and the key was attached with a different concrete type than you expect. The comma-ok form of the type assertion covers both: ```go id, ok := r.Context().Value(tenantKey{}).(string) ``` When the assertion fails, `id` is the zero value (`""`) and `ok` is false. When you use the single-result form instead, a mismatch panics at runtime with `interface conversion: interface {} is nil, not string`. ## Why the bare assertion is a trap The bare assertion looks fine in every test where the middleware is in the chain, which is every test you wrote. It fails the day someone registers a route on a mux that does not go through that middleware, or reorders the chain, or adds a health endpoint that skips it. Then a request that should have produced a clean "this route is misconfigured" error instead panics inside a handler. `net/http` recovers a handler panic per connection so the process survives, but the client gets an abrupt failure and the stack trace lands in the server's error log rather than anywhere you look first. The comma-ok form turns that into a decision you control. ## Handle the missing case deliberately The interesting judgment is what to do when `ok` is false. Three options, and only one of them is right for a tenant id: - **Return 500.** A missing request-scoped value is a server-side wiring defect, not something the caller did wrong. 500 is honest, it is loud in your error rate, and it does not teach the client to retry with different input. - **Return 400.** Wrong: the client's request may have been perfectly well formed; the middleware that would have resolved the tenant never ran. - **Fall back to a default.** Actively dangerous in a multi-tenant service. An empty tenant id flows into a query, and depending on how the data layer treats an empty scope you either return nothing or return rows belonging to somebody else. The cross-tenant data mix-up an engineer eventually has to debug starts as a defaulted empty string in one handler. ## Give the read a typed front door The pattern that keeps this small is to put the key and the accessor in the package that attaches the value, and export only the accessor: ```go type tenantKey struct{} func TenantFrom(ctx context.Context) (string, bool) { id, ok := ctx.Value(tenantKey{}).(string) return id, ok } ``` Handlers then call `TenantFrom(r.Context())` and get a `(string, bool)` pair. The concrete type of the stored value is decided in exactly one place, so a change from a `string` to a `*Tenant` struct is a compiler-checked refactor instead of a runtime surprise scattered across handlers. It also makes the read cheap to mock in tests: a test builds a context with the same package's attach function and calls the handler directly. ## Take a copy, not a reference to the request A request-scoped value is exactly that: scoped to the request. Read it out and use it inside the handler; do not stash the value, the context or the `*http.Request` in a package-level map, a struct field on a shared handler, or a long-lived cache so some other code can "look it up later". Those places are shared by every concurrent request, and the next request overwrites what the last one put there. If you need the id in a helper, pass it as an ordinary parameter or pass the context — that is what the parameter list is for. ## Small details worth stating - `r.Context()` is documented to be non-nil always, so you never need a nil check on the context itself; the nil you must handle is the `Value` result. - The lookup is a linear walk up the chain, so it is a lookup, not a map hit; reading the same value in a tight loop is worth hoisting into a local variable. - Reading is cheap but not free of ceremony — that is the point of the accessor: one place to get the key, the type and the missing-value contract right.

  • What exactly happens with the bare assertion id := r.Context().Value(tenantKey{}).(string) when nothing attached the key?
    It panics with an interface-conversion error, because the lookup returned a nil `any` and the single-result assertion requires the dynamic type to match. `net/http` recovers the panic for that connection, so the process survives but the client sees a broken response and the trace goes to the server error log.
  • Would you ever default a missing tenant id to the empty string and carry on?
    No. An empty scope is not a safe default in a multi-tenant service — depending on how the data layer treats it you either return nothing or return another tenant's rows. A missing request-scoped value means the middleware chain is wrong, so fail with 500 and fix the wiring.
  • Why put the accessor in the same package as the middleware that attaches the value?
    So the concrete type stored and the type asserted are decided in one place. Handlers import a function with a real signature instead of repeating an untyped assertion, which makes changing the stored type a compile-time refactor rather than a runtime panic discovered in production.

saying these in an interview costs you the question

  • Uses a bare type assertion and calls it fine
  • Expects a missing key to return the zero value
  • Defaults a missing tenant id to empty and continues
  • Returns 400 for a value the middleware failed to attach
  • Stashes the value in a package-level map for later lookup
  • Thinks the lookup returns an error when the key is absent