@hellocoop/httpsig
v2.2.0
Published
HTTP Message Signatures (RFC 9421) with Signature-Key header support
Readme
@hellocoop/httpsig
HTTP Message Signatures (RFC 9421) implementation with Signature-Key header support for Node.js and browsers.
Overview
This package implements RFC 9421 HTTP Message Signatures with support for the Signature-Key header proposal, enabling cryptographic signing and verification of HTTP requests.
Draft version
| Package line | Implements | npm dist-tag |
| ------------ | -------------------------------------- | ------------ |
| 2.x | draft-hardt-httpbis-signature-key-08 | latest |
| 1.x | draft-hardt-httpbis-signature-key-05 | — |
The draft is not yet adopted and -08 is not backward compatible with -05.
npm install @hellocoop/httpsig gives you 2.x. 1.x continues on the
1.x branch. See MIGRATING-2.0.md for what changed.
Key Features:
- Zero dependencies
- TypeScript support with full type definitions
- Works in Node.js and modern browsers
- Three key distribution schemes:
hwk,jwt, andjwks_uri - Simple API:
fetch()wrapper andverify()middleware helper - Automatic signature generation and header management
- Built-in JWKS caching for performance
Installation
npm install @hellocoop/httpsigQuick Start
Signing Requests
import { fetch } from '@hellocoop/httpsig'
// Make a signed GET request with inline public key (hwk)
const response = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk, // JsonWebKey with private key
signatureKey: { type: 'hwk' },
})
// Make a signed POST request
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ foo: 'bar' }),
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
})Verifying Requests
import { verify } from '@hellocoop/httpsig'
// In Express middleware
app.use(async (req, res, next) => {
try {
// Parse URL to extract path and query
const urlObj = new URL(
req.originalUrl,
`${req.protocol}://${req.hostname}`,
)
const result = await verify({
method: req.method,
authority: req.hostname,
path: urlObj.pathname,
query: urlObj.search ? urlObj.search.substring(1) : undefined,
headers: req.headers,
body: req.body,
})
if (result.verified) {
req.signature = result
next()
} else {
res.status(401).json({ error: 'Invalid signature' })
}
} catch (error) {
res.status(401).json({ error: error.message })
}
})API Reference
fetch(url, options)
A drop-in replacement for the standard fetch() that automatically signs requests.
Parameters:
url(string | URL): The URL to fetchoptions(HttpSigFetchOptions): Standard fetch options plus signing parameters
HttpSigFetchOptions extends RequestInit:
interface HttpSigFetchOptions extends RequestInit {
// Required: Private key as JWK
signingKey: JsonWebKey
// Required: Signature-Key header configuration
signatureKey:
| { type: 'hwk' }
| { type: 'jwt'; jwt: string }
| { type: 'jwks_uri'; id: string; kid: string; wellKnown: string }
// Optional parameters
label?: string // Signature label (default: 'sig')
components?: string[] // Override default components
// Content-Digest coverage for requests with a body (default: 'auto')
// 'auto' - cover content-digest when the body's exact bytes are
// available to hash (string, Uint8Array, ArrayBuffer, Buffer);
// streaming bodies (ReadableStream, FormData, Blob) are
// signed without it
// 'require' - like 'auto', but throw on a body that cannot be digested
// 'omit' - never auto-append content-digest
contentDigest?: 'auto' | 'require' | 'omit'
// Testing mode
dryRun?: boolean // Return headers without fetching (still returns Promise)
}Returns:
Promise<Response>- Standard fetch Response object- If
dryRun: true, returnsPromise<{ headers: Headers }>with the headers that would be sent
Example with hwk:
const response = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
})Example with JWT:
const response = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk,
signatureKey: {
type: 'jwt',
jwt: 'eyJhbGciOiJFZERTQSIsInR5cCI6ImFnZW50K2p3dCJ9...',
},
})Example with JWKS:
const response = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk,
signatureKey: {
type: 'jwks_uri',
id: 'https://agent.example',
kid: 'key-1',
wellKnown: 'agent-server', // Optional
},
})Testing mode (dry run):
const { headers } = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
dryRun: true,
})
console.log(headers.get('Signature'))
console.log(headers.get('Signature-Input'))
console.log(headers.get('Signature-Key'))Overriding default components:
import {
fetch,
DEFAULT_COMPONENTS_GET,
DEFAULT_COMPONENTS_BODY,
} from '@hellocoop/httpsig'
// Default components for requests without body (GET, DELETE):
// ['@method', '@authority', '@path', 'signature-key']
// Default components for requests with body (POST, PUT, PATCH):
// ['@method', '@authority', '@path', 'content-type', 'signature-key']
// Override defaults for RFC 9421 interoperability
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
date: new Date().toUTCString(),
'content-type': 'application/json',
},
body: JSON.stringify({ foo: 'bar' }),
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
// Override with different components
components: [
'date',
'@method',
'@path',
'@authority',
'content-type',
'signature-key',
],
})
// To extend defaults, add new components
const components = [
...DEFAULT_COMPONENTS_BODY,
'date', // Add date header to signature
'authorization', // Add authorization header
]
// Note: Duplicates are automatically removedverify(request, options?)
Verifies HTTP Message Signatures on incoming requests.
Parameters:
request(VerifyRequest): The request to verifyoptions?(VerifyOptions): Optional verification configuration
VerifyRequest:
interface VerifyRequest {
method: string
authority: string // Canonical authority (e.g., 'api.example.com')
path: string // Request path (e.g., '/api/data')
query?: string // Optional query string without leading '?' (e.g., 'foo=bar')
headers: Headers | Record<string, string | string[]>
body?: string | Buffer | Uint8Array
}Note: The body must be raw bytes (string, Buffer, or Uint8Array), NOT a parsed object. If you include content-digest in your components, signature verification will fail with parsed JSON because the content-digest is computed over the exact bytes.
VerifyOptions:
interface VerifyOptions {
// Timestamp validation
maxClockSkew?: number // Max clock skew in seconds (default: 60)
// JWKS caching
jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000)
// Algorithms this verifier accepts (default: SUPPORTED_ALGORITHMS)
supportedAlgorithms?: SignatureAlgorithm[]
// AAuth HTTPSig profile (Section 10.3) enforcement: when true, a request
// with a body fails verification unless the signature covers
// content-digest and the digest validates against the body
requireContentDigest?: boolean
}Returns: Promise<VerificationResult>
interface VerificationResult {
verified: boolean // Overall verification status
label: string // Signature label used
keyType: 'hwk' | 'jwt' | 'jwks_uri'
publicKey: JsonWebKey // Extracted public key
thumbprint: string // JWK thumbprint (RFC 7638) - stable key identifier
created: number // Signature timestamp
// JWT-specific fields (if keyType === 'jwt')
// Note: JWT is NOT validated - caller must validate issuer, expiration, etc.
jwt?: {
header: object
payload: object
raw: string // Raw JWT for caller to validate
}
// JWKS-specific fields (if keyType === 'jwks_uri')
jwks_uri?: {
id: string
kid: string
wellKnown: string
}
// Error information
error?: string
}Example with Express:
import express from 'express'
import { expressVerify } from '@hellocoop/httpsig'
const app = express()
// IMPORTANT: Use express.raw() NOT express.json()!
app.use(express.raw({ type: 'application/json' }))
app.use(async (req, res, next) => {
const result = await expressVerify(req)
if (result.verified) {
req.signature = result
next()
} else {
res.status(401).json({ error: result.error })
}
})Example with Fastify:
import Fastify from 'fastify'
import { fastifyVerify } from '@hellocoop/httpsig'
const fastify = Fastify({
// Preserve raw body for signature verification
preParsing: async (request, reply, payload) => {
const chunks: Buffer[] = []
for await (const chunk of payload) {
chunks.push(chunk)
}
request.rawBody = Buffer.concat(chunks)
return Buffer.concat(chunks)
},
})
fastify.addHook('preHandler', async (request, reply) => {
const result = await fastifyVerify(request)
if (!result.verified) {
reply.code(401).send({ error: result.error })
return
}
request.signature = result
})Example with Next.js App Router:
import { nextJsVerify } from '@hellocoop/httpsig'
export async function POST(request: Request) {
// IMPORTANT: Consume body BEFORE verification!
const body = await request.text()
const result = await nextJsVerify(request, body)
if (!result.verified) {
return Response.json({ error: result.error }, { status: 401 })
}
// Parse body after verification
const data = JSON.parse(body)
// ... handle request
}Example with JWT validation:
const result = await verify(request)
if (result.verified && result.keyType === 'jwt') {
// Caller is responsible for validating the JWT
const jwt = result.jwt
// Decode and validate JWT claims
const isValid = await validateJWT(jwt.raw, {
trustedIssuers: ['https://auth.example.com'],
// ... other validation logic
})
if (!isValid) {
throw new Error('Invalid JWT')
}
}Example using thumbprint for authorization:
// Store allowed public key thumbprints (e.g., from registration)
const ALLOWED_THUMBPRINTS = new Set([
'NZQltk3VvFCjGIx8-UtxKBwkjRZ6O8kPKYNa3mRYFX8',
'kOzFrbnFA0SWOSKmY76ok0Ke-soe9Ja41xzhlK9v8Yo',
])
app.use(async (req, res, next) => {
const result = await expressVerify(req)
if (!result.verified) {
return res.status(401).json({ error: result.error })
}
// Use thumbprint as stable identifier for rate limiting, access control, etc.
if (!ALLOWED_THUMBPRINTS.has(result.thumbprint)) {
return res.status(403).json({
error: 'Public key not authorized',
thumbprint: result.thumbprint,
})
}
// Store thumbprint for logging/auditing
req.callerThumbprint = result.thumbprint
next()
})Framework Integration Requirements
Critical Requirements for verify()
When verifying HTTP Message Signatures, you MUST provide:
- Raw Body Bytes - NOT parsed JSON objects
- Full URL - NOT just the path
❌ Common Mistakes
// ❌ WRONG - body is parsed object
app.use(express.json())
app.use((req, res) => {
verify({
body: req.body, // This is { foo: "bar" }, not raw bytes!
})
})
// ❌ WRONG - url is just the path
verify({
url: req.url, // This is "/api/data", not "https://example.com/api/data"
})✅ Correct Approach
Use the framework-specific verify functions which handle these requirements automatically:
import { expressVerify } from '@hellocoop/httpsig'
app.use(express.raw({ type: 'application/json' }))
app.use(async (req, res) => {
const result = await expressVerify(req)
})Why These Requirements Matter
Raw Body: If you use the content-digest component, it is computed over the exact bytes of the body. If you parse JSON and re-serialize it:
- Whitespace might differ:
{"foo":"bar"}vs{"foo": "bar"} - Key order might change
- The digest won't match → verification fails
Authority and Path: The signature covers @authority and @path. Providing incorrect values will produce a different signature base → verification fails.
Framework-Specific Verify Functions
The package provides framework-specific functions that handle URL construction and body handling automatically:
expressVerify(req, options?)- Express.jsfastifyVerify(request, options?)- FastifynextJsVerify(request, body?, options?)- Next.js App RouternextJsPagesVerify(req, body?, host?, options?)- Next.js Pages Router
These functions call verify() internally after correctly transforming the request.
See examples in the verify() documentation above.
Signature Components
Default Components
By default, requests are signed with these components:
Requests without a body (GET, DELETE):
@method- HTTP method@authority- Host authority@path- Request pathsignature-key- The Signature-Key header
Signature-Input: sig=("@method" "@authority" "@path" "signature-key");created=1730217600Requests with a body (POST, PUT, PATCH):
@method- HTTP method@authority- Host authority@path- Request pathcontent-type- Content-Type headersignature-key- The Signature-Key header
Signature-Input: sig=("@method" "@authority" "@path" "content-type" "signature-key");created=1730217600Content-Digest (automatic since 2.2.0)
Per the AAuth HTTPSig profile (Section 10.3), a request carrying a body to a
PS or AS endpoint MUST also cover content-digest (RFC 9530). fetch()
appends content-digest to the covered components automatically whenever the
body's exact bytes are available to hash — a string, Uint8Array, ArrayBuffer,
or Buffer. A body serialized by the fetch implementation (ReadableStream,
FormData, Blob) is signed without it; pass contentDigest: 'require' to
throw instead, or contentDigest: 'omit' to never auto-append. The header is
computed as:
Content-Digest: sha-256=:BASE64(SHA256(body)):A verifier enforces coverage with requireContentDigest: true.
Overriding Default Components
You can override the default components using the components parameter. The library exports helpful constants:
Exported Constants:
import {
VALID_DERIVED_COMPONENTS, // All valid RFC 9421 derived components
DEFAULT_COMPONENTS_GET, // Default for GET requests
DEFAULT_COMPONENTS_BODY, // Default for requests with body
} from '@hellocoop/httpsig'
// VALID_DERIVED_COMPONENTS contains:
// ['@method', '@target-uri', '@authority', '@scheme',
// '@request-target', '@path', '@query', '@query-param', '@status']
// DEFAULT_COMPONENTS_GET contains:
// ['@method', '@authority', '@path', 'signature-key']
// DEFAULT_COMPONENTS_BODY contains:
// ['@method', '@authority', '@path', 'content-type', 'signature-key']Example - Custom components:
// Add the date header to the covered components
await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
date: new Date().toUTCString(),
'content-type': 'application/json',
},
body: JSON.stringify({ data: 'value' }),
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
components: [
'@method',
'@authority',
'@path',
'date', // Include date header
'content-type',
'signature-key',
// content-digest is appended automatically for a digestible body
],
})Component Validation:
- Derived components (starting with
@) must be inVALID_DERIVED_COMPONENTS - Header components must exist in the request headers
- Duplicate components are automatically removed
- Invalid components throw an error with a clear message
Signature-Key Types
The Signature-Key header uses RFC 8941 Structured Fields Dictionary format with exactly one dictionary member. The member key (label) is used to correlate the three signature headers: Signature-Key, Signature-Input, and Signature.
Format: label=scheme;param1="value1";param2="value2"
Label Discovery: During verification, the label is automatically discovered from the Signature-Key header (per AAuth spec). The same label must appear in both Signature-Input and Signature headers.
AAuth Profile Requirement: When strictAAuth: true (default), the signature-key component must be included in the covered components list.
hwk (Header Web Key)
Inline public key in the header for pseudonymous verification.
const response = await fetch(url, {
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
})Generated headers (RFC 8941 Dictionary format):
Signature-Key: sig=hwk;kty="OKP";crv="Ed25519";x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"Use cases:
- Privacy-preserving agents
- Temporary or experimental access
- Rate limiting per key
jwt (JWT Confirmation Key)
Public key embedded in a signed JWT using the cnf.jwk claim.
const response = await fetch(url, {
signingKey: privateKeyJwk,
signatureKey: {
type: 'jwt',
jwt: agentToken, // JWT with cnf.jwk claim
},
})Generated headers (RFC 8941 Dictionary format):
Signature-Key: sig=jwt;jwt="eyJhbGciOiJFZERTQSIsInR5cCI6ImFnZW50K2p3dCJ9..."The JWT must contain:
{
"iss": "https://issuer.example",
"sub": "instance-123",
"exp": 1732210000,
"cnf": {
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
}
}
}Use cases:
- Distributed services with ephemeral keys
- Delegation scenarios
- Short-lived credentials for horizontal scaling
jwks_uri (JWKS URI Discovery)
Key discovery via HTTPS URLs with automatic caching.
const response = await fetch(url, {
signingKey: privateKeyJwk,
signatureKey: {
type: 'jwks_uri',
id: 'https://agent.example',
kid: 'key-1',
},
})Generated headers (RFC 8941 Dictionary format):
Signature-Key: sig=jwks_uri;id="https://agent.example";kid="key-1"With well-known metadata:
const response = await fetch(url, {
signingKey: privateKeyJwk,
signatureKey: {
type: 'jwks_uri',
id: 'https://agent.example',
kid: 'key-1',
wellKnown: 'agent-server',
},
})Generated headers (RFC 8941 Dictionary format):
Signature-Key: sig=jwks_uri;id="https://agent.example";kid="key-1";well-known="agent-server"Discovery process:
- If
well-knownpresent: fetch{id}/.well-known/{well-known}, extractjwks_uri, fetch JWKS - If
well-knownabsent: fetch{id}directly as JWKS - Find key with matching
kid - Cache JWKS with configurable TTL (default 1 hour)
Use cases:
- Identified services with stable HTTPS identity
- Search engine crawlers
- Services requiring explicit entity identification
Supported Algorithms
We support the two most widely recommended algorithms from the IANA HTTP Message Signatures registry:
Ed25519 (
ed25519) - EdDSA with Curve25519 - Recommended- Fast, secure, deterministic
- Small signatures (64 bytes)
- Perfect interoperability
ES256 (
ecdsa-p256-sha256) - ECDSA with P-256 and SHA-256- Industry standard (JWT, WebAuthn)
- Widely supported
- Perfect interoperability
Structured Fields (RFC 8941)
Every header this package reads or writes is an RFC 8941 Structured Field, and
the parser and serializer that handle them are exported. Use them for
neighbouring fields rather than writing another parser — AAuth-Requirement is
a Dictionary, AAuth-Capabilities a List of Tokens, and hand-rolled 8941 fails
on the same three things every time: quoting, escaping, and byte sequences.
import {
parseDictionary,
serializeDictionary,
Token,
bareItemToString,
} from '@hellocoop/httpsig'
// A `;` inside a quoted string does not end the parameter list.
const dict = parseDictionary(
'requirement=interaction; url="https://resource.example/i?a=1;b=2"; code="A1B2-C3D4"',
)
const [value, params] = dict.get('requirement') as Item
value instanceof Token // true — `interaction` is a Token, not a String
params.get('url') // 'https://resource.example/i?a=1;b=2'
params.get('code') // 'A1B2-C3D4'
serializeDictionary(
new Map([
[
'requirement',
[new Token('auth-token'), new Map([['resource-token', jwt]])],
],
]),
)Exported
| | |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Parsing | parseDictionary, parseList, parseItem, ParseError |
| Serializing | serializeDictionary, serializeList, serializeItem, serializeInnerList, serializeBareItem, serializeParameters, SerializeError |
| Values | Token, ByteSequence |
| Guards | isInnerList, isByteSequence, isValidTokenStr, isValidKeyStr |
| Helper | bareItemToString — reads a String or a Token, refuses anything else |
| Types | Dictionary, List, Item, InnerList, Parameters, BareItem |
Shapes:
Dictionary Map<string, Item | InnerList>
List (Item | InnerList)[]
Item [BareItem, Parameters]
InnerList [Item[], Parameters]
Parameters Map<string, BareItem>
BareItem number | string | Token | ByteSequence | booleanA Token is a bare word (hwk, Ed25519); a string is a quoted sf-string.
The distinction is load-bearing — @signature-params is covered by the
signature, so a parameter that arrives as a Token must go back out as a Token.
The implementation is vendored from
structured-headers v1.0.1 (MIT)
rather than taken as a dependency, because this package has zero runtime
dependencies by design. See src/vendor/structured-headers/README.md.
Security Considerations
Timestamp Validation
- Signatures must have a
createdtimestamp - Timestamp must be within ±60 seconds (configurable via
maxClockSkew) - Prevents replay attacks
JWT Handling
When verifying jwt signature-key types:
- The JWT is decoded and the
cnf.jwkclaim is extracted - The extracted public key is used to verify the HTTP signature
- JWT validation is NOT performed - the raw JWT is returned to the caller
- Caller is responsible for validating JWT signature, issuer, expiration, etc.
JWKS Caching
- JWKS responses are cached to prevent excessive fetches
- Default TTL: 1 hour (configurable)
- Cache respects HTTP
Cache-Controlheaders - Cache keyed by JWKS URL
Key Validation
- All cryptographic material is validated before use
- JWK structure and parameters are verified
- Algorithm/key type mismatches are rejected
Testing
The package includes a comprehensive test suite:
npm testTo run tests with coverage:
npm run test:coverageExamples
See the examples/ directory for complete examples:
examples/basic-fetch.ts- Simple GET and POST requestsexamples/express-middleware.ts- Express integrationexamples/fastify-middleware.ts- Fastify integrationexamples/all-key-types.ts- Using hwk, jwt, and jwks
Standards Compliance
This implementation follows:
- RFC 9421: HTTP Message Signatures
- RFC 9530: Digest Fields
- RFC 7515: JSON Web Signature (JWS)
- RFC 7517: JSON Web Key (JWK)
- RFC 7800: Proof-of-Possession Key Semantics for JWTs
- Signature-Key Header Proposal
License
MIT
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.
