Serverless Authentication with JSON Web Tokens

Let’s learn how we can use JSON Web Tokens to add authentication and authorization to our serverless functions! We will be using the Serverless framework.

The sample application is available on GitHub.

📬 Get updates straight to your inbox.

Subscribe to my newsletter so you don't miss new content.

Authentication & Authorization

First, a bit of background. Authentication and Authorization are two different processes.

Authentication determines a client’s identity - is the user who they claim to be? Clients present a set of credentials, which may be valid or invalid. Typically, a user verifies their identity with their username and password.

Authorization determines what a client is allowed to do. Clients may have presented valid authentication credentials, but they might not have sufficient privileges to perform an action. Each user has a different set of permissions, which limits what they can and cannot do.

We will look at how we can use JSON Web Tokens to add both Authentication and Authorization to our functions.

JSON Web Tokens (JWT)

JSON Web Tokens (JWT - pronounced ‘jot’) are a compact and self-contained way for securely transmitting information and represent claims between parties as a JSON object.

Below is an encoded JSON Web Token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9.cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q

A JSON Web Token is a string consisting of three components, each component delimited by a period (.) character.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9
.
cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q

Base64Url decoding the JSON Web Token above gives us the following:

{
  "alg": "HS256",
  "typ": "JWT"
}
.
{
  "jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
  "username": "hunter2",
  "scopes": ["repo:read", "gist:write"],
  "iss": "1452343372",
  "exp": "1452349372"
}
.
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

JSON Web Tokens consists of the Header, Payload, and Signature. A token is constructed as follows:

  1. You generate a claim of arbitrary JSON data (the Payload), which in our case contains all the required information about a user for the purposes of authentication. A Header typically defines the signing algorithm alg and type of token typ.

  2. You include some metadata in the Payload, such as when the claim expires, who the audience is, and so on. These claims are defined in the JWT IETF Draft.

  3. The data (both Header and Payload) is then cryptographically signed with a Hash-based Message Authentication Code (HMAC) secret. The resulting Signature is used to verify the user’s identity and to ensure that the message was not tampered in any way.

Finally the Header, Payload, and Signature are Base64 encoded and concatenated together with periods to delimit the fields, which results in the token we see in the first example.

For authentication purposes, a JWT serves as the credential/identity object that clients must show to gatekeepers to verify that you’re allowed to access protected resources. JWTs can be signed by a trusted party, and verified by gatekeepers.

Authentication Flow

One of the primary use cases of JWTs is to authenticate requests. Once a user is issued a JWT, future requests can include the JWT in order to access protected resources and services. The authentication flow happens between the following parties:

  • Resource Owner (the User): the party that owns the resource to be shared. Let’s call our user Sam.
  • Resource Service: the service that holds the protected resource. Let’s call our service PhotoService.
  • Authorizer: the service that verifies the identity of users.
  • Client: the application (web/mobile/others) that makes requests to the Resource Server on behalf of the Resource Owner.

The flow goes as follows:

  1. Sam the Resource Owner wishes to view the contents of her photo album wallet through the Client.
  2. The Client talks to the PhotoService - a Resource Service, requesting for Sam’s Photo resource.
  3. Photos are a protected resource. Clients will need to pass an authorization check to continue.
  4. The Client talks to the Authorizer to request an access token. The Authorizer responds by asking for the user’s credentials.
  5. The Client redirects Sam the Resource Owner to the Authorizer.
  6. Sam the Resource Owner supplies her credentials to the Authorizer, which gives Sam the option to either deny or accept the Client’s request for access.
  7. The Authorizer verifies Sam’s credentials, redirects her back to the Client, and grants an Authorization Code to the Client.
  8. The Client presents the authorization code to the Authorizer, receiving an access token (a JSON Web Token) in return.
  9. The Client presents the access token (a JSON Web Token) to the PhotoService and retrieves Sam’s Photo resource.
  10. The PhotoService validates the token, decoding the JWT, and parsing its contents.
  11. If the access token is valid for the requested operation and resource, PhotoService returns Sam’s information to the Client.
  12. The Client shows Sam his photos.

