Skip to main content

Why an event bridge

A grant is issued against the facts at the time: the business was active, the case was open, the principal was employed. Facts change after issuance. A provider that monitors a business learns it was dissolved; an identity provider learns a session was revoked. The event bridge lets those systems tell the auth service, so the grants that relied on the old facts stop working. The bridge has three rules:
  1. Only verified events count. A delivery that does not verify is refused with 401, counted, and never acted on.
  2. An event is acted on at most once. Every event id is claimed in a replay store before anything happens.
  3. Nothing is inferred. A verified event that no mapping rule matches is logged, counted and ignored. It never widens or narrows a grant by default.
The bridge is off unless EVENT_BRIDGE_ENABLED=true. With it off every event bridge route answers 404 (ingestion) or 403 FEATURE_DISABLED (registration), and nothing else in the auth service changes.

Registering a source

Sources belong to one developer and are managed with the developer API key.

SSF/CAEP transmitter

A transmitter pushes Security Event Tokens (RFC 8417) to the receiver, as in RFC 8935 push delivery. Register its issuer and its public keys, inline or by URL:
The response carries ingestUrl (/v1/event-bridge/ssf/<id>). audience defaults to that URL; set it to what the transmitter puts in aud if it differs. jwksUri is fetched through the same outbound guard as webhook URLs (no private hosts in production), cached for five minutes and refetched at most every 30 seconds when a token names an unknown kid. Inline jwks must contain public keys only. A SET is accepted only when all of these hold, and each failure has its own reason code: The subject is read from the SSF sub_id claim, or from an event’s subject member for transmitters on earlier CAEP drafts.

Generic signed webhook

For systems that do not speak SSF, register a webhook source:
The response includes secret once. It is stored encrypted with VAULT_ENCRYPTION_KEY, bound to the source id. The sender signs the timestamp and the exact body bytes:
This is the scheme the auth service uses for its own outbound webhooks (X-Grantex-Signature-V2), so one signer serves both directions. The body is JSON:
The signature is checked before the body is parsed; then the timestamp must be within toleranceSeconds of the receiver’s clock, in either direction (timestamp_out_of_window). Several sha256= values may be sent, comma separated, while the sender changes secrets. Rotation. POST /v1/event-sources/<id>/rotate-secret returns a new secret. The previous one keeps verifying for previousSecretTtlSeconds (default one day, at most seven). Pass 0 for a leaked secret so it stops immediately.

Replay protection

Each verified delivery claims (source, event id) — the SET jti or the webhook id — before it is processed: A replay outside the webhook window, or of a SET older than maxAgeSeconds, is refused before it reaches the replay store.

Responses

  • 202 {"status": "unmapped" | "applied" | "observed" | "duplicate"}
  • 401 {"err": "<reason>", "description": "…", "code": "EVENT_UNVERIFIABLE"} for every verification failure, including an unknown or disabled source (so source ids cannot be probed)
  • 415 for the wrong media type
  • 404 when the bridge is off for the source’s developer
  • 5xx when processing failed; the receipt is left failed so the sender’s retry is processed again

How an agent finds out: the revocation feed

enforce() verifies a grant token offline against the issuer’s JWK Set. That is what makes it fast and what makes revocation invisible to it: a revoked grant’s token stays cryptographically valid until it expires. Revoking a grant stops the auth service issuing anything new; it does not, on its own, stop an SDK that already holds a token. The revocation feed closes that gap. Three modes, chosen per client or per call:
A denial carries grant_revoked with a sub-reason: revoked, suspended, parent_revoked, feed_stale, feed_unavailable or status_unavailable.

Failing closed

The last three matter most. An agent whose feed has gone quiet does not know what has been revoked, so it stops authorising calls:
  • the feed records when it last heard from the auth service;
  • the stream sends a heartbeat every second, and only while the server has read the database successfully;
  • if the client has heard nothing for longer than its staleness bound (default 5 seconds), enforce() denies with feed_stale;
  • if the deployment does not serve the feed, or the feed is not ready, every call is denied with feed_unavailable;
  • in online mode, a check that cannot be completed denies with status_unavailable, and a grant the auth service does not recognise is refused rather than assumed live.
