DEV Community

Cover image for Dynamic JWT authentication and secrets rotation in Rails Applications
Ritikesh
Ritikesh

Posted on • Updated on

Dynamic JWT authentication and secrets rotation in Rails Applications

Introduction

JWT is one of the most popular authentication & authorization techniques employed in modern applications. There are several articles and guides available on how to get started with JWT in any application or framework. In this post however, we will talk about some lesser talked about 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 API calls. With internal systems however, organisations prefer to use JWT tokens because of their inherent flexibility and versatility. A sample JWT based handshake between 2 rails applications using ruby-jwt would look like this -

# 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 request resources being accessed
Enter fullscreen mode Exit fullscreen mode

Dynamic JWT authentication

The 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 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 come into the picture. JWT payloads usually advise carrying an issuer attribute, which points to the original issuer of the token. In this case, 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. DEADLOCK.

This is 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, it is advisable to 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 request resources being accessed
Enter fullscreen mode Exit fullscreen mode

Rotating JWT secrets

Securing applications is of the utmost importance in today's digital first world. Inspite of all the preventive measures organisations take, 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/data leaks, the leaked tokens/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 (this was 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. Sample below -

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

JWT.decode(token) do |payload|
  secrets[payload['iss']]
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This allows rotating secrets easily in production without impacting live systems.

Discussion (0)