This library signs and verifies HTTP requests and responses in Clojure and ClojureScript with HTTP Message Signatures (RFC 9421). It also implements Digest Fields and structured fields, which those signatures build on, as well as the older draft-cavage signatures that most of the fediverse still sends.
This library was proposed for podcast-clj, which reads comment threads from servers that answer only signed requests, and for federating a blog without a bridge. Like podcast-clj, it was developed with assistance from frontier LLMs.
The library needs Clojure 1.11+ and Java 11+ (15+ for Ed25519). In
ClojureScript it needs Node 22+ or a browser with WebCrypto. Add the latest
release from Clojars to the
:deps of your deps.edn:
dk.simongray/httpsig-clj {:mvn/version "0.1.1"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead. In shadow-cljs a Git dependency only works through
deps.edn, so set :deps true in your shadow-cljs.edn too.
A message is a request or response map like those of
wary-fetch. A key is a map with
a :keyid, an :alg and the key material. Make a key with
httpsig/generate-key, and publish its :public-pem, e.g. in an actor
document:
(require '[dk.simongray.httpsig :as httpsig])
(def actor-key
(httpsig/generate-key "rsa-v1_5-sha256"
"https://example.com/actor#main-key"))
(def signed
(httpsig/sign {:method :post
:url "https://social.example/inbox"
:headers {"Content-Type" "application/activity+json"}
:body "{\"type\": \"Follow\"}"}
actor-key))
(:headers signed)
;; => {"Content-Type" "application/activity+json"
;; "Content-Digest" "sha-256=:fVZGtwrwBZt50q8S3vzvkc1eYYz3eybgE…:"
;; "Signature-Input" "sig1=(\"@method\" \"@target-uri\" \"content-digest\");created=…"
;; "Signature" "sig1=:fN+cY1uVYoT0RXJKjzUT80zgGPBPsf3/XeR5Z…:"}
The key material is one of these:
:pem, a key in PEM: SPKI or PKCS#8, or the older PKCS#1 and SEC1:jwk, a JWK as a map:secret, the bytes or the text of a shared secret for HMAC:key, a webcrypto-clj key, e.g. a CryptoKey that a page keeps
unextractableYou can hide a PEM or a secret with wary-fetch's secret/hide, so that it
prints as #<secret>. Without an :alg, the algorithm follows from the
kind of key, e.g. rsa-v1_5-sha256 for RSA as in Mastodon, and
rsa-pss-sha512 for a key that OpenSSL marks as RSASSA-PSS.
By default the signature of a request covers its method and URL, and that
of a response its status. When you pass the :request that a response
answers, its signature covers the request's method and URL too, so that
nobody can pass it off as the answer to another request. Both cover a
digest of the body when there is one. Give :components to cover more,
e.g. ["@method" "@target-uri" "content-type" "content-digest"]. The
docstring of httpsig/sign has the other options.
Verify a message with a function that takes the parameters of a signature and returns its key, or nil for a key that you don't know or trust. It can also return a future or a promise, e.g. of a key that it fetches:
(httpsig/verify signed (fn [{:keys [keyid]}] (key-of keyid)))
;; => {:label "sig1"
;; :keyid "https://example.com/actor#main-key"
;; :alg "rsa-v1_5-sha256"
;; :key {…}
;; :components ["@method" "@target-uri" "content-digest"]
;; :created #inst "2026-10-14T06:45:08.000-00:00"}
When a signature fails, the map has an :error instead, e.g. for a body
that someone changed:
(httpsig/verify (assoc signed :body "{}") (constantly actor-key))
;; => {:label "sig1"
;; :keyid "https://example.com/actor#main-key"
;; :error :dk.simongray.httpsig/digest-mismatch}
The docstring of httpsig/verify lists the errors and the options, e.g.
which components a signature has to cover. The defaults follow sections 3.2
and 7 of the RFC. Replay protection needs a store, so check each result's
:nonce against your own.
Every fediverse server accepts draft-cavage signatures, but only some
accept those of RFC 9421. The cavage
namespace signs and verifies the draft as they use it, with the same key
maps and errors:
(require '[dk.simongray.httpsig.cavage :as cavage])
(get-in (cavage/sign {:url "https://social.example/users/ann"} actor-key)
[:headers "Signature"])
;; => "keyId=\"https://example.com/actor#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"wMt3zh8hzdBoBaoXh67w…\""
To send requests that a server in authorized-fetch mode answers, wrap
wary-fetch's send! in a sender. It signs each request, and signs each
hop of a redirect again for that hop's URL. It signs a hop to another
server only when it's a GET or a HEAD, so that a server can't redirect a
signed POST wherever it likes. When the server answers 400 or 401, the
sender signs the request with the next signer and sends it again:
(require '[dk.simongray.wary-fetch :as http])
(def send-signed!
(httpsig/sender http/send! actor-key {:signers [cavage/sign httpsig/sign]}))
(send-signed! {:url "https://social.example/users/ann/statuses/1"
:headers {"Accept" "application/activity+json"}})
This order works without remembering which server accepts which signature. Trying RFC 9421 first would fail silently with Misskey and Sharkey, which answer a bad signature with a stripped-down actor and a 200. A server checks a signature by fetching the actor of its keyid, so publish that actor where anyone can read it without a signature.
The ring namespace verifies Ring
requests. Use ring/verified in a Pedestal interceptor, or the middleware
ring/wrap-verify, which answers 401 to a signature that fails:
(require '[dk.simongray.httpsig.ring :as ring])
(def inbox
(ring/wrap-verify inbox-handler
actor-key-of
{:origin "https://example.com"
:cavage? true}))
The handler gets the result under :dk.simongray.httpsig/signature. It
can read the body again. Behind a proxy that ends TLS, the URL that Ring
sees isn't the one the client signed, so give the public :origin.
Without it, the URL comes from the Host header, which may hold only a
host name and a port. With :cavage?, the middleware checks a request
without a Signature-Input as draft-cavage.
The digest namespace computes a
Content-Digest or a Repr-Digest with sha-256 or sha-512, the algorithms
that RFC 9530 doesn't deprecate, and checks a Content-Digest:
(require '[dk.simongray.httpsig.digest :as digest])
(digest/field-value "{\"hello\": \"world\"}")
;; => "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
Use digest/verify to check a message's Content-Digest against its body,
and digest/wanted for the algorithms that a Want-Content-Digest asks for.
The sfv namespace parses and
serializes the structured fields of RFC 9651. A parsed field is plain
data, with each item a pair of its value and its parameters:
(require '[dk.simongray.httpsig.sfv :as sfv])
(sfv/parse :dictionary "sig1=(\"@method\" \"@path\");created=1618884473")
;; => [["sig1" [[["@method" []] ["@path" []]] [["created" 1618884473]]]]]
A token is a symbol and a byte sequence is bytes. Make a decimal with
sfv/decimal, since JavaScript can't tell 1.0 from 1, and a display
string with sfv/display-string, to tell it apart from a string.
WebCrypto's sign and verify return promises. So in ClojureScript,
sign, verify and the functions that call them return promises, while
on the JVM they return values. To write code for both at once, chain them
with wary-fetch's async/then. Everything that needs no key is
synchronous everywhere, e.g. digests, structured fields and signature
bases.
A page served over plain http has no WebCrypto. A browser's fetch also forbids a Date field, so draft-cavage signing works only on the JVM and in Node.
.cljc and passes the same
tests on the JVM, in Node and in a browser.dev/fetch.sh # once, for the structured-field tests
clojure -X:test # the tests on the JVM
npm install # once, for the tests in JavaScript
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # the tests for a browser,
node test/browser-run.mjs target/browser-test # run in a headless Chrome
The fetch script downloads the HTTP working group's structured-field
tests at a pinned commit into dev-resources/, which Git ignores. The
examples of RFC 9421 and of draft-cavage are in the tests. Two other RFC
9421 libraries check this one with random requests,
Visma's on the JVM and
dhensby's in
Node: each verifies what the other signs.
The public namespaces are the root, cavage, ring, digest and sfv.
The internal ones are under impl:
impl.components, the components and the signature baseimpl.keys, the keys and the algorithms in webcrypto-clj's termsimpl.sfv, the parsing and serializing of structured fieldsimpl.message, the fields and the body of a messageimpl.date, the time now, seconds since the epoch, and IMF-fixdateimpl.async, values on the JVM and promises in ClojureScriptThe httpsig-clj project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |