http-message-signatures is an implementation of the IETF RFC 9421 HTTP Message Signatures standard in Python.
Security considerations
It is recommended that you read and understand section 7 of the RFC, Security Considerations before using this library.
pip install http-message-signatures
import requests
from http_message_signatures import HTTPMessageSigner, HTTPMessageVerifier, HTTPSignatureKeyResolver, algorithms
class MyHTTPSignatureKeyResolver(HTTPSignatureKeyResolver):
keys = {"my-key": b"top-secret-key"}
def resolve_public_key(self, key_id: str):
return self.keys[key_id]
def resolve_private_key(self, key_id: str):
return self.keys[key_id]
request = requests.Request('POST', 'https://example.com/foo?param=Value&Pet=dog', json={"hello": "world"})
request = request.prepare()
signer = HTTPMessageSigner(signature_algorithm=algorithms.HMAC_SHA256, key_resolver=MyHTTPSignatureKeyResolver())
signer.sign(request, key_id="my-key", body=request.body)
verifier = HTTPMessageVerifier(signature_algorithm=algorithms.HMAC_SHA256, key_resolver=MyHTTPSignatureKeyResolver())
verifier.verify(request, body=request.body)Signing Content-Digest and checking it against the body protects the body against tampering.
Supply content bytes before decoding as body=. With this argument, sign() generates Content-Digest if absent
and adds digest coverage to the signature, while verify() checks the signed digest against those bytes.
When signing, components covered by the signature can be customized using covered_component_ids, for example
covered_component_ids=("@method", "@authority", "@target-uri", "content-type", "content-digest").
Signing a message with an existing Content-Digest requires use_existing_digest=True on sign() or
prepare(). With body= supplied, these methods validate the existing digest against those bytes and add digest
coverage to the signature. Without body=, they check only the digest's format and algorithms; you must ensure
the digest matches the content and include it in covered_component_ids to sign it. Both methods preserve existing
digest field lines and raise an error if the digest is malformed or does not match supplied content.
They also raise an error if you supply use_existing_digest=True with neither a digest nor body bytes.
See what is signed
It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP message signatures. The gist of this rule is: if your application neglects to verify that the information it trusts is what was actually signed, the attacker can supply a valid signature but point you to malicious data that wasn't signed by that signature. Failure to follow this rule can lead to vulnerability against signature wrapping and substitution attacks.
In http-message-signatures, you can ensure that the information signed is what you expect to be signed by only trusting the
data returned by the verify() method:
verify_results = verifier.verify(request, body=request.body)
This returns a list of VerifyResult s, which are namedtuple s with the following attributes:
- label (str): The label for the signature
- algorithm: (same as signature_algorithm above)
- covered_components: A mapping of component names to their values, as covered by the signature
- parameters: A mapping of signature parameters to their values, as covered by the signature
- body: Content bytes that the verifier validated against a signed digest, before decoding; otherwise
None.
Three integrations are available: Requests, HTTPX2, and aiohttp.
See https://github.com/pyauth/requests-http-signature.
Install the optional dependency:
pip install 'http-message-signatures[httpx2]'
http_message_signatures.httpx2.SignatureHooks signs requests and verifies responses through HTTPX2 event hooks.
Configure signing and verification with sign_options and verify_options. This example loads keys from
application key stores:
import httpx2
from http_message_signatures import HTTPSignatureKeyResolver, algorithms
from http_message_signatures.httpx2 import SignatureHooks, SignatureHooksMode
class ClientKeyResolver(HTTPSignatureKeyResolver):
def resolve_private_key(self, key_id):
return signing_key_store.load_private_key(key_id)
class ServerKeyResolver(HTTPSignatureKeyResolver):
def resolve_public_key(self, key_id):
return trusted_key_store.load_public_key(key_id)
client_key_resolver = ClientKeyResolver()
server_key_resolver = ServerKeyResolver()
signatures = SignatureHooks(
sign_options={
"origin": "https://api.example.com",
"signature_algorithm": algorithms.ED25519,
"key_id": "client-1",
"key_resolver": client_key_resolver,
"tag": "example-api",
},
verify_options={
"origin": "https://api.example.com",
"signature_algorithm": algorithms.ED25519,
"key_resolver": server_key_resolver,
"expect_tag": "example-api",
"require_components": ("@status", '"@method";req', '"@target-uri";req'),
},
)
with httpx2.Client(event_hooks=signatures.event_hooks) as client:
response = client.post("https://api.example.com/items", json={"hello": "world"})
response.raise_for_status()
data = response.json()
results = response.extensions["http_message_signatures"]
If you just want to supply the keys directly, use http_message_signatures.MappingKeyResolver with private_keys
and public_keys dictionaries keyed by key ID.
The hooks buffer bodies and verify responses before returning them, raising InvalidSignature on failure.
Response verification requires a signed Content-Digest for nonempty content or when the header is present.
Use require_components to require coverage of other fields your application trusts.
Use the async hook collection with AsyncClient:
async with httpx2.AsyncClient(event_hooks=signatures.async_event_hooks) as client:
response = await client.post("https://api.example.com/items", json={"hello": "world"})
data = response.json()
The first argument selects which operations to enable:
SignatureHooksMode.SIGN_REQUEST_VERIFY_RESPONSE(the default) requires bothsign_optionsandverify_options.SignatureHooksMode.SIGN_REQUESTrequires onlysign_options.SignatureHooksMode.VERIFY_RESPONSErequires onlyverify_options.
Warning
HTTPX2 updates its cookie jar before response verification. Failed verification does not undo those updates, so these hooks do not provide authenticated automatic cookie persistence.
Install the optional dependency (aiohttp 3.14 or later):
pip install 'http-message-signatures[aiohttp]'
http_message_signatures.aiohttp.SignatureMiddleware signs requests and verifies responses through
aiohttp.ClientSession. Pass session_kwargs to the session constructor:
import aiohttp
from http_message_signatures.aiohttp import SignatureMiddleware, SignatureMiddlewareMode
signatures = SignatureMiddleware(
sign_options={
"origin": "https://api.example.com",
"signature_algorithm": algorithms.ED25519,
"key_id": "client-1",
"key_resolver": client_key_resolver,
"tag": "example-api",
},
verify_options={
"origin": "https://api.example.com",
"signature_algorithm": algorithms.ED25519,
"key_resolver": server_key_resolver,
"expect_tag": "example-api",
"require_components": ("@status", '"@method";req', '"@target-uri";req'),
},
)
async with aiohttp.ClientSession(**signatures.session_kwargs) as client:
async with client.post("https://api.example.com/items", json={"hello": "world"}, allow_redirects=False) as response:
response.raise_for_status()
data = await response.json()
results = signatures.verification_results(response)
The first constructor argument selects the mode. SignatureMiddlewareMode.SIGN_REQUEST needs only sign_options;
SignatureMiddlewareMode.VERIFY_RESPONSE needs only verify_options. The default,
SignatureMiddlewareMode.SIGN_REQUEST_VERIFY_RESPONSE, needs both dictionaries.
The middleware buffers complete bodies and verifies encoded responses before decoding them for normal aiohttp readers.
Client buffering has no size limit, even with streaming readers. aiohttp's compress= request option is supported.
Place the signature middleware last, so other middleware can change requests before signing and read responses after
verification. Keep auto_decompress=False from session_kwargs and retain the signature middleware when overriding
the per-request middleware list.
Requests and redirects must match each enabled operation's configured origin. Keep allow_redirects=False unless
your verification policy requires signed Location on redirect responses: an origin check does not authenticate paths
within that origin. Require coverage of other fields the application trusts, including Content-Type and Content-Encoding.
Failed verification prevents cookie-jar updates; authenticating cookies also requires '"set-cookie";bs' coverage.
http_message_signatures.aiohttp.ServerSignatureMiddleware verifies requests and signs responses through
aiohttp.web.Application. This example uses the client and server key resolvers defined earlier:
from aiohttp import web
from http_message_signatures import InvalidSignature, algorithms
from http_message_signatures.aiohttp import ServerSignatureMiddleware
signatures = ServerSignatureMiddleware(
verify_options={
"signature_algorithm": algorithms.ED25519,
"key_resolver": client_key_resolver,
"expect_tag": "example-api",
"require_components": ("@method", "@target-uri"),
},
sign_options={
"signature_algorithm": algorithms.ED25519,
"key_id": "server-1",
"key_resolver": server_key_resolver,
"tag": "example-api",
"covered_component_ids": (
"@status", "content-type", '"@method";req', '"@target-uri";req',
),
},
)
@web.middleware
async def signature_errors(request, handler):
try:
return await handler(request)
except InvalidSignature:
raise web.HTTPForbidden(text="Invalid request signature") from None
async def echo(request):
results = signatures.verification_results(request)
# Apply your application's authorization policy using these verified results.
return web.json_response(await request.json())
app = web.Application(**signatures.application_kwargs)
app.middlewares.insert(0, signature_errors)
app.router.add_post("/items", echo)
app.on_response_prepare.append(signatures.on_response_prepare)
web.run_app(app)
The first argument selects ServerSignatureMiddlewareMode.VERIFY_REQUEST, SIGN_RESPONSE, or
VERIFY_REQUEST_SIGN_RESPONSE (the default). Supply options for the enabled operations. Configure response
covered_component_ids explicitly; the defaults cover requests. Content-Digest coverage is added automatically.
Keep the auto_decompress=False setting supplied by application_kwargs. Verification checks encoded content
before decoding it or invoking the handler. Encoded and decoded request buffers respect request.client_max_size.
Place trusted proxy middleware before signature verification and body consumers after it. Applications control accepted hosts, trusted proxies, authorization, and replay prevention. Require coverage of fields the application trusts.
For signing, return a buffered web.Response. Streaming, FileResponse, and chunked compression are unsupported;
response buffering has no size limit. Return finite Payloads without preparing them so the middleware can buffer them.
Register on_response_prepare after all callbacks that modify headers, including subapplication callbacks.
Place the signature middleware outside middleware that changes response bodies, and do not change bodies or covered
headers after preparation. Application error responses are signed too; a response signature does not imply that its
request was verified.
Use prepare() and apply() when a KMS, HSM, SSH agent, or asynchronous client holds the signing key.
prepare() builds the RFC 9421 signature base without reading a private key. When you supply body=, it generates
Content-Digest if absent and adds digest coverage to the signature.
It accepts the signing options used by sign(), including covered components, creation and expiration times,
nonce, tag, label, and include_alg. The returned PreparedSignature contains immutable bytes and strings.
apply() writes the Signature-Input and Signature headers after the external signer returns.
In this example, request is a prepared Requests request whose body contains encoded content bytes and whose
headers do not yet contain Content-Digest. Passing body=request.body makes prepare() generate the digest
and add it to the covered components before building the signing input.
The asynchronous sign_base function signs the supplied bytes with Ed25519 and returns the raw 64-byte signature.
The external signer must use the private key identified by key_id:
from collections.abc import Awaitable, Callable
from http_message_signatures import HTTPMessageSigner, algorithms
async def sign_request(request, key_id: str, sign_base: Callable[[bytes], Awaitable[bytes]]):
signer = HTTPMessageSigner(signature_algorithm=algorithms.ED25519)
prepared = signer.prepare(
request,
key_id=key_id,
body=request.body,
label="sig1",
)
signature = await sign_base(prepared.signature_base)
signer.apply(request, prepared, signature)
The same two calls work with a synchronous signer or a signing job completed in another process.
No local key_resolver is needed. The existing sign() method still resolves a local key and performs all three
steps synchronously.
Pass prepared.signature_base to the signing algorithm as bytes, without base64 encoding or a trailing newline.
prepared.signature_params is the serialized @signature-params value; it is useful for inspecting the covered
component list and signed metadata. Do not rebuild either value yourself. The selected algorithm applies to the remote
signer even when include_alg=False; omitting the parameter does not negotiate an algorithm.
External signing services differ in their accepted input and output formats. When calling a signing service, configure its signing operation according to RFC 9421 section 3.3. Ed25519 signs the base without prehashing. For RSA and ECDSA, an API that accepts raw messages must perform the specified hashing. If the API accepts a digest, hash the signature base before calling it and select a mode that does not hash the digest again. The RSA-PSS profile uses SHA-512, MGF1 with SHA-512, and a 64-byte salt.
apply() expects raw signature bytes and performs the Structured Field base64 encoding. Decode base64 responses
from a service before calling it. ECDSA P-256 clients often return DER, while RFC 9421 requires a 64-byte concatenation
of r and s, each padded to 32 bytes. Convert DER output using the algorithm's helper:
signer = HTTPMessageSigner(signature_algorithm=algorithms.ECDSA_P256_SHA256) prepared = signer.prepare(request, key_id="p256-key") # der_signature is the client's ECDSA P-256/SHA-256 signature of prepared.signature_base. signature = algorithms.ECDSA_P256_SHA256.signature_from_der(der_signature) signer.apply(request, prepared, signature)
Retain the prepared object and associate each remote result with its input. Use a trusted signing service and protect
queued signing input against modification and disclosure: the base contains covered header and URL values.
apply() compares the message's current signature base with the prepared bytes and rejects changes before writing
headers. For example, changing a covered method from POST to DELETE while awaiting the signature is rejected.
Changes to uncovered values or values with the same RFC normalization are permitted. This check does not authenticate
the prepared object, verify the returned signature, or check a body against its digest. Applications must handle those
responsibilities and keep the message stable during apply() and until it is sent.
Creation and expiration times are set during preparation and are not refreshed by apply(). Allow for signing delays
when choosing expiration times; an expired result will still be rejected by a verifier. To add the result alongside
existing signatures, pass append_if_signature_exists=True to apply(). Duplicate labels or malformed existing
signature headers are rejected. By default it replaces existing signature headers, just like sign().
For example, an application with an authenticated Boto3 AWS KMS client and an RSA key with SIGN_VERIFY usage
and permission to call kms:Sign can use the
KMS Sign API in digest mode:
import hashlib
import requests
from http_message_signatures import HTTPMessageSigner, algorithms
# kms_client and key_id are supplied by the application.
request = requests.Request("POST", "https://example.com/", json={"hello": "world"}).prepare()
signer = HTTPMessageSigner(signature_algorithm=algorithms.RSA_V1_5_SHA256)
prepared = signer.prepare(request, key_id=key_id, body=request.body)
kms_response = kms_client.sign(
KeyId=key_id,
Message=hashlib.sha256(prepared.signature_base).digest(),
MessageType="DIGEST",
SigningAlgorithm="RSASSA_PKCS1_V1_5_SHA_256",
)
signer.apply(request, prepared, kms_response["Signature"])Passing body=request.body generates Content-Digest and includes it in the signature's coverage.
Hash prepared.signature_base, which includes the covered components and signature parameters, rather than the
request body or its Content-Digest. MessageType="DIGEST" prevents KMS from hashing this SHA-256 digest again.
The KMS algorithm must match algorithms.RSA_V1_5_SHA256. Boto3 returns raw signature bytes; pass them directly to
apply() without base64 encoding or decoding. The receiving application must associate key_id with the
corresponding public key to verify the HTTP message signature.
Use the sf component parameter to cover a whole header field using Structured Fields serialization:
signer.sign(request, key_id="my-key", covered_component_ids=('"priority";sf',))
For example, Priority: u=03, i=?1 produces this signature-base line:
"priority";sf: u=3, i
The field's type comes from http_sf's registry; both endpoints must use matching type definitions.
Unknown types and invalid values are rejected.
Use ;sf only when all consumers interpret the field using Structured Fields semantics. For legacy fields such
as Cache-Control, differing duplicate-handling rules can change behavior without invalidating the signature.
Omit ;sf when compatibility is unknown.
Use covered_component_ids=('"set-cookie";bs',) to cover each field line as a separate Structured Field
Byte Sequence, following RFC 9421 section 2.1.3.
When Set-Cookie is covered and multiple field lines are present, sign() and prepare() automatically
add bs. Verification rejects signatures covering multiple Set-Cookie lines without it.
For binary wrapping, headers.items() must expose each original field line separately and in order.
The library cannot recover boundaries from values already combined by an HTTP client.
Requests supply headers, an uppercase method, and an encoded absolute url with a nonempty host.
Responses supply headers, status_code, and the associated request for components marked req.
headers.items() must expose all field lines in wire order, separately or already combined.
Signing also requires mutable headers. Binary wrapping requires separate field lines.
Supply message.request_target when @request-target cannot be inferred from the URL, such as absolute-form
proxy requests or OPTIONS *. The target and URL must describe the same request.
See HTTPSignatureComponentResolver in the API reference for the full interface.
By default, signing replaces existing signatures and verification requires a single signature. Before enabling multiple signatures, read section 7.2.6 of the RFC, Multiple Signature Confusion.
To append a signature, use:
signer.sign(request, append_if_signature_exists=True, ...)
To select signatures by their signed application tag, use:
verifier.verify(request, expect_tag="my_app_tag")
Every matching signature must verify. The method returns a list of VerifyResult s.
verify() raises InvalidSignature for failed verification. Library exceptions derive from
HTTPMessageSignaturesException; key resolver and algorithm errors can also propagate.
- Andrey Kislyuk <https://kislyuk.com>
- Project home page (GitHub)
- API reference and usage examples
- Package distribution (PyPI)
- Change log
- IETF HTTP Message Signatures standard tracker
- OWASP Top Ten
Please report bugs, issues, feature requests, etc. on GitHub.
Copyright 2022-2026, Andrey Kislyuk and http-message-signatures contributors. Licensed under the terms of the Apache License, Version 2.0. Distribution of attribution information, LICENSE and NOTICE files with source copies of this package and derivative works is REQUIRED as specified by the Apache License.
Our interoperability tests use published fixtures from Cloudflare's Web Bot Auth repository.