JWT Based Authorization

I had previously setup a bearer authorization scheme with a system in the past. It was a simple UUID that was associated with the user in the database. Today, that changed.

First, let’s get some clarity, as I keep mixing up two words that sound very similar, and fairly related. Authentication & Authorization. Authentication is the process of logging in with credentials to verify who you are. Authorization is how you access various resources using a token. Getting a government issued form of identification may be difficult, but once you have one, most businesses accept that you are authorized to work, purchase, or access resources.

I was doing some research regarding authorization in redux applications, and how to handle expired tokens. A few examples led me to believe that I should respond with a status code if a token has expired or become invalid, and reauthorize the request with a refresh token. If that fails as well, then redirect the user to the login page.

This concept of a refresh token was foreign to me. I’ve come to understand it as JWT Based Authorization, and it’s fairly common.

JSON Web Token logo

The first part of that is the 403 “Forbidden” status code. 403 Forbidden means that although you are authorized, you are not permitted to access the resource. 401 “Unauthorized” means that you are attempted to access a resource, but it requires you to be authenticated first. As I looked more into this 401/403 scheme, there seems to be a bit of contention in that some people argue against this as 403 is specifically meant to mean that you are still logged in, but are not permitted to access the resource – when theoretically, our expired/invalid token means that the user is not logged in. Some people tend to be very adamant about their position on the matter.

The next part is the refresh token. It can’t be used as an authorization token. Although authorization tokens have a short lifespan between 10 to 15 minutes, Refresh tokens have a longer lifespan over the course of a few days, and may be used more than once. Normally, the refresh token is persisted, such as within local storage, between browser sessions.

To use the reauthorization scheme, you need to setup your api calls so that when a query returns with a 401 status code, you make a separate call to get a new authorization token with your refresh token, and then attempt to make the original request again.

Microsoft Designer: Image Creator Prompt

The author implemented JWT-based Authorization in a Redux application. They used a separate refresh token to obtain new authorization tokens when the current one expires. They encountered challenges with token storage, header configuration, and state management within Redux. The author recommends using a custom middleware or listener to handle token refresh and local storage interactions. They also highlight the importance of security measures, such as storing refresh tokens securely and using strong secret keys.

What if you have multiple API calls at once? In this scenario, you need to setup an object to allow other processes to use as a locking mechanism. The first process to encounter a forbidden status needs to lock the state and reauthorize. All other processes bypass the reauthorization while the lock is in place, wait for the lock to be released, and run their original queries again.

This type of object is called a mutex (Mutual Exclusion). Although we aren’t doing parallel programming or running on other threads, we are running processes in an asynchronous state where other processes may step in and make requests while we are waiting for a response during the refresh token call. A small library called async-mutex allows us to carry out this async blocking.