Note that the Resource Owner does not share their credentials to the Client directly. Instead, users notify the Authorizer that the Client may access whatever it is that they requested, and the Client authenticates separately with an authorization code. For more details, check out the OpenID Connect spec.

In this article, we’re focusing on step 9 to 12. Whenever the Client wants to access a protected route or resource, it must send a JSON Web token in the request’s Authorization header like so:

Authorization: <token>

The Resource Server can read and verify this JWT to check if the user is allowed to perform certain actions.

Benefits of JWTs

Using a JSON Web Token as your identity object gives you some advantages compared to a traditional OAuth2 token:

1. Introspectable: A JSON Web Token carries an HTTP header-like metadata that can be easily inspected for client-side validation purposes. In contrast, plaintext Bearer OAuth tokens can only be decoded by making API calls to the authorization server.

2. Fine Grained Access Control: You can specify detailed access control information within the token payload. In the same way that you can create AWS security policies with very specific permissions, you can limit the token to only give read/write access to a single resource.

To illustrate, you can populate your tokens with private claims containing a dynamic set of scopes with JWTs like so:

{
  "jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
  "username": "hunter2",
  "scopes": ["repo:read", "gist:write"],
  "iss": "1452343372",
  "exp": "1452349372"
}

Your Resource Server’s authentication middleware can then parse the JWT’s payload and check its scopes.

3. Stateless: All the information needed to complete a particular request is sent along in the request. Since the JWT payload contains all the required information for us to authenticate the user, we can avoid making network calls to the authorization server. This is especially useful for mobile environments with unstable network conditions.

Hands-On

In this section, we will learn how to issue and verify JWTs. We’ll also look at a working serverless authorization example.

Issuing JWTs

For Node.js, use the node-jsonwebtoken NPM module to issue and verify JWTs.

import jwt from 'jsonwebtoken';

const secret = 'ssshhh';
const user = {
  id: 123,
  scopes: ['users:read']
};
const token = jwt.sign({ user }, process.env.JWT_SECRET, { expiresIn: JWT_EXPIRATION_TIME });
console.log(`JWT issued: ${token}`);

In the above snippet, we specify a payload object { user } to be signed with a secret string. This secret will also be used to verify the token later on.

Within your JWT Payload, you can include any fields. However, there are some reserved fields such as:

  • iat (Issued at): Unix timestamp for time when the token was issued
  • exp (Expires at): Unix timestamp for time after which this token should not be accepted.
  • nbf (Not before): Unix timestamp for time before which this token should not be accepted.
  • iss: The issuer of the token.
  • sub: The subject of the token.
  • aud: The audience of the token.
  • jti: Unique identifier for the JWT. Can be used to prevent the JWT from being replayed.

The node-jsonwebtoken module automatically populates the payload’s iat field for you by default, but it can be overriden. The exp attribute is populated by the expiresIn option. For more information, learn more about Reserved JWT Claims.

Note that instead of issuing tokens yourself, you can also use a third-party auth provider such as Auth0 that issues tokens for you.

Verifying JWTs

Using the shared secret you used to sign the JWT, you can verify the JWT’s authenticity:

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoiQXp1cmVEaWFtb25kIiwicGFzc3dvcmQiOiIqKioqKioqKioiLCJzY29wZXMiOlsicGFuZ29saW5zIl19LCJpYXQiOjE0OTk0MjAxMTEsImV4cCI6MTQ5OTQyMDQxMX0.KkoS0sKV1Hc5fFV5V7J1HlKVQYfmfpZZAwBZ9aDXRFc'; // JSON Web Token
const secret = 'ssshhh'; // Secret used in the JWT signature

try {
  const decoded = jwt.verify(token, secret);
  console.log(decoded.user.id) // 123
  console.log(decoded.user.scopes) // ['users:read']
} catch(err) {
  // Throws an error if the token is invalid.
}

By verifying the signature of the JWT with a shared secret, you can ensure that the token is issued by our authorization service without having to make a network call to our authorization service!

AWS Custom Authorizers

An AWS custom authorizer is a Lambda function that you provide to control access to your APIs. You can use an authorizer function to implement various authorization strategies, such as JSON Web Token (JWT) verification and OAuth provider callout, to return IAM policies that authorize the request.

