HTTP Signature
Create and validate HTTP Message Signatures per RFC 9421 using the Signature-Input and Signature headers.
TL;DR: You specify what should be signed in Signature-Input with components and lowercase headers. And then the signature is in the Signature header
Example:
Signature-Input: sig1=("@method" "@target-uri" "date");created=1767816111;keyid="Test";alg="hmac-sha256"
Signature: sig1=:7a1ajkE2rOu+gnW3WLZ4ZEcgCm3TfExmypM/giIgdM0=:
Installation
bundle add http_signatureUsage
Create signature
HTTPSignature.create returns both Signature-Input and Signature headers that you can include in your request.
headers = { "date" => "Tue, 20 Apr 2021 02:07:55 GMT" }
sig_headers = HTTPSignature.create(
url: "https://example.com/foo?pet=dog",
method: :get,
key_id: "Test",
key: "secret",
headers: headers,
components: %w[@method @target-uri date]
)
request["Signature-Input"] = sig_headers["Signature-Input"]
request["Signature"] = sig_headers["Signature"]All options
HTTPSignature.create(
url: "https://example.com/foo?pet=dog",
method: :get,
key_id: "Test",
key: "secret",
# Optional arguments
headers: headers, # Default: {}
body: "Hello world", # Default: ""
components: %w[@method @target-uri date], # Default: %w[@method @target-uri content-digest content-type]
created: Time.now.to_i, # Default: Time.now.to_i
expires: Time.now.to_i + 600, # Default: nil
nonce: "1", # Default: nil
label: "sig1", # Default: "sig1",
query_string_params: {pet2: "cat"} # Default: {}, you can pass query string params both here and in the `url` param
algorithm: "hmac-sha512" # Default: "hmac-sha256"
)Supported components
Derived components (prefixed with @) per RFC 9421 Section 2.2:
| Component | Description |
|---|---|
@method |
HTTP method (e.g., GET, POST) |
@target-uri |
Full request URI (https://example.com/foo?bar=1) |
@authority |
Host (and port if non-default) |
@scheme |
URI scheme (http or https) |
@path |
Request path (/foo) |
@query |
Query string (including ?; ?bar=1) |
@status |
Response status code (responses only) |
Any lowercase header name (e.g., content-type, date) can also be used as a component.
Default components are: @method @target-uri content-digest content-type
Validate signature
Call valid? with the incoming request headers (including Signature-Input and Signature)
HTTPSignature.valid?(
url: "https://example.com/foo",
method: :get,
headers: headers,
key: "secret"
)
# Returns true when all is good.
# Raises `SignatureError` for invalid signaturesLimiting signature age
Use max_age to reject signatures older than a specified number of seconds, regardless of the signature's expires parameter. This helps protect against replay attacks.
HTTPSignature.valid?(
url: "https://example.com/foo",
method: :get,
headers: headers,
key: "secret",
max_age: 300 # Reject signatures older than 5 minutes
)
# Raises `ExpiredError` if the signature was created more than 300 seconds agoOutgoing request examples
NET::HTTP
require "net/http"
require "http_signature"
uri = URI("http://example.com/hello")
Net::HTTP.start(uri.host, uri.port) do |http|
request = Net::HTTP::Get.new(uri)
sig_headers = HTTPSignature.create(
url: request.uri,
method: request.method,
headers: request.each_header.map { |k, v| [k, v] }.to_h,
key: "MYSECRETKEY",
key_id: "KEY_1",
algorithm: "hmac-sha256",
body: request.body || ""
)
request["Signature-Input"] = sig_headers["Signature-Input"]
request["Signature"] = sig_headers["Signature"]
response = http.request(request) # Net::HTTPResponse
endFaraday
As a faraday middleware
require "http_signature/faraday"
HTTPSignature::Faraday.key = "secret"
HTTPSignature::Faraday.key_id = "key-1"
Faraday.new("http://example.com") do |faraday|
faraday.use(HTTPSignature::Faraday)
faraday.adapter(Faraday.default_adapter)
end
# Now this request will contain the `Signature-Input` and `Signature` headers
response = conn.get("/")
# Request looking like:
# Signature-Input: sig1=("@method" "@authority" "@target-uri" "date");created=...
# Signature: sig1=:BASE64_SIGNATURE:Incoming request examples
Rack middleware
Rack middlewares sits in between your app and the HTTP request and validate the signature before hitting your app. Read more about rack middlewares here.
Here is how it could be used with sinatra:
require "http_signature/rack"
HTTPSignature.configure do |config|
config.keys = [
{id: "key-1", value: "MySecureKey"}
]
end
HTTPSignature::Rack.exclude_paths = ["/", "/hello/*"]
use HTTPSignature::Rack
run MyAppRails
Opt-in per controller/action using a before_action. It responds with 401 Unauthorized if the signature is invalid
# app/controllers/api/base_controller.rb
require "http_signature/rails"
class Api::BaseController < ApplicationController
include HTTPSignature::Rails::Controller
before_action :verify_http_signature!
endSet the keys in an initializer
# config/initializers/http_signature.rb
HTTPSignature.configure do |config|
config.keys = [
{id: "key-1", value: "MySecureKey"}
]
endDevelopment
Install dependencies and then you can start running the tests!
bundle install
Test
The tests are written with minitest using specs. Run them all with rake:
rake testOr a single with pattern matching:
rake test TEST=test/http_signature_test.rb TESTOPTS="--name=/appends\ the\ query_string_params/"License
This project is licensed under the terms of the MIT license.
Why/when should I use this?
When you need to make sure that the request or response has not been tampered with (integrity). And you can be sure that the request was sent by someone that had the key (authenticity). Don't confuse this with encryption, the signed message is not encrypted. It's just signed. You could add a layer of encryption on top of this. Or just use HTTPS and you're kinda safe for not that much hassle, which is totally fine in most cases.
Read more about HMAC here, even though you can sign your messages with RSA as well, but it's the same principle.
Beware that this has not been audited and should be used at your own risk!