JWT’s for API authentication in Magento 2

With the latest 2.4.4 release of Magento 2 comes the normal improvements including fixes and security patches, but theres normally a few new and exciting modules. For 2.4.3 this was the inclusion of Magento PageBuilder in the open-source edition, built on the solid foundation of Gene Bluefoot (Full disclosure, I work for Gene, but my opinions are my own and I do not speak for the company) which allowed merchants to easily build great looking CMS and product pages with minimal knowledge of HTML and CSS.

So digging though 2.4.4 I was interested to see a Magento_JwtUserToken module. For those of you that have no idea what a JWT is, it stands for JSON Web Token. JWT’s are (if you want a more technical explanation check out RFC 7519) in summary, a way to share a verifiable payload between 2 parties. In the simplest implementation, it is 3 json objects encoded as base-64 strings, concatenated together with full-stop characters. The middle string (the payload) has the important data. That being said. If you are just storing it as a token, you don’t actually need to decode or process it, just store it.

That being said… lets examine the payload eyJ1aWQiOjEsInV0eXBpZCI6MiwiaWF0IjoxNjUzMzQwNzA1LCJleHAiOjE2NTMzNDQzMDV9 becomes {"uid": 1,"utypid": 2,"iat": 1653340705,"exp": 1653344305}. In this there are 2 (of many) “registered claims”, which are values that are not required but suggested. In our case iad (issued at) and exp (Expiration time), which for us is the interesting one. Previously, the only way of determining if an auth token was expired was to already know what the expiary time was, or to make the request we wanted or to have logic wrapped around our API calls to check if the HTTP response code indicated that the token had expired, in which case we would need to re-authenticate and squire another token

So you may be thinking “Oh, SHIT, we use the Magento API, is this going to break our ERP integration?” Almost certainly not. In fact, if you have upgraded to 2.4.4 already, your integration may be using JWT already.

Lets take a bit of a deeper dive into how this works…

Lets start off by authenticating to the Magento API with our admin credentials:

curl --location --request POST 'https://magento.test/rest/V1/integration/admin/token' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "craig",
    "password": "hunter2"
}'

All /rest/ URLs are handled by Magento_Webapi which identifies all possible endpoints by reading the webapi.xml file from all enabled modules. So lets track track down the endpoint responsible for handling this request. Using your IDE of choice (For me and most Magento developers thats PhpStorm) lets filter for the string /V1/integration/admin/token in files webapi.xml. Now you may have noticed there are 2 results… When Magento added Two-Factor authentication as a dependancy for the core meta-package via Magento_TwoFactorAuth this included 2FA for the API as well. For the purposes of this example I have disabled this using Mark Shust’s MarkShust_DisableTwoFactorAuth (composer installable using composer require markshust/magento2-module-disabletwofactorauth) module.

NOTE: I can’t tell you what to do, but please, please, please… do not disable 2FA on production sites. If you do you are doing yourself and your client’s a massive dis-service. 2FA is a brilliantly effective measure in our toolbox to mitigate malicious actors aiming to undermine us, our customers and ecommerce stores in general. Our end users trust us with their data, the least we can do is use the free tools at out disposal to safeguard it.

Looking at vendor/magento/module-integration/etc/webapi.xml we see POST requests to /V1/integration/admin/token are handled by Magento\Integration\Api\AdminTokenServiceInterface::createAdminAccessToken(). Lets jump to the implementation in Magento\Integration\Model\AdminTokenService::createAdminAccessToken() and figure out where the token is actually created.

Right on the last line of the method we see return $this->tokenIssuer->create($context, $params); Knowing that Magento uses the Dependancy Injection to provide our code with its required dependancies lets take a look at the __construct() method.

Now… The $tokenIssuer property of this class is provided to us a little ambiguously, the type is nullable and there is code to detect a nullable type in which case the ObjectManager (? I think in this case it’s done for backwards compatibility?) is used to get us an implementation of Magento\Integration\Api\UserTokenIssuerInterface

Now comes the interesting part…

In Magento 2 we use the di.xml file to specify which class handles the implementation of an interface. So, lets do another search for the preference for Magento\Integration\Api\UserTokenIssuerInterface. We get 2 results. This is where Magento’s module preferences come into play.

We have 2 modules, and 2 implementations for UserTokenIssuerInterface:

  • <preference for="Magento\Integration\Api\UserTokenIssuerInterface" type="Magento\JwtUserToken\Model\Issuer" />
  • <preference for="Magento\Integration\Api\UserTokenIssuerInterface" type="Magento\Magento\Integration\Model\OpaqueToken\Issuer" />

They can’t both do it so who do we know which one takes priority?

As it turns out module.xml holds the answer. The sequence node tells Magento which modules should be loaded before the current module. Lets take a look at the 2 files in question:

<!-- module-integration/etc/module.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Magento_Integration" >
        <sequence>
            <module name="Magento_Store"/>
            <module name="Magento_User"/>
            <module name="Magento_Security"/>
        </sequence>
    </module>
</config>
<!-- module-jtw-user-token/etc/module.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Magento_JwtUserToken">
        <sequence>
            <module name="Magento_Integration" />
        </sequence>
    </module>
</config>

So from this we can determine modules probably load in the following order:

  • Magento_Store
  • Magento_User
  • Magento_Security
  • Magento_Integration
  • Magento_JwtUserToken

Which means when the di.xml gets merged the class Magento\JwtUserToken\Model\Issuer in Magento_JwtUserToken gets defined as the preference for Magento\Integration\Api\UserTokenIssuerInterface and overrides the one that Magento_Integration defines as the preference.

This is how we are able to ensure JWTs are used as the auth token method, with no system configuration, no plugins to change to logic.

It may seem more complex at first with all the XML merging etc, but let's think about it. How often do we change the authentication logic for our APIs? Do we really need settings to choose this if the more modern way of doing things, by default, takes priority? On top of this once that cache has been warmed, this decision making does not need to happen again, it has already been computed, lets just save it for every subsequent request.

But what if we want to keep using the old API tokens? Really simple… bin/magento module:disable Magento_JwtUserToken. When we disable the module, all the XML in the code is ignored so it does not get merged, restoring the preference for the implementation defined by Module_Integration.

I also made a YouTube video if that works better for you.