Signed URLs with Ruby

Posted on 2023-01-16 by Karol Bąk Comments
ruby
rails
security

Introduction

Signed URLs can be a very useful solution in many cases when you need to provide limited access to some resources or actions. Today I’ll focus on when and how to use them in Ruby, with Rails, or by providing a custom implementation.

About Signed URLs

Signed URLs, as the name suggests, contain signatures that allow us to validate if they were generated by a trusted source. What is more, they may expire over time.

They can be used in many cases:

  • account confirmations, password change confirmations, etc. without storing any tokens in DB
  • providing access to resources for not authenticated users (for example, users in the app can generate some reports and share them with anyone with a link)
  • providing access to resources in other apps without shared authentication (example: S3 direct upload) - in this case both services need to use the same secret to sign and verify URLs. What is important, your app doesn’t need to make any calls to API to generate such URLs.

How it works?

Signed URL apart from a standard query usually contains at least two additional params - signature and expires_at. To generate such URL we have to calculate the signature using the rest of the params.

Example:

/reports/50?signature=a6450e8d5049f511930f18c6d897adc31085453cf5bb8e246f35ab0109464499&expires_at=1673620751

Signature is calculated based on resource type - reports, id - 50, and expiry time - 1673620751.

To validate if the URL is valid we generate the signature again and compare it with the one in params.

In another approach (for example in the one implemented in Rails) you can merge all these things into one token. Example:

/reports/share/BAhJIhpyZXBvcnRzLTUwLTE2NzM2MjA5MjAGOgZFVA==--41083ebf51767fb089f0fbaac5b4a6c46638c51e

Th first part of this token (before --) contains information about resource and expiry time:

Marshal.load(Base64.decode64('BAhJIhpyZXBvcnRzLTUwLTE2NzM2MjA5MjAGOgZFVA=='))
=> "reports-50-1673620920"

The second part is just a signature.

Now let’s see how can we generate and validate such URLs in ruby.

Rails Signed ID

If you are using Rails and need to generate a signed URL for one of your models, the easiest way is to use Signed ID (introduced in 6.1).

signed_id = Report.find(50).signed_id(expires_in: 3.hours)
url = "/reports/share/#{signed_id}"

To find a resource and validate the signature:

Report.find_signed(params[:signed_id])
=> #<Report id: 50... 
# now we can allow user to view report

Report.find_signed("some-invalid-string")
=> nil
# signature is invalid, don't allow user to see the report

You can also pass purpose to signed_id method if you generate signed URLs for different actions on your model (for example password_reset and account_confirmation on User).

ActiveSupport::MessageVerifier

Another approach in Rails (or any other app using ActiveSupport) is MessageVerifier. It’s useful if your resource is not a model or you are not using ActiveRecord.

verifier = ActiveSupport::MessageVerifier.new(ENV['MY_SECRET_KEY'])
signed_msg = verifier.generate("reports-50", expires_in: 3.hours)
url = "/reports/share/#{signed_msg}"

Signature validation:

verifier.verified(params[:signed_msg])
=> "reports-50"

verifier.verified("some-invalid-string")
=> nil

verifier.verify(params[:signed_msg])
=> "reports-50"

verifier.verify("some-invalid-string")
=> ActiveSupport::MessageVerifier::InvalidSignature

From the scratch

If you are not using ActiveSupport or for some reason you have to implement it from scratch it’s pretty easy. To keep it simple let’s implement a case when signature is a separate param from resource id and expires_at (/reports/50?signature=...&expires_at=...).

class MessageSigner
  def initialize(key = ENV['MY_SECRET_KEY'])
    @key = key
  end

  def sign(message, expires_at:)
    OpenSSL::HMAC.hexdigest('SHA256', @key, "#{message}--#{expires_at}") # calculate signature
  end

  def verify(message, signature, expires_at:)
    # check if signature is still valid, 
    # you can trust expires_at params since if it was modified by user it'll fail on signature validation
    return false if Time.now.to_i >= expires_at

    expected_signature = sign(message, expires_at: expires_at) # recalculate signature
    Rack::Utils.secure_compare(signature, expected_signature) # use secure_compare to avoid timing attacks
  end
end

Creating signature:

signer = MessageSigner.new
expires_at = Time.now.to_i + 60*60*3
signature = signer.sign("reports-50", expires_at: expires_at)
url = "/reports/50?signature=#{signature}&expires_at=#{expires_at}"

Validating signature:

signer = MessageSigner.new
signer.verify("reports-#{params[:id]}", params[:signature], expires_at: params[:expires_at].to_i)
# allow user to see report if verify method returns true

To implement behavior like in Rails all you have to do is merge message and expires_at (manually to one string or with some serializer like Marshal) and encode it with Base64. You can consider it as your homework :)

Summary

As you can see generating and validating signed URLs in Ruby is really simple with or without Rails. It can be used for many purposes as an alternative to other solutions (temporary tokens in DB - ugh!).