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!).