Mermai blog

Securing requestState with OpenBao

published at 09-04-2026 by Max Körbächer

A follow-up to our article at the AAIF Blog Designing RequestState for Multi-Round Trip Requests. All code in this post is from the companion repository `openbao-mcp-requeststate` and runs against a real OpenBao and the official MCP Python SDK.

The most common question after our article on the AAIF blog was some variant of: "fine, requestState must be integrity-protected, encrypted, bound, expired, and replay-controlled, but with what?" The MCP 7-28 specification is not very descriptive on infrastructure. It tells you a server MUST treat `requestState` as attacker-controlled input and MUST protect its integrity when it influences authorization, resource access, or business logic. It does not tell you where the keys live, how twelve stateless replicas agree on them, or what enforces "this confirmation can be used at most once."

This post answers that question with OpenBao. OpenBao is an open source, community-driven secrets manager. Not because a secrets manager is the only answer, but because it turns out to be a surprisingly precise one: each of the three gaps the SDK leaves open maps onto an OpenBao feature.

Where the SDK already ends the discussion

Before adding infrastructure, I want ot be clear about what the SDK already gives you. On MCPServer, requestState protection is on by default. Your handler writes plaintext to ctx.request_state and reads plaintext back; the SDK seals the token on the way out and verifies it on the way in, so the wire only ever carries an opaque, encrypted, authenticated blob. 

The seal buys more than integrity: each token is bound to a time window (600 seconds by default, re-stamped every round), to the originating request (method, tool or prompt name, and a digest of the arguments), and, when the request carries an OAuth token the SDK validated, to the authenticated principal. Every inbound requestState on the three MRTR carriers (tools/call, prompts/get, resources/read) is checked before your handler runs, including one arriving at a handler that never mints state. Tampered, expired, replayed against a different tool, lifted from a different user, or sealed under a key this instance doesn't hold: all of it gets the same frozen -32602 "Invalid or expired requestState". One message for every cause, so the wire never says which check failed. The real reason goes to your log.

One caveat, and it's the reason the rest of this post exists: that default key is minted at process start and dies with the process. Single stdio server, fine. Two replicas behind a load balancer, or one rolling restart, and every in-flight retry gets that same -32602.

The envelope claims cover most of the checklist from my last post:

ClaimEnforces
`iat` / `exp`expiry window (default TTL 600 s)
`m`, `t`, `a` binding to method, tool/resource, and a digest of the salient arguments
`aud`audience — state minted by another service on the same keys is rejected 
`p` the authenticated principal, when the server runs with OAuth

Your tool never sees a token, only the plaintext it minted. The interesting question is what the envelope cannot do and all three answers are operational, not cryptographic.

  1. It cannot distribute its own key. The SDK's default policy is RequestStateSecurity.ephemeral(): a random key generated at process start. Recall why MRTR exists at all; it externalizes resumption state so that stateless, horizontally scaled servers can run interactive flows without sticky routing. Under an ephemeral key, that promise can break: the load balancer sends round 2 to replica B, replica B has a different key and a perfectly honest client gets Invalid or expired requestState. A restart mid-conversation does the same to every in-flight round. The SDK's own docstring says it plainly: multi-instance deployments must share keys. Sharing keys is a secrets-management problem.
  2. It cannot satisfy a key-custody mandate. The built-in codec holds AES key material in application memory. Plenty of environments are fine with that; some are not allowed to be.
  3. It cannot make anything single-use. This is the subtle one, clearly stated in the specs: principal binding, TTL and request binding bound the replay window BUT "do not by themselves guarantee single-use." Where a requestState must be consumed at most once, one-time redemptions being the canonical case, the server MUST enforce that invariant server-side. A sealed, bound, unexpired confirmation to delete files is still a valid confirmation to delete files the second time it is presented. At-most-once is in the end a state problem and no amount of cryptography on a stateless token solves it.

The Example: one destructive tool, two round trips

The repo runs storage-janitor, an MCP server with a single tool. purge_objects(prefix) scans a bucket, comes back with an elicitation like "Purge 3 object(s) under 'logs/'?" and completes on the retry:

https://liquidreply.net/storage/media/general/mermai-blog.png

Round 1 the server returns early instead of reaching back:

```json
{
 "resultType": "input_required",
 "inputRequests": {
   "confirm_purge": {
     "method": "elicitation/create",
     "params": {
       "mode": "form",
       "message": "Purge 3 object(s) under 'logs/'? logs/2026-08-01.log, …",
       "requestedSchema": { "…": "…" }
      }
    }
  },
 "requestState": "v1.XqqHE5Lx3YgnDaFdT4-fQFISc08pagH…"
}
```

That `v1.…` blob is the SDK's sealed envelope. What the tool actually put inside it is two fields: `{"step": "confirm", "claim": "s.lfT6gNneH4…"}; and that second field is where OpenBao comes into the play. 

This opens up three integrations, in increasing order of how opinionated they are.

1. A shared key ring, distributed and rotated by OpenBao

The default integration keeps the SDK's local AES-GCM sealing and moves only key management to OpenBao. Every replica fetches the same ring from KV at startup and hands it to the boundary:

```python
def load_key_ring(bao: OpenBao, path: str = "mcp/request-state-keys") -> list[bytes]:
   """Ring order matters to the SDK: keys[0] seals, all keys unseal."""
    return [bytes.fromhex(k) for k in bao.kv_get(path)["keys"].split(",")]

security = RequestStateSecurity(keys=load_key_ring(bao), ttl=120)
mcp = MCPServer("storage-janitor", request_state_security=security)
```

