Signing and Encrypting data with Rails

Rails provides abstractions for signing and encrypting data. If a Rails app needs to authenticate some data, (let’s say, to generate a magic link sent by email) Rails.application.message_verifier provides a convenient solution. This application-level interface was added in Rails 4.1, so it is already a mature Rails feature.

The examples below wrap an opaque login token that is associated with a user in the payload.

login_token = SecureRandom.urlsafe_base64(32) # => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"
verifier = Rails.application.message_verifier(:magic_link)

signed_message = verifier.generate(
  { token: login_token },
  expires_in: 30.minutes,
  purpose: :login
) # => "eyJfcmFpbHMiOnsiZGF0YSI6eyJ0b2tlbiI6IjJMZnpDbGVNUXRvTkxZaWo0QWhfeTVNODBMd1hiajN5UHFlb2pzVnZSQlkifSwiZXhwIjoiMjAyNi0wNy0yNlQxMjowODozNS42ODBaIiwicHVyIjoibG9naW4ifX0=--3f9948547736a1650e31c35c6c7b38f518785380"


payload = verifier.verify(signed_message, purpose: :login)
# => {"token" => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"}

Rails.application.message_verifier is a turnkey solution for signing and verifying application data with a MAC-based mechanism. It supports expiration and purpose scoping outside of the data itself. The secret is derived from secret_key_base.

If the data also needs to be encrypted, derive a key with Rails.application.key_generator and use ActiveSupport::MessageEncryptor.

login_token = SecureRandom.urlsafe_base64(32) # => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"
key = Rails.application.key_generator.generate_key(
  "magic_link",
  ActiveSupport::MessageEncryptor.key_len
)
encryptor = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)

encrypted_message = encryptor.encrypt_and_sign(
  { token: login_token },
  expires_in: 30.minutes,
  purpose: :login
) # => "DQ9iOuURrslK9h5/XKZ5ydvVUHIxbz8I9VkNs+wREelATjQK5xcyUq/6m9DbhvNyM334B2EaKeL6WqxCvqD+F0ZtJKH2Sdaw8YbouRyrqinItgbZCbUaCHQKH+zt6a59SDu6Ig3XwpyTfRrGX2JFa8HTD1NiEcAhJtc=--EwOTqF0nJSPO2wXC--alR5gn59ErM2ZW+lpRHgiQ=="

payload = encryptor.decrypt_and_verify(
  encrypted_message,
  purpose: :login
) # => {"token" => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"}

If the data is going to be exchanged with other services, JWT (the suggested pronunciation is /dʒɒt/, like the word “jot”) may be a better choice than Rails’ built-in abstractions.

require "jwt"

login_token = SecureRandom.urlsafe_base64(32) # => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"
jwt_secret = Rails.application.key_generator.generate_key("magic-link", 32)
payload = {
  token: login_token,
  exp: 30.minutes.from_now.to_i,
  aud: "magic-link"
} # => {token: "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY", exp: 1785067955, aud: "magic-link"}

jwt = JWT.encode(payload, jwt_secret, "HS256")
# => "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlbiI6IjJMZnpDbGVNUXRvTkxZaWo0QWhfeTVNODBMd1hiajN5UHFlb2pzVnZSQlkiLCJleHAiOjE3ODUwNjc5NTUsImF1ZCI6Im1hZ2ljLWxpbmsifQ.6W8DnJzgVtGS0CIYlFnaxrShn-ArxJIojLTsPwLjrms"

decoded_payload, header = JWT.decode(
  jwt,
  jwt_secret,
  true,
  algorithm: "HS256",
  aud: "magic-link",
  verify_aud: true,
  required_claims: ["exp"]
) # => [{"token" => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY", "exp" => 1785067955, "aud" => "magic-link"}, {"alg" => "HS256"}]

Use JWE when the JWT claims also need to be encrypted. Use jwe 1.1.1 or newer because earlier versions had an AES-GCM authentication-tag validation vulnerability.

require "jwe"

login_token = SecureRandom.urlsafe_base64(32) # => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY"
jwe_secret = Rails.application.key_generator.generate_key("magic-link:jwe:v1", 32)

payload = {
  token: login_token,
  exp: 30.minutes.from_now.to_i,
  aud: "magic-link"
} # => {token: "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY", exp: 1785067998, aud: "magic-link"}

encrypted_jwt = JWE.encrypt(
  payload.to_json,
  jwe_secret,
  alg: "dir",
  enc: "A256GCM",
  typ: "JWT"
) # => "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwidHlwIjoiSldUIn0..VaueSdODnOaeNh62.XVf1EogA_tWj6yfDabx8Y5xNXhYPI5mVAHy_DnCFMe972w_W660n6rTMmYjcbQas5WVNaKnC2VMruL9skmO4K-gsqMNHRWWdzxlLB5oGyV3BTIK_y-a0wa4F_w.GgA5OtgEep17kDnFleGbqg"

decoded_payload = JSON.parse(JWE.decrypt(encrypted_jwt, jwe_secret))
# => {"token" => "2LfzCleMQtoNLYij4Ah_y5M80LwXbj3yPqeojsVvRBY", "exp" => 1785067998, "aud" => "magic-link"}

raise "Wrong audience" unless decoded_payload.fetch("aud") == "magic-link"
raise "Token has expired" if Integer(decoded_payload.fetch("exp")) <= Time.current.to_i

Handy one-line environment setup to try the examples

docker run --rm -it ruby:4.0 bash -c '
  gem install rails --no-document &&
  rails new /tmp/app \
    --minimal \
    --skip-active-record \
    --skip-git \
    --skip-docker &&
  cd /tmp/app &&
  bundle add jwt --skip-install &&
  bundle add jwe &&
  exec bin/rails console
'

Refs

Gentaro "hibariya" Terada

Otakanomori, Nagareyama, Chiba, Japan
Email me

Likes Ruby, Internet, and Programming.