Linzer is a Ruby library for HTTP Message Signatures (RFC 9421), allowing you to sign and verify HTTP requests and responses with standard-compliant cryptographic signatures.
Useful for APIs, webhooks, and services that need to verify request authenticity or prevent tampering.
Add the following line to your Gemfile:
gem "linzer"Or just gem install linzer.
Add the middleware to your Rack application:
# config.ru
use Rack::Auth::Signature,
except: "/login",
default_key: {
material: Base64.strict_decode64(ENV["MYAPP_KEY"]),
alg: "hmac-sha256"
}
# or using a public/private key pair:
# default_key: { material: IO.read("app/config/pubkey.pem"), alg: "ed25519" }In this example, the middleware requires a valid HTTP Message Signature for all endpoints except /login.
To learn how to sign requests, see the Signing Requests section.
For more complex setups, you can load configuration from a file, e.g.:
# config.ru
use Rack::Auth::Signature,
except: "/login",
config_path: "app/configuration/http-signatures.yml"In a Rails application, add the middleware in your configuration:
# config/application.rb
config.middleware.use Rack::Auth::Signature,
except: "/login",
config_path: "http-signatures.yml"Once enabled, all protected routes will require a valid signature generated by a client using the corresponding private key. Requests without a valid signature will be rejected.
-
See a full configuration example: examples/sinatra/http-signatures.yml
-
Browse the middleware implementation for all options: lib/rack/auth/signature.rb
-
For more specific scenarios and use cases, continue below.
Linzer signs HTTP requests by adding the required Signature and
Signature-Input headers based on selected request components (e.g.
method, path, headers, etc).
Choose your client:
- Use the http gem → recommended (simplest)
- Use
Net::HTTP→ lower-level control - Use
Linzer::HTTP→ quick experiments / debugging
Using http gem
# first require http signatures feature class ready to be used with http gem:
require "linzer/http/signature_feature"
key = Linzer.generate_ed25519_key # generate a new key pair
# => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
# or load an existing key with:
# key = Linzer.new_ed25519_key(IO.read("key"), "mykeyid")
# then send the request:
url = "https://example.org/api"
response = HTTP.headers(date: Time.now.to_s, foo: "bar")
.use(http_signature: {key: key} # <--- covered components
.get(url) # and signature params can also be customized on the client
=> #<HTTP::Response/1.1 200 OK {"Content-Type" => ...
response.body.to_s
=> "protected content..."key = Linzer.generate_ed25519_key
# => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
uri = URI("https://example.org/api/task")
request = Net::HTTP::Get.new(uri)
request["date"] = Time.now.to_s
Linzer.sign!(
request,
key: key,
components: %w[@method @request-target date],
label: "sig1",
params: {
created: Time.now.to_i
}
)
request["signature"]
# => "sig1=:Cv1TUCxUpX+5SVa7pH0Xh..."
request["signature-input"]
# => "sig1=(\"@method\" \"@request-target\" \"date\" ..."}Then send the request:
require "net/http"
http = Net::HTTP.new(uri.host, uri.port)
http.set_debug_output($stderr)
response = http.request(request)
# opening connection to localhost:9292...
# opened
# <- "POST /some_uri HTTP/1.1\r\n
# <- Date: Fri, 23 Feb 2024 17:57:23 GMT\r\n
# <- X-Custom-Header: foo\r\n
# <- Signature: sig1=:Cv1TUCxUpX+5SVa7pH0X...
# <- Signature-Input: sig1=(\"date\" \"x-custom-header\" \"@method\"...
# <- Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\n
# <- Accept: */*\r\n
# <- User-Agent: Ruby\r\n
# <- Connection: close\r\n
# <- Host: localhost:9292
# <- Content-Length: 4\r\n
# <- Content-Type: application/x-www-form-urlencoded\r\n\r\n"
# <- "data"
#
# -> "HTTP/1.1 200 OK\r\n"
# -> "Content-Type: text/html;charset=utf-8\r\n"
# -> "Content-Length: 0\r\n"
# -> "X-Xss-Protection: 1; mode=block\r\n"
# -> "X-Content-Type-Options: nosniff\r\n"
# -> "X-Frame-Options: SAMEORIGIN\r\n"
# -> "Server: WEBrick/1.8.1 (Ruby/3.2.0/2022-12-25)\r\n"
# -> "Date: Thu, 28 Mar 2024 17:19:21 GMT\r\n"
# -> "Connection: close\r\n"
# -> "\r\n"
# reading 0 bytes...
# -> ""
# read 0 bytes
# Conn close
# => #<Net::HTTPOK 200 OK readbody=true>key = Linzer.generate_rsa_pss_sha512_key(4096)
uri = URI("https://example.org/api/task")
headers = {"date" => Time.now.to_s}
response =
Linzer::HTTP
.post("http://httpbin.org/headers",
data: "foo",
debug: true,
key: key,
headers: headers)
...
=> #<Net::HTTPOK 200 OK readbody=true>(This client is intended for testing and exploration. For production use, prefer a full-featured HTTP client).
You can sign responses using the same API as for requests, e.g.:
put "/baz" do
...
response
# => #<Sinatra::Response:0x0000000109ac40b8 ...
response.headers["x-custom-app-header"] = "..."
Linzer.sign!(response,
key: my_key,
components: %w[@status content-type content-digest x-custom-app-header],
label: "sig1",
params: {
created: Time.now.to_i
}
)
response["signature"]
# => "sig1=:2TPCzD4l48bg6LMcVXdV9u..."
response["signature-input"]
# => "sig1=(\"@status\" \"content-type\" \"content-digest\"..."
...
endLinzer verifies incoming requests (or responses) by checking:
- the signature is valid for the given key
- the signed components match the actual request
- any signature parameters (e.g. created, expires) are valid
If verification fails, an exception is raised explaining the reason.
The easiest way to verify incoming requests is via middleware:
use Rack::Auth::Signature, except: "/login"This automatically:
- verifies all incoming requests
- rejects invalid or unsigned requests
- integrates cleanly with Rack-based frameworks (Rails, Sinatra, etc.)
If you need more control, you can verify incoming requests manually:
post "/foo" do
request
# =>
# #<Sinatra::Request:0x000000011e5a5d60
# @env=
# {"GATEWAY_INTERFACE" => "CGI/1.1",
# "PATH_INFO" => "/api",
# ...
result = Linzer.verify!(request, key: some_client_key) rescue false
# => true
...
# proceed with trusted request
endIf the signature is missing or invalid, verify! will raise an exception.
head "/bar" do
begin
Linzer.verify!(request, key: key)
rescue Linzer::VerifyError => e
halt 401, e.message
end
endIn many cases, the verification key depends on the keyid parameter provided in
the signature.
You can supply a block to resolve keys dynamically:
get "/bar" do
...
result = Linzer.verify!(request) do |keyid|
retrieve_pubkey_from_db(db_client, keyid)
end
# => true
...
# request is now verified
endThis is useful when:
- you have multiple clients
- keys are stored in a database or external service
- keys rotate over time
As expected, signed responses are verified using the same API shown previously:
...
response
# => #<Net::HTTPOK 200 OK readbody=true>
response.body
# => "protected"
pubkey = Linzer.new_ed25519_key(IO.read("pubkey.pem"))
result = Linzer.verify!(response, key: pubkey, no_older_than: 600)
# => trueIf you’re using an HTTP library or framework other than Rack, http gem or
Net::HTTP, you can plug in your own adapter with very little effort.
In most cases, implementing an adapter just means mapping your library’s request/response objects to the small interface Linzer expects, then registering it.
To do this:
- implement a simple adapter for your request/response objects
- register it with
Linzer::Message
You can use the existing adapters as references:
Example of how to register an adapter before using a custom HTTP library:
Linzer::Message.register_adapter(HTTP::Response, Linzer::Message::Adapter::HTTPGem::Response)
# Linzer::Message.register_adapter(HTTP::Response, MyOwnResponseAdapter) # or use your own adapter
response = HTTP.get("http://www.example.com/api/service/task")
# => #<HTTP::Response/1.1 200 OK ...
response["signature"]
=> "sig1=:oqzDlQmfejfT..."
response["signature-input"]
=> "sig1=(\"@status\" \"foo\");created=1746480237"
result = Linzer.verify!(response, key: my_key)
# => trueFor low-level control over signing and verification, Linzer
exposes internal message and signature objects. This allows
you to work directly with Linzer::Message and Linzer::Signature,
or integrate custom HTTP adapters if needed.
test_ed25519_key_pub = key.material.public_to_pem
# => "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAK1ZrC4JqC356pRs..."
pubkey = Linzer.new_ed25519_public_key(test_ed25519_key_pub, "some-key-ed25519")
# => #<Linzer::Ed25519::Key:0x00000fe19b9384b0
message = Linzer::Message.new(request)
signature = Linzer::Signature.build(message.headers)
Linzer.verify(pubkey, message, signature)
# => trueTo reduce the risk of replay attacks (e.g. reusing a captured
valid request), you can validate the created timestamp in the signature.
Linzer supports this via the no_older_than option:
Linzer.verify(pubkey, message, signature, no_older_than: 500)no_older_than expects a number of seconds, but you can pass
anything that to responds to #to_i, including an ActiveSupport::Duration.
If the signature is older than the allowed window, verification fails with an error.
Linzer currently supports the following signature algorithms:
- RSASSA-PSS (SHA-512)
- RSASSA-PKCS1-v1_5 (SHA-256)
- HMAC-SHA256
- Ed25519
- ECDSA (P-256 and P-384 curves).
Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421, only Ed25519 is currently supported. Support for additional algorithms is planned and should be straightforward to add.
The goal is to support as much of the RFC as possible before the 1.0 release.
The codebase is well-documented, and the Ruby API documentation is available on Rubydoc.
For deeper details or edge cases, the source code and unit tests are also a good reference.
linzer is built in Continuous Integration on Ruby 3.0+.
Note
Ruby 3.0 is supported and tested in CI, but RSA-based signature algorithms (RSA-PSS and RSA PKCS#1 v1.5) may not work correctly due to its older OpenSSL bindings. If you need RSA algorithms, use Ruby 3.1 or later. Ruby 3.0 has been EOL since March 2024 — users are advised to upgrade to a supported Ruby release.
This gem is provided “as is” without any warranties. It has not been independently audited for security vulnerabilities. Users are advised to review the code and assess its suitability for their use case, particularly in production environments.
Despite this, Linzer is already used in production by other projects with security-sensitive requirements, including Mastodon (since version 4.5.0). This does not constitute a security guarantee or endorsement, but it may be useful context when evaluating adoption.
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/nomadium/linzer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Linzer project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.