Interceptors and secrets¶
Two cross-cutting production concerns, in one place: stamping something onto every request (auth headers, telemetry), and keeping secrets (tokens, passwords, PII) off the trace. Neither is required to be productive — reach for this page when your app grows into them.
Interceptors: stamp every request once¶
Threading "Authorization" into every call site is the kind of cross-cutting concern that belongs in one place. re-frame2 ships a per-frame HTTP interceptor chain for exactly this — the same {:before :after} onion you know from event interceptors, but wrapping the transport instead of the event handler. A :before transforms the request on its way out; an :after transforms the reply on its way back.
rf/reg-http-interceptor takes a positional id and an interceptor map. Here's Bearer auth as a single registration — note it reads the token fresh on every request, so rotation is picked up with zero re-registration:
(rf/reg-http-interceptor
:app/bearer-auth
{:doc "Stamp Bearer <token> on every outgoing request."
:before (fn [ctx]
(let [token (-> (rf/app-db-value (:frame ctx)) :auth :token)]
(cond-> ctx
token (assoc-in [:request :headers "Authorization"]
(str "Bearer " token)))))})
;; Now no handler threads auth — the header is added on the way out:
(rf/reg-event :articles/list
(fn [_ _]
{:fx [(rf.http/get "/articles" {:decode ArticleListResponse})]}))
The two phases:
- The
:beforefn receives a ctx of{:request :args :frame :event}and returns a ctx whose:requestis the modified envelope. (Above,cond-> ctxadds the header only when a token is present, leaving the ctx untouched otherwise.) It reads app state throughrf/app-db-value— an accessor that hands you the frame's current app-db value, never a live subscription. - The
:afterfn is(fn [ctx response] response'). It sees the same ctx its:beforeproduced — so a:beforethat stamps a start-time lets the matching:aftercompute an elapsed delta with no app state — plus the canonical{:status :ok …}/{:status :error …}response, and it returns a possibly-transformed response that the:on-success/:on-failuredispatch then carries.
That ctx-carried-forward shape is what makes per-request concerns — response-time telemetry, rate-limit header parsing, flagging a 401 for an auth refresh — single-interceptor jobs.
The rules that matter:
- Chains are per-frame. An interceptor registered on one frame never fires for a request from another. Multi-frame apps register independent chains.
- Onion order.
:befores run in registration order,:afters in reverse — A-registered-before-B meansA.before → B.before → transport → B.after → A.after. Exactly the event-interceptor mental model. - At least one phase is required. A map with neither
:beforenor:afteris rejected at registration with a named bad-interceptor error. A:before-only or:after-only interceptor is fine and composes cleanly. - Each phase returns a map. A
:beforereturns the request ctx; an:afterreturns the reply map. Returningnil, a vector, or anything else is rejected with:rf.error/http-interceptor-bad-returnso a bad interceptor cannot erase the request or reply. - A throw is named, not swallowed. A
:beforeor:afterthat throws classifies as a named interceptor-failed error (carrying the offending:interceptor-id); a request-side throw means the transport never sees the request. Wrap recoverable logic inside the interceptor yourself — the chain has no recovery cofx. - Clearing. Inside a frame scope,
(rf/clear-http-interceptor id)removes that frame's interceptor. Outside a frame scope, or when you want to name the frame directly, use the opts form(rf/clear-http-interceptor id {:frame frame-id})— the trailing{:frame …}opts map, mirroringreg-http-interceptor's:frame. Calling the single-arity form with no frame in scope fails loud with:rf.error/no-frame-context. Re-registering an existing id replaces it in place (hot-reload-friendly); clear-then-reg appends a fresh slot at the end.
reg-http-interceptor and clear-http-interceptor are the only two HTTP surfaces re-exported onto the rf/ facade (everything else lives in re-frame.http / re-frame.http.managed). This same seam is where resources and mutations get their request decoration too: register the auth interceptor once and every :rf.http/managed request, whether you issued it directly or a resource did, carries the header.
Keeping secrets out of the trace¶
HTTP is where the secrets are: passwords ride request bodies, auth tokens ride request headers, user PII rides response bodies. And every step of a managed request can land on the dev trace stream — the retry attempt, the failure category, the swallowed-failure warning — so without care the transport becomes the app's biggest leak. Managed HTTP applies data classification at that egress boundary so the real value renders on-box but a redaction sentinel is what crosses into a trace, Xray, or an off-box log. Three layers cooperate, and two of them need no opt-in.
Sensitive headers are redacted always — no flag required. A closed, framework-owned denylist of header names — Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key, X-CSRF-Token, and a handful more — is redacted to :rf/redacted in every :rf.http/* trace event, whether or not the request is marked sensitive. The name is the signal: a leaked Authorization header is a leak even from a handler nobody thought to flag. Matching is case-insensitive, and the built-in set is immutable — no frame can remove a name. The same is true on the URL side: a denylisted query-string parameter (?api_key=…, ?access_token=…, ?token=…, ?signature=…) has its value scrubbed inline (?api_key=:rf/redacted&page=2), name and position preserved so you can still see which endpoint was hit, and that hit alone stamps the trace :sensitive?.
For app-specific carriers, declare them on the fx registration. Got an X-Honeycomb-Team or a shop_token that the built-in lists don't know about? Don't reach for a per-call flag and don't mutate a global — name them once on the :rf.http/managed registration, and they union onto the immutable defaults:
(rf/reg-fx :rf.http/managed
{:carriers {:headers ["X-Honeycomb-Team"]
:query-params ["shop_token"]}}
re-frame.http.managed/managed-handler)
For a whole request, set :sensitive?. When an entire request is sensitive — a login POST whose body is the password — flag it and the framework redacts the body, the :params, and every URL query value (not just the denylisted ones) on the way to the trace. The flag lives either under :request or at the top level of the args map; the two are equivalent, and either being true wins:
(rf/reg-event :auth/login
(fn [_ [_ creds]]
{:fx [[:rf.http/managed
{:request {:method :post :url "/auth/login" :body creds}
:sensitive? true}]]})) ;; body + params + all URL values redacted in traces
For response bodies, mark the slots on your :decode schema. The response is classified per-slot through the schema you already hand :decode — the owner's natural declaration of the body's shape doubles as its sensitivity map. Mark {:sensitive? true} on a slot and that field is redacted in the trace; mark {:large? true} and it's elided to a size marker; an unmarked sibling rides in the clear:
;; [:token] is redacted in traces; [:user-id] rides verbatim.
:decode [:map
[:token {:sensitive? true} :string]
[:user-id :int]]
This is the schema's job whether or not the request also carries the coarse :sensitive? flag (the flag is the whole-body hammer; the schema marks are the scalpel). All of this rides the dev trace surface, so it elides wholesale in production along with the rest of tracing — the redaction step costs nothing in a release build. When trace data is exported outside the app, re-frame2 keeps secrets out by default: sensitive slots are denied, unknown error bodies stay local, and export policy can exclude specific places where secrets may appear. For the framework-wide story, see keep secrets out of traces.
Gotcha — a 4xx/5xx error body is always omitted off-box
A non-2xx response surfaces its raw body at :body, and that body never ran through your :decode schema (status is classified before decode), so its shape is unknown. Off-box egress therefore drops it unconditionally — error responses routinely echo back request context or tokens. On your local dev trace you still see it; it's the off-box boundary that fails closed. If you need fields out of an error body, decode :body yourself in the failure branch where the value stays on-box.