Faster API calls with JWT access tokens

At Faithlife, we’ve been using OAuth 1.0a to handle authentication between services. Instead of designing our apps as monoliths, we’ve been perferring to build lightweight frontend applications that call RESTful microservices, returning entities as JSON. These frontend applications don’t touch our databases directly. Among other benefits, this allows us to better allocate hardware resources (CPU, RAM, disk) to applications that need them.

A typical request to Faithlife might look something like this:

mermaid
sequenceDiagram
	participant Frontend
	participant Accounts
	participant Community Newsfeed
	participant Amber API
	participant Notifications API
	participant OAuth
	Frontend->> OAuth: Authenticate user
	Frontend->> Accounts: List groups
	Frontend->> Community Newsfeed: Fetch newsfeed
	Community Newsfeed->> OAuth: Authenticate user
	Community Newsfeed->> Amber API: Get post images
	Amber API->> OAuth: Authenticate user
	Frontend->> Notifications API: Get notifications
	Notifications API->> OAuth: Authenticate user

At the beginning of the request, Faithlife makes a call to OAuth API to ensure the current OAuth access token and secret are still valid. After that check passes, the current user’s OAuth credentials are also passed to all downstream services that require auth.

An authorization header presented to a downstream API looks something like:

Authorization: OAuth oauth_consumer_key="1E18E56BD0C3A51A945D98136D6462FCEAE65199",oauth_signature="0B847E32C6DE692A7BA899DF67EF5C1BCCAEFA89%262D3F6B2BD18B2DD85821EFF0F07EB130AD46E5C5",oauth_signature_method="PLAINTEXT",oauth_version="1.0",oauth_token="FE009074810F3D2E3A2EB6BF5603B1CA08082AB7"

However, this poses a problem - our microservices do not have access to the OAuth database directly, and can’t validate the current user’s authorization header without first calling OAuth API. These calls are not free - on average, we measured the time taking from 10-35 ms for apps within the same data center, depending on a variety of factors. As we add more API calls to Faithlife, it gets progressively worse:

  • Pages take longer to load, as each API dependency needs to call OAuth API.
  • APIs may need to fetch data from other downstream APIs, and each API needs to validate the authenticated user.
  • APIs hosted outside the datacenter (Azure, GKE, etc) can magnify this problem significantly if the app is not hosted geographically close to where OAuth API lives.
  • Locally caching the oauth validation state on a web node only solves part of the problem. Many APIs are backed by multiple web nodes, with round robin request balancing, so there could be a cache miss.

JWT Access Tokens

What we needed was a way to pass a token to downstream APIs that identifies the current user. OAuth 1.0a was suitable for a long time, and several years ago we decided to hold off on migrating to OAuth 2 because the need was not strong enough. OAuth 2 adds a few steps to the authentication flow:

  1. When signing a user in, obtain a refresh token and an access token.
  2. Use the access token for API calls. The OAuth 2 and OpenID Connect standards do not define the format that these access tokens have to be in, but OpenID Connect mandates JSON web tokens (JWTs) for identity tokens, and identity tokens can be used as access tokens. To future proof our implementation, we chose to use JWTs signed with ES256. This blog post explains the token differences in greater detail..
  3. When the access token expires, use the refresh token to obtain a new access token

JWT access tokens have a few desirable properties for our use case. Tokens contain claims about the current user (such as the user ID and current roles), an expiration date, and are signed with a public/private key pair. Downstream APIs can validate their integrity using the public key, but only the signing authority can issue new ones. For our use case, we established some requirements for all JWT access tokens:

  • Only OAuth API has access to the private key and is solely responsible for issuing JWT access tokens. The public key is available via a public API.
  • JWT access tokens can only be created from plaintext OAuth 1 access tokens. Creating a JWT access token from a previous JWT access token is not allowed.
  • JWT access tokens are signed with ES256. All other signatures must be rejected.
  • Expiration date is 10 minutes from the current time, and not valid before 10 minutes prior to current time.
  • JWT access tokens contain claims for the current user ID, frontend app consumer ID, and any other properties that would be normally obtained when validating the current user via OAuth API.

JWT access tokens are presented to the downstream APIs that Faithlife calls. On the very first request, the current public key is requested from OAuth API. Using this public key, tokens can now be validated locally. All future tokens for the lifetime of the app are validated with this public key. Because the token has claims stored within it, we now have no need to call OAuth API for successful authentication attempts. If the token can’t be validated locally, either because the token appears to be expired due to clock skew, or because the signing key was changed, the downstream API makes a validation call to OAuth API (just like it did before).

We did not end up implementing the full OAuth 2 authorization flow when adding support for JWT access tokens. Instead, we used the OAuth 1 credentials in place of OAuth 2 refresh tokens to obtain access tokens.