This is the opposite of a cache: it is a claim about freshness that expires.

The endpoints

A client starting cold reads the cursor and then pages the snapshot — every grant currently revoked or suspended and not yet expired, and every individually revoked token — then streams from the cursor. Because the cursor is read first, nothing that happens while it pages can fall between the two. Entries are {seq, action, grantId, jti, expiresAt, at} with action one of revoked, suspended, resumed or token_revoked. They are a set of identifiers, so a duplicate delivery changes nothing. The cursor never advances past an entry that could still be overtaken. A transaction that took its sequence number before another but committed after it would otherwise be skipped, so entries younger than the settle window (REVOCATION_FEED_SETTLE_SECONDS, default 15 s) are delivered but do not move the cursor. That costs a few repeated entries and removes a way to miss one. And never past the page it returned. A page is bounded by limit (1000 by default) while the settled maximum is not; a cursor taken from the larger number would skip everything in between while the client believed itself up to date. The cursor a response carries is therefore the lowest of the two — which matters exactly when there is a lot to deliver: a large cascade, an emergency stop, a sweep. Delivered entries are kept for REVOCATION_FEED_RETENTION_HOURS past the expiry of the credential they are about, and an hourly worker prunes the rest, so the table the snapshot reads does not grow without bound.

Where the entries come from

Database triggers on grants and grant_tokens, not from each revocation path. Every way a grant stops — DELETE /v1/grants/:id, a cascade from a provider event, an emergency stop, a consent withdrawal, an anomaly, a DPDP erasure, OAuth revocation, and the hard delete behind DELETE /v1/agents/:id — writes a feed entry in the same transaction as the revocation itself. Four triggers cover it: two on status changes and two on deletion, since a deleted row can appear in no snapshot. pg_notify wakes the receivers on commit; each instance also polls (REVOCATION_FEED_POLL_MS, default 500 ms), so a lost notification costs latency and never correctness. If those triggers are missing (the migration could not take the lock at startup), the feed endpoints answer 503 FEED_UNAVAILABLE rather than an empty feed, and clients fail closed.

Measured

scripts/revocation-release-test.sh starts the auth service against real Postgres and Redis, builds a delegation tree, revokes each parent and measures how long the child kept being authorised, through both SDKs. G-6 requires two seconds at the ninety-fifth percentile. Two things make that number mean something. The TypeScript measurement runs in a plain Node process loading the build from the checkout, and the Python one refuses to start unless grantex was imported from the checkout — an ambient install would otherwise “prove” the criterion against code nobody reviewed. And the clock starts when the revocation is committed (when the API call returns), not when the call was made: a developer on the free plan is rate limited to 100 requests a minute, and the SDK waiting out a Retry-After is not propagation. That wait is reported separately.

Observability

Every refused delivery also logs alert: "event_bridge_verification_failure" with the source id and reason, never the payload or signature. Alert rules are in deploy/prometheus/event-bridge-alerts.yml.

Settings

What this does not defend against

  • A transmitter whose signing key is stolen can send events that verify. Scope what its events can do with mapping rules, and disable the source (PATCH /v1/event-sources/<id> with {"status": "disabled"}) if its key leaks.
  • Events are only as timely as the sender. The bridge bounds how old an accepted event may be, not how late the sender is.
  • A disabled source’s events are refused, not queued: re-enable it and have the sender retransmit.
  • The feed tells an SDK what the auth service knows. An agent that does not use it (revocationCheck: 'offline', the default) keeps calling until its token expires, which is why short grant lifetimes still matter.
  • A revocation is bounded by the client’s staleness bound, not by zero: an agent can make calls in the window between the revocation and the entry arriving. Lower staleAfterMs and the poll interval to narrow it; the measured propagation on a local stack is well under 100 ms.
Last modified on September 21, 2026