JWT expiry validation with Varnish
A big Ruby on Rails application I work on handles all calls linked to a specific user or role with JSON Web Tokens. The implementation is fairly simple, and signing works with a private / public keypair. On every call we validate and extract the token from the Authorization
header
module WithAuthorizationToken
def authorized_payload
jwt = request.headers['Authorization'][/bearer (.+)/i, 1]
token = JWT.decode(jwt, JWKKEYPAIR, true, algorithms: ['RS256'])
token[0]
rescue JWT::DecodeError
head :unauthorized
end
end
This works very well, but combined with the Varnish caching proxy in front of our application this added a problem
The problem
Most Varnish implementations tell you to stop caching when a user-session is involved. In our use case we can’t do that. A lot of our requests are authenticated, and 95% of the requests can be cached for a long time.
Differentiating based on the Authorization
header is easy enough by adding it to Vary
, but requests would still be in cache even after the JWT was expired.
On other requests that weren’t cached, the Rails backend handled a whole bunch of requests just telling the client to refresh their token.
VCL to the rescue
So we decided to solve this problem in Varnish.
What we wanted to accomplish: when a Authorization header is present, validate it’s a JWT, and validate it’s expiry is in the future:
import std;
import var;
import digest;sub check_authorization_header {
if (req.http.Authorization ~ "(?i)^bearer") {
// Extract JWT
var.set("jwt", regsub(req.http.Authorization, "(?i)^bearer (.*)", "\1"));
var.set("rawHeader", regsub(var.get("jwt"), "^([^\.]+)\.[^\.]+\.[^\.]+$", "\1"));
var.set("rawPayload", regsub(var.get("jwt"), "^[^\.]+\.([^\.]+)\.[^\.]+$", "\1")); // Extract payload
var.set("payload", digest.base64url_decode(var.get("rawPayload")));
var.set("tokenAlg", regsub(digest.base64url_decode(var.get("rawHeader")), {"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1"));
var.set("exp", regsub(var.get("payload"), {"^.*?"exp"\s*:\s*([0-9]+).*?$"}, "\1")); if (var.get("tokenAlg") != "RS256") {
return(synth(400, "Non-supported token type"));
} if (std.time(var.get("exp"), now) < now) {
return(synth(401, "Authorization failed"));
}
}
This creates a method which we can then call in the relevant parts of vcl_recv
.
sub vcl_recv {
# ... other logic
call check_authorization_header;
}
To get this to run you need to compile the non-standard digest vmod
Beware
This is a naive implementation of JWT parsing on the Varnish side. This ensures no cache is served to expired tokens, but does not validate the JWT token itself. Whatever is handling the backend should always do that. There are solutions to also validate this in Varnish itself:
- Varnish Cache Plus’s JWT vmod (for paying customers)
- An open source JWT implementation