Phantom tokens: JWTs & sessions combined | Zoe's blog
Blogs<br>Posts<br>Tags
It’s easy to find online resources talking about the merits of JWT. You’ll read stuff like “it’s better for horizontal scaling”, “it’s better for perf since you don’t need to hit the db on every request” or how it has better security.<br>Most of those resources either only talk about auth on a surface level or will introduce workarounds for JWTs shortcomings that negate its advantages.
A story about state
The main benefits of JWT is that the token is stateful, so we can deduce the user’s permissions without having to hit the db or an auth server. This is extremely valuable in a microservice context, without a JWT you’ll have to ask db 50 times if you have 50 services.
The biggest disadvantage is that the token is stateful. This means the token will drift-offs. If your user gains or loses permissions, is banned or revoked their session, the JWT is still valid and has the permissions at the time of creation.
To mitigate those issues, some people will introduce deny-lists of JWT that should not be accepted by your servers. This re-introduces the initial problem of session: making a db call on each request (and on every service.)
Single use JWTs
Instead of creating sessions on top of JWTs, we can embrace what JWTs are good at: proving who you are & your permissions over something that doesn’t change over time or that expires very quickly.
For example, you could imagine an API that would compute something over a long period of time (say a week.) The API that enqueues the compute could return you a JWT allowing you to access progress/results of said task.<br>This simplifies auth handling for the compute service that now only has to verify the JWT & you have no risk of your permission changing or being revoked: the JWT only grants you access to a single compute that you started.
Phantom tokens
As stated previously, JWTs truly shines in microservices as long as you don’t need to check for token invalidation on each service. There’s a neat way to get this that I implemented recently for kyoo’s v5: phantom tokens!
The concept is simple:
use traditional sessions when communicating with the client
use JWT when communicating across services
when a user makes a request, have the gateway convert the session (after checking its validity) to a JWT
You get the benefits of JWT (aka no call to db/auth service in every service) while having traditional sessions in the user’s perspective (so no manual token refresh needed, no out-of-sync permissions & no token valid after session invalidation).
It might be easier to understand with a table:
FlowSessionJWTPhantom TokenClient sendsSession tokenJWTSession tokenInternal services Call auth service each time Verify signature locally Verify signature locallyDB calls per request 1+ (on each service) 0 1 (on gateway only)Token refresh needed No Yes (client-side) No (handled by gateway)Revocation immediate Yes No (until jwt expiry) Yes (next request)<br>The downsides of phantom tokens
SPOF on auth
Since every request going through your gateway needs to pass through the auth service to generate a jwt, you 100% have a single point of failure on the auth service.
I don’t think it’s as bad as it sounds since in any other scenario you would still need to reach out for the auth’s service/database to check for expired jwt or to get a session’s information. Instead having everything cleanly separated in an auth service makes it easier to be high availability and reduces the scope that might impact the available of the service.
Logic on the gateway
We do need to add logic to the gateway for phantom tokens to work but this isn’t as niche as it sounds. For example k8s’gateway api added support for it recently and a lot of gateways support it natively.
cilium
envoy gateway
traefik
nginx
add other proxy to this list by contributing! I was too lazy to find more references
Long lived flows or websockets
Some flows might outlive the duration of the temporary jwt created by the gateway. One such example is websockets: you will create a jwt when the websockets opens but if you send a message in your websocket 5 hours later your jwt will have expired.
I couldn’t find a silver lining solution for this, the best i came up with for kyoo is to consider the websocket handler as another gateway and refresh the jwt on each new message (excluding keep-alive/pings). If you’re interested here’s the implementation PR: https://github.com/zoriya/Kyoo/pull/1491.
Example implementation
For a minimal phantom token implementation you would need:
an auth service to login/logout/stuff that will return a session token
a route to convert a session token to a short lived jwt
a gateway that has a middleware to convert the session to a jwt by calling said route
an api that consumes the jwt
For our example we will use traefik, we can imagine a docker compose like this:
services:<br>auth:<br>build: ./auth<br>restart: on-failure<br>ports:<br>-...