Signing and Encrypting data with Rails
Posted on by Gentaro "hibariya" Terada
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
- https://edgeapi.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html
- https://edgeapi.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html
- https://edgeapi.rubyonrails.org/classes/Rails/Application.html#method-i-key_generator
- https://guides.rubyonrails.org/4_1_release_notes.html#message-verifiers
- https://github.com/jwt/ruby-jwt
- https://github.com/jwt/ruby-jwe
- https://github.com/jwt/ruby-jwe/security/advisories/GHSA-c7p4-hx26-pr73