@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
2.x | draft-hardt-httpbis-signature-key-07 | alpha |
1.x | draft-hardt-httpbis-signature-key-05 | latest |
The draft is not yet adopted and -07 is not backward compatible with -05.
The 2.x line tracks it and is published as a prerelease; npm install @hellocoop/httpsig continues to give you 1.x until 2.0.0 is released. 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, and jwks_uri
- Simple API:
fetch() wrapper and verify() middleware helper
- Automatic signature generation and header management
- Built-in JWKS caching for performance
Installation
npm install @hellocoop/httpsig
Quick Start
Signing Requests
import { fetch } from '@hellocoop/httpsig'
const response = await fetch('https://api.example.com/data', {
signingKey: privateKeyJwk,
signatureKey: { type: 'hwk' },
})
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'
app.use(async (req, res, next) => {
try {
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 fetch
options (HttpSigFetchOptions): Standard fetch options plus signing parameters
HttpSigFetchOptions extends RequestInit:
interface HttpSigFetchOptions extends RequestInit {
signingKey: JsonWebKey
signatureKey:
| { type: 'hwk' }
| { type: 'jwt'; jwt: string }
| { type: 'jwks_uri'; id: string; kid: string; wellKnown: string }
label?: string
components?: string[]
dryRun?: boolean
}
Returns:
Promise<Response> - Standard fetch Response object
- If
dryRun: true, returns Promise<{ 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',
},
})
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'
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' },
components: [
'date',
'@method',
'@path',
'@authority',
'content-type',
'signature-key',
],
})
const components = [
...DEFAULT_COMPONENTS_BODY,
'date',
'authorization',
]
verify(request, options?)
Verifies HTTP Message Signatures on incoming requests.
Parameters:
request (VerifyRequest): The request to verify
options? (VerifyOptions): Optional verification configuration
VerifyRequest:
interface VerifyRequest {
method: string
authority: string
path: string
query?: string
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 {
maxClockSkew?: number
jwksCacheTtl?: number
strictAAuth?: boolean
}
Returns: Promise<VerificationResult>
interface VerificationResult {
verified: boolean
label: string
keyType: 'hwk' | 'jwt' | 'jwks_uri'
publicKey: JsonWebKey
thumbprint: string
created: number
jwt?: {
header: object
payload: object
raw: string
}
jwks_uri?: {
id: string
kid: string
wellKnown: string
}
error?: string
}
Example with Express:
import express from 'express'
import { expressVerify } from '@hellocoop/httpsig'
const app = express()
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({
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) {
const body = await request.text()
const result = await nextJsVerify(request, body)
if (!result.verified) {
return Response.json({ error: result.error }, { status: 401 })
}
const data = JSON.parse(body)
}
Example with JWT validation:
const result = await verify(request)
if (result.verified && result.keyType === 'jwt') {
const jwt = result.jwt
const isValid = await validateJWT(jwt.raw, {
trustedIssuers: ['https://auth.example.com'],
})
if (!isValid) {
throw new Error('Invalid JWT')
}
}
Example using thumbprint for authorization:
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 })
}
if (!ALLOWED_THUMBPRINTS.has(result.thumbprint)) {
return res.status(403).json({
error: 'Public key not authorized',
thumbprint: result.thumbprint,
})
}
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
app.use(express.json())
app.use((req, res) => {
verify({
body: req.body,
})
})
verify({
url: req.url,
})
✅ 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.js
fastifyVerify(request, options?) - Fastify
nextJsVerify(request, body?, options?) - Next.js App Router
nextJsPagesVerify(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 path
signature-key - The Signature-Key header
Signature-Input: sig=("@method" "@authority" "@path" "signature-key");created=1730217600
Requests with a body (POST, PUT, PATCH):
@method - HTTP method
@authority - Host authority
@path - Request path
content-type - Content-Type header
signature-key - The Signature-Key header
Signature-Input: sig=("@method" "@authority" "@path" "content-type" "signature-key");created=1730217600
Optional: Content-Digest
If you want body integrity verification, you can add content-digest to your components list. When included, the content-digest header is computed as:
Content-Digest: sha-256=:BASE64(SHA256(body)):
Overriding Default Components
You can override the default components using the components parameter. The library exports helpful constants:
Exported Constants:
import {
VALID_DERIVED_COMPONENTS,
DEFAULT_COMPONENTS_GET,
DEFAULT_COMPONENTS_BODY,
} from '@hellocoop/httpsig'
Example - Adding content-digest for body integrity:
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',
'content-type',
'content-digest',
'signature-key',
],
})
Component Validation:
- Derived components (starting with
@) must be in VALID_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.
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,
},
})
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-known present: fetch {id}/.well-known/{well-known}, extract jwks_uri, fetch JWKS
- If
well-known absent: 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:
Security Considerations
Timestamp Validation
- Signatures must have a
created timestamp
- 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.jwk claim 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-Control headers
- 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 test
To run tests with coverage:
npm run test:coverage
Examples
See the examples/ directory for complete examples:
examples/basic-fetch.ts - Simple GET and POST requests
examples/express-middleware.ts - Express integration
examples/fastify-middleware.ts - Fastify integration
examples/all-key-types.ts - Using hwk, jwt, and jwks
Standards Compliance
This implementation follows:
License
MIT
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.