Dynamic JWT authentication and secrets rotation in Rails applications

JSON Web Tokens, commonly known as JWT is an open standard for representing and verifying claims securely between a client and a server. It is one of the most popular authentication and authorization techniques employed in modern applications. There are several articles available on how to get started with JSON Web Tokens in any application or framework. In this blog, however, we will talk about some lesser-known tricks of JWT, specifically in the context of large applications.

Generally speaking, the larger the application, the more internal and external services it has to talk to. External services usually have their own way of authenticating and authorizing third-party application programming interface (API) calls, which is a software intermediary that allows two or more applications to talk to one another. With internal systems, however, organizations prefer to use JWT tokens because of their inherent flexibility and versatility. Here’s a sample of a JWT-based handshake between 2 rails applications using ruby-jwt.

# caller
payload = {
 "iss": "auth_service",
 "exp": Time.now.to_i + 1.minute,
 "aud": "main_application",
 "resources": ["update_user"]
}

# most common attributes, but not limited to these.

#defaults to HMAC
token = JWT.encode(payload, Rails.application.credentials.jwt_secrets.main_application)
# make API call to main_application with token

# callee
# common auth service
token = request.headers['Authorization']
payload = JWT.decode(token, Rails.application.credentials.jwt_secrets.auth_service)
# use payload to authorize the requested resources being accessed

Authenticating JWT dynamically

The code snippet outlined above is a simplified take of how one would authorize API requests. However, as mentioned earlier, larger applications generally talk to a lot of services. To avoid service-to-service dependencies and to maintain a healthy security posture, it is advisable to have unique secrets for each service that the application talks to. To support authorizing the ever-growing list of services, the authentication logic needs to be implicitly generic.

This is where the flexibility of JWT tokens comes into the picture. JWT payloads usually advise carrying an issuer attribute, which points to the original issuer of the token. In this case, this points to our third-party services. We can use this attribute from the payload to identify which service has issued this token when making the API request.

Coming from a traditional authentication system like encryption or hashing, one might argue:  To identify the issuer from the payload, I would first need to decode the payload for which I need the secret. But to get the secret, I need to know the issuer from the payload. This is usually where they reach a deadlock. 

This is also where the versatility of JWT comes to the fore. One does not need the secret to decode a JWT payload. In fact, you can decode any JWT token on JWT’s website without ever needing the secret. However, you must always verify the claims.

Coming back to our requirement of identifying the service using the JWT payload, the ruby-jwt gem allows a block to be passed to the decode method, allowing access to the original payload. The return value of the block would then be used to verify the claim. Our earlier example can now be tweaked slightly to make it generic.

# caller logic does not change
payload = {
  "iss": "auth_service",
  "exp": Time.now.to_i + 1.minute,
  "aud": "main_application",
  "resources": ["update_user"]
} # most common attributes, but not limited to these.

#defaults to HMAC
token = JWT.encode(payload, Rails.application.credentials.jwt_secrets.main_application)
# make API call to main_application with token

# callee
# common auth service
token = request.headers['Authorization']
payload = JWT.decode(token) do |payload|
  Rails.application.credentials.jwt_secrets[payload['iss']]
end
# use payload to authorize the requested resources being accessed

 Rotating JWT secrets

Securing applications is of the utmost importance in today’s digital-first world. In spite of all the preventive measures that organizations undertake, there is never a zero vulnerability guarantee. In such an environment, teams must always be prepared to respond to security incidents.

In the event of security incidents involving secret or data leaks, the leaked tokens or secrets are first rotated to ensure bad actors are not able to fully leverage the exploit. A common problem with rotating secrets in large applications is that it is very hard to coordinate the changes across multiple systems at the same time.

This can also be solved natively with ruby-jwt (recently added to the library), which allows verifying claims against multiple secrets if the finding-a-key block returns an Array. For the first deployment, the application would need to maintain the old and the new secrets in the secrets hash against the issuer. Here’s a sample: 

# multiple secrets are supported for each issuer
secrets = { 'auth_service' => ['old_secret', 'new_secret'] }

JWT.decode(token) do |payload|
  secrets[payload['iss']]
end

Once the downstream services completely switch to the new secret, the application can remove support for the older secret.

# clean up the older secret
secrets = { 'auth_service': 'new_secret' }

JWT.decode(token) do |payload|
  secrets[payload['iss']]
end

Leveraging this approach,  developers can rotate secrets easily in production without impacting live systems.

JWT allows organizations to scale their microservices’ authentication and authorization flows quickly without compromising their security posture. The versatility and flexibility it offers along the way are an added bonus.