Mercure upgrade guide
#1.0 (from 0.x)
The 1.0 release aligns the hub with the standards-based specification: two matcher types and an OAuth 2.0 authorization model. It is a breaking change for subscribers, publishers, and token issuers.
If you only run the hub and don't author clients or mint tokens, the upgrade is a config change. If you do, plan a synchronized cutover of the hub and the clients that talk to it. A hub built with the deprecated_topic and deprecated_claim tags can run protocol_version_compatibility 8 to keep accepting 0.x clients during the transition (see Compatibility mode).
#What changed at a glance
| Area | 0.x | 1.0 |
|---|---|---|
| Matcher types | URI Template, string, plus exploratory types | exact and urlpattern only |
| Subscribe query parameter | topic=<pattern> (URI Template or string) | match=<exact> or match_urlpattern=<pattern> (case-sensitive) |
| Templating language | URI Templates (RFC 6570) | URL Patterns (WHATWG) |
| Token | bespoke mercure JWT claim | OAuth 2.0 access token: typ: at+jwt, iss, aud, authorization_details |
| Authorization | mercure.publish / mercure.subscribe string arrays | authorization_details entries with the Mercure type URI (see below) |
| Token in query / cookie | authorization param, mercureAuthorization cookie | __Secure-mercure_access_token cookie; no query parameter (RFC 9700) |
| Auth errors | 401 / silent drop | RFC 6750: 401 invalid_token, 403 insufficient_scope, 400 invalid_request |
| Subscription event topic | /.well-known/mercure/subscriptions/{topic}/{subscriber} | /.well-known/mercure/subscriptions/{match_type}/{match}/{subscriber} |
#Migrate your subscribers
The query parameter changes from topic= to match= (exact) or match_urlpattern= (templated):
// Before (0.x)
url.searchParams.append("topic", "https://example.com/books/1");
url.searchParams.append("topic", "https://example.com/books/{id}");
// After (1.0)
url.searchParams.append("match", "https://example.com/books/1");
url.searchParams.append("match_urlpattern", "https://example.com/books/:id");
- The exact-match parameter is
match(explicit spelling:match_exact); the templated one ismatch_urlpattern, using URL Pattern syntax (:id, not{id}). - Parameter names are case-sensitive. Any other name under the
matchprefix is rejected with400, so typos fail loudly. - The
URI Templatematcher type is gone; it survives only on a hub built withdeprecated_topicrunningprotocol_version_compatibility 8. Rewrite templated topics as URL Patterns and string topics as exact topics.
#Migrate your tokens
The bespoke mercure claim is replaced by a standard OAuth 2.0 JWT access token: a typ: at+jwt header, an iss claim matching one of the hub's trusted issuers, an aud claim holding the hub's resource identifier, a required exp, and an authorization_details array. RFC 9068 also requires issuers to populate sub, client_id, iat, and jti.
// Before (0.x): payload
{
"mercure": {
"publish": ["*"],
"subscribe": [
"https://example.com/users/42",
"https://example.com/books/{id}",
],
},
}
// After (1.0): payload; also set "typ": "at+jwt" in the header
{
"iss": "https://example.com",
"aud": "https://hub.example.com/.well-known/mercure",
"exp": 4102444800,
"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" },
{
"match": "https://example.com/books/:id",
"match_type": "urlpattern",
},
],
},
],
}
Rules:
- Each entry is
{ "type": "https://mercure.rocks/authorization-detail", "actions": [...], "topics": [...] }.actionsis a non-empty subset of["publish", "subscribe"];topicsis a non-empty array of{ "match", "match_type"? }objects (bare strings are rejected). match_typeis case-sensitive and defaults toexact. The reserved{ "match": "*" }matches every topic.- A
subscribeentry may carry apayload; the old top-levelmercure.payloadis gone. See subscriber payloads. - The claim must be unambiguous JSON: a duplicate object member or invalid UTF-8 rejects the token, rather than the hub picking one of the readings an authorization server may not have validated.
- One invalid Mercure detail rejects the whole token with
401 invalid_token.
#Migrate token presentation
- The cookie default name changes from
mercureAuthorizationto__Secure-mercure_access_token(override withcookie_name; use a prefix-less name for plain-HTTP development). - The
authorizationquery parameter is gone and has no replacement: RFC 9700 forbids access tokens in URLs. Browsers that can't set headers use the cookie; everything else (includingfetch()with a readable stream) uses theAuthorizationheader. - The
Authorization: Bearerheader is unchanged and takes precedence over the cookie.
#Migrate to RFC 6750 errors
Authorization failures now follow RFC 6750:
- No token where one is required ->
401with a bareWWW-Authenticate: Bearerchallenge and aresource_metadataparameter. - Invalid token ->
401error="invalid_token". - Valid token without a grant for the action on the topic ->
403error="insufficient_scope"(previously401or a silent drop). - Malformed request ->
400error="invalid_request".
#Migrate the subscription API and events
| Before | After |
|---|---|
/.well-known/mercure/subscriptions/<topic>/<subscriber> | /.well-known/mercure/subscriptions/<match_type>/<match>/<subscriber> |
"topic": "https://..." in the JSON-LD | "match": "https://..." and "match_type": "urlpattern" |
<match_type>, <match>, and <subscriber> must be percent-encoded. The mercure.subscriber claim is gone: the hub assigns the subscriber identifier. See Active subscriptions.
Subscription documents are now serialised compactly, and <, > and & are no longer escaped as \u003c, \u003e and \u0026. Both forms are the same JSON (RFC 8259 whitespace is insignificant, and the escapes were never required), so any conforming parser is unaffected; only a client string-matching the raw bytes needs updating. A subscription event is now a single data: line.
#Find-and-replace checklist
-
?topic=/&topic=in subscriber URLs ->match=(ormatch_urlpattern=if templated) -
URI Template syntax in subscribe URLs (
{id}) -> URL Pattern syntax (:id) -
"mercure": { "publish": [...] }in issuer code ->authorization_detailswithactions: ["publish"] -
"mercure": { "subscribe": [...] }->authorization_detailswithactions: ["subscribe"] -
mercureAuthorizationcookie ->__Secure-mercure_access_token;authorization=query param ->Authorizationheader or cookie (no query parameter) -
publisher_jwt/subscriber_jwt/*_jwks_urlin the Caddyfile ->issuer <iss> { publisher { jwt ... } subscriber { jwt ... } }(see JWT keys move intoissuerblocks) -
Hardcoded
subscriptions/{topic}/{subscriber}paths -> add the{match_type}segment -
Last-Event-IDread from a response ->Mercure-Last-Event-ID(the request header keeps its name; see Reconnection and history) -
JSON-LD subscription documents (
application/ld+json,@context) -> plain JSON served asapplication/json -
typevalues lowercased:Subscription->subscription,Subscriptions->subscriptions -
Subscription events now carry the SSE
event: mercurefield (route them withaddEventListener("mercure", ...)); publishing an update whosetypeismercureis rejected with a400 Bad Request
#Hub configuration changes
#New in 1.0 (nothing to migrate)
resource_identifier, public_urls, and RFC 9728 discovery have no 0.x equivalent. Configure them when you need a fixed audience, an origin allowlist, or authorization-server discovery.
- The hub derives its public URL, the OAuth 2.0 resource identifier (token
aud) and the RFC 9728 metadata from each request, so a hub reachable through several public URLs works with no domain configuration. Setresource_identifieronly to pin one canonical audience shared across every domain. On a catch-all site block (:443, no host matcher), addpublic_urls <url...>so a request whose origin is not listed is rejected with421 Misdirected Requestinstead of choosing the derived identity. List the origins the hub itself receives, scheme included: behind a TLS-terminating proxy that is thehttp://form (see Reverse proxies).
#JWT keys move into issuer blocks
The JWT key directives move into an issuer <id> { ... } block that binds the iss value your tokens carry to its verification keys. The block is required when JWT auth is enabled in modern mode.
# Before (0.x)
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
subscriber_jwks_url https://example.com/.well-known/jwks.json
}
# After (1.0)
mercure {
issuer https://example.com {
publisher {
jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
}
subscriber {
jwks_uri https://example.com/.well-known/jwks.json
}
}
}
publisher_jwt <key> [<alg>]becomespublisher { jwt <key> [<alg>] }, andsubscriber_jwtbecomessubscriber { jwt ... }.publisher_jwks_url <url>becomespublisher { jwks_uri <url> [<alg>...] }(note_uri, after the RFC 8414 member), and likewise forsubscriber_jwks_url.jwtandjwks_uriare mutually exclusive within one block.- The issuer identifier must equal the
issclaim of your tokens. Omittingpublisherorsubscriberrejects that action for the issuer. Addauthorization_serverinside the block to advertise it (see Discovery). Repeat the block to trust several issuers with distinct keys. See Issuer blocks. - The official Caddyfile and Docker image keep the
MERCURE_PUBLISHER_JWT_KEY,MERCURE_PUBLISHER_JWT_ALG,MERCURE_SUBSCRIBER_JWT_KEYandMERCURE_SUBSCRIBER_JWT_ALGvariables. The newMERCURE_TRUSTED_ISSUERSvariable sets the issuer identifier. It defaults tohttps://localhost, which only suits local development: set it to theissyour tokens carry. - The pre-1.0 top-level directives
publisher_jwt,subscriber_jwt,publisher_jwks_urlandsubscriber_jwks_urlstill parse but map to a single implicit issuer usable only in compatibility mode. Setting one withoutprotocol_version_compatibilityis now a configuration error, because that mode also drops the requiredexp, the audience check, theat+jwtcheck and the issuer check, and re-accepts the token in the URL query string. Migrate them into anissuerblock for modern mode, or addprotocol_version_compatibility 8to accept those trade-offs deliberately.
Key validation is stricter, including for the deprecated directives:
- A PEM-encoded key without an algorithm is a configuration error. 0.x defaulted to
HS256; set the algorithm explicitly (RS256,ES256,EdDSA, ...). A raw secret still defaults toHS256. - A PEM-encoded key paired with an HMAC algorithm is refused. 0.x used the public key as the HMAC secret, so anyone holding it could forge tokens.
- A JWK Set accepts only asymmetric algorithms by default (
EdDSA,ES*,RS*,PS*). 0.x accepted any algorithm its keys supported, HMAC included. List the allowed algorithms after the URL to change this (jwks_uri <url> HS256). The deprecated*_jwks_urldirectives take no algorithm list, so a JWK Set of HMAC keys needs anissuerblock.
#Changed configuration
- The official Caddyfile no longer redacts query parameters from logs or serves
/healthz; both only mattered for 0.x clients. Restore them if you run compatibility mode. transport_urlandMERCURE_TRANSPORT_URLrequire thedeprecated_transportbuild tag, which official builds still include. Migrate totransport <name> { ... }.- An unrecognized directive inside the
mercureblock is now a configuration error instead of being ignored. A typo previously disabled whatever it was meant to configure, silently, so check yourMERCURE_EXTRA_DIRECTIVESif the hub refuses to start after the upgrade. - The
uianddemodirectives were renamed.uiis nowdebugger: the prod-safe debugger UI, which also moved from/.well-known/mercure/ui/to/.well-known/mercure/debug/.demois nowplayground(the insecure playground: it additionally mints an all-access token prefilled in the UI, and its echo endpoints moved out of the reserved hub namespace, from/.well-known/mercure/ui/demo/to the root/playground/path, so the resources they expose are valid, subscribable topics). Because unknown directives now error, rename them in your Caddyfile. playgroundnow turns on the permissive dev settings it needs on its own:anonymous,subscriptions, wildcardcors_origins/publish_origins, and a prefix-lesscookie_name, unless you set them explicitly, and redirects the site root/to the debugger UI. This is INSECURE; never enableplaygroundin production.- The bundled
dev.Caddyfilewas removed. It was only the defaultCaddyfileplusplayground, so run a development hub with the default config andMERCURE_EXTRA_DIRECTIVES=playground(ordebuggerfor the prod-safe UI without a token). The Docker image and the Helm chart'sdev: truevalue do this for you.
#Legacy non-Caddy server removed
The standalone non-Caddy server has been removed. Deploy the Caddy-based binary or Docker image and translate your settings to a Caddyfile; see Installation. Compatibility mode restores selected 0.x protocol behaviors, not the old server.
#Compatibility mode
0.x behaviors are gated behind two build tags, honored only with protocol_version_compatibility 8:
deprecated_topic: URI Template selectors intopic=, bare-string JWT matcher claims, the/subscriptions/{topic}routes. Canonical and alternate topics (repeatedtopic=publish fields) are a modern-mode feature, not gated by this tag; see Alternate topics.deprecated_claim: the legacymercureclaim (string and object forms), thehttps://mercure.rocks/namespaced claim,mercure.payload, theauthorizationquery parameter, themercureAuthorizationcookie, and tokens withouttyp: at+jwt,aud,expor a matchingiss.
Enabling it therefore weakens access-token validation, which is why the hub never turns it on by itself.
protocol_version_compatibility 7 also lets publishers post public updates to topics outside their grants (deprecated). As in 0.13, the token must still carry a mercure.publish claim, even an empty one, or a publish authorization detail; other tokens get a 403.
Official binaries and Docker images ship with both tags, so you can run protocol_version_compatibility 8 during the migration. A hub built without a tag rejects the corresponding 0.x behavior outright. Custom builds must pass the tags to go build.
#Restore the removed Caddyfile directives
The official Caddyfile dropped two directives that only serve 0.x clients. Add them back to your Caddyfile when running compatibility mode.
0.x clients pass the token in the authorization query parameter, so keep it out of logs by restoring the log filter inside the site block:
log {
format filter {
fields {
request>uri query {
replace authorization REDACTED
}
}
}
}
The deprecated /healthz endpoint (superseded by the /mercure/health/ready and /mercure/health/live admin API endpoints):
log_skip /healthz
respond /healthz 200
#Go API changes
canReceive/canDispatchare replaced by the internal authorization-detail grant logic.WithUIis renamedWithDebugger, andWithDemois renamedWithPlayground. The playground can prefill an access token via the newWithPlaygroundTokenFunc(INSECURE, EXPERIMENTAL).NewHubno longer requires a resource identifier: it derives the identity from each request, resolving the origin fromNewRequestOriginContextwhen an embedding server sets it, else from the request's scheme andHost.WithResourceIdentifierstill pins one static value; a value ending in/.well-known/mercurealso sets the URL Pattern base.WithPublicURLsrestricts the hub to an allowlist of public URLs (scheme and host), returning421for an unlisted origin.
#Historical changes (0.x)
The entries below describe earlier upgrades. They are kept for users migrating across multiple major versions.
#Mercure 0.21 upgrade notes
When Mercure is compiled manually or used as a Go library, deprecated features are no longer included by default.
To re-enable deprecated transports, pass the deprecated_transport build tag when compiling Mercure:
go build -tags deprecated_transport
Official binaries and Docker images still include deprecated features.
#Mercure 0.17 upgrade notes
The MERCURE_TRANSPORT_URL environment variable and the transport_url directive were deprecated in favor of the transport directive.
Before:
transport_url bolt://mercure.db?cleanup_frequency=0.2
After:
transport bolt {
path mercure.db
cleanup_frequency 0.2
}
To configure the transport via an environment variable, append the directive to MERCURE_EXTRA_DIRECTIVES. Avoid putting credentials there; use {env.MY_VAR} placeholders in a custom Caddyfile instead.
#Mercure 0.16.2 upgrade notes
Caddyfile.dev was renamed to dev.Caddyfile to match Caddy best practices.
#Mercure 0.14.4 upgrade notes
This release moved to Caddy 2.6, which removed single-hyphen long-form flags. Use --config instead of -config.
#Mercure 0.14.3 upgrade notes
The Prometheus metric mercure_subscribers was renamed mercure_subscribers_connected for better interoperability with Datadog and others.
#Mercure 0.14.1 upgrade notes
The default development key changed from !ChangeMe! to !ChangeThisMercureHubJWTSecretKey! to satisfy the spec's 256-bit minimum.
#Mercure 0.14 upgrade notes
The Last-Event-ID query parameter was renamed last_event_id. Update your clients.
Publishing public updates in topics not listed in mercure.publish was removed; use ["*"] to keep the old behavior.
protocol_version_compatibility 7 was added to ease the transition. The hub still accepts 7 and 8; use 8 for the latest 0.x behavior during migration.
#Mercure 0.13 upgrade notes
The DEBUG environment variable was removed. Set GLOBAL_OPTIONS=debug instead.
#Mercure 0.11 upgrade notes
The hub became a Caddy module. Standalone binaries are now custom Caddy builds. The legacy server stayed available with a legacy build prefix until 1.0.
Before switching, migrate your configuration.
#Mercure 0.10 upgrade notes
The protocol changed substantially. Highlights:
- Targets are gone, replaced by topic selectors. Mark updates
privateand check themercure.publish/mercure.subscribeclaims. - Subscription JSON-LD:
"@type": "https://mercure.rocks/Subscription"->"type": "Subscription". dispatch_subscriptions->subscriptions.subscriptions_include_ipremoved; usemercure.payload.- IDs are now URNs (
urn:uuid:...). *as a topic became reserved.
#Mercure 0.8 upgrade notes
- Hub URL changed from
/hubto/.well-known/mercure. HISTORY_CLEANUP_FREQUENCY,HISTORY_SIZE,DB_PATHcollapsed intoTRANSPORT_URL.ACME_HOSTS,CORS_ALLOWED_ORIGINS,PUBLISH_ALLOWED_ORIGINSswitched to space-separated values.- The Go library's public API was rewritten.