tl;dr: If you understand why and how to support blacklisting JWTs, then skip to the code.
In a previous post, we proposed an approach to using JSON Web Tokens as API Keys, going over some of the benefits of doing so and also providing some examples based on our API v2 scenarios. This post follows up by explaining an aspect that was not covered before: how to add JWT API keys to a DenyList so they are no longer valid.
If you need an in-depth introduction to JSON Web Tokens, check out the free ebook below.
Interested in getting up-to-speed with JWTs as soon as possible?
Download the free ebookA Real World Example
Let's for a second assume that GitHub used JSON Web Tokens as API Keys and one of them was accidentally published on the web. You would want to make sure an app can no longer access your information by revoking that token:
Framing the Problem
Providing support for blacklisting JWTs poses the following questions:
- How are JWTs individually identified?
- Who should be able to revoke JWTs?
- How are tokens revoked?
- How do we avoid adding overhead?
This blog post aims to answer the previous questions by leveraging our experience from implementing this feature in our API v2.
1. How are JWTs individually identified?
To revoke a JWT we need to be able to tell one token apart from another one. The JWT spec proposes the jti
(JWT ID) as a means to identify a token. From the specification:
> The jti (JWT ID) claim provides a unique identifier for the JWT. The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well.
As a quick reminder, this is how the claims section of one of our JWT API tokens looks like:
The tokens accepted by our API use the aud
claim to determine the tenant for which the JWT is valid. If we use the (aud, jti)
pair as the token's identifier then each tenant is in charge of guaranteeing that there's no duplication among their tokens.
Similarly, if a token does not include the jti
claim we do not allow it to be revoked.
2. Who should be able to revoke JWTs?
If anyone could revoke our API keys then unfortunately they wouldn't be of much use. We need a way of restricting who can revoke a JWT.
"If anyone could revoke our API keys then unfortunately they wouldn't be of much use."
Tweet This
The way we solved it in our API is by defining a specific scope (permission) that allows blacklisting tokens. If you generate a JWT like the one shown in the next figure you will be able to revoke JWTs:
Notice the
blacklist
action nested inside thescopes
object.
3. How are tokens revoked?
To blacklist/revoke a token, you need a JWT API key (referred to as JWT_API_KEY
) like the one described in #2. With it you can issue a POST
request to /api/v2/blacklists/tokens
as shown below (new lines added for clarity):
curl -H "Authorization: Bearer {JWT_API_KEY}"
-X POST
-H "Content-Type: application/json"
-d '{"aud":"u6nnAxGVjbBd8etXjj554YKGAG5HuVrp","jti":"test-token"}'
https://login.auth0.com/api/v2/blacklists/tokens
The complete documentation for the endpoint is here but basically you need to:
- Send the
aud
andjti
claims of the JWT to revoke. - Send the JWT with the permissions necessary to blacklist tokens in the Authorization header.
To get the revoked tokens you can issue a GET
to /api/v2/blacklists/tokens
. You can use the docs to figure out the how.
4. How do we avoid adding overhead?
You might be thinking:
Wasn't the whole point of using JWTs avoiding a DB query?
Well, that is a benefit, though hardly the whole point. There is a caveat though: that question only applies if you have an application with a single issuer, not a multi-tenant system.
If there is more than one tenant, you don't want all of them to share the same secret. You still have to perform a database query to map the aud
claim to the required secret
.
With that in mind, these are some of the optimizations that you can implement:
Optimization 1: The aforementioned operation involves I/O so it can be performed in parallel with our query to verify if a token has been revoked.
Of course, you can also add a caching layer with a reasonable expiration time to avoid the DB trips altogether.
Optimization 2: Skip the expiration check if the
jti
claim is not part of the JWT.Optimization 3: To reduce the size of the revoked tokens store you could automatically remove JWTs from it once their
exp
is reached (assuming there is one).
Implementation
We have shipped version 1.3.0 of the open source express-jwt with support for multi-tenancy and blacklisted tokens. We also put together a sample that shows everything working together. The sample is based on our API v2 implementation.
The following code snippets use show the core sample parts:
Securing the endpoint
The first thing we have is an API that we would like to protect. The express-jwt
middleware is configured by providing:
secret
- A function in charge of retrieving the secret.
isRevoked
- A function in charge of checking if a JWT is revoked.
var expressJwt = require('express-jwt');
// to protect /api routes with JWTs
app.use('/api', expressJwt({
secret: secretCallback,
isRevoked: isRevokedCallback
}));
Handling multi-tenancy
The implementation for the secretCallback
function reads the backing data store to retrieve the secret for a tenant. It caches the secrets using the tenant identifier as the cache key.
If the data layer provides an encrypted tenant secret, it needs to be decrypted before calling done
.
var LRU = require('lru-cache');
var secretsCache = LRU({ /* options */ });
var secretCallback = function(req, payload, done){
var audience = payload.aud;
var cachedSecret = secretsCache.get(audience);
if (cachedSecret) { return done(null, cachedSecret); }
data.getTenantByIdentifier(audience, function(err, tenant){
if (err) { return done(err); }
if (!tenant) { return done(new Error('missing_secret')); }
var secret = utilities.decrypt(tenant.secret);
secretsCache.set(audience, secret);
done(null, secret);
});
};
Supporting revoked JWTs
Similarly, the isRevokedCallback
implementation caches whether a token is revoked or not using the (aud, jti)
pair as the cache key. It also skips the check in case the jti
claim is not present.
var jtiCache = LRU({ /* options */ });
var isRevokedCallback = function(req, payload, done){
var tokenId = payload.jti;
if (!tokenId){
// if it does not have jti it cannot be revoked
return done(null, false);
}
var tokenIdentifier = payload.aud + ':' + payload.jti;
var blacklisted = jtiCache.get(tokenIdentifier);
if (typeof blacklisted !== 'undefined') { return done(null, blacklisted); }
data.getRevokedTokenByIdentifier(tokenIdentifier, function(err, token){
if (err) { return done(err); }
blacklisted = !!token;
jtiCache.set(tokenIdentifier,blacklisted)
return done(null, blacklisted);
});
};
Conclusion
Most of the aforementioned content applies to blacklisting JWTs in general, not just JWT API keys.
Hopefully this blog post has provided some useful ideas on how to tackle this problem.
If you have any comments or questions don't hesitate to post them!
You an also get involved in express-jwt!
Aside: Delegating JWT Implementation to the Experts
JWTs are an integral part of the OpenID Connect standard, an identity layer that sits on top of the OAuth2 framework. Auth0 is an OpenID Connect certified identity platform. This means that if you pick Auth0 you can be sure it is 100% interoperable with any third party system that also follows the specification.
The OpenID Connect specification requires the use of the JWT format for ID tokens, which contain user profile information (such as the user's name and email) represented in the form of claims. These claims are statements about the user, which can be trusted if the consumer of the token can verify its signature.
While the OAuth2 specification doesn't mandate a format for access tokens, used to grant applications access to APIs on behalf of users, the industry has widely embraced the use of JWTs for these as well.
As a developer, you shouldn't have to worry about directly validating, verifying, or decoding authentication-related JWTs in your services. You can use modern SDKs from Auth0 to handle the correct implementation and usage of JWTs, knowing that they follow the latest industry best practices and are regularly updated to address known security risks.
For example, the Auth0 SDK for Single Page Applications provides a method for extracting user information from an ID Token, auth0.getUser
.
If you want to try out the Auth0 platform, sign up for a free account and get started! With your free account, you will have access to the following features:
- Universal Login for Web, iOS & Android
- Up to 2 social identity providers (like Twitter and Facebook)
- Unlimited Serverless Rules
To learn more about JWTs, their internal structure, the different types of algorithms that can be used with them, and other common uses for them, check out the JWT Handbook.