When a client calls your function via HTTP, AWS API Gateway verifies whether a custom authorizer is configured for the API. If so, API Gateway calls the Lambda function, supplying the authorization token extracted from a specified request header (e.g. Authorization). If the returned policy is invalid or the permissions are denied, the API call will not succeed.

In the Serverless framework, you can configure our HTTP endpoints to have a custom authorizer enabled like so:

# serverless.yml

functions:
  authorize:
    handler: functions/authorize.handler
  getPangolins:
    handler: functions/getPangolins.handler
    events:
      - http:
          path: pangolins
          method: get
          cors: true
          authorizer: authorize # Pangolins are protected by a custom authorizer

In the case of a valid policy, API Gateway caches the returned policy, associated with the incoming token and used for the current and subsequent requests, over a pre-configured time-to-live (TTL) period of up to 3600 seconds (1 hour.) As a result, we don’t have to call the custom authorizer function before every individual API call.

You can also set the TTL period to zero seconds to disable the policy caching. The default TTL value is 300 seconds. Currently, the maximum TTL value of 3600 seconds cannot be increased.

Sample Application Walkthrough

Let’s try out our serverless-auth application. In your terminal, do:

cd serverless-auth
serverless deploy

Our serverless-auth application has three HTTP endpoints:

  • GET /cats is a public endpoint anyone can access. You don’t need to be authenticated to access this endpoint.
  • GET /pangolins is a private endpoint, protected by an AWS Custom Authorizer. You need to have a valid identity to access this endpoint.
  • POST /sessions is a login endpoint. Pass a valid username and password in a JSON request body to get a JWT (see /lib/users.js for valid combinations.) For example, the following is a valid combination:
{
	"username": "Cthon98",
	"password": "hunter2"
}

In the sample application, we also have an authorize authorizer function that is executed on protected HTTP endpoints.

In order to pass the authentication check, clients need to supply a valid JWT in their Authorization request header when making calls to a protected endpoint.

In order to pass the authorization check, clients need a JWT belonging to a user with valid permissions. For this example, the user Cthon98 is authorized to access GET /pangolins; AzureDiamond is not.

Step 1: Access the public endpoint

Try calling the GET /cats public endpoint. Even without supplying any Authorization headers, you can get a response back:

> curl https://<yourServiceEndpoint>.execute-api.ap-southeast-1.amazonaws.com/dev/cats
{
  "cats": [
    {
      "id": 1,
      "name": "Furball",
      "address": "2 Fur Lane"
    }
  ]
}

This is because we don’t have a custom authorizer function set for this endpoint, making it publicly accessible even without a JWT.

Step 2: Attempt to access the protected endpoint

Next, try calling the GET /pangolins protected endpoint.

> curl https://<yourServiceEndpoint>.execute-api.ap-southeast-1.amazonaws.com/dev/pangolins
{
  "message": "Unauthorized"
}

We receive a 401 Unauthorized response, because we didn’t supply valid credentials in our HTTP call. This endpoint does have a custom authorizer function enabled, as defined in the project’s serverless.yml:

# serverless.yml

functions:
  authorize:
    handler: functions/authorize.handler
  getPangolins:
    handler: functions/getPangolins.handler
    events:
      - http:
          path: pangolins
          method: get
          cors: true
          authorizer: authorize # Pangolins are protected by a custom authorizer

Step 3: Get a JWT by logging in

In order to access the /pangolins protected endpoint, we need to include a JWT in the Authorization request header. Call the POST /sessions login endpoint with a username and password like so:

> curl -H "Content-Type: application/json" -X POST -d '{"username":"Cthon98","password":"hunter2"}' https://<yourServiceEndpoint>.execute-api.ap-southeast-1.amazonaws.com/dev/sessions
{
  "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoiQ3Rob245OCIsInNjb3BlcyI6WyJwYW5nb2xpbnMiXX0sImlhdCI6MTQ5OTc0MjI5OCwiZXhwIjoxNDk5NzQyNTk4fQ.TFFniOqk7cz3zKbe1b59CT2nKuLMFJlB4awvDbB9qZU"
}

