Mercure Hub's JWT Authorization

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Can anyone publish a message to any topic on a Mercure hub? Definitely not. So how does the Mercure Hub know that we are allowed to publish this message? It's entirely thanks to this long string that we're passing to the Authorization header.

Where does this come from? It turns out, it's a JSON web token. Copy that huge string... then head over to jwt.io: a lovely site for working with JSON web tokens - or JWT's. If you're familiar with how JWT's work, awesome. If not, here's a little primer.

A JWT + Mercure Primer

Scroll down a bit to find a JWT editor. Paste in the encoded token. So this weird string here can actually be decoded to this JSON. And... you don't need a secret key to do it: the long string is basically just a base64 encoded version of this JSON. Anyone can turn this string into this JSON.

So when we send this long string to the server, what we're really sending is this JSON data. For us, the subscribe part isn't important... and neither is the payload. But the publish part is important. This basically says:

Hi Mercure Hub! Guess what! I have permission to publish to any topic. Cool, huh?

Ok... but why does the Mercure server trust this? Can't anyone create a JSON web token that claims that they can publish to all topics? Yea! But those wouldn't be signed correctly unless they have the "secret".

When you run a Mercure Hub, you give it a "secret" value... which, by default - and for our Mercure Hub - is !ChangeMe!. This is the value that you see in our .env file.

Back over on jwt.io, look at the bottom. It says "invalid signature". When a JWT is created, it's signed by a secret key. When someone uses a JWT, after decoding it - which anyone can do - they are then supposed to verify the signature of the token. Right now, it's trying to verify the signature of our JWT... but using the wrong secret. If we paste in our real secret instead... it's verified!

This can... be a bit technical. The point is this: in order to generate a JWT that will have a valid signature, you need the secret. And while anyone can read a JWT, if you mess around with its contents, the signature will fail. That's why the Mercure Hub trusts us when we send a JWT that says we can publish to any topic: the signature of our message is valid. That means it was generated by someone who has the secret key.

Check this out: let's regenerate this JWT using the same "payload" but signed using the wrong secret... something a bad user might try to do. Copy the new JWT... update the curl command in our scratchpad... copy the whole command... and paste it into the terminal. Hit enter. Unauthorized! The Mercure Hub can totally read the JSON in this message, but it sees that the signature failed and does not publish the message.

Change back to our old key in the scratch pad. And at the browser, use the correct secret: !ChangeMe!.

Simplifying the Payload

To simplify things, change the payload to just the part we need. So remove the subscribe part - we're not trying to get access to subscribe to anything - and also remove payload. This is all we really need: some JSON that claims that we can publish to any topic signed with the correct secret. If you ever need to create a JWT by hand, this is how you do it: create the JSON you want and have something - like this site - sign it with your secret.

Copy the new, shorter JWT... and paste it in our scratchpad. Copy the entire command, paste it at your terminal and... yes! It works! In our browser, the listening tab shows a second message.

Publishing a Turbo Stream

Enough about authorization & JWT. In the real world, as long as we have the correct MERCURE_SECRET configured in our app, all of this will be handled automatically thanks to the Mercure PHP library. Internally, it will use the secret to generate the signed JWT for us.

But before we start publishing messages from our code, let's look closer at the data POST parameter. So far, we've been sending JSON. And, in theory, we could write some JavaScript that listens to this topic and does something with that JSON. But remember: the turbo_stream_listen() function activates a Stimulus controller that is already listening to this topic. It's listening and waiting for a message whose data isn't JSON, but <turbo-stream> HTML.

Check it out: over in our scratch pad, instead of setting the data to JSON, I'll paste in a turbo stream. It's a little ugly because it's all on one line, but it's valid: action="update, target="product-quick-stats" with some dummy content inside.

Let's first see if this message shows up inside our browser tab. Oh! It actually stopped listening. It probably hit a listening timeout - that's something you can configure or disable in Mercure. I'll refresh.

Now, go copy the command... find your terminal, paste, hit enter... and head back to the browser. No surprise: here's our message with the Turbo Stream HTML. But the really cool thing is back on our site. Scroll up. Yes! It updated the quick stats area! As soon as we published the message, the JavaScript from the Stimulus controller saw the message and passed the turbo-stream HTML to the stream-processing system. That's so cool.

Of course, we aren't normally going to publish via the command line & curl: we're going to publish messages via PHP... which is way easier. Let's do that next.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}