Authorization
The Mercure hub is an OAuth 2.0 protected resource. Clients present a JWT access token (RFC 9068); the token's authorization_details claim (RFC 9396) says which topics it may publish to and subscribe to. The hub validates every token; your application (or your authorization server) mints them.
Upgrading from 0.x? The bespoke
mercureclaim is gone. Tokens are now standard OAuth 2.0 access tokens: atyp: at+jwtheader,issandaudclaims, and anauthorization_detailsarray of Mercure entries. The legacymercureclaim is accepted only by a hub built with thedeprecated_claimtag and runningprotocol_version_compatibility 8. See the upgrade guide.
The access token
A Mercure access token is a JWT access token as defined by RFC 9068:
// header { "alg": "HS256", "typ": "at+jwt" }
// payload { "iss": "https://example.com", "sub": "https://example.com/users/42", "client_id": "https://example.com", "iat": 4102441200, "aud": "https://hub.example.com/.well-known/mercure", "exp": 4102444800, "jti": "urn:uuid:e70ff8d7-59d5-4a92-b0b7-505e8f6d09fa", "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["publish"], "topics": [{ "match": "*" }], }, { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://example.com/users/42/notifications" }, { "match": "https://example.com/books/:id", "match_type": "urlpattern", }, ], "payload": { "user": "https://example.com/users/42" }, }, ], }
The hub enforces, on every token:
typ: at+jwtheader. Tokens minted for other purposes (an OpenID Connect ID token, for example) are rejected.issclaim. It must exactly match one of the hub's trusted issuers, each declared with anissuerblock (see Discovery). Addauthorization_serverinside a block to advertise that issuer.audclaim. It must contain the hub's resource identifier: the value pinned withresource_identifier, or, when unset, the public URL the client contacted (derived per request).audmay be a string or an array.expclaim. Required. The hub rejects expired tokens, including on the first request.nbfis enforced when present.Signature with the issuer's configured key (its
publisher/subscriberverifier; see below). The token is verified only with the key(s) bound to itsiss, so keys are never pooled across issuers. The algorithm comes from hub configuration, never from the token, soalg=noneand algorithm-confusion attacks are blocked.
RFC 9068 also requires issuers to populate sub, client_id, iat, and jti; include them so any RFC 9068 validator accepts your tokens. The hub uses sub to derive subscriber identifiers for subscription events.
Minting a token
caddy mercure-token builds one of these for you instead of hand-writing the JSON above:
caddy mercure-token --dev # matches the quickstart's local hub, zero config caddy mercure-token \ --iss https://example.com --aud https://hub.example.com/.well-known/mercure \ --key '!ChangeMe!' \ --publish 'https://example.com/books/1' \ --subscribe 'https://example.com/users/42/notifications' \ --subscribe-urlpattern 'https://example.com/books/:id' \ --payload '{"user": "https://example.com/users/42"}'
--publish/--subscribe grant an exact topic and may be repeated; --publish-urlpattern/--subscribe-urlpattern grant a URL Pattern and may also be repeated — the same two matcher types as the subscribe query parameters. --key takes a raw secret, @path/to/file, @- to read it from stdin, or a PEM-encoded private key with --alg (the private counterpart of whatever public key or secret the hub's issuer block verifies with). Prefer @path/to/file or @- over a literal secret: an argument passed on the command line is visible to other processes on the same machine (ps) and lands in shell history. sub, client_id, iat, and jti are filled in automatically. Run caddy mercure-token --help for the full flag reference.
Authorization details
Each entry in authorization_details with "type": "https://mercure.rocks/authorization-detail" grants a set of actions over a set of topic matchers:
actions: a non-empty subset of["publish", "subscribe"].topics: a non-empty array of topic matcher objects{ "match": "...", "match_type": "exact" | "urlpattern" }. Bare strings are rejected;match_typeis case-sensitive and defaults toexact. Amatchof*matches every topic.payload(optional,subscribeonly): any JSON value, surfaced through subscription events.
One invalid Mercure detail rejects the whole token (401 invalid_token); there is no partial acceptance. Entries with another type are ignored, so a single token can carry authorization details for several resources.
Two ways to send the token
The hub reads the token from one of two places. Pick the one that matches your client:
Authorization: Bearer <token>header (preferred). Right for server-side code, mobile apps, command-line tools, and browser code usingfetch(): anything that can set custom headers. In the browser, consume the SSE stream through thefetch()response body when you need a per-tab or per-connection token, or when the hub lives on another domain — cases a cookie can't cover. TheBearerscheme name is matched case-insensitively.__Secure-mercure_access_tokencookie (forEventSource). Browsers can't attach headers to anEventSource; a cookie set withHttpOnly,Secure, andSameSitekeeps the token out of JavaScript (no XSS exfiltration), out of URL bars and history, and rides along automatically. Set it at discovery time so it's already in place when the SSE connection opens.
There is no query-parameter mechanism: RFC 9700 forbids passing access tokens in URLs, where they leak into proxy logs, browser history, and Referer headers. When a request carries both a header and a cookie, the header wins and the cookie is ignored.
The hub never accepts tokens over plain HTTP. Whichever method you pick, HTTPS is mandatory for any non-anonymous request.
Publishers
To publish, a token must carry an authorization_details entry whose actions include publish and whose topics match the update's topic. An update MAY carry more than one topic field — the first is the canonical topic, any others are alternate topics — in which case the token must be granted on every one of them, not only the canonical one.
// Publishers { "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["publish"], "topics": [ { "match": "https://example.com/books/:id", "match_type": "urlpattern", }, { "match": "https://example.com/announcements" }, ], }, ], }
Behavior:
No
publishgrant covering every topic of the update -> the publication is rejected with403 insufficient_scope, even when some of its topics are covered.[{ "match": "*" }]-> every topic is allowed.
* is the only "match anything" wildcard; you cannot get the same effect with a permissive URL Pattern.
Subscribers
A subscriber's token is only consulted for private updates. Public updates flow to any subscriber whose match* query parameters hit, with or without a token.
For a private update, the hub checks that a subscribe grant covers at least one of the update's topics (canonical or alternate). If it does, the update is delivered; if not, the subscriber never sees it. Since matching any one topic delivers the whole update, the audience of a private update is the union of the audiences of each of its topics — see alternate topics for what that means for publishers attaching them.
// Subscribers { "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://example.com/users/42/:resource", "match_type": "urlpattern", }, { "match": "https://example.com/announcements" }, ], }, ], }
A subscribe grant of [{ "match": "*" }] receives every private update. No subscribe grant means no private updates.
Anonymous subscribers
A hub with the anonymous directive set (development mode sets it for you) accepts subscribers without a token. Anonymous subscribers receive only updates that are not marked private; they have no grant to check against.
This is the right default for live feeds, public dashboards, and any case where the data isn't user-specific. For everything else, leave anonymous off.
Per-user authorization on shared resources
A subscriber should receive updates only about the resources it owns. When the resource's own topic already encodes ownership (a path segment per user or tenant), express this with a scoped matcher in the token — no need for anything else.
Publish each private update to its own per-user (or per-resource) topic:
# Per-user authorization on shared resources curl -X POST $HUB -H "Authorization: Bearer $JWT" \ -d 'topic=https://example.com/users/42/messages/1' \ -d 'private=on' \ -d 'data=...'
Mint each subscriber a token whose subscribe grant covers only its own space:
// Per-user authorization on shared resources { "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://example.com/users/42/:resource", "match_type": "urlpattern", }, ], }, ], }
User 42's token matches https://example.com/users/42/messages/1; user 99's token does not, so the hub never delivers it. The subscriber's match* query parameter can be as broad as match_urlpattern=https://example.com/users/:id/messages/:mid: the query selects what the client wants to receive, and the token decides what it is allowed to receive. The narrower of the two wins.
Shared resources without a per-user path
Sometimes the resource's own topic can't (or shouldn't) encode ownership — a resource shared by several users, each with a different reason to read it. Publishing per-user copies works, but costs one publish request per authorized reader for what is really a single event. Alternate topics solve this: attach a per-user (or per-tenant) alternate topic to the update in addition to its shared canonical topic, and scope each subscriber's grant to its own alternate namespace instead of the canonical resource.
# One event, several private audiences, one publish request curl -X POST $HUB -H "Authorization: Bearer $JWT" \ -d 'topic=https://example.com/books/1' \ -d 'topic=https://example.com/users/42/books/1' \ -d 'private=on' \ -d 'data=...'
The publisher's token must be granted publish on both https://example.com/books/:id and https://example.com/users/42/* — a grant on the canonical topic alone is not enough once an alternate is attached. A subscriber whose token only grants subscribe on https://example.com/users/42/* receives this update even though it has no grant on books/1 itself, because its grant matches the alternate topic.
Never attach an alternate that a broader audience than the intended readers can match: any subscriber authorized for any one topic of the update receives its full content. See Private Update Audience in the spec.
Subscriber payloads
A subscribe detail can carry a payload (any JSON value). The hub attaches it to the subscription event and the subscription API record for every subscription that detail authorizes.
// Subscriber payloads { "authorization_details": [ { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [{ "match": "https://example.com/users/42" }], "payload": { "username": "alice", "ip": "10.0.0.1" }, }, { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://example.com/books/:id", "match_type": "urlpattern", }, ], "payload": { "username": "alice" }, }, ], }
For each topic the subscriber asks for, the hub finds the first subscribe detail whose topics match it and attaches that detail's payload. Use payloads to ship per-subscriber metadata to other subscribers via subscription events: usernames, group memberships, IP address, role.
RFC 6750 error responses
The hub answers authorization failures with standard RFC 6750 bearer-token errors:
| Situation | Status | Body / header |
|---|---|---|
| No token on an operation that needs one | 401 | bare WWW-Authenticate: Bearer with a resource_metadata parameter |
Token presented but invalid (signature, aud, exp, typ, malformed authorization_details) | 401 | WWW-Authenticate: Bearer error="invalid_token" |
| Valid token, but no grant for the action on the topic | 403 | error="insufficient_scope" |
| Malformed request | 400 | error="invalid_request" |
The resource_metadata parameter points clients at the hub's protected resource metadata so they can discover where to obtain a token. Error descriptions are deliberately terse: the hub never discloses why a token failed (a valid signature over malformed claims still returns invalid_token).
Cookies in detail
Set the cookie during discovery, when the user fetches the page or the API resource that links to the hub. By the time the browser opens the SSE connection, the cookie is already in place.
# Cookies in detail 200 OK Set-Cookie: __Secure-mercure_access_token=<JWT>; Domain=example.com; Path=/.well-known/mercure; Secure; HttpOnly; SameSite=Strict Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"
Required attributes:
Secure: only sent over HTTPS.HttpOnly: not readable from JavaScript (XSS protection).SameSite=StrictorLax: CSRF protection.Path=/.well-known/mercure: limits the cookie to the hub URL.
The default cookie name is __Secure-mercure_access_token; the __Secure- prefix makes browsers refuse it over insecure transport. Override it with the cookie_name directive when several hubs share a domain, or with a prefix-less name for plain-HTTP local development. If the publisher and the hub run on different subdomains of the same registrable domain, set Domain=example.com. If they're on different domains, you can't use cookies; consume the stream with fetch() and an Authorization header instead.
EventSource does not send cookies on cross-origin requests by default. Pass withCredentials: true to opt in:
// Cookies in detail new EventSource(url, { withCredentials: true });
The hub must respond with the right CORS headers; a wildcard cors_origins * disables credentials, since the protocol forbids combining Access-Control-Allow-Origin: * with credentials. See Configuration.
Token expiration
The exp claim is required. The hub closes the subscriber's connection when the token expires; the browser auto-reconnects, and the now-expired token fails with 401 invalid_token.
To handle expiry cleanly:
Keep
expshort enough to limit the blast radius of a leaked token (minutes to hours, not days).On the application side, refresh the token before it expires and update the cookie. The next reconnection picks up the new one.
For long-lived sessions, run a small endpoint on your origin that mints a fresh hub token in exchange for the user's session, or front the hub with an OAuth 2.0 authorization server.
Validating with JWKS
When an identity provider or authorization server (Keycloak, Cognito, Auth0) issues the tokens, point the hub at its JWKS endpoint instead of hardcoding a key:
# Validating with JWKS mercure { issuer https://idp.example.com { authorization_server publisher { jwks_uri https://idp.example.com/.well-known/jwks.json } subscriber { jwks_uri https://idp.example.com/.well-known/jwks.json } } }
The hub fetches and caches the keys, rotates them when the provider does, and validates each token against the matching kid. See Configuration.
Verifying tokens with RSA and ECDSA keys
The default algorithm is HS256 (symmetric HMAC). For asymmetric verification (the hub holds only the public key), set the *_JWT_ALG environment variable or pass the algorithm as the second argument of the directive:
# Verifying tokens with RSA and ECDSA keys mercure { issuer https://example.com { publisher { jwt {env.PUBLISHER_PUBLIC_KEY} RS256 } subscriber { jwt {env.SUBSCRIBER_PUBLIC_KEY} RS256 } } }
Asymmetric keys keep the signing key off the hub entirely, which is useful when the hub is operated by a different team than the publisher, or when an external authorization server mints the tokens.
Common authorization errors
| Symptom | Cause |
|---|---|
401 invalid_token on subscribe | Expired token, missing/wrong iss or aud, missing typ: at+jwt, malformed authorization_details, wrong signing key |
401 with a bare Bearer challenge | No token presented on an operation that requires one |
403 insufficient_scope on publish | No publish grant covers the topic |
| Subscriber never receives a private update | No subscribe grant covers the update's topic |
| Browser doesn't send the cookie | Missing withCredentials: true, wrong Domain/Path, or cross-origin without CORS credentials |
Troubleshooting covers each of these in more detail.