Decoding JSON Web Tokens (VCL)
The popular JSON Web Token format is a useful way to maintain authentication state and synchronize it between client and server. You are using JWTs as part of your authentication process and you want to decode and validate the tokens at the edge, so that content can be cached efficiently for all authentication states.

Instructions
The solution explained on this page is a particularly comprehensive one, covering multiple use cases and potential constraints that you might want to place on your token, and is a great way to learn about the full capabilities of VCL. However, don't be intimidated! There are several steps you can skip here if they don't apply to your use case.
NOTE: This tutorial uses VCL. There is alsoa version available for the Compute platform.
Generate a secret signing key
Most authentication tokens protect against manipulation using a signature, and JSON Web Tokens are no exception. Therefore, start by generating a secret signing key, which can be used to generate a signature for your token (and therefore validate that the token the user submits is valid). You may already have this if you are already generating your JWTs at your origin server.
Using an HMAC key (simpler and shorter)
An HMAC key is simply any string of your choice.
IMPORTANT: The VCL code in this tutorial does not support secret keys containing NUL characters.
You can use the following command to generate a random secret key without NUL characters:
$ env LC_ALL=C tr -d '\000' < /dev/random | head -c 32 | base64Using an RSA key (more secure)
To use an RSA key, generate a key pair, and extract the public key. Make sure you keep a record of the passphrase on the key if you choose to set one:
$ ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key$ openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pubSet a valid JWT at your origin
In order for your users to present a request to Fastly that contains a JWT, they need to have previously received that token from you. Most likely, you're going to want to set this in a cookie on a previous response, but you could also bake the JWT into a link via a query parameter, or even into the URL path itself. Regardless, you're going to need to generate a JWT.Most programming technologies have a package for generating JWTs, such as those forNode.js,Ruby, andPHP. You can also test JWTs using theJWT.io tool.
Within theheader section of the token, make sure you include the name of the signing algorithm you want to use (this is required by the JWT spec), e.g.:
{"alg":"HS256","typ":"JWT"}Within thepayload section of the token, this is where you put your own session data, but to enable Fastly to verify your token, some payload fields have a special meaning to us in this tutorial:
key: ID (of your choice) of the key used to sign this token. (string,REQUIRED)exp: Expiration Unix timestamp of this token. (number, optional)nbf: Unix timestamp before which this token is not valid. (number, optional)tag: Makes the token only valid for content tagged with the specifiedsurrogate key. (string, optional)path: URL path pattern in which the token is valid. (string, optional, may start with, end with, or contain one*wildcard)ip: IP of the client for which this token is valid. (string, optional)
For example (try out this example on jwt.io):
{"key":"key1","exp":1592244331,"nbf":1562254279,"tag":"subscriber-content","path":"/foo"}Make your secret signing key accessible to Fastly
If you are installing this solution in a Fastly service, set up aprivate dictionary calledsolution_jwt_keys to store your secret. The secret is a single key-value pair in the dictionary, where the dictionary item key is the name of the secret, and the value is the secret key itself (either the HMAC secret or the RSA public key), base-64 encoded. If you areexperimenting in Fastly Fiddle, create the table by writing the code literally in theINIT space:
table solution_jwt_keys {"key1":"base64-encoded-signing-key-here"}NOTE: If your secret signing key contains NUL characters, decoding it later with
digest.base64_decodewill produce unexpected results.
When you come to want to rotate your keys, you will need to have the old key and new keys briefly valid at the same time, so the approach here allows for multiple keys to be defined. The name (in this example,key1) is used to differentiate between them, and could be a version number or a date, e.g., 'key-june2019'.
Declare variables
Now start to implement the decoding and validation in VCL which will run on Fastly's edge cloud.
During the validation and transformation of the JWT cookie into the payload data, you will need to store the data in various intermediate states. VCL is statically typed so you need to declare those variables ahead of time. Local variables are scoped to the subroutine, so it's best to declare them at the top of the scope.
declare local var.jwtSourceSTRING;declare local var.jwtHeaderSTRING;declare local var.jwtHeaderDecodedSTRING;declare local var.jwtPayloadSTRING;declare local var.jwtPayloadDecodedSTRING;declare local var.jwtSigSTRING;declare local var.jwtStringToSignSTRING;declare local var.jwtCorrectSigSTRING;declare local var.jwtSigVerifiedBOOL;declare local var.jwtKeyIDSTRING;declare local var.jwtAlgoSTRING;declare local var.jwtKeyDataSTRING;declare local var.jwtNotBeforeINTEGER;declare local var.jwtExpiresINTEGER;declare local var.jwtTagSTRING;declare local var.jwtPathSTRING;JWTs comprise three sections: the header, payload, and signature. These declarations create a variable for each of these, plus one for the correct signature that can be separately calculated, so you can compare the signature supplied with what is expected, and a bunch of other intermediate state variables.
Now, add some variables for option settings:
declare local var.jwtOptionTimeInvalidBehaviorSTRING;set var.jwtOptionTimeInvalidBehavior="anon";# Choose from 'anon' or 'block'declare local var.jwtOptionPathInvalidBehaviorSTRING;set var.jwtOptionPathInvalidBehavior="anon";# Choose from 'anon' or 'block'declare local var.jwtTokenSourceSTRING;set var.jwtTokenSource="cookie";# Choose from 'cookie' or 'query'declare local var.jwtOptionAnonAccessSTRING;set var.jwtOptionAnonAccess="allow";# Choose from 'allow' or 'deny'Detect, extract and decode the JWT
If the request contains an authentication cookie (calledauth in this example) which passes a basic syntax check for the format of a JWT (header.payload.signature), it can be read and validated. You could also obtain the JWT from a query string parameter.
The three parts of the token are separated by dots, and will be extracted into the specialre.group object by the regular expression engine as part of theif statement. These parts can now be assigned to more helpfully-named variables:
if (var.jwtTokenSource=="cookie") {set var.jwtSource=subfield(req.http.Cookie,"auth",",");}else {set var.jwtSource=subfield(req.url.qs,"auth","&");}if (var.jwtSource~"^([A-Za-z0-9-_=]+)\.([A-Za-z0-9-_=]+)\.([A-Za-z0-9-_.+/=]*)\z") {set var.jwtHeader=re.group.1;set var.jwtHeaderDecoded=digest.base64url_decode(var.jwtHeader);set var.jwtPayload=re.group.2;set var.jwtPayloadDecoded=digest.base64url_decode(var.jwtPayload);set var.jwtSig=re.group.3;}All three components of the token are base64 encoded, and we need to keep the encoded versions hanging around, because the payload and header are needed in encoded form in order to calculate the correct signature.
Extract required signing data from the JWT
In order to construct a signature, you need to know which algorithm and key to use:
set var.jwtAlgo=if(var.jwtHeaderDecoded~{"\{(?:.+,)?\s*"alg" *: *"([^"]*)""},re.group.1,"");set var.jwtKeyID=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"key" *: *"([^"]*)""},re.group.1,"");set var.jwtKeyData=digest.base64_decode(table.lookup(solution_jwt_keys, var.jwtKeyID,""));set var.jwtStringToSign= var.jwtHeader"." var.jwtPayload;set var.jwtNotBefore=std.atoi(if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"nbf" *: *"?(\d+)[",\}]"},re.group.1,"0"));set var.jwtExpires=std.atoi(if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"exp" *: *"?(\d+)[",\}]"},re.group.1,"0"));set var.jwtPath=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"path" *: *"([^\"]+)""},re.group.1,"");set var.jwtTag=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"tag" *: *"([^\"]+)""},re.group.1,"");IMPORTANT: Currently, Fastly does not support native JSON decoding, which is why we need to use regex to extract data from the JWT source.
Verify the signature
To calculate the expected signature, we use the concatenated header and payload (in base64-encoded form), and calculate a signature using the appropriate algorithm, and the secret key from your keys configuration table. You might choose to produce all your JWTs using the same algorithm but we'll implement support for all of them.
if (var.jwtHeader) {if (var.jwtAlgo!~"^HS256|HS512|RS256|RS512$") {error618"jwt:algo-not-supported"; }elseif (var.jwtKeyData=="") {error618"jwt:key-not-found"; }elseif (std.prefixof(var.jwtAlgo,"HS")) {if (var.jwtAlgo=="HS256") {set var.jwtCorrectSig=digest.hmac_sha256_base64(var.jwtKeyData, var.jwtStringToSign); }else {set var.jwtCorrectSig=digest.hmac_sha512_base64(var.jwtKeyData, var.jwtStringToSign); }# Convert from standard base64 with padding to URL-safe base64 with no padding, consistent with the JWT specification.set var.jwtCorrectSig=std.replace_suffix(std.replaceall(std.replaceall(var.jwtCorrectSig,"+","-"),"/","_"),"=","");if (digest.secure_is_equal(var.jwtCorrectSig, var.jwtSig)) {set var.jwtSigVerified=true; }else {set var.jwtSigVerified=false; } }elseif (var.jwtAlgo=="RS256") {set var.jwtSigVerified=digest.rsa_verify(sha256, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url); }elseif (var.jwtAlgo=="RS512") {set var.jwtSigVerified=digest.rsa_verify(sha512, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url); }if (!var.jwtSigVerified) {error618"jwt:signature-fail"; }}In error conditions, trigger a unique HTTP error code of your choice, so that the error can be handled by thevcl_errorsubroutine. Passing a known value in the error status text with more detail helps to add context to the error and avoid clashes with other solutions that might also be using the same error code.
Check for time constraints (optional)
In your JWT, you can specify a 'not before' and 'not after' time, via thenbf andexp properties of the payload.A token that is missing theexp property should be considered invalid. If they are specified, check that the current time is within the allowed constraints:
if (var.jwtExpires==0) {if (var.jwtOptionTimeInvalidBehavior=="anon") {set var.jwtSigVerified=false; }else {error618"jwt:expires-not-present-or-valid"; }}elseif ((var.jwtNotBefore>0&&!time.is_after(now,std.integer2time(var.jwtNotBefore)))|| (var.jwtExpires>0&&time.is_after(now,std.integer2time(var.jwtExpires)))) {if (var.jwtOptionTimeInvalidBehavior=="anon") {set var.jwtSigVerified=false; }else {error618"jwt:time-out-of-bounds"; }}Rather than throwing an error here, it often makes more sense to allow users with time-invalid tokens to be regarded as anonymous, but your use case may vary so we're supporting both options here.
Check for path constraint (optional)
Another thing we're supporting in the JWT is thepath property, which allows a token to be scoped to a URL path. If this is set within the token, verify it now:
if (var.jwtPath~{"^(\*)?(\/[^\*]+)(/\*)?\z"}) {if (# Exact match, eg "/index.html" (!re.group.1&&!re.group.3&& var.jwtPath!=req.url.path)||# Prefix match eg "/products/..." (!re.group.1&&re.group.3&&!std.prefixof(req.url.path,re.group.2))||# Suffix match eg ".../protected.html" (re.group.1&&!re.group.3&&!std.suffixof(req.url.path,re.group.2))||# Both eg ".../somedirectory/..." (re.group.1&&re.group.3&&!std.strstr(req.url.path,re.group.2)) ) {if (var.jwtOptionPathInvalidBehavior=="anon") {set var.jwtSigVerified=false; }else {error618"jwt:path-mismatch"; } } log"Checked path constraint";}There are many possible matching schemes you could employ here. For this solution, we'll assume that the path constraint comprises whole URL path segments, so, for example we'll accept a constraint ofadmin/* but not/ad*.
Check for tag constraint (optional)
Fastly has a mechanism for tagging content known assurrogate keys. These are very useful as a way to group together resources that share a common trait, and we also offer the ability to purge all objects that share a particular tag.
If you wish, you can support constraining tokens to only be valid for objects tagged with a particular key. We already extracted the tag from the token intovar.jwtTag. Since you don't yet know whether the content being requested has the necessary tag, start by copying the constraint into a request header:
if (var.jwtTag!="") {setreq.http.auth-require-tag= var.jwtTag;}Now, in cases where a token is otherwise valid, the resource will be fetched, whether from cache or origin, and can be examined in thevcl_deliver subroutine to see if it matches your tag constraint:
declare local var.jwtRequiredTagSTRING;declare local var.jwtAvailableTagsSTRING;if (req.http.auth-require-tag) {set var.jwtRequiredTag=" "req.http.auth-require-tag" ";set var.jwtAvailableTags=" "req.http.Surrogate-Key" "; log"Checked tag constraint: " var.jwtRequiredTag" vs. " var.jwtAvailableTags;if (!std.strstr(var.jwtAvailableTags, var.jwtRequiredTag)) { unsetreq.http.auth-require-tag;setreq.http.Fastly-JWT-Error="jwt:tag-missing"; restart; }}Fastly automatically strips theSurrogate-Key header from the object in deliver, and moves it to a request header, so that you don't leak information about how your objects are tagged. That's why you need to checkreq.http.Surrogate-Key here, and notresp.http.Surrogate-Key. Finally, performing anerror is not permitted invcl_deliver, so instead, trigger arestart and catch the error at the top ofvcl_recv:
# Place this just below all the `declare` statementsif (req.http.Fastly-JWT-Error) {error618req.http.Fastly-JWT-Error;}Decode and extract the token payload
With all checks completed, you can now extract your profile data from the token.
The fields within the payload are not specified by the JWT format, so these are up to you. We've assumed in this example that the payload contains an object with propertiesuid,groups,name, andadmin, but yours will likely be different.
The important thing is that you extract the fields to separate HTTP headers, so that this data can be used for cache variation.
if (var.jwtSigVerified) {setreq.http.Auth-State="authenticated";setreq.http.Auth-UserID=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"uid" *: *"([^\"]+)""},re.group.1,"");setreq.http.Auth-Groups=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"groups" *: *"([^\"]+)""},re.group.1,"");setreq.http.Auth-Name=if(var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"name" *: *"([^\"]+)""},re.group.1,"");setreq.http.Auth-Is-Admin=if (var.jwtPayloadDecoded~{"\{(?:.+,)?\s*"admin" *: *true"},"1","0");}else {if (var.jwtOptionAnonAccess=="allow") {setreq.http.Auth-State="anonymous"; unsetreq.http.Auth-UserID; unsetreq.http.Auth-Groups; unsetreq.http.Auth-Name; unsetreq.http.Auth-Is-Admin; }else {error618"jwt:anonymous"; }}For some use cases, it makes sense to only allow requests to origin if a token is present. It's important tounset these headers if the user is judged to be anonymous, because otherwise the user could send these headers themselves, and we would simply forward them to your origin server.
Handle the error cases
When that custom error is triggered, execution flow moves to thevcl_errorsubroutine. You can use this to construct a custom response, which is likely to be a redirect to a login page.
if (obj.status==618&&obj.response~"^jwt:(.+)\z") {setobj.status=307;setobj.response="Temporary redirect";setobj.http.Location="/login?return_to="+urlencode(req.url);setobj.http.Fastly-JWT-Error=re.group.1; synthetic"";return (deliver);}This pattern, known as a 'synthetic response', involves triggering an error from somewhere else in your VCL, catching it in thevcl_error subroutine, and then converting the errorobj into the response that you want to send to the client. To make sure you trap the right error, it's a good idea to use a non-standardHTTP status code in the6xx range, and also to set an error 'response text' as part of theerror statement. These two pieces of data then becomeobj.status andobj.response in thevcl_error subroutine. Checking both of them will ensure you are trapping the right error.
Once you know you are processing the error condition that you triggered from your earlier code, you can modify theobj to create the response you want. Normally this includes some or all of the following:
- Set
obj.statusto the appropriate HTTP status code - Set
obj.responseto the canonical HTTP response status descriptor that goes with the status code, e.g., "OK" for 200 (this feature is no longer present in HTTP/2, and has no effect in H2 connections) - Add headers using
obj.http, such asobj.http.content-type - Create content using
synthetic. Long strings, e.g. entire HTML pages, can be included here using the{"...."}syntax, which may include newlines - Exit from the
vcl_errorsubroutine by explicitly performing areturn(deliver)
Remove the cookie
Now that the authentication state data from the cookie has been resolved, you no longer need to keep the cookie around. In fact, it's better that you don't, because if you do, you will sendtwo sources of authentication information to the origin server, and you can't control which ones the server will use. Keep your application better encapsulated by removing data higher up the stack if it should not penetrate any lower.
This code goes at the end of thevcl_recv subroutine, because we want it to run regardless of whether the cookie was valid or not.
if (var.jwtTokenSource=="cookie") { unsetreq.http.Cookie:auth;}HINT: If a cookie is not present, trying to unset it is a no-op, and not harmful.
Use the authentication data on your origin server
The user profile data is presented to your origin server as HTTP headers, prefixedauth-. In the examples above, we createdAuth-State,Auth-UserID,Auth-Groups,Auth-Name, andAuth-Is-Admin. You can use this information to adjust the response you generate.
It's vital that you tell Fastly which data you used to determine the content of the page, so we can cache multiple variants of the response where appropriate. Sometimes, the user might request some resource that is not affected by their authentication state - perhaps an image - and in this case you don't need to do anything. We will cache just one copy of the resource, and use it to satisfy all requests. However, if you do use anyauth- headers to decide what to output, then you need to tell us that you did this, using aVary header:
Vary: auth-stateIn this case, you're saying that the response contains information that varies based on theauth-state header, so Fastly needs to keep multiple copies of this resource, one for each of the possible values of auth-state (only two in our example here: "Authenticated" and "Anonymous"). We don't need to keep separate copies for all the differentauth-userids though, because you didn't use that information to generate the page output.
Authentication data with low granularity, such as 'is authenticated', 'level', 'role', or 'is admin' are really good properties to use to vary page output in a way that still allows it to be efficiently cached. Medium granularity data such as 'Groups' (which we assume to be a string containing multiple group names) can also work, but think about normalising this kind of data, e.g., by making it lowercase and sorting the tokens into alphabetical order. Making use of high granularity data such as 'Name' and 'user ID' generally renders a response effectively uncacheable at the edge.
It's possible that you always inspect something likeauth-state, and then for certain states, you also inspect another property likeUserID. That's fine, and in that case, the responses that have inspected userID should include it in the vary header:
Vary: auth-state, auth-useridNext steps
This solution contains a VCL table that stores credentials. You can define the table using anprivate dictionary, which enables you to manage the table via HTTP API calls, without having to clone and activate new versions of your service, and without having the credential data visible in your service configuration.
If your origin servers are exposed to the internet (and not privately peered with Fastly), then you may want to take steps to ensure that users cannot send 'authenticated' requests directly to origin. You can do this with a client certificate, a pre-shared key, or by adding Fastly network IP addresses to a firewall.
Related content
Reference
Quick install
The embedded fiddle below shows the complete solution. Feel free to run it, and click theINSTALL tab to customize and upload it to a Fastly service in your account:
Once you have the code in your service, you can further customize it if you need to.
All code on this page is provided underboth the BSD and MIT open source licenses.