DEV Community

Cover image for Invalidating JSON Web Tokens (JWT): NodeJS + Express
José Pablo Ramírez Vargas
José Pablo Ramírez Vargas

Posted on • Originally published at webjose.hashnode.dev

Invalidating JSON Web Tokens (JWT): NodeJS + Express

Originally published @ hashnode.

Heredia, Costa Rica, 2022-12-10

Series: JWT Diaries, Article 2

Hello and welcome. This article will demonstrate how to invalidate JWT's based on the iat claim. If you haven't done so already, you can find the explanation about this invalidation method in the previous article of this series.

Right, so let's get started. This article will cover the complete (but basic) implementation of a simple website and will provide an alternative for API-only servers.

NOTES

  • Being a big fan of proper software architecture, the sample provided here will follow layering techniques.

  • The folder structure and package.json file contents are shown near the end of the article.

  • You can clone this GitHub repository to quickly obtain this example.

The JWT Service

The JWT service will both issue new JWT's and will validate said JWT's. This service will need the jsonwebtoken NPM package as well as some configuration values. For configuration, we'll use the amazing wj-config NPM package. If you are unfamiliar with this configuration package, I highly recommend you read all about it here, or in a more tutorial-based way here.

Ok, without further ado, here is the code for the token-service.js file:

import jwt from 'jsonwebtoken';
import config from '../config.js';
import jwtInvalidationService from './jwt-invalidation-service.js';

