A lightweight Shared Signals Framework receiver based on SGNL's ssfreceiver library that validates incoming Security Event Tokens (SETs) and forwards them to one or more sinks.
For easy deployment, see the Docker deployment guide.
We've created some guides for common use cases we call "recipes":
- Go 1.26+
- A running SSF transmitter that supports push delivery
go install github.com/twosense/ssf-forwarder/cmd/ssf-forwarder@latestOr build from source:
git clone https://github.com/twosense/ssf-forwarder
cd ssf-forwarder
go build -o ssf-forwarder ./cmd/ssf-forwarderCreate a YAML config file. The only required fields are receiver.public_url, transmitter.metadata_url, transmitter.auth, and at least one sink.
receiver:
public_url: "https://receiver.example.com" # externally reachable URL for this service
listen_addr: ":8080" # default: :8080
endpoint: "/events" # default: /events
auto_register: true # default: true; set false to manage stream registration out of band
transmitter:
metadata_url: "https://transmitter.example.com/.well-known/ssf-configuration"
auth:
type: bearer
token: "your-token-here"
events_requested:
- "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
- "https://schemas.openid.net/secevent/caep/event-type/credential-change"
sinks:
- type: webhook
url: "https://webhook.example.com/events"The service registers public_url + endpoint as the push delivery URL when it connects to the transmitter. Make sure that address is reachable by the transmitter.
Bearer token:
transmitter:
auth:
type: bearer
token: "your-token-here"OAuth2 client credentials:
transmitter:
auth:
type: oauth2
token_url: "https://auth.example.com/token"
client_id: "your-client-id"
client_secret: "your-client-secret"By default, the raw SET (the JWT string) is POST-ed to the webhook URL, with the original Content-Type header forwarded. The request is retried up to three times with exponential backoff.
Add or override headers:
sinks:
- type: webhook
url: "https://webhook.example.com/events"
headers:
Authorization: "Bearer sink-token"
X-Source: "ssf-forwarder"Rewrite the request body with a Go template:
The template has access to .RawToken (the raw JWT string) and .Claims (a map of the decoded JWT payload claims).
sinks:
- type: webhook
url: "https://webhook.example.com/events"
headers:
"Content-Type": "application/json"
body_template: |
{"token": "{{.RawToken}}", "issuer": "{{index .Claims "iss"}}"}Multiple sinks:
By default, all sinks receive every event (see Filtering events to scope a sink to a subset). Delivery to each sink is attempted concurrently.
sinks:
- type: webhook
url: "https://first.example.com/events"
- type: webhook
url: "https://second.example.com/events"
headers:
Authorization: "Bearer other-token"The log sink prints structured information about each received SET to stdout. Useful for debugging or as an audit trail alongside other sinks.
sinks:
- type: logEach event is logged with the following fields: issuer, jti, iat, event_types, and txn (if present).
By default, every sink receives every event. Add a filters list to a sink to forward only the events it should receive. Each filter is an expr expression that must evaluate to a boolean, and a SET is forwarded to the sink only if all of its filters return true. A sink with no filters (or an empty list) receives everything.
Each expression is evaluated against three variables:
| Variable | Type | Description |
|---|---|---|
event_type |
string | The event's type URI (the key under the SET's events claim) |
event |
map | The event's payload, e.g. event.current_level |
claims |
map | The full decoded SET, e.g. claims.iss, claims.sub_id.sub |
For example, to forward only risk-level-change events where the risk level is HIGH to a webhook, while still logging everything:
sinks:
- type: webhook
url: "https://davinci.example.com/events"
filters:
- 'event_type == "https://schemas.openid.net/secevent/caep/event-type/risk-level-change"'
- 'event.current_level == "HIGH"'
- type: log # no filters: receives every eventFilters within a sink are evaluated in order and short-circuit at the first one that returns false, so put the event_type check first.
A SET almost always carries a single event. In the rare case one carries multiple, event and event_type are unavailable (a warning is logged) — use claims.events to address them directly.
Filters are compiled when the config loads, so a malformed expression, or one that doesn't return a boolean, fails startup. At runtime a missing field evaluates to false rather than erroring; if a filter references a field that isn't present in the SET, a warning is logged so a typo'd path doesn't silently drop events.
ssf-forwarder --config config.yamlThe --config flag defaults to config.yaml in the current directory.
On startup, the service:
- Fetches transmitter metadata from
metadata_url - If
auto_registeristrue(the default), registers a push stream with the transmitter. If one is already registered it reuses that stream and logs a warning, since that usually means either a previous run shut down uncleanly or another instance is already running - Starts listening for incoming SETs
On shutdown (SIGINT/SIGTERM), if auto_register is true, the stream is deleted from the transmitter before the process exits.
ssf-forwarder register — registers (or updates) the stream for public_url + endpoint. Idempotent; safe to re-run. Re-run after changing public_url or events_requested to update the existing stream in place.
ssf-forwarder deregister — deletes the stream. Idempotent.
ssf-forwarder / ssf-forwarder serve — runs the receiver (default).
Point a load balancer at every instance and use its address as the shared public_url. Opt every instance out of boot registration — leave even one instance auto-registering and it will delete the shared stream when it shuts down or restarts, cutting off delivery to the others:
receiver:
public_url: "https://ssf-forwarder.example.com" # the load balancer
auto_register: falseRegister the stream once during provisioning (safe to re-run; updates in place if the URL or event types changed):
ssf-forwarder registerThen start the instances normally (ssf-forwarder). Each warns on boot if no stream is registered for its public_url, but keeps serving. To tear the integration down, run ssf-forwarder deregister once.
ssf-forwarder supports all CAEP event types defined in the CAEP specification.
| Event type | URI |
|---|---|
| Session Revoked | https://schemas.openid.net/secevent/caep/event-type/session-revoked |
| Token Claims Change | https://schemas.openid.net/secevent/caep/event-type/token-claims-change |
| Credential Change | https://schemas.openid.net/secevent/caep/event-type/credential-change |
| Assurance Level Change | https://schemas.openid.net/secevent/caep/event-type/assurance-level-change |
| Device Compliance Change | https://schemas.openid.net/secevent/caep/event-type/device-compliance-change |
| Session Established | https://schemas.openid.net/secevent/caep/event-type/session-established |
| Session Presented | https://schemas.openid.net/secevent/caep/event-type/session-presented |
| Risk Level Change | https://schemas.openid.net/secevent/caep/event-type/risk-level-change |
| Verification | https://schemas.openid.net/secevent/ssf/event-type/verification |
| Stream Updated | https://schemas.openid.net/secevent/ssf/event-type/stream-updated |
go test ./...
go vet ./...The end-to-end tests in test/e2e/ run the real compiled binary against an in-process fake transmitter and webhook sink. They are excluded from go test ./... by a build tag and must be run explicitly:
go test -tags e2e -count 1 ./test/e2e/...The test builds the binary from source automatically — no extra setup required.
You can also run the E2E tests against the built Docker image. This requires host networking, so it will only work on Linux:
E2E_DOCKER=1 go test -tags e2e -count 1 ./test/e2e/...