JWTs on a Journey — Sending JWT Access Tokens across APIs
Access controls are essential for securing APIs. OAuth enables token-based authorization, where access controls demand access tokens that fulfill certain requirements. Such a token, commonly in the form of a JSON Web Token (JWT), serves as the entry ticket to APIs. When entering the system, there are access controls at various places, like in the API gateway and in each microservice. This paradigm is called zero trust. When applying zero trust and token-based authorization, JWTs inevitably travel through the system, from one endpoint to another.
JWTs are validated and verified constantly while they follow a flow. Validation means checking that the token is valid — that the signature is trusted and that the token has not yet expired. Verification implies that the token fulfills the requirements for passing access controls, like the expected issuer, audience, scope and claims values. Consequently, there are two challenges with JWTs on a journey that this article addresses:
- Setting the lifetime of the token.
- Providing adequate data in tokens.
Security best practices recommend keeping the lifetime of JWTs short, preferably just a few minutes. However, users would likely be frustrated if they had to log in all the time to renew an access token. Therefore, OAuth provides a flow where client applications can use refresh tokens for access without having to interact with the user. The refresh token is for the client, though, and not for the API. Consequently, APIs cannot refresh an access token but eventually return an error when they receive an expired token.
Whenever APIs return an authorization error (HTTP 401) because of an expired or otherwise invalid token, the client should retry the request with a new access token. However, requests may be asynchronous. A JWT may have already moved to upstream APIs before it expires, and the API may not be able to return an error to the client as part of the current request. In that case, there must be a callback or notification mechanism that allows an upstream API to inform any downstream API, and eventually the client, about an expired token so the client can restart the flow. To minimize such callbacks, clients can refresh access tokens before they expire so that JWTs have enough lifetime left before they are sent out.
Minimization for Privacy
Security is about minimization. Not only should the token lifetime be the minimum acceptable value, but the JWT should also only contain the absolute necessary data required by the access control. To stretch it further, even though access tokens are issued to clients, ideally clients should not have access to any data associated with the access token. Therefore, the JWTs journey should not pass clients, but opaque access tokens should.
If clients receive opaque access tokens, the JWT’s journey will start at the API gateway. Opaque access tokens are not only opaque to clients but also APIs. Consequently, APIs need to look up the data associated with such a token. This flow is called introspection. Since introspection affects performance, especially if required for every microservice, it is better to have the API gateway translate the opaque token to a JWT and cache the result. This approach is called the Phantom Token Pattern. In this way, APIs in the backend still handle JWTs.
As mentioned earlier, access controls are spread out as they are enforced by APIs. Thus, APIs require requests to include an access token, the JWT, on which they base decisions. Token-based authorization allows for transporting user data in a verifiable and auditable manner between APIs by putting it into JWTs.
The easiest way to share JWTs between APIs and microservices is to simply forward them to upstream APIs. However, with hundreds of different APIs, and potentially hundreds of different access controls, it is infeasible to feed a JWT with all potentially required data. It is infeasible because it may simply not be known beforehand which path the JWT will travel and what data to add. Even if it is known, adding all data and access rights to cover all possible flows violates the principle of least privilege. For example, in this case an access token needs to include all valid scope values so that it is accepted by any API, despite a single API only requiring a subset of scopes. The goal should be to keep the data at a minimum to cover the most common cases.
One approach to narrow down the data in JWTs when forwarding tokens is to embed one token in another — one of the claims in the JWT contains another JWT. Instead of having one and the same JWT travel through the system, an API (or most likely the API gateway) can forward an embedded, narrower JWT to upstream APIs. Consequently, the upstream API receives tailored tokens, which satisfies the principle of least privilege. The downside of the embedded token approach is that it not only increases the size of the outer token, but all data still needs to be known when JWTs are issued. This may not always be the case. For example, a JWT will not embed any tokens for new APIs if they were not known when the JWT was created. In this case, new tokens are first embedded when the JWT has expired and gets renewed.
The most flexible approach for token sharing is to exchange tokens. The token exchange approach is similar to the refresh token flow where one token is exchanged for another. While the refresh token flow is designated for the client and limited to refreshing an (existing) access token with the help of the refresh token, token exchange can be used for a range of tokens and other use cases. For example, it allows for exchanging an access token for a new one with a different scope and claims than the original token. Token exchange is not only useful when sharing tokens for the same user, but also for implementing delegation and impersonation use cases where the subject of an access token represents a completely different user.
Consequently, token exchange enables a highly customizable token-sharing mechanism where an API or API gateway can, on demand, request a new JWT for upstream APIs that follows the principle of least privilege.
Once JWTs are sent off, they cannot be changed. Therefore, both token lifetime and token data must be thoroughly considered when designing JWTs. JWTs should contain adequate but minimal data for the challenges they meet on their way (aka access controls). Therefore:
- Keep JWT lifetimes short and handle expiry.
- Keep JWT access tokens private and do not expose them to clients.
- Use token-sharing techniques to meet security requirements between APIs.
There are various token-sharing approaches that help to fulfill security requirements. A combination of token forwarding and token exchange is probably sufficient even for complex cloud native applications to enable a safe journey for JWTs.