That is the entire fix for gap 1. Round 1 can land on replica A and round 2 on replica B; the demo shows this behavior and B completes a purge it never planned. Restarts stop invalidating in-flight rounds, because the key outlives the process.

Because `keys[0]` seals and every key unseals, rotation is a ring edit and maps cleanly onto a runbook:

```bash
./openbao/setup.sh rotate  # prepend fresh key: it seals, old keys still unseal
# … wait at least one requestState TTL …
./openbao/setup.sh retire  # drop old keys; nothing sealed under them survives
```

In a rolling fleet there is one extra thing to keep an eey on: distribute the new key for unsealing first (`keys=[old, new]`) and only start sealing with it (`keys=[new, old]`) once every replica has reloaded. Otherwise a not-yet-reloaded replica rejects state minted by an already-rotated one. The TTL you chose for the envelope is also your rotation latency.

The server authenticates to OpenBao with AppRole under a five-line policy that translates to read the ring, use transit, wrap and unwrap and so a compromised MCP server can use keys but never exfiltrate the Transit key or write anything.

2. Transit: when the key must not exist in your process at all

If policy says AEAD keys never live in application memory, invert the arrangement: leave key management and the cryptography in OpenBao. The SDK makes this a ten-line code, because `RequestStateSecurity` accepts any object with `seal`/`unseal`:

```python
class TransitCodec:
    def __init__(self, bao: OpenBao, key: str = "mcp-request-state") -> None:
        self._bao, self._key = bao, key

    def seal(self, payload: bytes) -> str:
        return self._bao.transit_encrypt(self._key, payload)

    def unseal(self, token: str) -> bytes:
        if not token.startswith("vault:"):
            raise InvalidRequestState("malformed")
        return self._bao.transit_decrypt(self._key, token)

security = RequestStateSecurity(codec=TransitCodec(bao), ttl=120)
```

The SDK still mints and verifies every claim; OpenBao only performs the AEAD. The client-visible `requestState` becomes literal Transit ciphertext `vault:v2:73oTCJZRcrTdFBTK…`  and rotation collapses into `transit/keys/<name>/rotate` plus `min_decryption_version`, with per-operation audit as a side effect: every seal and unseal is now a line in OpenBao's audit log.

3. Response wrapping: at-most-once

Gap 3 needs server-side state with unusual properties: short-lived, consumed exactly once, tamper-evident, auditable. So here we are again with OpenBao and it’s response wrapping. Wrap a payload and you get back a single-use token; the payload sits in the token's private body until someone unwraps it and the second unwrap fails because the first one destroyed it. 

So the tool parks the destructive plan in OpenBao and puts only the claim check into `requestState`:

```python
# Round 1: plan stays server-side behind a single-use claim
claim = single_use.issue({"op": "purge", "files": doomed})   # sys/wrapping/wrap, TTL 120s
return InputRequiredResult(
   input_requests={"confirm_purge": ElicitRequest(...)},
   request_state=json.dumps({"step": "confirm", "claim": claim}),
)

# Round 2: the envelope already proved the state is ours, fresh, and
# bound to these arguments, now enforce at-most-once and act
try:
    plan = single_use.redeem(claim)          # sys/wrapping/unwrap succeeds once, ever
except AlreadyRedeemed:
    return "REFUSED: this confirmation was already used or has expired. Start over."
```

In my AAIF blog where I mentioned the value-versus-reference choice: with this approache it refuses to choose. The resumption context travels by value inside the sealed envelope; the dangerous part, the authoritative list of what gets deleted, travels by reference and never crosses the trust boundary at all. The client carries a capability it cannot read (the envelope is encrypted, so the claim token never appears in client logs), cannot alter and cannot use twice. If the user declines, the tool burns the token immediately rather than letting it age out.

What the attacks look like

`run_demo.sh` starts OpenBao-configured replicas and runs four moves against one legitimately issued `requestState`:

```text
tamper one byte     -> error -32602: Invalid or expired requestState
swap args to secrets/ -> error -32602: Invalid or expired requestState
honest confirmation -> Purged 3 object(s): logs/2026-08-01.log, …
replay same state   -> REFUSED: this confirmation was already used or has expired.
```

Each line is a different layer doing its job:

  • The tampered token dies in the codec (GCM tag)
  • the re-targeted one dies in the claims check (args digest; a confirmation for `logs/` is not a confirmation for `secrets/`)
  • the replay survives both, because it is a genuinely valid token and dies at the only layer that can kill it, the single-use redemption in OpenBao.

The rejected attempts also leave the right traces: the SDK logs the real reason server-side while the wire sees only the frozen message and the replay shows up in OpenBao's audit log as a failed unwrap of a consumed token. A simple signal alerting on.

Production notes

The demo runs OpenBao in dev mode; the distance to production is the usual checklist: TLS on `BAO_ADDR`, durable storage and auto-unseal, an audit device enabled and real secret-id delivery for AppRole. Two knobs deserve actual thought. The envelope TTL is a UX decision disguised as a security decision; it must cover honest user think time on the elicitation and 600 seconds of default generosity may be more resumption capability than a destructive tool wants; the demo runs both TTLs at 120 s. And if your server terminates OAuth, the SDK binds every envelope to the authenticated principal with zero additional code, so the cross-user replay dies in the boundary before your tool runs.

The pattern itself is small: keep the SDK's envelope, because it is correct; give it keys from a system whose job is keys; and put the one thing cryptography cannot promise at most once behind a primitive that promises exactly that. `requestState` is a bearer capability handed to an untrusted party. 

Treat it with token discipline and give that discipline infrastructure.