← iamidentity.ai/blog
IAMIDENTITY.AI
Shared Signals MCP IBM Verify IBM Antenna CAEP

Securing the MCP, Part Two: Shared Signals and the Kill Switch

Use Shared Signals to move revocation into the MCP server, then let Verify enforce it across every federated app

Robert Graham 26 May 2026 ~10 min read

Part one of this series put authorization in one place: the MCP server. Every tool call exchanges the user's bearer for a fresh OBO at IBM Verify, attaches a Rich Authorization Request describing the exact action, and walks the OBO into HashiCorp Vault, which mints a five-minute PostgreSQL credential. The agent transports. Verify decides. Vault mints. The MCP server stands aside.

That covers the happy path. It does not cover what happens when the clinician taps Deny three times in a row, or when Identity Protection flags the user, or when anything else upstream concludes that this session should not continue.

The OBO that Vault verifies is short-lived. The Postgres lease is shorter. The user's underlying IBM Verify access token is not. Once a bearer is in the wild, it works for every app federated to the same tenant. The clinical record system. The HR portal. Whatever else trusts the same identity. A token issued at sign-in does not naturally know when something has gone wrong on a separate downstream call.

So the perimeter has to tell it.

Put revocation in the same MCP server that already owns authorization. Emit a signed event. Let IBM Antenna deliver it, and let Verify enforce the revoke.

I extended this cookbook with exactly that pattern. Same agent, same MCP server, same Verify tenant, same Vault. New piece: IBM Antenna v26.03 sitting between the MCP server and the IBM Verify session admin API, carrying signed CAEP events over the OpenID Shared Signals Framework.

→ Cookbook
github.com/iamidentity-ai/mcp-ssf-verify-vault
Extends mcp-verify-vault · Adds Antenna v26.03 · ~15 min to add on top
This post is the why behind it. What the signal is, where it gets fired, why a signed event is better than a direct API call, and what a security reviewer should look for when they audit the kill path.
End-to-end architecture. Two chains share one perimeter: the per-call identity chain in teal (clinician to agent to MCP server to Verify, Vault, and PostgreSQL) and the SSF revocation chain in magenta (MCP server to Antenna transmitter to Antenna receiver to the Verify session admin API).
Figure 1. Two chains, one perimeter. Per-call identity in teal. SSF revocation in magenta. The MCP server is the only process with credentials to talk to either Verify or Vault.

01Where revocation actually lives (and where it used to live)

An earlier post in this series put the per-call wrapper inside the agent process itself. The agent loop ran in our SvelteKit chat route. It composed the RAR per tool call, made the Token Exchange against IBM Verify, and decided when to act on an mfa_denied. The Vercel-breach follow-up showed the same wrapper at the agent level composing two-route RAR for a banking transfer. That architecture works. I created quite a few demos with it.

The lesson learned over the next two builds is that the wrapper is more durable when it lives in the MCP server, not the agent. Both places see the user identity. Both places can build the RAR. What changes is what surrounds them. The agent process is whatever framework you chose this quarter: SvelteKit, FastAPI, Strands, Bedrock AgentCore Runtime. Swap the framework and the wrapper goes with it. The MCP server is the contract every agent in your environment talks to. Move the wrapper there once and every future agent inherits the security model for free.

The IDP knows the user and the policy but not the shape of the tool call. The model in the loop knows the shape of the tool call but not the user. The agent process, depending on how it is hosted, can know both, or only one. The MCP server is the one component that always knows both, on a per-call basis, regardless of what is on the other side of it.

So in this post, and in the cookbook, the wrapper lives in the MCP server. If you already built the wrapper inside an agent (or you read along earlier in the series and built one there), the pattern still works. It is the same RAR, the same Token Exchange, the same threshold counter, the same CAEP emit. What you get by moving it down is one less component that has to stay disciplined.

What matters

The important design choice is that the MCP server emits an event, not a product-specific API call. In this build, Verify consumes that event and revokes sessions. The same event could also drive other actions without changing the MCP server at all

02The trigger: three denials inside the MCP server

The simplest trigger is a counter. The dispatch-wrapper counts consecutive MFA denials on sensitive reads per session principal. The first deny increments the counter. The second deny increments the counter. The third deny trips the threshold.

Three is the demo number. Production builds pair it with non-MFA risk signals, things like time of day, geo, device posture, the velocity of denials across all of a user's sessions, the shape of the requested RAR itself. The point is that the same component that owns authorization on the way in also owns the decision that things have gone wrong on the way back. There is no second perimeter to keep in sync with the first.

