Skip to content

Shared Signals Framework: A Basic Transmitter in Go

10 min read Posted by Hector Yeomans
A Shared Signals Framework transmitter turning a security state change into a signed SET, delivering it by push or poll event stream, and a receiver verifying it before applying local policy

An access token is a snapshot. It says what was true when the issuer created it. But security state can change one second later. A user can sign out. An administrator can disable an account. A device can fall out of compliance.

How does another system learn about that change without waiting for the token to expire?

The Shared Signals Framework (SSF) 1.0 gives cooperating systems a standard way to exchange security events. This post explains the small set of ideas behind SSF and builds a runnable transmitter in Go. The transmitter signs a session-revoked event, stores it in SQLite, and keeps offering it to a receiver until the receiver acknowledges it.

What is SSF?

SSF is an OpenID standard for sending security signals between trusted parties. It defines how two roles work together:

  • A transmitter creates and sends events.
  • A receiver gets those events, verifies them, and decides what to do.

The two parties communicate through an event stream. A stream records which event types the receiver can expect and whether delivery uses HTTP push or HTTP polling.

Each security event travels as a Security Event Token (SET). A SET is simply a signed JWT for a security event. Its events claim says what happened. The SET specification defines the token; SSF defines how it moves from the transmitter to the receiver.

SSF is the pipe, not the complete meaning of every event. Profiles define useful event families on top of it:

  • CAEP covers changes such as a revoked session, changed token claims, or changed device compliance.
  • RISC covers account-risk events such as an account being disabled or compromised.

A signal is a fact, not a remote command. The receiver still owns the policy decision. A session-revoked event may cause one receiver to end a session immediately and another to ask for stronger authentication.

A practical example

Imagine a company that connects Apple Business to its identity provider. Employees use their work credentials to sign in to Managed Apple Accounts. During setup, Apple Business asks for the identity provider’s SSF configuration URL. Apple uses that URL to find the SSF endpoints and signing keys.

Apple is the receiver in this connection. The identity provider is the transmitter.

Now suppose an employee leaves the company. An administrator disables the employee’s work account. The identity provider creates a signed SET that describes the change and sends it through the event stream. Apple verifies the SET before using it. It checks that the identity provider signed it and that the event was meant for Apple.

An administrator disables an employee account, the identity provider sends a signed SET to Apple Business, Apple verifies it, and the employee must sign in again

If the SET is valid, Apple can end the employee’s Managed Apple Account session or ask the employee to sign in again. Apple decides which action to take. The identity provider only reports what changed.

Apple can react as soon as the identity provider knows about the change. It does not have to wait for an old access token to expire.

Why SSF matters

Without shared signals, a receiver often has two weak choices. It can trust an access token until it expires, even when the issuer already knows the session is unsafe. Or it can keep asking the issuer whether anything changed, which adds delay and load.

SSF adds a third option: send a small, signed event when the state changes.

That helps in four practical ways:

  1. Faster response. A receiver can react to a revoked session without waiting for token expiry.
  2. Clear trust. Signed SETs let the receiver verify who issued an event and who should receive it.
  3. Shared language. CAEP and RISC give different products the same event names and shapes.
  4. Reliable delivery. Poll and push protocols define acknowledgements, errors, and redelivery behavior.

SSF does not replace OAuth or OpenID Connect. OAuth still protects API access. OpenID Connect still handles sign-in and identity claims. SSF carries later changes that may affect an existing session or access decision.

What we will build

The demo is a small poll-based SSF transmitter. It implements the parts needed to prove one complete delivery:

  • main.go is the company’s identity provider and SSF transmitter.
  • main_test.go plays Apple as the SSF receiver.

The code uses https://apple.example/ssf as Apple’s audience. The .example address is deliberately fictional; it is not an Apple production endpoint.

  • transmitter discovery at /.well-known/ssf-configuration;
  • a public Ed25519 key at /jwks.json;
  • one preconfigured event stream at /ssf/stream;
  • a local-only /emit endpoint that creates a CAEP session-revoked event;
  • RFC 8936 poll delivery at /events;
  • a SQLite table that holds unacknowledged SETs and schedules redelivery; and
  • an end-to-end test in which the Apple receiver discovers the IdP, verifies its SET, and acknowledges delivery.

The /emit endpoint is a demo input, not an SSF endpoint. In a real identity system, an account service or risk engine would call the transmitter through an internal interface.

How the demo transmitter moves one security event The Go service signs a Security Event Token, keeps it in SQLite, and removes it only after the receiver acknowledges its JWT ID.

1. Publish 1 / 2

The event becomes durable before the transmitter reports success.

Read the diagram as text

The Go service signs a Security Event Token, keeps it in SQLite, and removes it only after the receiver acknowledges its JWT ID.

1. Publish — The event becomes durable before the transmitter reports success.

  1. Report the session revocation: event-source → transmitter. A trusted internal caller tells the transmitter which session was revoked.
  2. Sign and store the SET: transmitter → queue. The transmitter creates an explicitly typed, Ed25519-signed SET and commits it to SQLite.

