You appear to be offline. Some site functionality may not work.

Decoding JSON Web Tokens

The popular JSON Web Token format is a useful way to maintain authentication state and synchronise 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.

  • LEARN

    Start coding from the instructions below. Make the tests pass!

  • PLAY

    Try out a fully functional example. Just press play.

  • USE

    Add this solution to a service in your Fastly account

Illustration of pattern concept

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.

  1. 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. Using openssl is a great way to generate a random string:

    openssl rand 32 -base64
    Using 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:

    openssl genrsa -des3 -out private.pem 4096 openssl rsa -in private.pem -outform PEM -pubout -out public.pem fastlyctl dictionary upsert solution_jwt_keys key_name "`cat public.pem`"
  2. Set 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 for NodeJS, Ruby, and PHP. You can also test JWTs using the JWT.io tool.

    Within the header 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) eg:

    { "alg": "HS256", "typ": "JWT" }

    Within the payload 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 solution pattern:

    1. key: ID (of your choice) of the key used to sign this token. (string, REQUIRED)
    2. exp: Expiration unix timestamp of this token. (number, optional)
    3. nbf: Unix timestamp before which this token is not valid. (number, optional)
    4. tag: Makes the token only valid for content tagged with the specified surrogate key. (string, optional)
    5. path: URL path pattern in which the token is valid. (string, optional, may start with, end with, or contain one * wildcard)
    6. 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"
    }
  3. Make your secret signing key accessible to Fastly

    If you are installing this solution in a Fastly service, set up a private edge dictionary called solution_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. If you are experimenting in Fastly Fiddle, create the table by writing the code literally in the init subroutine:

    INIT
    table solution_jwt_keys {
      "key1": "your-signing-key-here"
    }

    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, eg 'key-june2019'.

  4. 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.

    RECV
    declare local var.jwtSource STRING;
    declare local var.jwtHeader STRING;
    declare local var.jwtHeaderDecoded STRING;
    declare local var.jwtPayload STRING;
    declare local var.jwtPayloadDecoded STRING;
    declare local var.jwtSig STRING;
    declare local var.jwtSigDecoded STRING;
    declare local var.jwtStringToSign STRING;
    declare local var.jwtCorrectSig STRING;
    declare local var.jwtSigVerified BOOL;
    declare local var.jwtKeyID STRING;
    declare local var.jwtAlgo STRING;
    declare local var.jwtKeyData STRING;
    declare local var.jwtNotBefore INTEGER;
    declare local var.jwtExpires INTEGER;
    declare local var.jwtTag STRING;
    declare local var.jwtPath STRING;
    

    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.jwtOptionTimeInvalidBehavior STRING;
    set var.jwtOptionTimeInvalidBehavior = "anon"; # Choose from 'anon' or 'block'
    declare local var.jwtOptionPathInvalidBehavior STRING;
    set var.jwtOptionPathInvalidBehavior = "anon"; # Choose from 'anon' or 'block'
    declare local var.jwtTokenSource STRING;
    set var.jwtTokenSource = "cookie"; # Choose from 'cookie' or 'query'
    declare local var.jwtOptionAnonAccess STRING;
    set var.jwtOptionAnonAccess = "allow"; # Choose from 'allow' or 'deny'
    
  5. Detect, extract and decode the JWT

    If the request contains an authentication cookie (called auth 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 querystring parameter.

    The three parts of the token are separated by dots, and will be extracted into the special re.group object by the regular expression engine as part of the if statement. These parts can now be assigned to more helpfully-named variables:

    RECV
    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-_.+/=]*)$") {
      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;
      set var.jwtSigDecoded = digest.base64url_decode(var.jwtSig);
    }

    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.

  6. Extract required signing data from the JWT

    In order to construct a signature, you need to know which algorithm and key to use:

    RECV
    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, "");

    Currently, Fastly does not support native JSON decoding, which is why we need to use regex to extract data from the JWT source.

  7. 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.

    RECV
    if (var.jwtHeader) {
      if (var.jwtAlgo !~ "^HS256|HS512|RS256|RS512$") {
        error 818 "jwt:algo-not-supported";
      } else if (!var.jwtKeyData) {
        error 818 "jwt:key-not-found";
      } else if (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);
        }
        set var.jwtCorrectSig = digest.base64_decode(var.jwtCorrectSig);
        if (var.jwtCorrectSig == var.jwtSigDecoded) {
          set var.jwtSigVerified = true;
        } else {
          set var.jwtSigVerified = false;
        }
      } else if (var.jwtAlgo == "RS256") {
        set var.jwtSigVerified = digest.rsa_verify(sha256, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url);
      } else if (var.jwtAlgo == "RS512") {
        set var.jwtSigVerified = digest.rsa_verify(sha512, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url);
      }
      if (!var.jwtSigVerified) {
        error 818 "jwt:signature-fail";
      }
    }

    In error conditions, trigger a unique HTTP error code of your choice (in the example here, we use 813), so that the error can be handled by the error subroutine. Setting a known header 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.

  8. Check for time constraints (optional)

    In your JWT, you can specify a 'not before' and 'not after' time, via the nbf and exp properties of the payload. If these are specified, check that the current time is within the allowed constraints:

    RECV
    if ((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 {
        error 818 "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.

  9. Check for path constraint (optional)

    Another thing we're supporting in the JWT is the path property, which allows a token to be scoped to a URL path. If this is set within the token, verify it now:

    RECV
    if (var.jwtPath ~ {"^([^\*]+)?(\*?)([^\*]+?)?$"} && var.jwtPath != "")  {
      if (
        (re.group.1 && !re.group.2 && !re.group.3 && var.jwtPath != re.group.1) ||
        (!re.group.1 && re.group.2 && !re.group.3 && !std.prefixof(var.jwtPath, re.group.1)) ||
        (!re.group.1 && re.group.2 && re.group.3 && !std.suffixof(var.jwtPath, re.group.3)) ||
        (re.group.1 && re.group.2 && re.group.3 && (!std.prefixof(var.jwtPath, re.group.3) || !std.suffixof(var.jwtPath, re.group.3)))
      ) {
        if (var.jwtOptionPathInvalidBehavior == "anon") {
          set var.jwtSigVerified = false;
        } else {
          error 818 "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 if a token was only valid for a path of /ad we should not accept it for /admin, despite the fact that one is a prefix of the other. However, the same token (with path constraint of /ad) should be accepted for a request to /ad/foocorp/creative.mp4.

    You can achieve this scheme by removing any trailing slash from the supplied path, and then adding one when matching a URL that is longer.

  10. Check for tag constraint (optional)

    Fastly has a mechanism for tagging content known as surrogate 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 into var.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:

    RECV
    if (var.jwtTag != "") {
      set req.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 the deliver event to see if it matches your tag constraint:

    DELIVER
    declare local var.jwtRequiredTag STRING;
    declare local var.jwtAvailableTags STRING;
    
    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)) {
        unset req.http.auth-require-tag;
        set req.http.Fastly-JWT-Error = "jwt:tag-missing";
        restart;
      }
    }

    Fastly automatically strips the Surrogate-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 check req.http.Surrogate-Key here, and not resp.http.Surrogate-Key. Finally, performing an error is not permitted in deliver, so instead, trigger a restart and catch the error at the top of recv:

    RECV
    # Place this just below all the `declare` statements
    if (req.http.Fastly-JWT-Error) {
      error 818 req.http.Fastly-JWT-Error;
    }
  11. 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 properties uid, groups, name, and admin, 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.

    RECV
    if (var.jwtSigVerified) {
      set req.http.Auth-State = "authenticated";
      set req.http.Auth-UserID = if(var.jwtPayloadDecoded ~ {"\{(.+,)?\s*"uid" *: *"([^\"]+)""}, re.group.1, "");
      set req.http.Auth-Groups = if(var.jwtPayloadDecoded ~ {"\{(.+,)?\s*"groups" *: *"([^\"]+)""}, re.group.1, "");
      set req.http.Auth-Name = if(var.jwtPayloadDecoded ~ {"\{(.+,)?\s*"name" *: *"([^\"]+)""}, re.group.1, "");
      set req.http.Auth-Is-Admin = if (var.jwtPayloadDecoded ~ {"\{(.+,)?\s*"admin" *: *true"}, "1", "0");
    } else {
      if (var.jwtOptionAnonAccess == "allow") {
        set req.http.Auth-State = "anonymous";
        unset req.http.Auth-UserID;
        unset req.http.Auth-Groups;
        unset req.http.Auth-Name;
        unset req.http.Auth-Is-Admin;
      } else {
        error 818 "jwt:anonymous";
      }
    }

    For some use cases, it makes sense to only allow requests to origin if a token is present. It's important to unset 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.

  12. Handle the error cases

    When that 813 error is triggered, execution flow moves to the error subroutine. You can use this to construct a custom response, which is likely to be a redirect to a login page.

    ERROR
    if (obj.status == 818 && obj.response ~ "^jwt:(.+)$") {
      set obj.status = 307;
      set obj.response = "Temporary redirect";
      set obj.http.Location = "/login?return_to=" urlencode(req.url);
      set obj.http.Fastly-JWT-Error = re.group.1;
      synthetic "";
      return (deliver);
    }

    You may want to consider other options for when the token is invalid, such as redirecting to a login page.

  13. 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 send two 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 the recv subroutine, because we want it to run regardless of whether the cookie was valid or not.

    RECV
    if (var.jwtTokenSource == "cookie") {
      unset req.http.Cookie:auth;
    }

    If a cookie is not present, trying to unset it is a no-op, and not harmful.

  14. Use the authentication data on your origin server

    The user profile data is presented to your origin server as HTTP headers, prefixed auth-. In the examples above, we created Auth-State, Auth-UserID, Auth-Groups, Auth-Name, and Auth-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 any auth- headers to decide what to output, then you need to tell us that you did this, using a Vary header:

    Vary: auth-state

    In this case, you're saying that the response contains information that varies based on the auth-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 different auth-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, eg by lowercasing it 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 like auth-state, and then for certain states, you also inspect another property like UserID. That's fine, and in that case, the responses that have inspected userID should include it in the vary header:

    Vary: auth-state, auth-userid

Next steps

This solution contains a VCL table that stores credentials. You can define the table using an private edge 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.

See also

Reference docs:

Quick install

This solution can be added directly to an existing service in a Fastly account as a set of VCL snippets. The embedded fiddle below shows the complete solution. Feel free to run it, and click the 'INSTALL' tab to customise and upload it to your service:

Click to view the fiddle code

Once you have the code in your service, you can further customise it to your needs, but if you keep it unmodified, it will be eligible for automatic upgrades if this recommended solution is improved in future.

All code provided through Build on Fastly is provided under both the BSD and MIT open source licenses.

Get in touch

Help us make this resource more useful for the entire Fastly community. Email your questions, requests, and big ideas to developers@fastly.com — or reach out and let us know what you’re working on.