Where this lives in the repo

The wrapper is one file: mcp-server/src/ssf/dispatch-wrapper.ts. It counts. The event builder is one more file: mcp-server/src/ssf/antenna-emitter.ts. It POSTs. Plus a tiny deny-counter.ts that holds the per-user counter state. Together, under 300 lines across three files. A reviewer can read all three in ten minutes and grep the rest of the MCP server source to confirm nobody else emits revocation events.

03The CAEP payload, exactly

CAEP is the Continuous Access Evaluation Profile, an OpenID Foundation specification. It defines a small vocabulary of event types that an identity provider, or an application acting as one, can emit when something about a session changes. The event type for this flow is session-revoked. The other event types in CAEP 1.0 are token-claims-change, credential-change, assurance-level-change, and device-compliance-change. The CAEP 2.0 draft adds session-established, session-presented, and risk-level-change — useful to know they exist when you're wiring downstream consumers, even if your transmitter only emits a subset. The official OpenID CAEP 1.0 specification is here: openid.net/specs/openid-caep-1_0-final.html.

The payload is JSON. The shape has to be exact. Three field-level mistakes are easy to make, and all three can fail quietly because the JSON still parses and the ingester returns 201.

{
  "sub_id": {
    "format": "email",
    "verifyUserId": "643002NOIP",
    "email": "clinician@example.com"
  },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
      "event_timestamp": 1748208000,
      "initiatingEntity": "policy",
      "reasonAdmin": {
        "en": "3 consecutive MFA denials on VIP read attempts"
      },
      "reasonUser": {
        "en": "3 consecutive MFA denials on VIP read attempts"
      }
    }
  }
}
Three traps worth their own callout
  • event_timestamp is seconds, not milliseconds. Date.now() will produce a value the transmitter accepts and never signs. Use Math.floor(Date.now()/1000).
  • initiatingEntity is camelCase. The snake_case initiator_entity is wrong and silently ignored.
  • reasonAdmin and reasonUser are language-keyed dicts like { "en": "..." }, not flat strings. A plain string here also persists unsigned.

The format is not forgiving. The Antenna ingester returns HTTP 201 for any of those three malformed payloads — the failure only surfaces in the transmitter log as persisted unsigned SSF event with no follow-up signing or delivery. docker logs vva-antenna-transmitter | grep "persisted unsigned" is the first thing to check when a CAEP event seems to vanish. Read the spec twice, then read the ingester logs the first time something silently fails.

04The pipeline: IBM Antenna v26.03

IBM Antenna is the component that takes a CAEP payload and turns it into something Verify can act on. Version 26.03 ships as two container images with one binary. A transmitter image and a receiver image. Same binary, different YAML.

The transmitter is the ingestion side. It receives the CAEP event over HTTPS, validates it against an ingestion rule defined in the transmitter YAML, signs it as a Security Event Token (a JWS-signed JSON object per RFC 8417), and holds it for delivery. The relevant slice of infra/antenna/deploying/transmitter/configs/transmitter.yml:

transmitter:
  # Issuer claim on the signed SETs.
  issuer: "https://antenna-transmitter"
  event_types:
    - "https://schemas.openid.net/secevent/caep/event-type/session-revoked"

  runtime:
    enabled: true
    sign_poll_set: true           # every outbound SET is RS256-signed
    jwks:
      signing_keystore: jwks_keys     # keystore name from storage.yml
      default_signing_key: jwtsigner  # key label inside that keystore

  ingester:
    enabled: true
    sources:                          # each source = one validated ingestion rule
      - id: mcp                       # MCP server POSTs to /sources/mcp/events
        type: http_push
        transform_rule:
          type: javascript
          content: "@js/mcp_mapper.js"  # pass-through: MCP emits canonical CAEP

The receiver is the consumer side. It polls the transmitter for unread events using the SSF poll-based delivery model from RFC 8936. When a signed SET arrives, the receiver matches the event type against an action rule. The session-revoked action rule runs a small JavaScript function that calls IBM Verify's session admin API. The relevant slice of infra/antenna/deploying/receiver/configs/receiver.yml:

receiver:
  # Expose POST /mgmt/v2.0/receivers/config so create-stream.sh can subscribe.
  # Bind 9043 to 127.0.0.1 only -- this endpoint ships unauthenticated.
  management:
    enabled: true

  runtime:
    enabled: true
    action_rules:                       # can list MULTIPLE rules per event type
      - event_type: "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
        type: javascript
        content: "@js/session_revoked.js"