2. Discover — Discovery tells the receiver where to find stream metadata and signing keys.

  1. Request transmitter metadata: receiver → transmitter. The receiver starts with the trusted issuer URL and asks for the SSF endpoints.
  2. Return endpoints and capabilities: transmitter → receiver. The metadata names the issuer, JWKS URI, stream endpoint, and poll delivery support.
  3. Fetch the signing key: receiver → transmitter. The receiver fetches the public Ed25519 key identified by the SET header's kid value.
  4. Return the public JWKS: transmitter → receiver. The receiver now has the public key it needs to verify delivered SETs.

3. Poll + ack — A missing acknowledgement leaves the event eligible for redelivery.

  1. Poll for available SETs: receiver → transmitter. The receiver asks for up to ten events and requests an immediate response.
  2. Claim events ready for delivery: transmitter → queue. One SQLite statement selects due rows and moves their next delivery time forward.
  3. Return the signed SET: queue → transmitter. SQLite returns the JWT ID and compact signed token without deleting the row.
  4. Deliver the SET map: transmitter → receiver. The response maps each jti to its signed SET so the receiver can verify each item.
  5. Acknowledge the JWT ID: receiver → transmitter. After validation, the receiver returns the jti in its next poll request.
  6. Remove the acknowledged event: transmitter → queue. The row is deleted only after the receiver confirms the exact jti.

The important boundary is the SQLite commit. The transmitter reports that an event was queued only after the signed SET is durable. Polling does not delete it. Only an acknowledgement containing the event’s jti removes the row.

The SET we will send

The signed payload looks like this before JWT encoding:

{
  "iss": "http://localhost:8080",
  "aud": "https://apple.example/ssf",
  "iat": 1787337600,
  "jti": "e77cf6b5-4876-4450-b380-bd8b92cc7287",
  "txn": "af59b929-e94a-4d4c-b009-5f91b99981c0",
  "sub_id": {
    "format": "opaque",
    "id": "session-123"
  },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
      "event_timestamp": 1787337600
    }
  }
}

A few details are easy to miss:

  • The JOSE header uses typ: "secevent+jwt". This stops code from confusing a SET with an ID token or access token.
  • The top-level sub_id identifies the subject of the event. An SSF SET must not use the normal JWT sub claim for this purpose.
  • SSF SETs must not contain exp. Delivery and retention rules decide when an event is still useful.
  • The iss value must match the stream and the trusted discovery issuer.
  • The receiver must check that aud identifies it.
  • jti identifies this SET. Poll delivery uses that value for acknowledgement and deduplication.
  • txn connects SETs that came from the same underlying incident.

The code signs this payload with Ed25519 and publishes the public key as a JSON Web Key Set. A production receiver should verify the signature, typ, iss, aud, event type, and subject before it changes local access.

How the receiver and transmitter interact

The receiver starts with the transmitter’s SSF configuration URL. It uses that document to find the event stream and the transmitter’s public signing key. When a security change happens, the transmitter creates and stores a signed SET.

The receiver polls for events, verifies each SET, and decides what to do. It then acknowledges the SET by its jti. Until that acknowledgement arrives, the transmitter keeps the SET so it can deliver it again.

sequenceDiagram
    autonumber
    participant Receiver as Receiver (Apple)
    participant Transmitter as Transmitter (IdP)

    Receiver->>Transmitter: GET /.well-known/ssf-configuration
    Transmitter-->>Receiver: Return stream and JWKS URLs
    Receiver->>Transmitter: GET /jwks.json
    Transmitter-->>Receiver: Return Ed25519 public key

    Note over Transmitter: Security state changes
    Transmitter->>Transmitter: Create and sign the SET
    Transmitter->>Transmitter: Store the SET durably

    Receiver->>Transmitter: POST /events (poll)
    Transmitter-->>Receiver: Return signed SET keyed by jti
    Receiver->>Receiver: Verify signature, issuer, and audience
    Receiver->>Receiver: Apply local policy

    alt Receiver acknowledges the SET
        Receiver->>Transmitter: POST /events with ack: [jti]
        Transmitter->>Transmitter: Delete the acknowledged SET
    else Acknowledgement is missing
        Note over Transmitter: Keep the SET for redelivery
    end

Create the Go project

The example needs Go 1.24 or newer. It uses the pure-Go modernc.org/sqlite driver, so it does not need a C compiler.

mkdir ssf-transmitter
cd ssf-transmitter

Create go.mod:

module example.com/ssf-transmitter

go 1.24.0

require modernc.org/sqlite v1.39.1

require (
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/ncruces/go-strftime v0.1.9 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
	golang.org/x/sys v0.36.0 // indirect
	modernc.org/libc v1.66.10 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
)

Then create main.go. This is the complete transmitter used by the tests in this post:

package main

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/subtle"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	_ "modernc.org/sqlite"
)

