Secure calls between processes
Once a frond runs in another process, the receiver has to know who is calling it. On one machine that is free; across a network it is a signature.
Loopback or signed
In one process a call is a function call: nothing crosses, nothing needs proving. Split a frond out and the call becomes a payload, which can say anything. So a receiver establishes who calls it rather than accepting what the payload claims.
It binds 127.0.0.1 by default. Widening the address is one line, and it comes with a condition,
checked at boot:
await serve(runner, { hosts: ['0.0.0.0'] }) // refuses to start on its own
await serve(runner, { hosts: ['0.0.0.0'], verify }) // verifies every call
127.0.0.1 starts, nothing to configure
0.0.0.0 + FOUGERE_ROOT_KEY starts, verifies every call
0.0.0.0 with no root does not start, and says what to do
0.0.0.0 + allowUnsigned: true starts — something in front already authenticated
A receiver reachable from outside, that establishes nothing, would believe whatever state it is handed — so it does not start. Refusing at boot rather than per call means you find out at deployment, not in production.
A caller may send up to maxBodyBytes (2 MiB unless fougere.config.ts says otherwise); a hop
keeps 64 KiB above it for what it adds around the call — the trace, the identity, the state — so a
body that passes in one process passes split.
What travels
Every call across a link carries an envelope, signed by the caller:
{ "method": "post.list",
"params": { "params": {}, "query": {}, "input": null,
"identity": "eyJhbGciOiJFZERTQSIs…" } }
The envelope carries the state — who is signed in, if anyone — and the receiver takes it from there rather than from the payload. Sending both would leave every reader downstream choosing between a proof and a claim about the same thing.
It binds the whole call: address, op, params, query and state by value, the body by
digest. Signing the state alone would prove who without proving what, and a captured
envelope could be replayed against another operation for as long as it stayed valid —
post.list re-sent as post.delete, same signature, still good.
Two signatures, one public key
GRANT the root says "this key is blog" — issued once, at deployment
ENVELOPE blog says "here is my whole call" — signed per call
A receiver holds the root's public key and nothing else. It admits a frond it has never seen, and a frond granted tomorrow is admitted by a receiver deployed today without its configuration being touched. That is the difference between an authority and a list of known callers: the list has to be updated everywhere, and it goes stale.
Verification is offline — no service is joined, on any call or at any boot.
Issuing
fougere keys # once per system — writes fougere.key, prints FOUGERE_ROOT_KEY
fougere grant blog # once per frond that CALLS — prints FOUGERE_KEY and FOUGERE_GRANT
fougere keys is a command, not a service: it runs at deployment time and exits. Nothing
stays alive, nothing is joined at boot. The root's private key is written to
fougere.key (mode 600, added to .gitignore) and never printed — it signs grants,
and a grant is the only thing that has to travel.
fougere grant prints and stores nothing. Re-running it issues a new key rather than
showing the old one, which is what rotation is: run it again, redeploy that frond, and
nobody else's configuration moves.
What a deployment injects
Three variables, no configuration key — a private key does not belong in a committed file. PEM or base64, both are read.
| Where | What it is | |
|---|---|---|
FOUGERE_ROOT_KEY | every frond that answers | public — safe in an image or a manifest |
FOUGERE_KEY | every frond that calls | secret, shown once |
FOUGERE_GRANT | beside it | the root's word that this key is that frond |
A Frond may hold either half or both: one that only answers has no key, one that only calls trusts no root, one in the middle does both. Trusting a root is asking to refuse — there is no second flag, because a deployment that names an authority and then accepts unsigned calls has said two things at once.
What a handler sees
Nothing new to write. The state arrives where it always did, so a
collector reading ctx.state.user is unchanged — in process,
and split. What is added is one field:
invocation.caller // 'blog' — the frond that signed, as the root named it
It is top-level and not a key of state, and the reason decides how to read it: state
travels unverified when no verifier is wired, so a caller living inside it would be
forgeable exactly where nothing checks. Absent means not established, and there is no
spelling for a claim. A sender clears it on every hop, so it names the last one:
shop → catalog → billing has billing read catalog.
What this does not do
Authorization. A signature says who is calling, never what they may call.
if (user.role !== 'admin') throw is still yours to write.
A mesh's job. Under a service mesh the sidecar terminated mTLS before your process saw
the request, and asking for a second signature would redo what was just done a centimetre
away. That is what allowUnsigned: true is for — spelled separately from everything else,
so it can only ever mean "I thought about this".
Cross-language bodies. The body is bound by digest over its JSON text, which is the one place key order matters. A sender written in another language must emit the body the way it hashed it. Everything else is bound by value, which is what keeps a canonical-JSON dependency out of the wire.
The browser is not in this
A browser sits outside the topology and holds no key. It never signs, and nothing it sends
is taken as identity: the host resolves the session server-side from the request's own
cookie, and the payload's state is dropped. This page is about the link between two processes.