Skip to content

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

Area0.x1.0
Matcher typesURI Template, string, plus exploratory typesexact and urlpattern only
Subscribe query parametertopic=<pattern> (URI Template or string)match=<exact> or match_urlpattern=<pattern> (case-sensitive)
Templating languageURI Templates (RFC 6570)URL Patterns (WHATWG)
Tokenbespoke mercure JWT claimOAuth 2.0 access token: typ: at+jwt, iss, aud, authorization_details
Authorizationmercure.publish / mercure.subscribe string arraysauthorization_details entries with the Mercure type URI (see below)
Token in query / cookieauthorization param, mercureAuthorization cookie__Secure-mercure_access_token cookie; no query parameter (RFC 9700)
Auth errors401 / silent dropRFC 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 is match_urlpattern, using URL Pattern syntax (:id, not {id}).
  • Parameter names are case-sensitive. Any other name under the match prefix is rejected with 400, so typos fail loudly.
  • The URI Template matcher type is gone; it survives only on a hub built with deprecated_topic running protocol_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": [...] }. actions is a non-empty subset of ["publish", "subscribe"]; topics is a non-empty array of { "match", "match_type"? } objects (bare strings are rejected).
  • match_type is case-sensitive and defaults to exact. The reserved { "match": "*" } matches every topic.
  • A subscribe entry may carry a payload; the old top-level mercure.payload is 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 mercureAuthorization to __Secure-mercure_access_token (override with cookie_name; use a prefix-less name for plain-HTTP development).
  • The authorization query 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 (including fetch() with a readable stream) uses the Authorization header.
  • The Authorization: Bearer header 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 -> 401 with a bare WWW-Authenticate: Bearer challenge and a resource_metadata parameter.
  • Invalid token -> 401 error="invalid_token".
  • Valid token without a grant for the action on the topic -> 403 error="insufficient_scope" (previously 401 or a silent drop).
  • Malformed request -> 400 error="invalid_request".

#Migrate the subscription API and events

BeforeAfter
/.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= (or match_urlpattern= if templated)

  • URI Template syntax in subscribe URLs ({id}) -> URL Pattern syntax (:id)

  • "mercure": { "publish": [...] } in issuer code -> authorization_details with actions: ["publish"]

  • "mercure": { "subscribe": [...] } -> authorization_details with actions: ["subscribe"]

  • mercureAuthorization cookie -> __Secure-mercure_access_token; authorization= query param -> Authorization header or cookie (no query parameter)

  • publisher_jwt / subscriber_jwt / *_jwks_url in the Caddyfile -> issuer <iss> { publisher { jwt ... } subscriber { jwt ... } } (see JWT keys move into issuer blocks)

  • Hardcoded subscriptions/{topic}/{subscriber} paths -> add the {match_type} segment

  • Last-Event-ID read 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 as application/json

  • type values lowercased: Subscription -> subscription, Subscriptions -> subscriptions

  • Subscription events now carry the SSE event: mercure field (route them with addEventListener("mercure", ...)); publishing an update whose type is mercure is rejected with a 400 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. Set resource_identifier only to pin one canonical audience shared across every domain. On a catch-all site block (:443, no host matcher), add public_urls <url...> so a request whose origin is not listed is rejected with 421 Misdirected Request instead of choosing the derived identity. List the origins the hub itself receives, scheme included: behind a TLS-terminating proxy that is the http:// 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>] becomes publisher { jwt <key> [<alg>] }, and subscriber_jwt becomes subscriber { jwt ... }.
  • publisher_jwks_url <url> becomes publisher { jwks_uri <url> [<alg>...] } (note _uri, after the RFC 8414 member), and likewise for subscriber_jwks_url. jwt and jwks_uri are mutually exclusive within one block.
  • The issuer identifier must equal the iss claim of your tokens. Omitting publisher or subscriber rejects that action for the issuer. Add authorization_server inside 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_KEY and MERCURE_SUBSCRIBER_JWT_ALG variables. The new MERCURE_TRUSTED_ISSUERS variable sets the issuer identifier. It defaults to https://localhost, which only suits local development: set it to the iss your tokens carry.
  • The pre-1.0 top-level directives publisher_jwt, subscriber_jwt, publisher_jwks_url and subscriber_jwks_url still parse but map to a single implicit issuer usable only in compatibility mode. Setting one without protocol_version_compatibility is now a configuration error, because that mode also drops the required exp, the audience check, the at+jwt check and the issuer check, and re-accepts the token in the URL query string. Migrate them into an issuer block for modern mode, or add protocol_version_compatibility 8 to 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 to HS256.
  • 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_url directives take no algorithm list, so a JWK Set of HMAC keys needs an issuer block.

#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_url and MERCURE_TRANSPORT_URL require the deprecated_transport build tag, which official builds still include. Migrate to transport <name> { ... }.
  • An unrecognized directive inside the mercure block is now a configuration error instead of being ignored. A typo previously disabled whatever it was meant to configure, silently, so check your MERCURE_EXTRA_DIRECTIVES if the hub refuses to start after the upgrade.
  • The ui and demo directives were renamed. ui is now debugger: the prod-safe debugger UI, which also moved from /.well-known/mercure/ui/ to /.well-known/mercure/debug/. demo is now playground (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.
  • playground now turns on the permissive dev settings it needs on its own: anonymous, subscriptions, wildcard cors_origins/publish_origins, and a prefix-less cookie_name, unless you set them explicitly, and redirects the site root / to the debugger UI. This is INSECURE; never enable playground in production.
  • The bundled dev.Caddyfile was removed. It was only the default Caddyfile plus playground, so run a development hub with the default config and MERCURE_EXTRA_DIRECTIVES=playground (or debugger for the prod-safe UI without a token). The Docker image and the Helm chart's dev: true value 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 in topic=, bare-string JWT matcher claims, the /subscriptions/{topic} routes. Canonical and alternate topics (repeated topic= publish fields) are a modern-mode feature, not gated by this tag; see Alternate topics.
  • deprecated_claim: the legacy mercure claim (string and object forms), the https://mercure.rocks/ namespaced claim, mercure.payload, the authorization query parameter, the mercureAuthorization cookie, and tokens without typ: at+jwt, aud, exp or a matching iss.

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 / canDispatch are replaced by the internal authorization-detail grant logic.
  • WithUI is renamed WithDebugger, and WithDemo is renamed WithPlayground. The playground can prefill an access token via the new WithPlaygroundTokenFunc (INSECURE, EXPERIMENTAL).
  • NewHub no longer requires a resource identifier: it derives the identity from each request, resolving the origin from NewRequestOriginContext when an embedding server sets it, else from the request's scheme and Host. WithResourceIdentifier still pins one static value; a value ending in /.well-known/mercure also sets the URL Pattern base. WithPublicURLs restricts the hub to an allowlist of public URLs (scheme and host), returning 421 for 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 private and check the mercure.publish / mercure.subscribe claims.
  • Subscription JSON-LD: "@type": "https://mercure.rocks/Subscription" -> "type": "Subscription".
  • dispatch_subscriptions -> subscriptions.
  • subscriptions_include_ip removed; use mercure.payload.
  • IDs are now URNs (urn:uuid:...).
  • * as a topic became reserved.

#Mercure 0.8 upgrade notes

  • Hub URL changed from /hub to /.well-known/mercure.
  • HISTORY_CLEANUP_FREQUENCY, HISTORY_SIZE, DB_PATH collapsed into TRANSPORT_URL.
  • ACME_HOSTS, CORS_ALLOWED_ORIGINS, PUBLISH_ALLOWED_ORIGINS switched to space-separated values.
  • The Go library's public API was rewritten.