And the action handler — the actual API call that does the kill:

function deleteSessions(userId, token) {
  let url = `https://${TENANT}/v1.0/auth/sessions/${userId}`;
  let result = http.delete(
    { headers: { "Authorization": `Bearer ${token}` } },
    url
  );
  logger.info(`[deleteSessions] status=${result.statusCode}`);
}

function main() {
  const SETPayload = JSON.parse(eventStr).claimsJson;
  let token  = getToken();                          # client_credentials
  let userId = SETPayload.sub_id.verifyUserId;     # from the CAEP event
  deleteSessions(userId, token);                     # tenant-wide kill
}
main();

The handler is purpose-narrow on purpose. ~140 lines including the OAuth2 client-credentials helper. Reading it is the auditor's verification that the receiver does exactly one thing — call the Verify session admin API — and nothing else.

More samples are found here: https://github.com/ibm-verify/verify-antenna-recipes

SSF kill flow sequence diagram. Denials 1 and 2 increment a counter inside the MCP dispatch-wrapper with no event emitted. Denial 3 reaches the threshold; the wrapper POSTs a CAEP session-revoked event to the Antenna transmitter, which returns 201. The MCP server replies to the user that the session has been revoked. Asynchronously, 30 to 75 seconds later, the receiver polls the transmitter, receives a signed SET, and calls DELETE /v1.0/auth/sessions/{userId} against IBM Verify. Verify returns 204 No Content, and every session for that user returns 401 on the next request.
Figure 2. Three consecutive MFA denials on VIP reads trip the threshold. The synchronous reply to the user fires immediately. The asynchronous Verify revocation completes in 30 to 75 seconds.
v26.03 deployment gotchas, read before the first run

Every YAML file under infra/antenna/deploying/{transmitter,receiver}/configs/ must start with version: 26.03 as the first key. A missing version produces "No configuration to merge" with no further explanation. Configs mount at /configs, not the path the recipe repo's docker-compose suggests. action_rules live under receiver.runtime.action_rules. Stream create moved to the receiver at POST :9043/mgmt/v2.0/receivers/config. The receiver's management port ships unauthenticated; bind it to 127.0.0.1 only and never expose it on another interface.

05Tenant-wide revoke, asynchronously

The synchronous side of the kill is the reply the user sees. The MCP server hands a stable error back to the agent. The string contains the phrase "your session has been revoked." The UI matches on that substring, stops the agent loop, clears local state, and routes the clinician back to sign-in.

The asynchronous side is the actual revoke. The receiver's poll interval is configurable, typically a few seconds. The receiver fetches the signed SET by sending a POST to the transmitter's /streams/{id}/events endpoint — the SSF poll-based delivery in RFC 8936 is HTTP POST, not GET, because the receiver also acknowledges previously-consumed events in the same request body. The JavaScript action rule then runs, and the receiver calls DELETE /v1.0/auth/sessions/{userId} against the IBM Verify admin API. In my lab, the whole cycle landed in about 30 seconds or less, depending on poll cadence and queue position.

This API call invalidates every active session for the user. Not just the one with the MCP server. Every application federated to the same Verify tenant returns 401 on the next request. The user is signed out of Verify, of the clinical record system, of dashboards, Mobile app, any other app using Verify.

Here is what the operator sees on the MCP server and Antenna logs during that third-deny moment, with identifiers and internal hostnames scrubbed.

[mcp-server]   mfa challenge fired                jti=8b3f...a91c
[mcp-server]   USER_DENIED                        count=1
[mcp-server]   mfa challenge fired                jti=e441...77f0
[mcp-server]   USER_DENIED                        count=2
[mcp-server]   mfa challenge fired                jti=f99e...3b21
[mcp-server]   USER_DENIED                        count=3
[mcp-server]   threshold reached → emit CAEP     sub_id=clinician@example.com
[mcp-server]   POST antenna-tx/sources/mcp/events payload=session-revoked
[antenna-tx]  event received                     201
[antenna-tx]  persisted unsigned SSF event       eventId=8605...88aa
[antenna-tx]  signing SET                        alg=RS256
[antenna-tx]  persisted signed SSF event         eventId=599f...0bae
[antenna-rx]  POST /streams/<id>/events HTTP/2.0 200  # RFC 8936 poll
[antenna-rx]  SET received                       type=session-revoked
[antenna-rx]  action_rule matched                handler=session_revoked.js
[antenna-rx]  DELETE /v1.0/auth/sessions/userId  204 No Content
[clinician]   next request, any federated app    401 Unauthorized
Why a poll, not a webhook

