Securing a Ruby on Rails API with JWTs

Prerequisites

This post assumes you have Ruby and Rails 6 installed. If you don’t, we suggest you follow the steps in the Getting Started with Rails guide. Other than that we presume nothing about your knowledge of Ruby or Rails.

Build the API

To build the API, we’re going to create a new Rails application. Using the --api switch avoids generating a bunch of functionality we won’t need (like views).

rails new hello_api --api
Rails.application.routes.draw do
resources :messages, only: [:index]
end
class MessagesController < ApplicationController
def index
messages = []
messages << "Hello"
render json: { messages: messages }.to_json, status: :ok
end
end
rails s -p 4000
{"messages":["Hello"]}
require 'test_helper'class MessagesTest < ActionDispatch::IntegrationTest
test "can get messages" do
get "/messages"
assert_response :success
end
test "can get messages content" do
get "/messages"
res = JSON.parse(@response.body)
assert_equal '{"messages"=>["Hello"]}', res.to_s
end
end
$ rails test test/integration/messages_test.rb
Running via Spring preloader in process 15492
Run options: --seed 1452
# Running:..Finished in 0.119373s, 16.7542 runs/s, 16.7542 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Secure the API

As a reminder, we’re going to use a JWT to secure this API. While you can secure Rails APIs using a variety of methods, using a JWT has certain advantages. You can integrate with a number of identity providers offering OAuth or SAML support. This allows you to leverage an existing robust identity management system to control API access. You can also embed additional metadata into a JWT, including attributes like roles.

# ...
gem 'jwt'
bundle install
class MessagesTest < ActionDispatch::IntegrationTest
test "can' get messages with no auth" do
get "/messages"
assert_response :forbidden
end
test "can get messages with header" do
get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt }
assert_response :success
end
test "expired jwt fails" do
get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt(-1) }
assert_response :forbidden
end
test "can get messages content" do
get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt }
res = JSON.parse(@response.body)
assert_equal '{"messages"=>["Hello"]}', res.to_s
end
def build_jwt(valid_for_minutes = 5)
exp = Time.now.to_i + (valid_for_minutes*60)
payload = { "iss": "fusionauth.io",
"exp": exp,
"aud": "238d4793-70de-4183-9707-48ed8ecd19d9",
"sub": "19016b73-3ffa-4b26-80d8-aa9287738677",
"name": "Dan Moore",
"roles": ["USER"]
}
JWT.encode payload, Rails.configuration.x.oauth.jwt_secret, 'HS256' end
end
# ...
def build_jwt(valid_for_minutes = 5)
exp = Time.now.to_i + (valid_for_minutes*60)
payload = { "iss": "fusionauth.io",
"exp": exp,
"aud": "238d4793-70de-4183-9707-48ed8ecd19d9",
"sub": "19016b73-3ffa-4b26-80d8-aa9287738677",
"name": "Dan Moore",
"roles": ["USER"]
}
JWT.encode payload, Rails.configuration.x.oauth.jwt_secret, 'HS256'
# ...
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJmdXNpb25hdXRoLmlvIiwiZXhwIjoxNTkwMTgxNjE5LCJhdWQiOiIyMzhkNDc5My03MGRlLTQxODMtOTcwNy00OGVkOGVjZDE5ZDkiLCJzdWIiOiIxOTAxNmI3My0zZmZhLTRiMjYtODBkOC1hYTkyODc3Mzg2NzciLCJuYW1lIjoiRGFuIE1vb3JlIiwicm9sZXMiOlsiVVNFUiJdfQ.P7KXBV8fNElGGr1McKIMQbU7-mZPMxv8tw5AbufZgr0
class ApplicationController < ActionController::API
before_action :require_jwt
def require_jwt
token = request.headers["HTTP_AUTHORIZATION"]
if !token
head :forbidden
end
if !valid_token(token)
head :forbidden
end
end
private
def valid_token(token)
unless token
return false
end
token.gsub!('Bearer ','')
begin
decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true
return true
rescue JWT::DecodeError
Rails.logger.warn "Error decoding the JWT: "+ e.to_s
end
false
end
end

Verify claims

But really, what does valid mean? That’s something developers define on an application by application basis, though the jwt gem provides a baseline: it checks the exp and nbf claims and verifies the signature.

# ...
decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true
# ...
# ...
expected_iss = 'fusionauth.io'
expected_aud = '238d4793-70de-4183-9707-48ed8ecd19d9'
# ...
decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true, { verify_iss: true, iss: expected_iss, verify_aud: true, aud: expected_aud, algorithm: 'HS256' }
# ...

Take it further

If you are interested in extending this example, make the API more realistic. Create a Messages model and store them in the database. Change your claims to include a preferred greeting, and prepend that to any messages. Add more API endpoints and only allow users with certain roles to access them.

Next steps

You’ll notice we never specified the source of the JWT. We just generated one using the jwt gem. In general, tokens are provided by an authentication process. Integrating a user identity store, such as FusionAuth, to provide such tokens is what we’ll tackle next.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
FusionAuth

FusionAuth

49 Followers

FusionAuth solves the problem of building essential user security without distracting from the primary application.