Linzer

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.
Install
Add the following line to your Gemfile:
gem "linzer"
Or just gem install linzer.
Usage
Quick start
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.
Using a configuration file
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"
Rails
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"
What this does?
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.
Next steps
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.
Signing HTTP requests and responses
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..."
Using Net::HTTP (manual control)
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>
Using the built-in client
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).
Signing HTTP responses (server-side)
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\"..."
...
end
Verifying HTTP signatures
Linzer 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.
Recommended: Rack middleware
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.)
Manual verification (controller / route level)
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
end
If 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.
end
end
Dynamic key lookup
In 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
end
This is useful when:
- you have multiple clients
- keys are stored in a database or external service
- keys rotate over time
Verifying responses (client-side)
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)
# => true
Using a custom HTTP library
If 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)
# => true
Advanced verification
For 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.
Verifying a signature manually
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
= Linzer::Message.new(request)
signature = Linzer::Signature.build(.headers)
Linzer.verify(pubkey, , signature)
# => true
Preventing replay attacks
To 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, , 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.
Supported algorithms
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.
Documentation
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.
Ruby version compatibility
linzer is built in Continuous Integration on Ruby 3.0+.
Security
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.
Development
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.
Contributing
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.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Linzer project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.