We receive a JWT in response.

Step 4: Use the JWT to access protected endpoints

Call the GET /pangolins protected endpoint with an Authorization request header set to the JWT:

> curl -H "Authorization: <yourJWT>" https://<yourServiceEndpoint>.execute-api.ap-southeast-1.amazonaws.com/dev/pangolins
{
  "pangolins": [
    {
      "id": 2,
      "name": "Pengu",
      "address": "123 Carapace Drive"
    }
  ]
}

By supplying a valid JWT with sufficient credentials, we pass the authorizer check and are able access the endpoint.

In our example application, only the user Cthon98 has access to GET /pangolins due to the scopes defined in the example users database.

// User credentials and scopes in the example application
const UsersDB = [
  {
    username: 'Cthon98',
    password: 'hunter2', // User password
    scopes: ['pangolins'], // Authorized actions
  },
  {
    username: 'AzureDiamond',
    password: '*********',
    scopes: [],
  },
];

You can try logging in as user AzureDiamond and receive a JWT, but it will not have sufficient privileges to access the protected endpoint. This example demonstrates how you can implement granular user permissions with JWTs.

Code Walkthrough

To make it more concrete, let’s walk through the serverless-auth serverless authorization example.

Follow along by referring to the serverless-auth example included in the book’s sample code.

The example has four functions:

  • login accepts a username and password pair, returning a JWT.
  • authorize is an authorization middleware function that can be enabled to protect individual functions.
  • getCats and getPangolins returns a dummy JSON response.

Login Function

The login function takes in a username and password pair, checks it against a dummy database, and returns a JSON Web Token (JWT) that can be used to access protected endpoints.

// /functions/login.js

const jwt = require('jsonwebtoken');
const users = require('../lib/users');

const JWT_EXPIRATION_TIME = '5m';

/**
  * POST /sessions
  *
  * Returns a JWT, given a username and password.
  * @method login
  * @param {String} event.body.username
  * @param {String} event.body.password
  * @throws Returns 401 if the user is not found or password is invalid.
  * @returns {Object} jwt that expires in 5 mins
  */
module.exports.handler = (event, context, callback) => {
  const { username, password } = JSON.parse(event.body);

  try {
    // Authenticate user
    const user = users.login(username, password);

    // Issue JWT
    const token = jwt.sign({ user }, process.env.JWT_SECRET, { expiresIn: JWT_EXPIRATION_TIME });
    const response = { // Success response
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
      body: JSON.stringify({
        token,
      }),
    };

    // Return response
    callback(null, response);
  } catch (e) {
    const response = { // Error response
      statusCode: 401,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
      body: JSON.stringify({
        error: e.message,
      }),
    };
    callback(null, response);
  }
};

In the above code:

  • First, we check the username and password to see if it matches against a user in our database.
  • If the authentication process fails, we return a 401 response.
  • We encode the user’s attributes (username and scopes) as a JWT with a secret string. The token expires in JWT_EXPIRATION_TIME 5 minutes.
  • Finally, we return the JWT to the client. Clients can include this token in their Authorization header to access the /getPangolins protected endpoint.

Authorizer Function

Authorizer functions are executed before another function. We specify which functions have a custom authorizer enabled in serverless.yml:

# serverless.yml

functions:
  ...
  authorize:
    handler: functions/authorize.handler
  getCats:
    handler: functions/getCats.handler
    events:
      - http:
          path: cats
          method: get
          cors: true
          # authorizer: authorize # Cats are public, so it doesn't have an authorizer enabled
  getPangolins:
    handler: functions/getPangolins.handler
    events:
      - http:
          path: pangolins
          method: get
          cors: true
          authorizer: authorize # Pangolins are protected by the authorizer

Within the authorize function, we verify and decode any JWTs in the Authorization request header. This is to determine if the client can access the endpoint:

// /functions/authorize.js

const _ = require('lodash');
const jwt = require('jsonwebtoken');
const utils = require('../lib/utils');