const (
	caepSessionRevoked = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
	pollDeliveryMethod = "urn:ietf:rfc:8936"
	// This reserved example URL labels Apple as the receiver in the demo. It is
	// not an Apple production endpoint.
	demoAppleAudience = "https://apple.example/ssf"
)

type config struct {
	issuer          string
	audience        string
	bearerToken     string
	keyID           string
	retryAfter      time.Duration
	longPollTimeout time.Duration
}

// server is the company identity provider in the article's Apple example.
// The identity provider operates the SSF transmitter; Apple is the receiver.
type server struct {
	db         *sql.DB
	privateKey ed25519.PrivateKey
	config     config
	now        func() time.Time
}

type pollRequest struct {
	Ack               []string            `json:"ack,omitempty"`
	SetErrors         map[string]setError `json:"setErrs,omitempty"`
	MaxEvents         *int                `json:"maxEvents,omitempty"`
	ReturnImmediately bool                `json:"returnImmediately,omitempty"`
}

type setError struct {
	Error       string `json:"err"`
	Description string `json:"description,omitempty"`
}

type pollResponse struct {
	Sets          map[string]string `json:"sets"`
	MoreAvailable bool              `json:"moreAvailable,omitempty"`
}

type streamConfig struct {
	StreamID        string         `json:"stream_id"`
	Issuer          string         `json:"iss"`
	Audience        []string       `json:"aud"`
	EventsSupported []string       `json:"events_supported"`
	EventsRequested []string       `json:"events_requested"`
	EventsDelivered []string       `json:"events_delivered"`
	Delivery        map[string]any `json:"delivery"`
}

func main() {
	address := envOr("SSF_ADDR", ":8080")
	issuer := envOr("SSF_ISSUER", "http://localhost:8080")
	bearerToken := os.Getenv("SSF_BEARER_TOKEN")
	if bearerToken == "" {
		log.Fatal("SSF_BEARER_TOKEN is required")
	}

	db, err := openDatabase(envOr("SSF_DATABASE", "ssf.db"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	privateKey, err := loadOrCreatePrivateKey(envOr("SSF_SIGNING_KEY", "ssf-ed25519.key"))
	if err != nil {
		log.Fatal(err)
	}

	identityProvider := &server{
		db:         db,
		privateKey: privateKey,
		config: config{
			issuer:          strings.TrimRight(issuer, "/"),
			audience:        envOr("SSF_AUDIENCE", demoAppleAudience),
			bearerToken:     bearerToken,
			keyID:           "demo-key-1",
			retryAfter:      30 * time.Second,
			longPollTimeout: 15 * time.Second,
		},
		now: time.Now,
	}

	httpServer := &http.Server{
		Addr:              address,
		Handler:           identityProvider.routes(),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       20 * time.Second,
		WriteTimeout:      20 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	log.Printf("identity-provider SSF transmitter listening on %s", address)
	log.Printf("issuer: %s", identityProvider.config.issuer)
	log.Fatal(httpServer.ListenAndServe())
}

func (s *server) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /.well-known/ssf-configuration", s.handleDiscovery)
	mux.HandleFunc("GET /jwks.json", s.handleJWKS)
	mux.HandleFunc("GET /ssf/stream", s.requireBearer(s.handleStream))
	mux.HandleFunc("POST /events", s.requireBearer(s.handlePoll))
	// /emit represents an internal IdP account or risk service, not an SSF API.
	mux.HandleFunc("POST /emit", s.requireBearer(s.handleEmit))
	return mux
}

func openDatabase(path string) (*sql.DB, error) {
	dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)", path)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}
	// One connection keeps this small demo's queue operations easy to reason about.
	db.SetMaxOpenConns(1)

	const schema = `
CREATE TABLE IF NOT EXISTS ssf_events (
    jti              TEXT PRIMARY KEY,
    set_token        TEXT NOT NULL,
    status           TEXT NOT NULL DEFAULT 'queued'
                     CHECK (status IN ('queued', 'failed')),
    created_at       INTEGER NOT NULL,
    deliver_after    INTEGER NOT NULL DEFAULT 0,
    attempts         INTEGER NOT NULL DEFAULT 0,
    last_error       TEXT
);
CREATE INDEX IF NOT EXISTS ssf_events_ready
    ON ssf_events(status, deliver_after, created_at);`
	if _, err := db.Exec(schema); err != nil {
		db.Close()
		return nil, fmt.Errorf("create queue schema: %w", err)
	}
	return db, nil
}

func (s *server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{
		"spec_version":               "1_0",
		"issuer":                     s.config.issuer,
		"jwks_uri":                   s.config.issuer + "/jwks.json",
		"delivery_methods_supported": []string{pollDeliveryMethod},
		"configuration_endpoint":     s.config.issuer + "/ssf/stream",
	})
}

func (s *server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
	publicKey := s.privateKey.Public().(ed25519.PublicKey)
	writeJSON(w, http.StatusOK, map[string]any{
		"keys": []map[string]string{{
			"kty": "OKP",
			"crv": "Ed25519",
			"use": "sig",
			"alg": "EdDSA",
			"kid": s.config.keyID,
			"x":   base64.RawURLEncoding.EncodeToString(publicKey),
		}},
	})
}