export default function (jwtInvSvc) {
    jwtInvSvc = jwtInvSvc ?? jwtInvalidationService();
    return {
        issueToken: function (payload) {
            return jwt.sign(payload, config.jwt.secret, {
                expiresIn: config.jwt.tokenTtl
            });
        },
        validateToken: async function (token) {
            let verifiedToken = null;
            try {
                verifiedToken = jwt.verify(token, config.jwt.secret);
            }
            catch (e) {
                // This logging is probably unneeded in production environments.
                console.error('Error verifying token: %o', e);
                return {
                    valid: false
                };
            }
            // Standard validation succeeded.  Let's see about the iat:
            const globalInv = await jwtInvSvc.globalInvalidation();
            const userInv = await jwtInvSvc.userInvalidation(verifiedToken.name);
            // Using the most recent date of the two is enough.
            let minimumIat = Math.max(globalInv, userInv);
            if (minimumIat) {
                minimumIat = new Date(minimumIat);
                console.debug('Token subject to minimum issued at verification: %s', minimumIat);
                const issuedAt = new Date(verifiedToken.iat * 1000);
                if (issuedAt < minimumIat) {
                    // This logging is probably unneeded in production environments.
                    console.warn("Token issued at %s for user %s is not acceptable.", issuedAt, verifiedToken.name);
                    return {
                        valid: false
                    };
                }
            }
            return {
                valid: true,
                token: verifiedToken
            };
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

Let's go through this logic unless, of course, you got all this, in which case please proceed to the next level-2 section.

Exporting a Function

The first thing to note in the code is that we are exporting a constructor-like function (not to be confused with a JavaScript constructor). This allows us to unit-test this service by injecting a mock implementation of the real token invalidation service, which is another service we will be creating. This constructor-like function then proceeds to create an object with two functions: One to create JWT's and another one for validating them. The second one is the object of interest in this article.

Validation of JWT's

The validation starts as per usual, calling the built-in functionality of the jsonwebtoken NPM package. If this standard validation passes, we go into our special invalidation code.

We obtain 2 possible minimum issued at date values from the (yet mysterious) token invalidation service: The global invalidation date, if it exists, and the per-user invalidation date, if it exists. Since a more recent date covers older dates, we use Math.max() to pick up the more recent of the two dates.

Once we have confirmed we need to do issued at invalidation, it is just a matter of a simple if statement using the verified token's iat claim value. Since our basic math school teachers told us we should always compare apples to apples and oranges to oranges, we convert the value of the iat claim to a JavaScript date by multiplying its value by 1000 and passing it to the Date's constructor.

In the end, the validation function will return its caller an object with one or two properties, depending on the result. If the token is valid, then the returned object will look like this: { "valid": true, "token": { "name": "jose", "iat": 1234567, ... } }. If the token is invalid, the response is simplified to: { "valid": false }.

The Token Invalidation Service

This service is in charge of obtaining and saving global invalidation and per-user invalidation dates. It uses the constructor pattern we saw above to allow unit testing. This service, as seen by its description, requires some form of storage. The common storage options for this are Database, Redis or Memcached, or a mixture of these. You pick your poison. This example uses a MySQL database as the storage solution.

Here's the code for jwt-invalidation-service.js:

import jwtInvalidationRepository from '../repositories/jwt-invalidation-repository.js';
import mysqlTransactionFactory from '../repositories/mysql-transaction-factory.js';

const globalId = '__global';

export default function (jwtInvRepository, txnFactory) {
    jwtInvRepository = jwtInvRepository ?? jwtInvalidationRepository;
    txnFactory = txnFactory ?? mysqlTransactionFactory;
    return {
        globalInvalidation: async function (newTime) {
            if (newTime) {
                // Set a new global invalidation record.
                const txn = await txnFactory.create();
                try {
                    await txn.start();
                    // Clear all invalidation records.
                    await jwtInvRepository.clear(txn);
                    // Insert the global invalidation record.
                    await jwtInvRepository.add(globalId, newTime, txn);
                    // Commit the transaction.
                    await txn.commit();
                }
                catch (err) {
                    console.error('Error caught while trying to add global invalidation.\n%o', err);
                    await txn.rollback();
                    throw err;
                }
                return newTime;
            }
            const r = await jwtInvRepository.get(globalId);
            if (r?.length) {
                return r[0].MinimumIat;
            }
            return null;
        },
        userInvalidation: async function (userId, newTime) {
            if (newTime) {
                await jwtInvRepository.add(userId, newTime);
                return newTime;
            }
            const r = await jwtInvRepository.get(userId);
            if (r?.length) {
                return r[0].MinimumIat;
            }
            return null;
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

As seen before, the exported object is a function that receives repository and transaction factory objects. This is done to support mocking the repository and transaction factory while unit testing. Let's discuss the rest.

Per-User Invalidation Logic

This logic is found inside the userInvalidation() function. It is very simple: If a time is provided, then it is assumed the caller wants to save an invalidation date for the provided user ID. In this case, the service calls the repository's add() function to fulfill the request.

If no time is provided, then it is assumed the caller wants to retrieve the current invalidation record for the provided user ID. The service turns once more its eyes towards the repository and calls its get() function. After obtaining the record, the MinimumIat value of the returned record is returned (or null is returned if no record was returned).

Global Invalidation Logic

If no time is provided when calling the globalInvalidation() function, then the same logic found in the userInvalidation() function is followed. The only difference is the user ID. I, as a very personal choice, decided to create a special user ID, __global, to refer to the global invalidation record. I did not want to create the MySQL table with a nullable UserId column. That's all.

The saving part, however, differs greatly from the userInvalidation() counterpart.

As stated in the previous article in the series, whenever global invalidation occurs, all other records can be safely deleted because, chronologically speaking, the global record will include them all. Wiping the table clean is a performance enhancement.

So whenever the service is requested to invalidate globally, a database transaction is started, all the table records are deleted, and then the global invalidation record is added. At this point, the transaction is committed.


So far I have shown the code that covers everything this article advertises:

  • How to create global and per-user invalidation records.

  • How to retrieve global and per-user invalidation records.

  • How to use the invalidation records in a token-verifying function.

The rest of this article will talk about the rest of the files that make up for the entire sample project, so you can run this test for yourself. You may skip the rest of the article if you don't need it, but note that the final section explains how to make use of the token validation service in two cases: A web server that serves content, and an API-only web server.

Repository

Let's see the details about the MySQL repository I used for this article, but let's start with the SQL that defines the table, for your reference.

create table JwtBlacklist
(
    Id int not null auto_increment,
    UserId varchar(50) not null,
    MinimumIat datetime not null,
    constraint PKC_JwtBlacklist primary key (Id),
    constraint UX_JwtBlacklist_UserId unique (UserId)
);
Enter fullscreen mode Exit fullscreen mode

MySQL2 NPM Package

The project uses the mysql2 package to read and write MySQL records. The API in it, however, is callback-based. I hate callback-based API. Because of this, I made three helpers to create a promise-based (awaitable) API.

The first one is a Query object. Here are the contents of the mysql-query.js file:

export default function Query(dbConn) {
    this._dbConn = dbConn;
    this.query = function (sql, sqlParams) {
        return new Promise((rslv, rjct) => {
            this._dbConn.query(sql, sqlParams, (err, results, fields) => {
                if (err) {
                    rjct(err);
                    return;
                }
                rslv({
                    results: results,
                    fields: fields
                });
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This is just a wrapper object that provides an awaitable query() function. It returns a single object with two properties: results and fields.

The second one is a transaction factory function. It creates Transaction objects that are wrappers to the beginTransaction(), commit() and rollback() functions in mysql2.

This is mysql-transaction-factory.js:

import dbConnectionFactory from "./mysql-connection-factory.js";

function Transaction(dbConn) {
    this.connection = dbConn;
    this.started = false;
    this.disposed = false;
    this.start = function () {
        if (this.disposed) {
            throw new Error("A transaction object cannot be reused and this one has already been used.  Create a new Transaction object.");
        }
        if (this.started) {
            throw new Error("This transaction has already been started and cannot undergo the start process again.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.beginTransaction(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = true;
                rslv();
            });
        });
    };
    this.commit = function () {
        if (!this.started) {
            throw new Error("Cannot commit a transaction that hasn't started.");
        }
        if (this.disposed) {
            throw new Error("Cannot commit a disposed transaction.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.commit(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = false;
                this.disposed = true;
                rslv();
            });
        });
    };
    this.rollback = function () {
        if (!this.started) {
            throw new Error("Cannot roll back a transaction that hasn't started.");
        }
        if (this.disposed) {
            throw new Error("Cannot roll back a disposed transaction.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.rollback(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = false;
                this.disposed = true;
                rslv();
            });
        });
    }
}

export default {
    create: async function () {
        const dbConn = await dbConnectionFactory();
        return new Transaction(dbConn);
    }
};
Enter fullscreen mode Exit fullscreen mode

Then comes the connection factory as helper number 3. This is mysql-connection-factory.js:

import mysql from 'mysql2';
import config from '../config.js';

export default function () {
    const conn = mysql.createConnection(config.db);
    return new Promise((rslv, rjct) => {
        conn.connect(err => {
            if (err) {
                rjct(err);
                return;
            }
            rslv(conn);
        });
    });
};
Enter fullscreen mode Exit fullscreen mode

Now, with an awaitable API, I proceed to show jwt-invalidation-repository.js:

import dbConnectionFactory from './mysql-connection-factory.js';
import Query from './mysql-query.js';

const clearInvalidationSql = "truncate table JwtBlacklist;";
const upsertInvalidationSql = "insert into JwtBlacklist(`UserId`, `MinimumIat`)"
    + " values(?, ?) as new"
    + " on duplicate key update `MinimumIat` = new.`MinimumIat`;";
const getInvalidationSql = "select `Id`, `UserId`, `MinimumIat` from JwtBlacklist where `UserId` = ?;";

export default {
    clear: async function (txn) {
        const dbConn = txn?.connection ?? await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(clearInvalidationSql);
        console.log('Cleared table.  Results: %o', r);
    },
    add: async function (userId, newTime, txn) {
        const dbConn = txn?.connection ?? await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(upsertInvalidationSql, [userId, newTime]);
        console.log('Record upserted.  Results: %o', r);
    },
    get: async function (userId) {
        const dbConn = await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(getInvalidationSql, userId);
        console.log('Record read.  Results: %o', r);
        return r.results;
    }
};
Enter fullscreen mode Exit fullscreen mode

You'll notice that the exported object's methods receive an optional transaction object. I included this only in the data-modifying functions because I did not need to use get() while inside a transaction. If you implement a repository like this, consider adding the txn optional parameter to functions that retrieve data as well. This is necessary because only the transacted connection is usually permitted to read data being modified in the transaction (optimistic approach, like SQL Server and probably many others).

Configuration

As you may have noticed in the import sections of mysql-connection-factory.js and token-service.js, a configuration object config is imported. It contains the database connection information as well as the JWT configuration.

This configuration comes courtesy of wj-config, the best configuration solution for JavaScript (server-sided and browser-sided) currently available. This is config.js:

import wjConfig from "wj-config";

export default await wjConfig()
    .addObject({
        port: 7777,
        jwt: {
            tokenTtl: 600, // 10-minute tokens
            secret: "katOnCeyboard"
        },
        db: {
            host: "<your MySQL server, such as localhost>",
            user: "<your MySQL user ID>",
            password: "<the user's MySQL password>",
            database: "<your databse's name>"
        }
    })
    .build();
Enter fullscreen mode Exit fullscreen mode

If you would like to know more about this great configuration package, head to this series.

Entry Point

The index.js file is the express application's entry point:

import express from 'express';
import config from './config.js';
// Service that issues and validates tokens.
import tokenService from './services/token-service.js';
// Middleware that copies a valid token's payload into reqest.sec.
import auth from './middleware/auth-middleware.js';
// Service that provides token invalidation dates.
import jwtInvalidationService from './services/jwt-invalidation-service.js';

const jwtInvSvc = jwtInvalidationService();
const tokenSvc = tokenService(jwtInvSvc);
const app = express();
/**
 * Homepage.  It greets the user.  If the request comes with a token,
 * then the greeting contains the user's name and age.
 * 
 * It uses the authentication middleware to ensure the token, if present, 
 * is read.  If a token is present and valid, then the token's payload 
 * will be available as req.sec.
 */
app.get('/', auth, (req, res) => {
    let name = 'stranger';
    let age = null;
    if (req.sec) {
        name = req.sec.name;
        age = req.sec.age;
    }
    res.write(`Hi, ${name}!`);
    if (age) {
        res.write(`  I see you are ${age} years old.`);
    }
    res.status(200).end();
});
/**
 * Login.  Specify name and age in the query string.
 */
app.get('/login', (req, res) => {
    const token = tokenSvc.issueToken({
        name: req.query.name,
        age: req.query.age
    });
    res.status(200).send(token).end();
});
/**
 * Globally invalidate.  Invalidates all previously issued tokens.
 */
app.get('/ginv', (req, res) => {
    const invDate = new Date();
    jwtInvSvc.globalInvalidation(invDate);
    res.write(`Global invalidation set at ${invDate}.`);
    res.status(200).end();
});
/**
 * Per-user invalidate.  Provide the user's name in the query string.
 */
app.get('/uinv', (req, res) => {
    const invDate = new Date();
    jwtInvSvc.userInvalidation(req.query.name, invDate);
    res.write(`Invalidation for user ${req.query.name} set at ${invDate}.`);
    res.status(200).end();
});

app.listen(config.port, () => {
    console.log(`Running on port ${config.port}.  Now listening...`);
});
Enter fullscreen mode Exit fullscreen mode

This is a super simple Express server that serves a single page: The homepage. This page is not even HTML: It is just a greeting. If the HTTP request comes with a valid token, then the homepage greets the user by name. If the token is invalid or there is no token, the greeting just says "Hi, stranger!".

The other 3 routes are used to manipulate invalidations and to log in. Logging in does not require a password, and a valid token is issued simply by providing name and age. Like this: http://localhost:7777/login?name=jose&age=18. Both pieces of data become part of the token's payload (claims).

To globally invalidate just visit http://localhost:7777/ginv; to invalidate for a given user, visit http://localhost:7777/uinv?name=userToInvalidate.

Feel free to play around. While you play, peek at the server console to see the output messages. Here's the package.json file and a screenshot of the folder structure:

{
  "name": "minimalexpress",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "jsonwebtoken": "^8.5.1",
    "mysql2": "^2.3.3",
    "wj-config": "^2.0.0-beta.2"
  },
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

File structure:

Project's File Structure

Using the Token Service

You may have noticed that I did not present (so far) the contents of ./middleware/auth-middleware.js. Well, let's present it now:

import tokenService from "../services/token-service.js";

export default async function (req, res, next) {
    const tokenSvc = tokenService();
    // Look for the Authorization header.  It must be a bearer token header.
    const authHeader = req.headers['authorization'];
    if (authHeader && authHeader.startsWith('Bearer ')) {
        const token = authHeader.substring(7);
        const vToken = await tokenSvc.validateToken(token);
        if (vToken.valid) {
            req.sec = vToken.token
        }
    }
    await next();
};
Enter fullscreen mode Exit fullscreen mode

This is an Express middleware function that searches for the Authorization HTTP header. If found, it extracts the token and validates it. If validation passes, the token's payload is added to the request object under the property named sec.

This middleware is added to the homepage's route in index.js, making the homepage user-aware.

This middleware is designed to merely collect the token's payload if available. If not available, the HTTP server does not bother the user about it. This means that the homepage may be visited by authenticated and unauthenticated users alike. This is usually desirable if your Express server serves actual web content.

There will be cases, however, where you may want to deny access to a resource. In this case, you need a middleware that either redirects the user to a login page, or that simply returns an HTTP 401 UNAUTHORIZED error code. I do not recommend the latter for servers that serve web content, though. This behavior should only be seen in API-only servers.

The middleware above can be followed by a redirecting middleware to obtain the redirection behavior. One like this one:

export default async function (req, res, next) {
    if (!req.sec) {
        // Unauthenticated user.  Redirect.
        res.redirect('/login');
        res.end();
        return;
    }
    // Authenticated user.  Proceed.
    await next();
};
Enter fullscreen mode Exit fullscreen mode

A middleware that flat-out rejects unauthenticated users would be very similar:

export default async function (req, res, next) {
    if (!req.sec) {
        // Unauthenticated user.  Redirect.
        res.status(401).end();
        return;
    }
    // Authenticated user.  Proceed.
    await next();
};
Enter fullscreen mode Exit fullscreen mode

The former is suitable for web servers that serve application pages; the latter is suitable for API servers.


Hopefully, this has been helpful to many of you. The next article in the series will showcase this same exercise but in C# for ASP.Net 6.

Happy coding!


FAQ

Why expose the MySQL transaction to the token invalidation service? Isn't this a violation of the layered architecture?

Layering is tricky; unit testing is tricky too. When it comes to repositories, it is usually very difficult to mock a database. I once worked for a company that dedicated a database server for unit testing. I don't like this approach. I prefer to not unit-test database-driven repositories. If you also choose this path, the correct way is to make repositories completely dumb, devoided of all business logic. This means that all business logic must go into service objects that can be unit-tested.

So for this particular example, the business logic is to wipe the invalidations table clean before inserting a global invalidation record. To achieve this safely in a multi-user environment, I must transact the operations. The only way to keep the repository dumb and transact the operations is if the service gains knowledge of the transacting technology that the repository understands.

In JavaScript there is no violation of the layered architecture because JavaScript is not typed, and basically any object exposing start(), commit() and rollback() functions will do. I can then freely jump from MySQL to MariaDB or SQL Server or any other RDBMS without having to make code changes to the invalidation service.

In typed languages like C#, the same is achieved by consuming an interface.

Top comments (10)

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

Interesting implementation, looking forward to see it in dotnet as well.

Collapse
 
jareechang profile image
Jerry

If you are using a DB, wouldn’t it be easier to not use JWT and just use session store (ie memory store + DB) ?

My understanding of JWT is that it was proposed to avoid storage of “tokens” by using digital signatures instead.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Hello. You are partly correct: Ideally, a JWT contains all the required data for security-related tasks, making the use of storage completely unnecessary. A reality check, however, brings us down to Earth: Especially with long-lived tokens such as refresh tokens, it is handy to be able to invalidate them.

This is where I say many people have been doing it wrong: You must not store tokens in a black list, you should instead blacklist by using the issued at claim value. This way you still don't save any security-related user data in the database but you gain the ability to invalidate potentially compromised tokens.

The whole thing stems from the fact that the JWT standard did not account for potential security threads in any way other than using short times to live.

The part you would be incorrect is by thinking JWT's are used for ease alone. They are more secure because people with database access cannot modify tokens, making tokens more secure regardless.

As stated in another comment somewhere, this technique of invalidating by issued at is used to log people out of devices when changing a password. Netflix, for instance, offers you this option when you set a new password. The TV's usually have a long-lasting access or refresh token. How can these be invalidated? Like this.

Collapse
 
jareechang profile image
Jerry • Edited

I agree but my main point is that I don’t see JWTs as a good application to managing client sessions.

Many people mis-apply JWTs, and use it for session management.

Thats where they run into problems with invalidations and security issues.

Then they add a db and memory store to create complicated designs like allow and deny lists and more.

You lose all the benefits of using JWTs at this point.

At that point, its easier to go with traditional way of managing session (ie using opaque token in redis) 😂

Like you pointed out, you need some way to manage state via a store with sessions.

I see JWTs more applicable to handle authz for system-to-system communications.

This is especially useful within a microservice design infrastructure.

In one client request, I may call multiple services, within those services I only need to to validate the JWT signature in a call chain.

No storage, no external service and no network call.

This creates a trust relationship without managing session state. This scales very well.

Basically you get all the benefit of JWTs, and assuming these microservices are secured at network level, it should be ok to have longer expiry JWTs.

The process described is basically Open ID connect.

More about Open ID Connect here - developer.okta.com/blog/2019/10/21...

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

Having gone through a couple of microservice-based projects, I can say that it is not correct to validate the token every step of the way. For instance, a token may be valid on entry point, but become invalid 10 milliseconds later. Not good. This means that JWT verification can only happen once: At entry point. After entry point, security data intra-microservices needs to happen decoded and trusted.

Imagine for example an asynchronous communication between microservices. Easily 10 seconds could go by before a message is picked up from a queue at any given point in time. Would it be fair for the end user to stop processing because the token was valid 10 seconds ago but not now? The answer is No, it is not fair.

Thread Thread
 
jareechang profile image
Jerry

I mean there is two approaches to authz:

  1. Talk to an external user/auth microservice
  2. Validate token in that service

Either 1 or 2. Otherwise, you let request through without authz.

Maybe our definition is not the same ?

I see Microservice as independent services running on their own.

For Example: these are things like a payments, catalog, ad, content service etc.

Sometimes you may need to call multiple services for one request.

Why does token expiration have to be 10 ms ? You can make it longer for service-to-service communication.

You can also have mechanism to refresh the token, it doesn’t have to be static all the time.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

Hi.

Why does token expiration have to be 10 ms ? You can make it longer for service-to-service communication.

You misunderstand. A token could be valid for 10 hours. No matter how long a token lives, a request using a token close to its expiration time may not make it all the way through if it is constantly validated on every step in the request.

What I was saying is: JWT's cannot go through JWT validation in every microservice because it could expire while going through this chain and creates a bad user experience.

I believe that mechanism about validating in every microservice is what you refer to in your option #2. I would say that needs to be discarded.

That leaves you with #1 to verify the JWT at the very beginning of the request.

Thread Thread
 
jareechang profile image
Jerry

There are multiple ways to do this.

What you describe is validating at gateway level. This is assuming all microservices has same gateway.

Typically, for high traffic environments (from what I’ve seen), microservices will have their all gateway.

So, its not always possible to aggregate them all into one gateway. If you do this, its basically a monolithic architecture (and not exactly microservice).

One traffic overload would scale up all microservices in the gateway.

The one I am describing is similar to what this person talks about in this answer (but not exactly the same because they are describing using httpOnly cookies) - stackoverflow.com/a/56149939/4308460

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas • Edited

Hello again. No, I am not saying a single gateway. You can have 100's of gateways. The only requirement I am saying is: The JWT is validated at the very beginning of the request, just once. It can have a thousand possible entry points, as long as JWT validation happens at the beginning, only once.

Thread Thread
 
jareechang profile image
Jerry

do you have an example ?

It’s difficult to have architectural discussions via text 😅