How is this approach different from OAuth 1?

This approach follows the full OAuth 1.0a authorization flow, but replaces OAuth 1 plaintext tokens with JWT access tokens when communicating with downstream APIs. An OAuth 1 plaintext token is still obtained and stored by the frontend web application (in an encrypted cookie), and then upgraded to an JWT access token at the beginning of a frontend request. We could have migrated our auth services to a full OAuth 2 implementation, but this would be a non-trivial amount of work, and was more than we wanted to take on in this iteration. We were mainly interested in the scalability wins of using JWT access tokens, and leaving open the future possibility of using the full OAuth 2 authorization flow in the future.

Tokens in action

When Faithlife gets a web request, it makes a call to OAuth API:

GET /oauth/v1/users/current
Authorization: OAuth oauth_consumer_key="1E18E56BD0C3A51A945D98136D6462FCEAE65199",oauth_signature="0B847E32C6DE692A7BA899DF67EF5C1BCCAEFA89%262D3F6B2BD18B2DD85821EFF0F07EB130AD46E5C5",oauth_signature_method="PLAINTEXT",oauth_version="1.0",oauth_token="FE009074810F3D2E3A2EB6BF5603B1CA08082AB7"

And gets back a JWT:

X-Bearer-Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyOTg2Njg5IiwiY29uc3VtZXJOYW1lIjoiRmFpdGhsaWZlIiwiY29uc3VtZXJUb2tlbiI6IjRFNTdGQTk1MDE1MTJDMUM0RjdFMzQ1NzE0NjNDMjI0QjBCMzc1NEQiLCJpc0FkbWluQ29uc3VtZXIiOiJ0cnVlIiwibmJmIjoxNTMyOTgyMDQ2LCJleHAiOjE1MzI5ODMyNDYsImlhdCI6MTUzMjk4MjY0NiwiaXNzIjoiYXV0aC5mYWl0aGxpZmUuY29tIiwiYXVkIjoiZmFpdGhsaWZlLWJhY2tlbmQtYXBpcyJ9.7GSdItnnCr8QOLS3uCbJMY0X-D7jTjp_XUAp8clo9LY4X5Zlf_5I7RSMZr3J6kOihwbHjEbuh0AFMXmF5YQZLg

Which decodes to:

{
  "sub": "2986689",
  "consumerName": "Faithlife",
  "consumerToken": "4E57FA9501512C1C4F7E34571463C224B0B3754D",
  "isAdminConsumer": "true",
  "nbf": 1532982046,
  "exp": 1532983246,
  "iat": 1532982646,
  "iss": "auth.faithlife.com",
  "aud": "faithlife-backend-apis"
}

This JWT is passed to all downstream services in the Authorization header. Our request graph now looks much better:

mermaid
sequenceDiagram
	participant Frontend
	participant Accounts
	participant Community Newsfeed
	participant Amber API
	participant Notifications API
	participant OAuth
	Frontend->> OAuth: Authenticate user and obtain JWT
	Frontend->> Accounts: List groups
	Frontend->> Community Newsfeed: Fetch newsfeed
	Community Newsfeed->> Amber API: Get post images
	Frontend->> Notifications API: Get notifications

On average we measured validating this token taking less than 4 ms per request. We’re happy with the results so far and are in the process of rolling support out to all of our APIs.

Alternate strategies

There is more than one way to solve this problem. A few other strategies we considered:

  • Using a shared service account to communicate with downstream services. This increases the chance that a frontend regression reveals access to data that the user is not allowed to see (e.g. posts to a secret group). There are many different teams in charge of the APIs that Faithlife calls (e.g. commerce APIs are separated from community APIs), and validation at the API layer is much easier to enforce across team boundaries.

  • Using a shared key and pass a signed token or cookie. This presents several security problems. Rotating the shared token would have been very complicated, and having the signing key on all of our web nodes increases the attack surface. We wanted a solution that was standardized and would scale well into the future. Some of these solutions are also tighly coupled to the web application stack (ASPXAUTH cookies for example), and our auth solution needs to work on multiple API platforms.

Technologies used

We primarily use ASP.NET here at Faithlife, although we also host services using NodeJS and .NET Core. A sample .NET Core app that demonstrates both signing and validating ES256 tokens is hosted here:

https://github.com/Faithlife/ES256-Demo

This demo uses these NuGet packages:

Thanks for reading!

If you’re intersted in working on projects like this, come work with us! Thanks to Robert Bolender, Justin Brooks, and Bradley Grainger for giving feedback on early drafts of this post.

Posted by Dustin Masters on August 06, 2018