Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Signing Requests – ERC-8128
Skip to content

Signing Requests

Learn how to sign HTTP requests with ERC-8128.

Overview

ERC-8128 signing creates cryptographic proof that an HTTP request was authorized by a specific Ethereum address. The signature covers the request components (method, URL, headers, body) and includes timing/replay protection.

With a signed request, servers can:

  • Authenticate users by their Ethereum address (no passwords or API keys)
  • Verify request integrity (tampering detection)
  • Prevent replay attacks (via nonces)

Basic Signing

The simplest way to sign a request using signRequest:

import { signRequest } from '@slicekit/erc8128'
 
const signedRequest = await signRequest(
  'https://api.example.com/resource',
  signer
)

This uses all defaults:

  • Request-bound — signs authority, method, path, query, body digest
  • Non-replayable — auto-generated nonce
  • 60-second TTL
  • Label: "eth"

Creating a Signer

Implement the EthHttpSigner interface by providing a chain ID, address, and signMessage function. This example uses viem's privateKeyToAccount:

import type { EthHttpSigner } from '@slicekit/erc8128'
import { privateKeyToAccount } from 'viem/accounts'
 
const account = privateKeyToAccount('0x...')
 
const signer: EthHttpSigner = {
  chainId: 1,
  address: account.address,
  signMessage: async (message) => {
    return account.signMessage({ message: { raw: message } })
  },
}

Using createSignerClient

For repeated requests, use createSignerClient to bind the signer:

import { createSignerClient } from '@slicekit/erc8128'
 
const client = createSignerClient(signer, {
  ttlSeconds: 120,
})
 
// Use client.fetch
const response = await client.fetch('https://api.example.com/orders', {
  method: 'POST',
  body: JSON.stringify({ amount: '100' }),
})
 
// Override options per-call
const response = await client.fetch(
  'https://api.example.com/status',
  { method: 'GET' },
  { replay: 'replayable' }
)

Signing Options

Customize TTL, timestamps, nonce generation, and other signature parameters via SignOptions.

Custom TTL
const signedRequest = await signRequest(
  'https://api.example.com/resource',
  signer,
  { ttlSeconds: 300 } // Valid for 5 minutes
)

Binding Modes

Request-bound (default) signs all applicable components for maximum security. Class-bound signs only the components you explicitly specify.

Request-Bound (Default)
// Signs all applicable components for maximum security
const signedRequest = await signRequest(
  'https://api.example.com/search?q=test',
  { method: 'POST', body: '{"data": true}' },
  signer,
  { binding: 'request-bound' }
)
 
// Signature covers: @authority, @method, @path, @query, content-digest

Class-Bound

Class-bound signatures authorize a class of requests rather than a single concrete request. By signing only specific components, a single signature can apply to multiple requests that match the signed components.

This is useful when:

  • You want a single signature to work across multiple endpoints on the same domain
  • You're optimizing for performance in high-frequency scenarios
  • You're building delegation or session-like patterns

When using binding: 'class-bound', you must provide the components array explicitly. The library always includes @authority (required by the spec).

Additional Components

Sign extra headers alongside the default request-bound set:

const signedRequest = await signRequest(
  request,
  signer,
  { components: ['x-idempotency-key', 'x-request-id'] }
)
 
// Signature covers: @authority, @method, @path, content-digest, 
//                   x-idempotency-key, x-request-id

Replay Modes

Non-replayable (default) includes a unique nonce per request. Replayable omits the nonce — use only for safe, idempotent operations.

Non-Replayable (Default)
// Every request gets a unique nonce
const signedRequest = await signRequest(
  'https://api.example.com/resource',
  signer,
  { replay: 'non-replayable' }
)

Replayable signatures trade single-use guarantees for reduced overhead (no nonce generation, no server-side nonce state). They still provide authentication and request integrity (if request-bound), comparable to bearer credentials like JWTs — but without requiring server-issued tokens. The verifier must explicitly opt in to accepting replayable signatures and must implement early invalidation mechanisms. See Replayable Signatures for the full tradeoff analysis.

Content-Digest

Automatic (Default)

Content-Digest is computed automatically for request-bound requests with a body:

const signedRequest = await signRequest(
  'https://api.example.com/data',
  {
    method: 'POST',
    body: JSON.stringify({ data: 'value' }),
  },
  signer
)
 
// Content-Digest header added automatically

Manual Control

Disable automatic Content-Digest computation when you want to handle it yourself or skip it entirely.

const signedRequest = await signRequest(
  request,
  signer,
  { contentDigest: 'off' } // Don't add Content-Digest
)

Error Handling

Catch Erc8128Error to handle signing failures with structured error codes.

import { signRequest, Erc8128Error } from '@slicekit/erc8128'
 
try {
  const signedRequest = await signRequest(input, signer)
} catch (error) {
  if (error instanceof Erc8128Error) {
    console.error(`Signing failed: ${error.code} - ${error.message}`)
  }
  throw error
}

Common errors:

  • CRYPTO_UNAVAILABLE — No WebCrypto (older Node.js or restricted environment)
  • UNSUPPORTED_REQUEST — Can't sign this request (e.g., invalid URL)
  • BODY_READ_FAILED — Couldn't read request body for Content-Digest