RFC 8936 defines poll-based delivery as the default for Security Event Tokens because the receiver does not have to expose a public endpoint. The receiver pulls. The transmitter signs and holds. The architecture survives behind a strict firewall, on a private subnet, with no inbound public traffic. That is a real operational property, and the reason the standard chose poll over push as the default profile.

06The bigger point: Antenna calls any API

Re-read the action rule on the receiver. It is a small JavaScript function that takes a signed SET as input, decides whether to act, and makes one HTTP call. In the cookbook that HTTP call is DELETE /v1.0/auth/sessions/{userId} against IBM Verify. There is nothing in the SSF spec, nor in the Antenna binary, nor in the cookbook's wiring, that pins the action to a session revocation.

The same pipeline, with a different action rule, can call any API you choose to wire to it.

This is the design point that usually gets lost in the session revocation demo. The kill switch is not something Antenna gives you by default. The kill switch is what the action rule happened to be wired to in the cookbook. The pattern is simple: the MCP server emits one signed event into a generic pipeline, and the receiver-side action rule decides what the event triggers.

The MCP server emits one signed event. Antenna delivers it. The action rule decides what to do with it. Revoke the session, disable the agent, page the SOC, or all of the above.

That separation is the useful part. You write the perimeter logic once, in the MCP server's dispatch-wrapper, and then you wire up whatever consequences you want without ever touching the MCP server again.

07What an auditor should grep for

Same fifteen-minute audit pattern as part one, extended for the revocation path. An auditor should be able to inspect the implementation, trace the revocation flow end to end, and verify that the control is enforced in practice

Operational logging across six surfaces with one jti join key. The MCP server log, the PostgreSQL session log, the IBM Verify event stream, the HashiCorp Vault audit log, and the Antenna transmitter and receiver logs all forward to a SIEM (Splunk, Sentinel, QRadar, or Chronicle) and are joined on the OBO jti claim. All six surfaces speak open standards: syslog, JSON, RFC 8693 and 9396, SSF, and CAEP. No bespoke parser is required.
Figure 3. Six independent logs, one join key. The OBO jti reconstructs any tool call, and any kill, end to end across Splunk, Sentinel, QRadar, or Chronicle.

Same auditor discipline as part one. If a future PR ever has the MCP server call the Verify session admin API directly, or skip the SET signing, or use a Vault token with broader entitlements than the kill needs, fail the PR. The pattern only works if the perimeter stays disciplined about what authority lives where, and the kill switch is no exception.

08What's next

→ Run the cookbook
Added Antenna v26.03 to the cookbook
iamidentity-ai.github.io/mcp-ssf-verify-vault · github.com/iamidentity-ai/mcp-ssf-verify-vault
Builds on mcp-verify-vault. Adds the dispatch-wrapper, the Antenna transmitter and receiver, the action rule, and a smoke test that runs the three-deny demo end to end against your own IBM Verify tenant. Cookbook is rendered live at the Pages link; source + issues at the GitHub link.

Try to break it. Tap Deny twice, then Approve once, then tap Deny three more times. Does the counter reset, or do you get a false emit on the second cluster? Read the dispatch-wrapper and decide whether the answer should make you happy.

Wire a second action rule. The Antenna receiver supports more than one action rule per event type. Add a second one that posts the CAEP event to a Slack webhook, or to your SOAR platform, or to an in-house registry. The MCP server does not change. The new consequence is live.

Audit the kill. Walk the five checks in section seven against your own tenant. Either the chain holds or you have found a real bug worth filing.

One signed event, many consequences. The MCP server doesn't change when you add the second action rule. Or the third. The perimeter stays disciplined; the response surface grows.

That is what a kill switch looks like when revocation lives in the same perimeter that owns authorization. The cookbook makes it real.


→ Read part one
The MCP Server Is Your Security Perimeter
blog.iamidentity.ai/blog/securing-the-mcp
The foundation. Where authorization lives in an MCP server, what the MCP server and the agent are forbidden from doing, and the fifteen-minute audit a reviewer can run against any production MCP deployment.
→ Companion cookbook
AWS Bedrock AgentCore variant
github.com/iamidentity-ai/agentcore-verify-vault
Same identity chain, hosted on AWS Bedrock AgentCore Runtime instead of your laptop. The SSF extension in this post drops in cleanly there too.