// Returns a boolean whether or not a user is allowed to call a particular method
// A user with scopes: ['pangolins'] can
// call 'arn:aws:execute-api:ap-southeast-1::random-api-id/dev/GET/pangolins'
const authorizeUser = (userScopes, methodArn) => {
  const hasValidScope = _.some(userScopes, scope => _.endsWith(methodArn, scope));
  return hasValidScope;
};

/**
  * Authorizer functions are executed before your actual functions.
  * @method authorize
  * @param {String} event.authorizationToken - JWT
  * @throws Returns 401 if the token is invalid or has expired.
  * @throws Returns 403 if the token does not have sufficient permissions.
  */
module.exports.handler = (event, context, callback) => {
  const token = event.authorizationToken;

  try {
    // Verify JWT
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = decoded.user;

    // Checks if the user's scopes allow her to call the current function
    const isAllowed = authorizeUser(user.scopes, event.methodArn);

    const effect = isAllowed ? 'Allow' : 'Deny';
    const userId = user.username;
    const authorizerContext = { user: JSON.stringify(user) };
    // Return an IAM policy document for the current endpoint
    const policyDocument = utils.buildIAMPolicy(userId, effect, event.methodArn, authorizerContext);

    callback(null, policyDocument);
  } catch (e) {
    callback('Unauthorized'); // Return a 401 Unauthorized response
  }
};

In the above code:

  • First, we verify and decode the JWT.
  • If the JWT is invalid or has expired, we return an HTTP 401 Unauthorized response.
  • Then, we check that the user’s scopes attribute includes the current function (e.g. getPangolins) with the authorizeUser function.
  • Finally, we return an IAM policy object that Allow or Deny access to the current function.

For your reference, here is the utils.buildIAMPolicy function:

// /lib/utils.js

/**
  * Returns an IAM policy document for a given user and resource.
  *
  * @method buildIAMPolicy
  * @param {String} userId - user id
  * @param {String} effect  - Allow / Deny
  * @param {String} resource - resource ARN
  * @param {String} context - response context
  * @returns {Object} policyDocument
  */
const buildIAMPolicy = (userId, effect, resource, context) => {
  const policy = {
    principalId: userId,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: effect,
          Resource: resource,
        },
      ],
    },
    context,
  };

  return policy;
};

module.exports = {
  buildIAMPolicy,
};

For a detailed reference on AWS Custom Authorizers, check out the official AWS docs.

Accessing the user object

You can include a context object that will be available in the event.requestContext.authorizer of protected functions. This lets you pass the user context to the called function! For example:

// /functions/getPangolins.js

/**
  * GET /pangolins
  *
  * Returns a collection of pangolins.
  * @returns {Array.Object}
  */
module.exports.handler = (event, context, callback) => {
  const user = JSON.parse(event.requestContext.authorizer.user);
  console.log(user); // > This is the user context!

  const response = {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': '*', // Required for CORS support to work
    },
    body: JSON.stringify({
      pangolins: [
        {
          id: 2,
          name: 'Pengu',
          address: '123 Carapace Drive',
        },
      ],
    }),
  };

  callback(null, response);
};

In the above protected function getPangolins, we can get the user object from event.requestContext.authorizer.

Note that the AWS Custom Authorizer context object cannot have an object attribute - we needed to JSON.stringify the user object.

Summary

Congratulations! You learned about:

  • The difference between Authentication and Authorization
  • What JSON Web Tokens are
  • How to use AWS Custom Authorizers to secure your functions

With these basic abstractions in mind, you can build an auth layer to secure your serverless APIs!

Get the Book

Interested to learn more?

Going Serverless is a practical guide to building Scalable applications with the Serverless framework and AWS Lambda. This book will teach you how to design, develop, test, deploy, monitor, and secure Serverless applications from planning to production.

In this book we’ll build three hands-on projects using Javascript, Amazon Web Services (AWS), and the Serverless framework. The projects you’ll build include:

  • An event-driven image processing pipeline
  • A scalable web scraper
  • A serverless microservice

Each hands-on project is a real-life implementation of a serverless design pattern. You’ll also learn how to use a suite of AWS technologies beyond just Lambda such as API Gateway, DynamoDB, SNS, S3, and more.

Get the first three chapters for free!