const mutex = new Mutex();
export const apiSlice = createApi({
  reducerPath: `api ${emoji.wireless}` as const,
  tagTypes: [TAG_TYPE, TAG_TYPE_FEATURE],
  baseQuery: async (args, api, extraOptions) => {
    const baseQuery = fetchBaseQuery({
      baseUrl: 'https://localhost/api',
      prepareHeaders(headers, api) {
        const state = api.getState() as {
          [authSlice.reducerPath]: AuthState
        };
        const token = authSlice.selectors.selectToken(state);
        if (token && token !== '') {
          headers.append("Authorization", `Bearer ${token}`);
        }
        return headers;
      }
    })
    let result = await baseQuery(args, api, extraOptions);
    if (result.error && result.error.status === 401) {
      if (!mutex.isLocked()) {
        const release = await mutex.acquire();
        const state = api.getState() as {
          [authSlice.reducerPath]: AuthState
        };
        const refreshToken = authSlice.selectors
          .selectRefreshToken(state);
        try {
          const refreshResult = await baseQuery(
            {
              url: 'auth/refresh', 
              method: 'POST', 
              body: { refreshToken },
            },
            api,
            extraOptions,
          );
          if (refreshResult.data && 
               typeof refreshResult.data === 'object' && 
               ('authToken' in refreshResult.data) && 
               typeof refreshResult.data.authToken === 'string'
           ) {
            api.dispatch(tokenUpdated(refreshResult.data.authToken));
            result = await baseQuery(args, api, extraOptions);
          } else {
            api.dispatch(logout());
          }
        } finally {
          release();
        }
      } else {
        await mutex.waitForUnlock();
        result = await baseQuery(args, api, extraOptions);
      }
    }
    return result;
  },

JWT Tokens

The nice thing about JWT Tokens are that they are signed, and they have the capability of including expiration and additional information. You can verify that a token is invalid or expired without having to make a trip to the database.

I touched on JSON Web Tokens briefly in the past when working with the Web Push API. In that scheme, JWT was signed with VAPID private keys and used as one part of authorization, as both symmetric and asymmetric encryption were involved as well.

JWT does support encryption, but for the most part, data is sent in the clear – almost. The payload, or claims as they are called, is simply a base64 encoded JSON object, which is also URL encoded to sit in the authorization header. What makes a JWT important is the signed data. At first glance, a token just looks like a string of random data.

ewogICAgImFsZyI6ICJIUzI1NiIsCiAgICAidHlwIjogIkpXVCIKfQ.ewogICAgIm5iZ
iI6IDE3Mjc4Mjc5NDksCiAgICAiZXhwIjogMTcyNzgzMTU1NCwKICAgICJzdWIi
OiAibG1vdGVuIgp9.nhFXlxnsx_esuY_ODawmksUVy-fsWJv8CCw9O_Fj_Mw

The format of a JSON Web Token is as follows.

<header>.<payload>.<signature>

The first two values are JSON that is base64 and URL encoded. Which means that anyone who has access to the tokens can read the headers and payload. The last header is just base64 and URL encoded.

Formatting JSON for the token is fairly strait forward.

str_replace(
    ['+', '/', '='],
    ['-', '_', ''],
    base64_encode(
        json_encode($data)
    )
);

I was able to copy & paste the JWT token into the JWT.io website and they were able to decode both the header and payload. Once I entered my secret, they were able to verify that the data was authentic.

The header doesn’t change much. For mine, I specify the algorithm and type.

{"alg": "HS256", "typ": "JWT"}

The header lets you know that you have a JSON Web Token, and how it’s signed. The “typ” is optional. Tokens are meant to be small – and that’s reflected in the names that the standard follows, as they recommend no more than 8 characters.

Let’s build a JWT.

$header = self::json_base64_url_encode(
    ['alg' => 'HS256', 'typ' => 'JWT']
);

$timestamp = time();

$payload = [
    'nbf' => $timestamp - 5,
    'exp' => $timestamp + 3660,
    'sub' => 'lmoten',
];
$payload = self::json_base64_url_encode($payload);

$signature = hash_hmac(
    "sha256",
    "$header.$payload",
    $this->secret_key,
    true
);
$signature = self::base64_url_encode($signature);

$token = "$header.$payload.$signature";
return $token;

The next part is to parse the token. We need to read the header and determine what algorithm to run on the <header>.<payload> to verify that it matches the <signature>.

$header_encoded = $matches['header'];
$payload_encoded = $matches['payload'];
$signature_encoded = $matches['signature'];

$header = self::json_base64_url_decode(
  $header_encoded
);

if (
  isset($header['typ']) && 
  strtoupper($header['typ']) !== 'JWT'
) {
    return false;
}
if (
  !isset($header['alg')) || 
  $header['alg'] !== 'HS256'
) {
  return false;
}

$decoded_signature = self::base64_url_decode(
  $signature_encoded
);

$expected_signature = hash_hmac(
    "sha256",
    "$header_encoded.$payload_encoded",
    $this->secret_key,
    true
);

if (!hash_equals(
  $expected_signature, 
  $decoded_signature
)) {
    return false;
}
$claims = self::json_base64_url_decode(
  $payload_encoded
);
return $claims;

When comparing the signature, PHP has a function, hash_equals that ensures that the processing time to read a full matching signature is the same as the time it takes to read a signature that doesn’t match. For example, if I compare “ABC” to “XYZ”, most string comparison operations will return once they see that the first byte doesn’t match. A hacker may use this to their advantage to help determine how close they are to getting the entire signature to match. The hash_equals function compares all bytes, regardless if it already has an answer after evaluating the first byte.

So our signature matches. It looks like we did in fact, issue the token and sign it. The payload contains our claims. Here is an example of a fairly packed payload

{
    "iat": 1727824778, // issued at
    "jti": "66fc838ac435e", // JWT token ID
    "nbf": 1727824773, // not before
    "exp": 1727828378, // expires at
    "iss": "localhost", // issuer
    "aud": "authorization", // audience
    "sub": "lmoten", // subject
    "username": "lmoten" // custom data
}

The exp claim lets us know when the token expires. nbf lets us know when the token may first be used.

if (
  isset($claims['exp']) && 
  (time() > $claims['exp'])
) {
  return false; // expired
}
if (
  isset($claims['nbf']) && 
  (time() < $claims['nbf'])
) {
  return false; // not valid yet
}

Now we are able to reject the users request, even if the token was previously valid. JWT is self-contained, in that it allows us to verify the user without database requests. The claims can store additional information such as a username. However, sensitive information needs to be encrypted as well.

As a preemptive measure, our web application can read the tokens expiration date, and opt to refresh the token a minute or so before it expires to prevent unnecessary calls to the server.

I ended up writing a little class to create JSON web tokens that encapsulates a bit of the logic. I can setup configuration options when I first instantiate the class, and change those options later. Here is how to create authorization and refresh tokens:

$username = 'lmoten';
$secret_key = 'foo';
$jwt = new JsonWebToken($secret_key, ['delay' => -5]);

$jwt->configure(['ttl' => 60 * 5]);
$authToken = $jwt->build([
  'sub' => $username, 
  'aud' => 'auth'
]);

$jwt->configure(['ttl' => 60 * 60 * 24 * 7]);
$refreshToken = $jwt->build([
  'sub' => $username, 
  'aud' => 'refresh'
]);

Show::data([
    'authToken' => $authToken,
    'refreshToken' => $refreshToken,
]);

The resulting data looks like this:

{
    "authToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey
JuYmYiOjE3Mjc4MzAzNTcsImV4cCI6MTcyNzgzMDY2Miwic
3ViIjoibG1vdGVuIiwiYXVkIjoiYXV0aCJ9.6Hb5Mtp-ZoYurvN
xNC790-T31wnCJ8u-9wo3CWe5Ipw",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVC
J9.eyJuYmYiOjE3Mjc4MzAzNTcsImV4cCI6MTcyODQzNT
E2Miwic3ViIjoibG1vdGVuIiwiYXVkIjoicmVmcmVzaCJ9.cf
MnkvfenVt1idh8KFsO3d8JoE8dSqBgRtoTh7m4lbQ"
}

The class itself doesn’t fully implement JWT with the various algorithms. I’m not sending and receiving JSON Web Tokens with other services, so I don’t need to support everything. It’s a simple implementation that does what I need it to do, and that’s what counts.

JsonWebToken.php
<?php
class JsonWebToken
{
    private $secret_key;
    private $config = [
        'iss' => null,
        'aud' => null,
        'sub' => null,
        'ttl' => null,
        'delay' => null,
        'jti' => false,
        'iat' => false,
    ];

    public function __construct(
        #[SensitiveParameter] string $secret_key = null,
        array $config = null
    ) {
        if (!empty($config)) {
            $this->configure($config);
        }
        if (!empty($secret_key)) {
            $this->secret_key = $secret_key;
            return;
        }
        $base64 = getenv('JWT_KEY');
        if (empty($base64)) {
            throw new Exception("JWT Secret not available", 500);
        }
        $secret_key = base64_decode($base64);
        if ($secret_key === false) {
            throw new Exception("JWT Secret base64 decoding failed", 500);
        }
        $this->secret_key = $secret_key;
    }
    public function build(
        array $claims = null
    ) {
        $header = self::json_base64_url_encode(
            ['alg' => 'HS256', 'typ' => 'JWT']
        );

        $timestamp = time();
        $payload = [];

        if ($this->config['iat'] !== false) {
            $payload['iat'] = $timestamp;
        }

        if ($this->config['jti']) {
            $payload['jti'] = uniqid();
        }

        if ($this->config['delay'] !== null) {
            $payload['nbf'] = $timestamp + $this->config['delay'];
        }
        if ($this->config['ttl'] !== null) {
            $payload['exp'] = $timestamp + $this->config['ttl'];
        }
        foreach (['iss', 'aud', 'sub'] as $name) {
            if ($this->config[$name] !== null) {
                $payload[$name] = $this->config[$name];
            }
        }

        if (is_array($claims)) {
            foreach ($claims as $claim => $value) {
                $payload[$claim] = $value;
            }
        }

        if (count($payload) === 0) {
            return false;
        }

        $payload = self::json_base64_url_encode($payload);

        $signature = hash_hmac(
            "sha256",
            "$header.$payload",
            $this->secret_key,
            true
        );
        $signature = self::base64_url_encode($signature);

        $token = "$header.$payload.$signature";
        return $token;
    }
    public function get_claims(string $token)
    {
        if (
            preg_match(
                "/^(?<header>.+)\.(?<payload>.+)\.(?<signature>.+)$/",
                $token,
                $matches
            ) !== 1
        ) {
            return false;
        }

        $header_encoded = $matches['header'];
        $payload_encoded = $matches['payload'];
        $signature_encoded = $matches['signature'];

        $header = self::json_base64_url_decode($header_encoded);
        if (!is_array($header)) {
            return false;
        }

        if (isset($header['typ']) && strtoupper($header['typ']) !== 'JWT') {
            return false;
        }

        $signature = self::base64_url_decode($signature_encoded);

        switch ($header['alg']) {
            case 'none':return true;
            case 'HS256':break;
            default:return false;
        }

        $expected_signature = hash_hmac(
            "sha256",
            "$header_encoded.$payload_encoded",
            $this->secret_key,
            true
        );

        if (!hash_equals($expected_signature, $signature)) {
            return false;
        }

        $claims = self::json_base64_url_decode($payload_encoded);

        if (!is_array($claims)) {
            return false;
        }
        if (count($claims) === 0) {
            return false;
        }

        if (isset($claims['nbf']) && (time() < $claims['nbf'])) {
            return false;
        }

        if (isset($claims['exp']) && (time() > $claims['exp'])) {
            return false;
        }

        return $claims;
    }
    public function configure(array $options)
    {
        foreach ($options as $option => $value) {
            $this->config[$option] = $value;
        }
    }
    public function get_config()
    {
        return $this->config;
    }
    private static function json_base64_url_encode($data)
    {
        return self::base64_url_encode(
            json_encode($data, JSON_PRETTY_PRINT)
        );
    }
    private static function json_base64_url_decode($data)
    {
        return json_decode(
            self::base64_url_decode($data),
            true
        );
    }
    private static function base64_url_encode(string $data)
    {
        return str_replace(
            ['+', '/', '='],
            ['-', '_', ''],
            base64_encode($data)
        );
    }
    private static function base64_url_decode(string $data)
    {
        $base64 = str_replace(
            ['-', '_'],
            ['+', '/'],
            $data
        );

        $padding = 4 - (strlen($base64) % 4);
        if ($padding !== 0) {
            $base64 .= str_repeat('=', $padding);
        }

        return base64_decode($base64);
    }
}

I had setup the class to read from an environment variable, which I have stored in the .htaccess file. I have a deploy process that fills in the correct value, but this is how it’s setup as a template:

SetEnv JWT_KEY {{web.jwt}}

The key should be 256 bits and base64 encoded.

1XxOaO8RuBuXxlevPnv4XG+c31yPhj1AmfjXxW3F8iI=

You could generate a few for testing using this AES Key Generator. Keep in mind that the websites owner has access to the key generated, as well as potentially every server that the data went through before downloading to your web browser. It’s best to generate them locally.

openssl rand -base64 32

Authorizing

The token should be provided in the authorization header. What we can do is create an include file to post at the top of our endpoints that require authorization. The file reads the authorization header, and considers everything after the Bearer text as a token. It then passes that to our JsonWebToken class.

authorization_token_required.php
<?php
require_once "HTTP_STATUS.php";
require_once "Show.php";
require_once 'JsonWebToken.php';

function get_authorization_token()
{
    if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
        $authHeader = $_SERVER['HTTP_AUTHORIZATION'];
        if (preg_match('/Bearer\s+(.*)/', $authHeader, $matches)) {
            return $matches[1];
        }
    }
    return null;
}
function authorization_token_required()
{
    $token = get_authorization_token();
    if (empty($token)) {
        Show::error('Invalid or expired token', HTTP_STATUS_UNAUTHORIZED);
        exit;
    }
    $secret_key = 'foo';
    $jwt = new JsonWebToken($secret_key);
    $claims = $jwt->get_claims($token);
    if (
        is_array($claims) &&
        isset($claims['aud']) &&
        $claims['aud'] === 'auth'
    ) {
        return;
    }
    Show::error('Invalid or expired token', HTTP_STATUS_UNAUTHORIZED);
    exit;
}

authorization_token_required();

One of the things here is that we are also checking the audience claim, ensuring it is for authorization. This is because we have two tokens – refresh and authorization. If we didn’t check this value, someone could use their refresh key to access any resource. The benefits from having a separate, long-living refresh key would be pointless.

The user is authorized – but who are they? Again, we go back to the token and get our claims. For mine, I stored the username as the subject.

get_authorized_subject
function get_authorized_subject()
{
    $token = get_authorization_token();
    $secret_key = 'foo';
    $jwt = new JsonWebToken($secret_key);
    $claims = $jwt->get_claims($token);
    if (is_array($claims) && isset($claims['sub'])) {
        return $claims['sub'];
    }
    return false;
}

Refreshing

So how do we refresh the token? The JWT has all of the information that we need. We can create an endpoint /auth/refresh to read the refresh token and issue a new authorization token. A database is not necessary in this process since the JWT has all of the information that we need.

Although the refresh token is a JWT token as well, it should be posted in the body of the request as a security measure. The refresh token is more sensitive than the authorization token due to its longer life. Posting the refresh tokens can expose them to potential interception. Authorization tokens have the same risks, but it is standard practice to include them within the headers, and they are short lived.

refresh.php
$posted = new PostedJson(3, 'POST');
if (!$posted->keysExist(
    'refreshToken'
)) {
    Show::error($posted->lastError(), $posted->lastErrorCode());
    exit;
}
$refreshToken = $posted->getValue('refreshToken');

$secret_key = 'foo';
$jwt = new JsonWebToken($secret_key, ['delay' => -5]);

$claims = $jwt->get_claims($refreshToken);

if (
  is_array($claims) && 
  isset($claims['aud']) && 
  $claims['aud'] === 'refresh'
) {
    $sub = $claims['sub'];

    $jwt->configure(['ttl' => 60 * 5]);
    $authToken = $jwt->build(['sub' => $sub, 'aud' => 'authentication']);

    Show::data([
        'authToken' => $authToken,
    ]);
}

Bigger Boats

If this was a larger system, spread out on multiple servers, the refresh token would have a separate secret than the authorization token. Other servers could verify the authorization token with a shared secret – but as for the refresh token, only one server would have the secret key to create and read refresh tokens. In that sense, a separate createApi slice would be setup solely for authorization.

Over time, the authorization token will probably have more data added to it that’s needed. Some of it will probably be encrypted. The refresh token only has the bare minimum necessary to identify the user, fetch associated data that’s needed, and reissue a new authorization token.

Implementation

I must admit, putting it all together into something usable, I ran into one problem after another. There are many pieces to the puzzle. Here are a few things I ran into.

When calling the baseQuery during the token refresh, the Content-Type header needed to be manually added.

prepareHeaders(headers, api) {
        headers.set('Content-Type', 'application/json');

When using createApi, the api in baseQuery: async (args, api, extraOptions) is not typed, in that it’s unaware of your state when calling api.getState(), which makes it difficult to work with selectors.

baseQuery: async (args, api, extraOptions) => {
    const baseQuery = fetchBaseQuery({
      baseUrl: 'https://localhost/api',
      prepareHeaders(headers, api) {
        headers.set('Content-Type', 'application/json');
        const state = api.getState() as {
          [authSlice.reducerPath]: AuthState
        };
        const token = authSlice.selectors.selectToken(state);

You can’t call your endpoints directly, once you are inside of the baseQuery. You only have access to hooks and actions, and those can’t be used to do what you need.

Getting data responses from fetchBaseQuery have the same problems where its not typed.

if (refreshResult.data) {
  const data = refreshResult.data as { authorizationToken: string };
  api.dispatch(authorizationTokenIssued(data.authorizationToken));
  result = await baseQuery(args, api, extraOptions);
} else {
  api.dispatch(logout());
}

Restoring the refreshToken with the next browser session is a bit difficult. You can’t listen for the built-in @@INIT action. I ended up dispatching a method as the store is created.

export const store = createAppStore();
store.dispatch(restore());

This in turn read from local storage via listener middleware, and then dispatched an action to update the state with that value.

I’ve seen a few demonstrations of how do it this by reading local storage from within a reducer. Even a large language model (LLM) kept suggesting to do the same thing. This breaks the pattern of pure functions and having side effects. A reducer must always provide the same result based on what it’s provided. Having it grab data from an external resource is one of the biggest red flags when it comes to Redux. That’s why you have saga’s, async thunks, observers, and listeners. These are not part of Redux, but they take care of the side effects, which works out well for playback and jumping in history.

Although the process of implementing JWT Refresh Tokens is fairly strait forward, it seems that there are many decisions on how people implement it. When an authorization token is issued, you could send it back as a JSON response, a string, or as an authorization header. When refreshing a token, I’ve seen examples of passing in query string parameters, authorization headers, cookies, and posting JSON in the body. In one case, someone had posted the refresh token as a cookie with every request, so that the server could refresh the authorization token if it expired. However, that seemed to defeat the purpose of a refresh token as the authorization token was never needed at that point.

I also have this question of what data should be in that token? Which data is encrypted? Are there standard fields that I should be using for my data. I caught on that the sub (Subject) field should be a username or id. What else do people normally store in JWT beside the username? Should I store profile information inside, such as Name, Email, etc.? What about roles, features, and access rights?

It’s very important that the token is small. I’m under the impression that most browsers will be fine with 1 kilobyte of data. However, that’s after you’ve encoded it and included the Bearer prefix. The underlying data would be just 762 characters of clear text.

Should tokens, or an id for the token be stored in a database, and checked for revocation later? When logging out, it seems the refresh token should be removed from the persons computer. If by some odd chance someone had access to the refresh token, that persons account could be compromised until the token expires – unless logging out could send a request to the server to revoke the use of the token afterwards.

JWT seems great for multiple browser sessions with the same account. On some systems, they offer the ability to log out of all devices. That may become a bit of a problem with JWT, unless you confirm that the authorization and refresh tokens haven’t been revoked with each trip to the server.

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading