Secure your API Keys with JWT

A static, never-changing API key poses a security risk - it's essentially an obfuscated primary key.

If a static API key is ever leaked - for example, in a log file or you accidentally use HTTP - then anyone can act on behalf of you indefinitely until the API key is changed.

Thankfully, JWT provides a simple solution to prevent sending a static API token as an authorization mechanism.

Note: you really should be using an authorization mechanism like OAuth2 instead of hand-rolling API keys. But if you can't use OAuth2, JWT is way better than a static API key.

JSON Web Tokens

Taking from the ruby-jwt docs, you can securely sign a user ID with the shared secret using JWT like so:

secret = 'my$ecretK3y'
payload = { data: JSON.to_string(user_id: 1234) }
token = JWT.encode(payload, hmac_secret, 'HS256')

# i.e. "eyJhbGciOiJIUzI1NiJ9.eyJkYXRhI..."
puts token

# Since we're hand-rolling a custom authorization scheme,
# make sure to set a the Authorization type to Custom-Jwt'https://example.api', 
  headers: { 'Authorization' => "Custom-Jwt #{token}" })

The major benefit of this approach is that the secret key is never sent between the client and server. Though a downside is that both parties need to share a secret in advance.

Now the client can securely decode the  token, and get the authenticated user ID:

# Make sure you limit the algorithms supported when receiving
# from the client, to ensure they aren't using a weak algorithm 
# or no algorithm!

token = authorization_header.replace("Custom-JWT ", "")
decoded = JWT.decode(token, secret, { 

# decoded => [
#  { "data" => '{"user_id":1234}' }, # payload
#  { "alg" => 'HS256' } # header
# ]

authenticated_user_id = JSON.parse(decoded[0]["data"])["user_id"]

Expiring Tokens

Our current approach allows an attacker who has found one of these JWT tokens to keep on using it over and over again. A simple way around this is to support an expiration time:

expires_at = + 60 # Expire in 1 minute
payload = {
  data: JSON.to_string({ user_id: 1234 }),
  exp: expires_at

The JWT gem automatically checks the exp field, but doesn't require it to be present, or ensure it's not too far in the future. So you need to modify your server code with the following:

payload = JWT.decode(...)[0]
raise 'exp not given' unless payload['exp'].present?
if payload['exp'] > + 120
  raise 'exp too far in the future' 

In the event an attacker found a token, it will only be valid for 1 minute.

One-Time Use Tokens

For even greater security, you can create one-time use tokens via the use of the jti field.

A client can send a random jit header that is highly unlikely to collide within the expiration window:

payload = {
  # ... existing fields
  jti: rand(2 << 64).to_s

Using this example from the ruby-jwt gem, we can use Redis provide a method to validate that a jti has not been seen yet:

def validate_jti_used_once(jti, payload)
  # todo: check that jti is a value suitable in redis, i.e. just a number
  redis_expire_at = - payload['exp']
  exists = @redis.set("jti.#{jti}", true, 
    nx: true, ex: redis_expire_at)

If the expiration of the Redis key is the same as when the JWT expires, and you have a limit to how far in the future a JWT key can expire, you probably don't have to worry about your Redis cluster storing too much information.

Share this article: Link copied to clipboard!

You might also like...

Secure your API Keys with JWT

DRY Your JSON APIs with Rails