func (s *server) handleStream(w http.ResponseWriter, r *http.Request) {
	stream := streamConfig{
		StreamID:        "demo-stream",
		Issuer:          s.config.issuer,
		Audience:        []string{s.config.audience},
		EventsSupported: []string{caepSessionRevoked},
		EventsRequested: []string{caepSessionRevoked},
		EventsDelivered: []string{caepSessionRevoked},
		Delivery: map[string]any{
			"method":       pollDeliveryMethod,
			"endpoint_url": s.config.issuer + "/events",
		},
	}
	streamID := r.URL.Query().Get("stream_id")
	if streamID == "" {
		writeJSON(w, http.StatusOK, []streamConfig{stream})
		return
	}
	if streamID != stream.StreamID {
		http.Error(w, "stream not found", http.StatusNotFound)
		return
	}
	writeJSON(w, http.StatusOK, stream)
}

func (s *server) handleEmit(w http.ResponseWriter, r *http.Request) {
	var input struct {
		SubjectID string `json:"subject_id"`
	}
	if err := decodeJSON(r.Body, &input); err != nil {
		http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(input.SubjectID) == "" {
		http.Error(w, "subject_id is required", http.StatusBadRequest)
		return
	}

	jti, token, err := s.makeSessionRevokedSET(input.SubjectID)
	if err != nil {
		http.Error(w, "create SET", http.StatusInternalServerError)
		return
	}
	if _, err := s.db.Exec(
		`INSERT INTO ssf_events (jti, set_token, created_at) VALUES (?, ?, ?)`,
		jti, token, s.now().Unix(),
	); err != nil {
		http.Error(w, "queue SET", http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusCreated, map[string]any{"jti": jti, "queued": true})
}

func (s *server) handlePoll(w http.ResponseWriter, r *http.Request) {
	var request pollRequest
	if err := decodeJSON(r.Body, &request); err != nil {
		http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
		return
	}
	if err := validatePollRequest(request); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	response, err := s.poll(r.Context(), request)
	if err != nil {
		http.Error(w, "poll queue", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Cache-Control", "no-store")
	writeJSON(w, http.StatusOK, response)
}

func (s *server) poll(ctx context.Context, request pollRequest) (pollResponse, error) {
	response, err := s.applyFeedbackAndClaim(ctx, request)
	if err != nil || len(response.Sets) > 0 || request.ReturnImmediately || maxEvents(request) == 0 {
		return response, err
	}

	deadline := time.NewTimer(s.config.longPollTimeout)
	defer deadline.Stop()
	ticker := time.NewTicker(250 * time.Millisecond)
	defer ticker.Stop()

	request.Ack = nil
	request.SetErrors = nil
	for {
		select {
		case <-ctx.Done():
			return pollResponse{}, ctx.Err()
		case <-deadline.C:
			return pollResponse{Sets: map[string]string{}}, nil
		case <-ticker.C:
			response, err = s.applyFeedbackAndClaim(ctx, request)
			if err != nil || len(response.Sets) > 0 {
				return response, err
			}
		}
	}
}

func (s *server) applyFeedbackAndClaim(ctx context.Context, request pollRequest) (pollResponse, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return pollResponse{}, err
	}
	defer tx.Rollback()

	for _, jti := range request.Ack {
		if _, err := tx.ExecContext(ctx, `DELETE FROM ssf_events WHERE jti = ?`, jti); err != nil {
			return pollResponse{}, err
		}
	}
	for jti, setErr := range request.SetErrors {
		message := strings.TrimSpace(setErr.Error + ": " + setErr.Description)
		if _, err := tx.ExecContext(ctx,
			`UPDATE ssf_events SET status = 'failed', last_error = ? WHERE jti = ?`,
			message, jti,
		); err != nil {
			return pollResponse{}, err
		}
	}

	sets := map[string]string{}
	limit := maxEvents(request)
	if limit > 0 {
		now := s.now()
		rows, err := tx.QueryContext(ctx, `
WITH ready AS (
    SELECT jti
    FROM ssf_events
    WHERE status = 'queued' AND deliver_after <= ?
    ORDER BY created_at, jti
    LIMIT ?
)
UPDATE ssf_events
SET deliver_after = ?, attempts = attempts + 1
WHERE jti IN (SELECT jti FROM ready)
RETURNING jti, set_token`, now.Unix(), limit, now.Add(s.config.retryAfter).Unix())
		if err != nil {
			return pollResponse{}, err
		}
		for rows.Next() {
			var jti, token string
			if err := rows.Scan(&jti, &token); err != nil {
				rows.Close()
				return pollResponse{}, err
			}
			sets[jti] = token
		}
		if err := rows.Close(); err != nil {
			return pollResponse{}, err
		}
	}

	var readyCount int
	if err := tx.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM ssf_events WHERE status = 'queued' AND deliver_after <= ?`,
		s.now().Unix(),
	).Scan(&readyCount); err != nil {
		return pollResponse{}, err
	}
	if err := tx.Commit(); err != nil {
		return pollResponse{}, err
	}
	return pollResponse{Sets: sets, MoreAvailable: readyCount > 0}, nil
}

func maxEvents(request pollRequest) int {
	if request.MaxEvents == nil {
		return 100 // A transmitter-side batch cap keeps responses bounded.
	}
	return *request.MaxEvents
}

func validatePollRequest(request pollRequest) error {
	if request.MaxEvents != nil && (*request.MaxEvents < 0 || *request.MaxEvents > 100) {
		return errors.New("maxEvents must be between 0 and 100")
	}
	seen := make(map[string]struct{}, len(request.Ack))
	for _, jti := range request.Ack {
		if jti == "" {
			return errors.New("ack values cannot be empty")
		}
		seen[jti] = struct{}{}
	}
	for jti, setErr := range request.SetErrors {
		if jti == "" || setErr.Error == "" {
			return errors.New("setErrs requires a jti and err value")
		}
		if _, exists := seen[jti]; exists {
			return errors.New("the same jti cannot appear in ack and setErrs")
		}
	}
	return nil
}

func (s *server) makeSessionRevokedSET(subjectID string) (string, string, error) {
	jti, err := randomID()
	if err != nil {
		return "", "", err
	}
	txn, err := randomID()
	if err != nil {
		return "", "", err
	}
	now := s.now().Unix()
	header := map[string]any{
		"alg": "EdDSA",
		"kid": s.config.keyID,
		"typ": "secevent+jwt",
	}
	claims := map[string]any{
		"iss": s.config.issuer,
		"aud": s.config.audience,
		"iat": now,
		"jti": jti,
		"txn": txn,
		"sub_id": map[string]string{
			"format": "opaque",
			"id":     subjectID,
		},
		"events": map[string]any{
			caepSessionRevoked: map[string]any{"event_timestamp": now},
		},
	}

	headerJSON, err := json.Marshal(header)
	if err != nil {
		return "", "", err
	}
	claimsJSON, err := json.Marshal(claims)
	if err != nil {
		return "", "", err
	}
	encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON)
	encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
	signingInput := encodedHeader + "." + encodedClaims
	signature := ed25519.Sign(s.privateKey, []byte(signingInput))
	token := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
	return jti, token, nil
}

func (s *server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
	expected := []byte("Bearer " + s.config.bearerToken)
	return func(w http.ResponseWriter, r *http.Request) {
		provided := []byte(r.Header.Get("Authorization"))
		if len(provided) != len(expected) || subtle.ConstantTimeCompare(provided, expected) != 1 {
			w.Header().Set("WWW-Authenticate", "Bearer")
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func loadOrCreatePrivateKey(path string) (ed25519.PrivateKey, error) {
	key, err := os.ReadFile(path)
	if err == nil {
		if len(key) != ed25519.PrivateKeySize {
			return nil, fmt.Errorf("%s does not contain an Ed25519 private key", path)
		}
		return ed25519.PrivateKey(key), nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("read signing key: %w", err)
	}
	_, privateKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, fmt.Errorf("generate signing key: %w", err)
	}
	if err := os.WriteFile(path, privateKey, 0o600); err != nil {
		return nil, fmt.Errorf("save signing key: %w", err)
	}
	return privateKey, nil
}

func randomID() (string, error) {
	var value [16]byte
	if _, err := rand.Read(value[:]); err != nil {
		return "", err
	}
	value[6] = (value[6] & 0x0f) | 0x40
	value[8] = (value[8] & 0x3f) | 0x80
	return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
		value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil
}

func decodeJSON(body io.Reader, destination any) error {
	decoder := json.NewDecoder(io.LimitReader(body, 1<<20))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(destination); err != nil {
		return err
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return errors.New("request body must contain one JSON object")
	}
	return nil
}

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(value); err != nil {
		log.Printf("encode JSON response: %v", err)
	}
}

func envOr(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}

The queue has a small state model:

  • A new row starts as queued and can be delivered now.
  • A poll atomically moves deliver_after into the future and returns the SET.
  • If no acknowledgement arrives, the same row becomes available again after 30 seconds.
  • An ack deletes the row.
  • A setErrs entry marks the row as failed so the demo keeps it for inspection instead of sending the same invalid token forever.

SQLite is not part of SSF. It is only the transmitter’s internal durable queue. PostgreSQL, a cloud queue, or another durable store can serve the same role as long as claiming and acknowledging events stay safe under concurrency.

Run the transmitter

First download the dependency and start the server:

go mod tidy
export SSF_BEARER_TOKEN=dev-token
go run .

The first run creates two local files:

  • ssf.db holds the delivery queue.
  • ssf-ed25519.key holds the private signing key with file mode 0600.

The discovery document is public:

curl -s http://localhost:8080/.well-known/ssf-configuration | jq

It points the receiver to the JWKS and stream configuration endpoints.

Queue and poll one event

In another terminal, create a session-revoked event:

curl -s \
  -H 'Authorization: Bearer dev-token' \
  -H 'Content-Type: application/json' \
  -d '{"subject_id":"session-123"}' \
  http://localhost:8080/emit | jq

The response contains the new jti:

{
  "jti": "e77cf6b5-4876-4450-b380-bd8b92cc7287",
  "queued": true
}

Now poll the stream and save the response:

POLL_RESPONSE=$(curl -s \
  -H 'Authorization: Bearer dev-token' \
  -H 'Content-Type: application/json' \
  -d '{"maxEvents":10,"returnImmediately":true}' \
  http://localhost:8080/events)

echo "$POLL_RESPONSE" | jq

RFC 8936 returns a sets object. Each key is a jti; each value is the compact signed SET:

{
  "sets": {
    "e77cf6b5-4876-4450-b380-bd8b92cc7287": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRlbW8ta2V5LTEiLCJ0eXAiOiJzZWNldmVudCtqd3QifQ..."
  }
}

Poll again before acknowledging and the response is empty until the 30-second retry window passes. After that window, the same jti and SET are delivered again. This is at-least-once delivery, so a receiver must deduplicate events by jti.

Once the receiver has verified and retained the SET, acknowledge it:

JTI=$(echo "$POLL_RESPONSE" | jq -r '.sets | keys[0]')

curl -s \
  -H 'Authorization: Bearer dev-token' \
  -H 'Content-Type: application/json' \
  -d "{\"ack\":[\"$JTI\"],\"maxEvents\":0,\"returnImmediately\":true}" \
  http://localhost:8080/events | jq

maxEvents: 0 makes this an acknowledge-only request. The transmitter deletes the matching SQLite row and returns an empty set:

{
  "sets": {}
}

You can inspect the queue directly if the sqlite3 CLI is installed:

sqlite3 ssf.db \
  'SELECT jti, status, attempts, last_error FROM ssf_events;'

Before the acknowledgement, the query shows one row. After it, the query returns no rows.

Prove the complete flow with a test

Create main_test.go beside main.go:

package main

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

const demoBearerToken = "test-token"

// appleReceiver is the Apple side of this public-protocol example. It uses
// transmitter metadata supplied by the IdP; it does not model Apple's private
// implementation or use an Apple production endpoint.
type appleReceiver struct {
	pollEndpoint     string
	bearerToken      string
	expectedIssuer   string
	expectedAudience string
	keyID            string
	publicKey        ed25519.PublicKey
}

type transmitterMetadata struct {
	Issuer                   string   `json:"issuer"`
	JWKSURI                  string   `json:"jwks_uri"`
	DeliveryMethodsSupported []string `json:"delivery_methods_supported"`
	ConfigurationEndpoint    string   `json:"configuration_endpoint"`
}

type jwksDocument struct {
	Keys []struct {
		KeyType string `json:"kty"`
		Curve   string `json:"crv"`
		Use     string `json:"use"`
		Alg     string `json:"alg"`
		KeyID   string `json:"kid"`
		X       string `json:"x"`
	} `json:"keys"`
}

func TestAppleReceiverPollsIdentityProviderUntilAcknowledged(t *testing.T) {
	db, err := openDatabase(t.TempDir() + "/ssf.db")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })

	_, privateKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Unix(1_787_337_600, 0)

	// IDP SIDE: the company's identity provider creates, signs, queues, and
	// serves SETs. In the Apple Business example, this is the SSF transmitter.
	identityProvider := &server{
		db:         db,
		privateKey: privateKey,
		config: config{
			audience:        demoAppleAudience,
			bearerToken:     demoBearerToken,
			keyID:           "test-key",
			retryAfter:      30 * time.Second,
			longPollTimeout: time.Second,
		},
		now: func() time.Time { return now },
	}
	idpHTTPServer := httptest.NewServer(identityProvider.routes())
	t.Cleanup(idpHTTPServer.Close)
	identityProvider.config.issuer = idpHTTPServer.URL

	// APPLE SIDE: Apple starts with the IdP's SSF configuration URL. It uses
	// discovery to learn the stream endpoint and download the IdP's public key.
	apple := connectAppleReceiver(
		t,
		idpHTTPServer.URL+"/.well-known/ssf-configuration",
		demoBearerToken,
	)

	// An IdP account service reports that the employee session was revoked.
	// /emit is an internal demo input; Apple never calls it.
	emit := postJSON(
		t,
		idpHTTPServer.URL+"/emit",
		demoBearerToken,
		`{"subject_id":"session-123"}`,
	)
	if emit.StatusCode != http.StatusCreated {
		t.Fatalf("emit status = %d", emit.StatusCode)
	}
	var emitted struct {
		JTI string `json:"jti"`
	}
	decodeResponse(t, emit, &emitted)

	// Apple polls the IdP, then verifies the SET before applying local policy.
	first := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
	token := first.Sets[emitted.JTI]
	if token == "" {
		t.Fatalf("first poll did not include %s", emitted.JTI)
	}
	apple.verifySET(t, token, emitted.JTI, "session-123")

	// The IdP keeps the SET until Apple acknowledges it, so a missed response
	// does not lose the security event.
	assertQueueSize(t, db, 1)
	now = now.Add(31 * time.Second)
	second := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
	if second.Sets[emitted.JTI] != token {
		t.Fatal("unacknowledged SET was not redelivered")
	}

	ackBody := `{"ack":["` + emitted.JTI + `"],"maxEvents":0,"returnImmediately":true}`
	ack := apple.poll(t, ackBody)
	if len(ack.Sets) != 0 {
		t.Fatalf("ack-only response contained %d SETs", len(ack.Sets))
	}
	assertQueueSize(t, db, 0)

	empty := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
	if len(empty.Sets) != 0 {
		t.Fatalf("poll after ack contained %d SETs", len(empty.Sets))
	}
}

func connectAppleReceiver(t *testing.T, configurationURL, bearerToken string) appleReceiver {
	t.Helper()

	metadataResponse := get(t, configurationURL, "")
	if metadataResponse.StatusCode != http.StatusOK {
		t.Fatalf("discovery status = %d", metadataResponse.StatusCode)
	}
	var metadata transmitterMetadata
	decodeResponse(t, metadataResponse, &metadata)
	if metadata.Issuer == "" || metadata.JWKSURI == "" || metadata.ConfigurationEndpoint == "" {
		t.Fatalf("incomplete transmitter metadata: %#v", metadata)
	}
	if len(metadata.DeliveryMethodsSupported) != 1 || metadata.DeliveryMethodsSupported[0] != pollDeliveryMethod {
		t.Fatalf("unexpected delivery methods: %#v", metadata.DeliveryMethodsSupported)
	}

	jwksResponse := get(t, metadata.JWKSURI, "")
	if jwksResponse.StatusCode != http.StatusOK {
		t.Fatalf("JWKS status = %d", jwksResponse.StatusCode)
	}
	var jwks jwksDocument
	decodeResponse(t, jwksResponse, &jwks)
	if len(jwks.Keys) != 1 {
		t.Fatalf("JWKS contains %d keys", len(jwks.Keys))
	}
	key := jwks.Keys[0]
	if key.KeyType != "OKP" || key.Curve != "Ed25519" || key.Use != "sig" || key.Alg != "EdDSA" {
		t.Fatalf("unexpected JWK: %#v", key)
	}
	publicKey, err := base64.RawURLEncoding.DecodeString(key.X)
	if err != nil {
		t.Fatal(err)
	}
	if len(publicKey) != ed25519.PublicKeySize {
		t.Fatalf("public key has %d bytes", len(publicKey))
	}

	streamResponse := get(t, metadata.ConfigurationEndpoint, bearerToken)
	if streamResponse.StatusCode != http.StatusOK {
		t.Fatalf("stream configuration status = %d", streamResponse.StatusCode)
	}
	var streams []streamConfig
	decodeResponse(t, streamResponse, &streams)
	if len(streams) != 1 {
		t.Fatalf("stream configuration contains %d streams", len(streams))
	}
	stream := streams[0]
	if stream.Issuer != metadata.Issuer {
		t.Fatalf("stream issuer = %q, want %q", stream.Issuer, metadata.Issuer)
	}
	if len(stream.Audience) != 1 || stream.Audience[0] != demoAppleAudience {
		t.Fatalf("stream audience = %#v", stream.Audience)
	}
	if stream.Delivery["method"] != pollDeliveryMethod {
		t.Fatalf("delivery method = %v", stream.Delivery["method"])
	}
	pollEndpoint, ok := stream.Delivery["endpoint_url"].(string)
	if !ok || pollEndpoint == "" {
		t.Fatalf("missing poll endpoint: %#v", stream.Delivery)
	}

	return appleReceiver{
		pollEndpoint:     pollEndpoint,
		bearerToken:      bearerToken,
		expectedIssuer:   metadata.Issuer,
		expectedAudience: demoAppleAudience,
		keyID:            key.KeyID,
		publicKey:        ed25519.PublicKey(publicKey),
	}
}

func (a appleReceiver) poll(t *testing.T, body string) pollResponse {
	t.Helper()
	response := postJSON(t, a.pollEndpoint, a.bearerToken, body)
	if response.StatusCode != http.StatusOK {
		t.Fatalf("poll status = %d", response.StatusCode)
	}
	var result pollResponse
	decodeResponse(t, response, &result)
	return result
}

func (a appleReceiver) verifySET(t *testing.T, token, expectedJTI, expectedSubjectID string) {
	t.Helper()
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		t.Fatalf("SET has %d parts", len(parts))
	}
	signature, err := base64.RawURLEncoding.DecodeString(parts[2])
	if err != nil {
		t.Fatal(err)
	}
	if !ed25519.Verify(a.publicKey, []byte(parts[0]+"."+parts[1]), signature) {
		t.Fatal("SET signature is invalid")
	}

	headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil {
		t.Fatal(err)
	}
	var header map[string]any
	if err := json.Unmarshal(headerJSON, &header); err != nil {
		t.Fatal(err)
	}
	if header["typ"] != "secevent+jwt" || header["alg"] != "EdDSA" || header["kid"] != a.keyID {
		t.Fatalf("unexpected JOSE header: %#v", header)
	}

	claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		t.Fatal(err)
	}
	decoder := json.NewDecoder(bytes.NewReader(claimsJSON))
	decoder.UseNumber()
	var claims map[string]any
	if err := decoder.Decode(&claims); err != nil {
		t.Fatal(err)
	}
	if claims["iss"] != a.expectedIssuer {
		t.Fatalf("iss = %v, want %q", claims["iss"], a.expectedIssuer)
	}
	if claims["aud"] != a.expectedAudience {
		t.Fatalf("aud = %v, want %q", claims["aud"], a.expectedAudience)
	}
	if claims["jti"] != expectedJTI {
		t.Fatalf("jti = %v", claims["jti"])
	}
	if _, exists := claims["sub"]; exists {
		t.Fatal("SSF SET must not contain sub")
	}
	if _, exists := claims["exp"]; exists {
		t.Fatal("SSF SET must not contain exp")
	}
	subject, ok := claims["sub_id"].(map[string]any)
	if !ok || subject["format"] != "opaque" || subject["id"] != expectedSubjectID {
		t.Fatalf("unexpected subject: %#v", claims["sub_id"])
	}
	events, ok := claims["events"].(map[string]any)
	if !ok || events[caepSessionRevoked] == nil {
		t.Fatalf("missing CAEP session-revoked event: %#v", claims["events"])
	}
}

func get(t *testing.T, url, bearerToken string) *http.Response {
	t.Helper()
	request, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		t.Fatal(err)
	}
	if bearerToken != "" {
		request.Header.Set("Authorization", "Bearer "+bearerToken)
	}
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	return response
}

func postJSON(t *testing.T, url, bearerToken, body string) *http.Response {
	t.Helper()
	request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	request.Header.Set("Authorization", "Bearer "+bearerToken)
	request.Header.Set("Content-Type", "application/json")
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	return response
}

func decodeResponse(t *testing.T, response *http.Response, destination any) {
	t.Helper()
	defer response.Body.Close()
	if err := json.NewDecoder(response.Body).Decode(destination); err != nil {
		t.Fatal(err)
	}
}

func assertQueueSize(t *testing.T, db interface {
	QueryRow(query string, args ...any) *sql.Row
}, expected int) {
	t.Helper()
	var count int
	if err := db.QueryRow(`SELECT COUNT(*) FROM ssf_events`).Scan(&count); err != nil {
		t.Fatal(err)
	}
	if count != expected {
		t.Fatalf("queue size = %d, want %d", count, expected)
	}
}

Run it:

go test ./...

The test proves more than a handler returning 200. It checks the boundary between both roles:

  1. Apple discovers the IdP’s stream endpoint and downloads its public signing key;
  2. the IdP commits a SET to SQLite before delivery;
  3. the IdP redelivers the same token when Apple has not acknowledged it;
  4. Apple verifies the signature, key ID, typ, iss, aud, event type, and subject; and
  5. the IdP removes the row after Apple acknowledges its jti.

What to change before production

This demo is intentionally small. It is useful for learning and local integration tests, but it is not a production SSF service.

Before using the design in production:

  • Serve every endpoint over HTTPS. The http://localhost issuer exists only for this local demo.
  • Replace the shared development bearer token with a strong authorization scheme. Depending on the partnership, that may be OAuth client credentials, private-key JWT authentication, or mutual TLS.
  • Store signing keys in a key-management system, publish overlapping keys during rotation, and handle JWKS caching safely.
  • Implement the full stream management, status, subject, and verification APIs that your interoperability profile requires.
  • Validate requested event types and ensure one receiver cannot read another receiver’s stream.
  • Encrypt SETs with JWE when TLS does not provide enough confidentiality for the event data.
  • Add rate limits, request size limits, audit logs, metrics, retention rules, and dead-letter handling.
  • Test duplicate delivery, concurrent pollers, database failures, key rotation, unknown event types, and malformed acknowledgements.

Most importantly, agree on receiver behavior. SSF tells you how to exchange a trusted signal. The two parties still need a clear contract for what that signal means and how quickly the receiver should act.

The main idea

SSF closes the gap between “this token was valid when I issued it” and “the security state has changed now.”

A basic transmitter needs only a few solid pieces: create a correctly shaped SET, sign it, store it before reporting success, deliver it through a standard method, and keep it until the receiver acknowledges the exact jti.

The Go service in this post does that with one process and one SQLite file. The code is small enough to inspect, but the delivery behavior is real: no acknowledgement means redelivery; acknowledgement means the message leaves the queue.

Further reading