# ERC-8128 > Signed HTTP Requests with Ethereum ## Specification ERC-8128 defines how Ethereum accounts sign HTTP requests using RFC 9421 (HTTP Message Signatures). > **Full Specification:** [ERC-8128 on GitHub](https://github.com/slice-so/ERCs/blob/d9c6f41183008285a0e9f1af1d2aeac72e7a8fdc/ERCS/erc-8128.md) ### Overview ERC-8128 is a profile of RFC 9421 that specifies: 1. **Signature algorithm**: Ethereum message signing (EIP-191) 2. **Key identification**: `erc8128::
` format 3. **Default components**: Request-bound signing 4. **Replay protection**: Nonce-based with time bounds ### Headers #### Signature-Input Structured Dictionary (RFC 8941) listing signed components and parameters: ``` Signature-Input: eth=("@authority" "@method" "@path" "@query" "content-digest");created=1618884473;expires=1618884533;nonce="abc123";keyid="erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045" ``` **Required parameters:** * `created` — Unix timestamp when signature was created * `expires` — Unix timestamp when signature expires * `keyid` — Signer identifier in `erc8128::
` format **Optional parameters:** * `nonce` — Unique value for replay protection * `tag` — Application-specific tag #### Signature Structured Dictionary with base64-encoded signature: ``` Signature: eth=:MEUCIQDXtPCJ5f7oKr...base64...: ``` #### Content-Digest SHA-256 hash of the request body (RFC 9530): ``` Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: ``` ### Keyid Format The `keyid` parameter identifies the signer using an ERC-8128-specific URI format: ``` erc8128::
``` * `chainId`: Decimal integer (e.g., `1` for Ethereum mainnet) * `address`: Checksummed or lowercase Ethereum address with `0x` prefix **Examples:** ``` erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045 erc8128:8453:0x1234567890abcdef1234567890abcdef12345678 erc8128:42161:0xABCDEF1234567890ABCDEF1234567890ABCDEF12 ``` ### Signature Base The message signed is an RFC 9421 signature base — a canonical representation of the HTTP request: ``` "@authority": api.example.com "@method": POST "@path": /orders "@query": ?market=ETH-USD "content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: "@signature-params": ("@authority" "@method" "@path" "@query" "content-digest");created=1618884473;expires=1618884533;nonce="abc123";keyid="erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045" ``` This is then signed as an Ethereum message: ``` keccak256("\x19Ethereum Signed Message:\n" + len(signatureBase) + signatureBase) ``` ### Binding Modes #### Request-Bound The signature MUST cover at minimum: * `@authority` * `@method` * `@path` * `@query` (if query string present) * `content-digest` (if body present) #### Class-Bound The signer explicitly specifies which components to sign. The verifier MUST enforce its own required components. ### Replay Protection #### Non-Replayable * `nonce` parameter MUST be present * Verifier MUST track consumed nonces * Verifier SHOULD reject if `expires - created` exceeds policy limit #### Replayable * `nonce` parameter MAY be absent * Verifier MAY accept based on time bounds alone * SHOULD only be used for safe, idempotent operations ### Signature Verification #### EOA (Externally Owned Account) 1. Reconstruct the signature base from the request 2. Apply EIP-191 prefix: `"\x19Ethereum Signed Message:\n" + length + message` 3. Recover the public key using `ecrecover` 4. Derive address and compare to `keyid` address #### SCA (Smart Contract Account) 1. Reconstruct the signature base from the request 2. Apply EIP-191 prefix 3. Call `isValidSignature(hash, signature)` on the contract at `keyid` address 4. Verify return value is `0x1626ba7e` (ERC-1271 magic value) ### Time Constraints * `created` MUST be a positive integer (Unix timestamp) * `expires` MUST be greater than `created` * Verifier SHOULD reject if `now < created - clockSkew` * Verifier MUST reject if `now > expires + clockSkew` * Verifier SHOULD enforce `expires - created <= maxValiditySec` ### Error Codes | Code | Meaning | | ------------------------- | -------------------------------------------- | | `missing_headers` | No Signature-Input or Signature header | | `label_not_found` | Requested label not in Signature-Input | | `bad_signature_input` | Malformed Signature-Input header | | `bad_signature` | Signature verification failed | | `bad_keyid` | Invalid keyid format | | `bad_time` | Invalid time parameters | | `not_yet_valid` | Signature not yet valid | | `expired` | Signature expired | | `validity_too_long` | Validity window exceeds policy | | `nonce_required` | Nonce required but missing | | `replayable_not_allowed` | Replayable signature not accepted by policy | | `class_bound_not_allowed` | Class-bound signature not accepted by policy | | `nonce_window_too_long` | Nonce validity window exceeds policy limit | | `replay` | Nonce already consumed | | `not_request_bound` | Required components not signed | | `digest_required` | Content-Digest header missing | | `digest_mismatch` | Content-Digest doesn't match body | | `alg_not_allowed` | Signature algorithm not permitted | | `bad_signature_bytes` | Invalid signature byte encoding | | `bad_signature_check` | Signature verification function failed | ### References * [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) — HTTP Message Signatures * [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) — Digest Fields * [RFC 8941](https://www.rfc-editor.org/rfc/rfc8941.html) — Structured Field Values * [EIP-191](https://eips.ethereum.org/EIPS/eip-191) — Signed Data Standard * [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) — Standard Signature Validation * [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492) — Signature Validation for Pre-deploy Contracts ## 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](/api/signRequest): ```typescript 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`: ```typescript 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](/api/createSignerClient) to bind the signer: ```typescript 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`. :::code-group ```typescript [Custom TTL] const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { ttlSeconds: 300 } // Valid for 5 minutes ) ``` ```typescript [Explicit Timestamps] const now = Math.floor(Date.now() / 1000) const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { created: now, expires: now + 120, // 2 minutes } ) ``` ```typescript [Custom Nonce] const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { nonce: `request-${crypto.randomUUID()}` } ) ``` ```typescript [Async Nonce Generator] const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { nonce: async () => { const res = await fetch('https://nonce.example.com/generate') return res.text() }, } ) ``` ::: ### Binding Modes Request-bound (default) signs all applicable components for maximum security. Class-bound signs only the components you explicitly specify. :::code-group ```typescript [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 ``` ```typescript [Class-Bound] // Signs only specified components — authorizes a class of requests const signedRequest = await signRequest( 'https://api.example.com/any-endpoint', signer, { binding: 'class-bound', components: ['@authority'], // Only sign the domain replay: 'replayable', // Often combined for performance } ) ``` ::: #### 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). :::warning Class-bound signatures weaken request integrity — the verifier must explicitly opt in to accepting them. A server that only accepts request-bound signatures (the default) will reject class-bound signatures. See [Server Enforcement](/concepts/request-binding#server-enforcement-with-additionalrequestboundcomponents-and-classboundpolicies) for how verifiers control this. ::: #### Additional Components Sign extra headers alongside the default request-bound set: ```typescript 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. :::code-group ```typescript [Non-Replayable (Default)] // Every request gets a unique nonce const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { replay: 'non-replayable' } ) ``` ```typescript [Replayable] // Omits the nonce — reusable within validity window const signedRequest = await signRequest( 'https://api.example.com/status', { method: 'GET' }, signer, { replay: '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](/concepts/replay-protection#early-invalidation) mechanisms. See [Replayable Signatures](/concepts/replay-protection#replayable-signatures) for the full tradeoff analysis. ### Content-Digest #### Automatic (Default) Content-Digest is computed automatically for request-bound requests with a body: ```typescript 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. ```typescript 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. ```typescript 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 ## Smart Contract Accounts How to use ERC-8128 with smart contract accounts (SCAs) like ERC-4337 wallets and Safe. ### How It Works ERC-8128 supports smart contract accounts through ERC-1271 signature verification. When verifying a signature: 1. The verifier calls `isValidSignature(hash, signature)` on the signer's contract 2. The contract returns `0x1626ba7e` (magic value) if valid 3. The signature format is contract-dependent (could be multi-sig, threshold, etc.) ### Signing with an SCA To use ERC-8128 with a smart contract account, create an `EthHttpSigner` where the `address` is the contract address and `signMessage` produces a signature the contract will accept via ERC-1271. #### Session Keys Many SCAs support session keys — temporary EOAs authorized to sign on behalf of the contract: ```typescript import type { EthHttpSigner } from '@slicekit/erc8128' const sessionKeySigner: EthHttpSigner = { // The smart contract account address address: '0xSmartContractAccount...', chainId: 1, signMessage: async (message) => { // Sign with the session key EOA const sessionSig = await sessionKeyAccount.signMessage({ message: { raw: message }, }) // Some SCAs require wrapping the signature with metadata // Check your SCA's documentation return sessionSig }, } ``` #### Delegate Signing Some SCAs allow a delegate key to sign on behalf of the contract without wrapping. The contract's `isValidSignature` implementation verifies the delegate internally: ```typescript const delegateSigner: EthHttpSigner = { address: '0xSmartContractAccount...', chainId: 1, signMessage: async (message) => { // The SCA will verify this delegate signature internally return delegateKey.signMessage({ message: { raw: message } }) }, } ``` ### Verifying SCA Signatures The `verifyMessage` function you pass to `verifyRequest` must support ERC-1271: #### With viem viem's `verifyMessage` automatically handles both EOA signatures (ecrecover) and ERC-1271 contract signatures: ```typescript import { createPublicClient, http } from 'viem' import { mainnet } from 'viem/chains' const publicClient = createPublicClient({ chain: mainnet, transport: http(), }) // Use directly in policy — works for both EOA and SCA const result = await verifyRequest({ request, verifyMessage: publicClient.verifyMessage, nonceStore }) ``` No extra configuration is needed. When the signer address is a contract, viem automatically calls `isValidSignature` on it instead of using ecrecover. ### Common SCA Patterns #### Safe Safe accounts require collecting signatures from multiple owners before producing a combined signature that passes ERC-1271 validation. Use the Safe Protocol Kit to manage this: ```typescript import Safe from '@safe-global/protocol-kit' const protocolKit = await Safe.init({ provider: RPC_URL, signer: OWNER_PRIVATE_KEY, safeAddress: SAFE_ADDRESS, }) const safeSigner: EthHttpSigner = { address: SAFE_ADDRESS, chainId: 1, signMessage: async (message) => { // Create and sign the message with the Protocol Kit const safeMessage = protocolKit.createMessage(bytesToHex(message)) const signed = await protocolKit.signMessage(safeMessage) // Encode signatures for ERC-1271 verification return buildSignatureBytes( Array.from(signed.signatures.values()) ) }, } ``` For multi-owner Safes, collect signatures from each owner via `protocolKit.signMessage()` before encoding. See the [Safe SDK docs](https://docs.safe.global/sdk/protocol-kit/guides/signatures/messages) for details. #### ERC-4337 Wallets Most ERC-4337 smart accounts expose a `signMessage` method through their SDK. The exact API varies by provider (ZeroDev, Alchemy, Biconomy, etc.): ```typescript const erc4337Signer: EthHttpSigner = { address: smartAccountAddress, chainId: 1, signMessage: async (message) => { // Most 4337 wallets expose a signMessage method // The exact API depends on the wallet provider return smartAccount.signMessage({ message: { raw: message } }) }, } ``` ### Chain Considerations When using SCAs, the `chainId` in the keyid matters: 1. **Same chain verification** — If the verifier is on the same chain as the SCA, ERC-1271 calls work directly. 2. **Cross-chain verification** — If verifying on a different chain, the verifier must have an RPC client for the SCA's chain. Use `parseKeyId` to extract the chain and route to the right client: ```typescript import { parseKeyId } from '@slicekit/erc8128' // Multi-chain verifier using the keyid from the signed request function getVerifyMessage(keyid: string): VerifyMessageFn { const parsed = parseKeyId(keyid) if (!parsed) throw new Error('Invalid keyid') const publicClient = getClientForChain(parsed.chainId) return publicClient.verifyMessage } ``` ### Security Considerations 1. **Session key expiry** — Ensure session keys have appropriate TTLs 2. **Signature validity** — SCA signatures may have their own expiry 3. **Contract state** — SCA authorization state can change (signers removed, thresholds changed) 4. **RPC dependency** — ERC-1271 verification requires an RPC call (`eth_call`), adding latency compared to EOA ecrecover ### Best Practices 1. **Cache contract code** — Check if address is a contract once, not per request 2. **Use appropriate timeouts** — ERC-1271 calls can be slow 3. **Handle contract errors** — Contracts may revert for various reasons 4. **Log SCA verification** — Debugging SCA issues requires good observability ## Verifying Requests Learn how to verify ERC-8128 signed requests on your server. ### Overview Verification confirms that a request was signed by the claimed Ethereum address and hasn't been tampered with. The process checks: * Signature validity (cryptographic verification) * Timing (created/expires window) * Replay protection (nonce consumption) * Request binding (signed components match) ### Basic Verification The simplest way to verify a request using [verifyRequest](/api/verifyRequest): ```typescript import { verifyRequest } from '@slicekit/erc8128' const result = await verifyRequest({ request, verifyMessage, nonceStore }) if (result.ok) { console.log(`Authenticated: ${result.address}`) console.log(`Chain: ${result.chainId}`) } else { console.log(`Failed: ${result.reason}`) } ``` ### Using createVerifierClient For repeated verification with the same dependencies, use [createVerifierClient](/api/createVerifierClient) to bind `verifyMessage` and `nonceStore`: ```typescript import { createVerifierClient } from '@slicekit/erc8128' const verifier = createVerifierClient({ verifyMessage, nonceStore, defaults: { maxValiditySec: 120, }, }) const result = await verifier.verifyRequest({ request, policy: { strictLabel: true }, }) ``` ### Required Setup #### Nonce Store For replay protection, you need a store that tracks consumed nonces. :::code-group ```typescript [Redis (Production)] import type { NonceStore } from '@slicekit/erc8128' import { Redis } from 'ioredis' const redis = new Redis() export const nonceStore: NonceStore = { async consume(key: string, ttlSeconds: number) { const result = await redis.set(`nonce:${key}`, '1', 'EX', ttlSeconds, 'NX') return result === 'OK' }, } ``` ```typescript [In-Memory (Development)] import type { NonceStore } from '@slicekit/erc8128' const seen = new Set() export const nonceStore: NonceStore = { consume: async (key: string) => { if (seen.has(key)) return false seen.add(key) return true }, } ``` ::: ### Policy Configuration Choose the appropriate policy based on your endpoint's security requirements: :::code-group ```typescript [Strict (Mutations)] // For high-security endpoints that modify state const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { replayable: false, maxValiditySec: 60, // Short window maxNonceWindowSec: 60, clockSkewSec: 5, // Small tolerance }, }) ``` ```typescript [Relaxed (Reads)] // For idempotent read operations const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { replayable: true, // Allow replayable signatures maxValiditySec: 300, // Longer window clockSkewSec: 30, // More tolerance }, }) ``` ::: #### Label Selection When multiple signatures exist on a request: :::code-group ```typescript [Default (Prefer Label)] // Prefer "eth" label, fall back to first valid const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { label: 'eth', }, }) ``` ```typescript [Strict (Require Label)] // Require exact label match const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { label: 'user', strictLabel: true, // Fail if "user" label not found }, }) ``` ::: #### Component Policies By default, the verifier enforces that signatures are **request-bound** — covering `@authority`, `@method`, `@path`, plus `@query` and `content-digest` when the request has a query string or body. This rejects all class-bound signatures. Non-replayable request-bound signatures always pass when their components match the required policy. Other cases depend on the configured policies and signature params (e.g. `replayable`, class-bound policy, timing, nonce window). Use `additionalRequestBoundComponents` to require additional components alongside the default request-bound set. Use `classBoundPolicies` to opt into class-bound signatures by listing acceptable component sets (order does not matter): ```typescript // Default: rejects class-bound signatures (full request-bound check) const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore }) // Require custom headers in request-bound signatures const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { additionalRequestBoundComponents: ['x-idempotency-key'], }, }) // Accept class-bound signatures that cover a minimal policy const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy: { classBoundPolicies: ['@authority', '@method'], }, }) ``` See [Server Enforcement](/concepts/request-binding#server-enforcement-with-additionalrequestboundcomponents-and-classboundpolicies) for details on default behavior, custom components, and the difference between request-bound and class-bound verification. :::info The component order in signatures does not affect verification — the library reads component order from the signature inputs. This allows clients to produce signatures with components in any order, and verifiers to accept them as long as the required components are present. ::: #### Signature Selection Order When a request contains multiple signatures (multiple labels in the headers), the verifier: 1. Filters to ERC-8128 keyIds 2. Filters to signatures allowed by your request-bound/class-bound policies 3. Verifies the remaining candidates in the exact order they appear in `Signature-Input` The first signature that passes all checks (cryptographic verification, time bounds, nonce, component policy) is accepted. ### Framework Integration Drop `verifyRequest` into middleware or route handlers. These examples show integration patterns for Hono, Next.js, and Express. :::code-group ```typescript [Hono] import { verifyRequest } from '@slicekit/erc8128' import { createMiddleware } from 'hono/factory' const erc8128Auth = createMiddleware(async (c, next) => { const result = await verifyRequest({ request: c.req.raw, verifyMessage: verifyMessageFn, nonceStore, policy: { maxValiditySec: 300, }, }) if (!result.ok) { return c.json({ error: 'Unauthorized', reason: result.reason }, 401) } c.set('auth', { address: result.address, chainId: result.chainId, }) await next() }) ``` ```typescript [Next.js] import { verifyRequest } from '@slicekit/erc8128' import type { NextRequest } from 'next/server' export async function POST(req: NextRequest) { const result = await verifyRequest({ request: req, verifyMessage: verifyMessageFn, nonceStore }) if (!result.ok) { return Response.json({ error: result.reason }, { status: 401 }) } // Authenticated! Use result.address return Response.json({ address: result.address }) } ``` ```typescript [Express] import { verifyRequest } from '@slicekit/erc8128' import type { Request, Response, NextFunction } from 'express' const erc8128Auth = async (req: Request, res: Response, next: NextFunction) => { // Convert Express request to Fetch Request const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` const fetchRequest = new Request(url, { method: req.method, headers: req.headers as HeadersInit, body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined, }) const result = await verifyRequest({ request: fetchRequest, verifyMessage: verifyMessageFn, nonceStore, policy: { maxValiditySec: 300, }, }) if (!result.ok) { return res.status(401).json({ error: 'Unauthorized', reason: result.reason, }) } // Attach identity to request req.auth = { address: result.address, chainId: result.chainId, } next() } ``` ::: ### Handling Failures When verification fails, inspect `result.reason` to return appropriate HTTP status codes and error messages. ```typescript const result = await verifyRequest({ request, verifyMessage: verifyMessageFn, nonceStore, policy, }) if (!result.ok) { switch (result.reason) { case 'missing_headers': // No signature present return { status: 401, error: 'Authentication required' } case 'expired': case 'not_yet_valid': case 'validity_too_long': // Time-based rejection return { status: 401, error: 'Signature expired or not yet valid' } case 'replay': case 'replayable_not_allowed': case 'nonce_window_too_long': // Replay protection rejection return { status: 401, error: 'Request replay detected or not allowed' } case 'bad_signature': case 'bad_signature_bytes': case 'bad_signature_check': // Cryptographic verification failed return { status: 401, error: 'Invalid signature' } case 'digest_mismatch': case 'digest_required': // Body integrity issue return { status: 400, error: 'Body integrity check failed' } case 'not_request_bound': case 'class_bound_not_allowed': // Policy rejection return { status: 401, error: 'Signature does not meet security requirements' } default: return { status: 401, error: `Authentication failed: ${result.reason}` } } } ``` ### Multi-Chain Support The `chainId` in the verification result tells you which chain the signer claims to be on: ```typescript if (result.ok) { const { address, chainId } = result // Verify the signer has permissions on this chain const hasAccess = await checkPermissions(address, chainId) if (!hasAccess) { // Return 403 Forbidden } } ``` ### Best Practices 1. **Always use nonce stores in production** — In-memory stores don't work across server restarts or multiple instances. 2. **Keep validity windows short** — 60-300 seconds is reasonable. Longer windows increase replay risk. 3. **Use strict label matching** when you expect a specific signature source. 4. **Log verification failures** — They can indicate attacks or client bugs. 5. **Consider clock skew** — Allow 5-30 seconds for network latency and clock drift. 6. **Use Redis or a distributed store** — Essential for multi-instance deployments. ## Installation Install `@slicekit/erc8128` — the official ERC-8128 implementation for signing HTTP requests with Ethereum accounts. ### Install Add the package using your preferred package manager: :::code-group ```bash [bun] bun add @slicekit/erc8128 ``` ```bash [pnpm] pnpm add @slicekit/erc8128 ``` ```bash [npm] npm install @slicekit/erc8128 ``` ```bash [yarn] yarn add @slicekit/erc8128 ``` ::: ### Requirements * **Bun** 1.0+ (recommended), Node.js 18+, or modern browser with Web Crypto API * **TypeScript** 5.0+ (recommended, but not required) ### Exports Everything is available from the main entry point — client functions, server functions, utilities, types, and error classes. ```typescript import { // Client createSignerClient, signRequest, signedFetch, // Server createVerifierClient, verifyRequest, // Utilities formatKeyId, parseKeyId, // Types type EthHttpSigner, type SignOptions, type VerifyPolicy, type VerifyResult, type NonceStore, type VerifyMessageFn, // Errors Erc8128Error, } from '@slicekit/erc8128' ``` ### TypeScript `@slicekit/erc8128` is written in TypeScript and ships with full type definitions. No additional `@types/*` packages needed. Types are exported directly from the main entry point: ```typescript import type { EthHttpSigner, SignOptions, VerifyPolicy, VerifyResult, } from '@slicekit/erc8128' ``` ### Ethereum Signing For producing Ethereum signatures, any library or wallet that supports `personal_sign` (EIP-191) will work. The examples in this documentation use [viem](https://viem.sh). ### Next Steps * [Quick Start](/getting-started/quick-start) — Sign your first request in 5 minutes * [Concepts](/concepts/overview) — Understand the security model ## Quick Start Learn to sign and verify HTTP requests with ERC-8128 in 5 minutes. ### 1. Install Add `@slicekit/erc8128` to your project: :::code-group ```bash [bun] bun add @slicekit/erc8128 ``` ```bash [pnpm] pnpm add @slicekit/erc8128 ``` ```bash [npm] npm install @slicekit/erc8128 ``` ```bash [yarn] yarn add @slicekit/erc8128 ``` ::: ### 2. Sign a Request Create a signer from an Ethereum account, wrap it in a client, and use `client.fetch` to sign and send requests. :::code-group ```typescript [main.ts] import { client } from './client' const response = await client.fetch( 'https://api.example.com/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ side: 'buy', amount: '1.5' }), } ) ``` ```typescript [client.ts] import { createSignerClient } from '@slicekit/erc8128' import { signer } from './signer' export const client = createSignerClient(signer) ``` ```typescript [signer.ts] import type { EthHttpSigner } from '@slicekit/erc8128' import { privateKeyToAccount } from 'viem/accounts' const account = privateKeyToAccount('0x...') export const signer: EthHttpSigner = { chainId: 1, address: account.address, signMessage: async (message) => { return account.signMessage({ message: { raw: message } }) }, } ``` ::: The signed request has these headers added: * `Signature-Input` — Components and parameters * `Signature` — Ethereum signature * `Content-Digest` — SHA-256 hash of body ### 3. Verify Request On the server, use `createVerifierClient` to bind a nonce store and a message verification function. The result contains the authenticated address and chain ID. :::code-group ```typescript [verify.ts] import { verifier } from './client' const result = await verifier.verifyRequest({ request: request }) if (result.ok) { console.log(`Authenticated: ${result.address} on chain ${result.chainId}`) } else { console.log(`Failed: ${result.reason}`) } ``` ```typescript [client.ts] import { createVerifierClient } from '@slicekit/erc8128' import { createPublicClient, http } from 'viem' import { mainnet } from 'viem/chains' import { nonceStore } from './nonce' export const publicClient = createPublicClient({ chain: mainnet, transport: http(), }) export const verifier = createVerifierClient({ verifyMessage: publicClient.verifyMessage, nonceStore }) ``` ```typescript [nonce.ts] import type { NonceStore } from '@slicekit/erc8128' // Use KV in production const seen = new Set() export const nonceStore: NonceStore = { consume: async (key: string) => { if (seen.has(key)) return false seen.add(key) return true } } ``` ::: ### What Just Happened? 1. **Client signed the request** — The signature covers `@authority`, `@method`, `@path`, `@query`, and `content-digest`. Any tampering fails verification. 2. **Automatic nonce** — By default, a unique nonce is generated for replay protection. 3. **Server verified** — The server reconstructed the signature base, verified the Ethereum signature, and checked the nonce wasn't reused. ### Defaults | Setting | Default | Meaning | | ------------ | ------------------ | ------------------------------ | | `binding` | `"request-bound"` | Sign all applicable components | | `replay` | `"non-replayable"` | Include auto-generated nonce | | `ttlSeconds` | `60` | Signature valid for 1 minute | | `label` | `"eth"` | Signature label in headers | ### Next Steps * [Concepts Overview](/concepts/overview) — Understand request binding and replay protection * [Signing Requests](/guides/signing-requests) — Full guide with all options * [Verifying Requests](/guides/verifying-requests) — Production verification setup * [Smart Contract Accounts](/guides/smart-contract-accounts) — Using ERC-1271 signers ## Concepts Overview ERC-8128 combines three technologies to create a secure HTTP authentication system: 1. **RFC 9421** — HTTP Message Signatures standard 2. **EIP-191** — Ethereum Signed Message format 3. **ERC-1271** — Smart contract signature verification ### The Problem Traditional HTTP authentication has limitations: * **Bearer tokens** (JWTs, API keys) can be stolen and reused * **Session cookies** require server-side state * **OAuth** requires a handshake and centralized identity providers What if the client could prove their identity on every request using their existing Ethereum keys? ### The Solution ERC-8128 lets Ethereum accounts sign HTTP requests directly: ``` ┌─────────────┐ Signed Request ┌─────────────┐ │ │ ───────────────────▶ │ │ │ Client │ Signature-Input │ Server │ │ (signer) │ Signature │ (verifier) │ │ │ Content-Digest │ │ └─────────────┘ └─────────────┘ │ │ │ Signs with ETH key │ Verifies signature ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ EOA │ │ ecrecover │ │ or │ │ or │ │ SCA │ │ ERC-1271 │ └─────────────┘ └─────────────┘ ``` ### Core Components #### Signature Base The "message" being signed is an RFC 9421 **signature base** — a canonical representation of the HTTP request: ``` "@authority": api.example.com "@method": POST "@path": /orders "@query": ?market=ETH-USD "content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: "@signature-params": ("@authority" "@method" "@path" "@query" "content-digest");created=1618884473;expires=1618884533;nonce="abc123";keyid="erc8128:1:0x1234...abcd" ``` This is signed as an Ethereum message (EIP-191). #### Headers Three headers carry the signature: | Header | Purpose | | ----------------- | ------------------------------------ | | `Signature-Input` | Lists what was signed and parameters | | `Signature` | The cryptographic signature | | `Content-Digest` | SHA-256 hash of the body | Example: ```http POST /orders HTTP/1.1 Host: api.example.com Content-Type: application/json Signature-Input: eth=("@authority" "@method" "@path" "content-digest");created=1618884473;expires=1618884533;nonce="abc123";keyid="erc8128:1:0x1234...abcd" Signature: eth=:MEUCIQDXtPCJ5...base64...: Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: {"amount": "100"} ``` #### Keyid Format The `keyid` identifies the signer: ``` erc8128::
``` Examples: * `erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045` — Ethereum mainnet * `erc8128:8453:0x1234...abcd` — Base * `erc8128:42161:0x1234...abcd` — Arbitrum ### Security Properties | Property | How It's Achieved | | --------------------- | --------------------------------------------------- | | **Authentication** | Signature proves knowledge of private key | | **Integrity** | Tampering with signed components fails verification | | **Replay Protection** | Nonce prevents request reuse | | **Time Bounds** | `created`/`expires` limit validity window | ### Binding Modes #### Request-Bound (Required) To be ERC-8128 compliant, implementations **must** support request-bound signatures. This mode signs everything relevant to the request: * `@authority` — Domain (prevents use on different server) * `@method` — HTTP method (prevents GET→POST) * `@path` — Path (prevents use on different endpoint) * `@query` — Query string (if present) * `content-digest` — Body hash (if body present) Request-bound provides full request integrity. The request sender can always choose this strictest option for maximum security. #### Class-Bound (Optional) A class-bound signature authorizes a *class of requests* rather than a single concrete request. It signs only specified components, so a single signature can apply to any request matching those components: * A signature covering only `@authority` works for any endpoint on that domain * Reduces signing and verification overhead for high-frequency, low-risk operations * Enables broad authorization patterns (e.g., "any GET on this API") The signer chooses to produce a class-bound signature, but **the verifier decides whether to accept it**. By default, verifiers reject class-bound signatures — they must explicitly opt in via `classBoundPolicies`. See [Request Binding](/concepts/request-binding#class-bound-signatures) for details. :::info Class-bound signatures sign fewer components, which means the unsigned parts of the request are not integrity-protected. The verifier and signer decide together whether this tradeoff is appropriate for their use case. ::: #### Signature Selection Priority When multiple signatures are present on a request, the verifier evaluates them in priority order: 1. **Request-bound signatures** are tried first (strongest security) 2. **Class-bound signatures** are tried in order of policy specificity (fewer required components = less restrictive = tried first) 3. **Header order** breaks ties within the same priority tier This ensures the most specific authorization is accepted when available, while still allowing broader authorizations when explicitly configured. ### Replay Protection Two modes, chosen by the signer: | Mode | Nonce | Properties | | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `non-replayable` | Required | Single-use authorization. Verifier must track nonces. | | `replayable` | Omitted | Reusable within validity window. No nonce state needed. Verifier must implement [early invalidation](/concepts/replay-protection#early-invalidation). | Non-replayable is the baseline — all compliant verifiers must accept it. Replayable acceptance is optional and up to the verifier. The verifier can define different security profiles on a per-endpoint or per-request basis. For example, critical endpoints (like `/transfer` or `/admin`) may require request-bound, non-replayable signatures for maximum security, while read-only or low-risk endpoints (like `/balance` or `/profile`) could accept class-bound or replayable signatures for better performance. See [Replay Protection](/concepts/replay-protection) for the full tradeoff analysis. ### Account Types | Account Type | Signature | Verification | | ------------ | ---------------- | --------------------------- | | EOA | ECDSA (65 bytes) | `ecrecover` | | SCA | Contract-defined | ERC-1271 `isValidSignature` | Both are supported transparently — the verifier checks if the address is a contract and uses the appropriate method. ### Why RFC 9421? RFC 9421 (HTTP Message Signatures) is an IETF standard that provides: * Canonical serialization of HTTP components * Extensible component system * Multi-signature support * Time bounds and nonce support ERC-8128 is a profile of RFC 9421 with Ethereum-specific defaults. ### Next Steps * [Request Binding](/concepts/request-binding) — Deep dive into what gets signed * [Replay Protection](/concepts/replay-protection) — Nonce handling details * [Security Model](/concepts/security-model) — Threat model and mitigations ## Replay Protection Replay attacks occur when an attacker captures a valid signed request and resends it. ERC-8128 provides multiple layers of defense. ### The Threat Without protection: ``` 1. Alice signs: POST /api/orders {"item": "coffee", "quantity": 1} 2. Attacker intercepts the signed request 3. Attacker replays it 100 times 4. Alice has 100 orders instead of 1 ``` ### Defense Layers ERC-8128 provides three complementary protections: | Layer | Protection | Bypass Difficulty | | --------------- | ----------------------- | ------------------------ | | Time bounds | `expires` parameter | Wait and retry | | Nonce | Unique per request | Requires new nonce | | Request binding | Components in signature | Requires valid signature | ### Time Bounds Every signature has `created` and `expires` timestamps: ``` Signature-Input: eth=(...);created=1618884473;expires=1618884533;... ``` :::code-group ```typescript [Client Side] const signedRequest = await signRequest( request, signer, { ttlSeconds: 60, // Default: 60 seconds // Or explicit timestamps: // created: Math.floor(Date.now() / 1000), // expires: Math.floor(Date.now() / 1000) + 120, } ) ``` ```typescript [Server Side] const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { maxValiditySec: 300, // Max 5 minutes allowed clockSkewSec: 30, // Allow 30s clock drift now: () => Math.floor(Date.now() / 1000), // Time source ... }, }) ``` ::: #### Failure Reasons * `not_yet_valid` — `now < created` (clock skew issue) * `expired` — `now > expires` * `validity_too_long` — `expires - created > maxValiditySec` * `bad_time` — Invalid time values (e.g., `expires <= created`) ### Nonces For non-replayable requests, a unique nonce ensures each request can only be used once: ``` Signature-Input: eth=(...);nonce="a1b2c3d4-e5f6-7890";... ``` :::code-group ```typescript [Client: Auto Nonce] // Auto-generated (default) const signedRequest = await signRequest( request, signer, { replay: 'non-replayable' } // Default ) ``` ```typescript [Client: Custom Nonce] // Custom nonce const signedRequest = await signRequest( request, signer, { nonce: `${Date.now()}-${crypto.randomUUID()}` } ) ``` ```typescript [Client: Async Nonce] // Async nonce generator const signedRequest = await signRequest( request, signer, { nonce: async () => { const res = await fetch('https://nonce-service.example.com/generate') return res.text() }, } ) ``` ```typescript [Server] const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { replayable: false, // Reject requests without nonce maxNonceWindowSec: 60, // Optional: limit nonce validity window ... }, }) ``` ::: #### Nonce Store The nonce store tracks consumed nonces: ```typescript interface NonceStore { consume(key: string, ttlSeconds: number): Promise } ``` **Requirements:** * Atomic — no race conditions * TTL — automatic cleanup **Redis example:** ```typescript const nonceStore: NonceStore = { async consume(key, ttlSeconds) { const result = await redis.set( `nonce:${key}`, '1', 'EX', ttlSeconds, 'NX' // Only set if not exists ) return result === 'OK' }, } ``` #### Nonce Key Format By default: `${keyid}:${nonce}` This scopes nonces per signer. Custom format: ```typescript const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { nonceKey: (keyid, nonce) => `myapp:${keyid}:${nonce}`, ... }, }) ``` ### Replayable Signatures A **replayable** signature omits the `nonce` parameter, which means it can be reused within its validity window. This is a deliberate security tradeoff — the signer chooses a lower security posture, and the verifier decides whether to accept it. Like non-replayable signatures, replayable signatures are still time-bounded (`created`/`expires`) and can be request-bound. They provide authentication and integrity for every request — comparable to bearer credentials like JWTs or API keys, but with the additional benefit that the proof is cryptographically bound to the signer's Ethereum address and (if request-bound) to the specific request components. #### Why Use Replayable Signatures **From the signer's perspective:** * **Reduced overhead** — No need to generate a unique nonce per request. In high-frequency scenarios, this avoids the cost of nonce generation and the dependency on nonce sources. * **Cacheable authorization** — The same signed proof can be reused across multiple identical requests within the validity window, avoiding repeated signing operations. **From the verifier's perspective:** * **No nonce state** — Verifiers don't need to persist nonce state. The `nonceStore` parameter is still required by the API, but it won't be used when the request has no nonce. * **Verification caching** — Because the same signature may appear on multiple requests, verifiers can cache successful verification results (keyed by signature bytes) to avoid repeating expensive cryptographic checks. This is most relevant for high-frequency clients and large-scale gateways where repeated signature verification becomes a material CPU cost. #### Security Properties Replayable signatures provide **authentication** (proof of signer identity) and **integrity** (if request-bound, tampering detection) but do not provide **single-use authorization**. Anyone who intercepts a valid replayable signature can reuse it until it expires. This is the same security model as bearer credentials (cookies, JWTs, API keys) — the credential is valid until it expires or is revoked. The key difference is that replayable ERC-8128 signatures are self-issued, tied to an Ethereum identity, and can be request-bound. | Property | Non-Replayable | Replayable | Bearer Token (JWT) | | --------------------- | -------------------- | -------------------- | ------------------- | | Authentication | ✅ | ✅ | ✅ | | Request integrity | ✅ (if request-bound) | ✅ (if request-bound) | ❌ | | Single-use | ✅ | ❌ | ❌ | | Requires server state | Nonce store | No | Session/token store | | Self-issued | ✅ | ✅ | ❌ (server-issued) | #### Early Invalidation Because replayable signatures lack cryptographic replay protection, verifiers that accept them **must** implement early invalidation mechanisms that the signer can trigger. This ensures signers can revoke authorization before the signature's natural expiration. Required mechanisms include at least one of: * **Per-`keyid` not-before timestamps** — Invalidate all signatures for an address created before a certain time * **Per-signature invalidation** — Invalidate a specific signature by its fingerprint Importantly, any request that triggers early invalidation must itself be authenticated with a **request-bound** signature. This prevents a single authorization proof from being used to revoke multiple, unrelated authorizations. :::code-group ```typescript [Client] // Omit nonce const signedRequest = await signRequest( 'https://api.example.com/status', { method: 'GET' }, signer, { replay: 'replayable' } ) ``` ```typescript [Server] // Allow missing nonce, must implement early invalidation const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { replayable: true, // Accept with or without nonce }, }) ``` ::: #### Combining with Class-Bound Replayable and class-bound are orthogonal and can be combined. A replayable, class-bound signature covering only `@authority` provides the broadest authorization: any request to the domain, reusable within the validity window. This is the lowest security posture but can be useful in narrowly scoped, low-risk scenarios where performance is a priority. ```typescript const signedRequest = await signRequest( request, signer, { binding: 'class-bound', components: ['@authority'], replay: 'replayable', } ) ``` All four combinations are valid: | Binding | Replay | Security Level | Use Case | | ------------- | -------------- | ------------------------------- | ---------------------------------------------- | | Request-bound | Non-replayable | Strongest (baseline) | State changes, payments | | Request-bound | Replayable | Strong integrity, no single-use | High-frequency requests to specific endpoints | | Class-bound | Non-replayable | Single-use, broad scope | One-time authorization for a class of requests | | Class-bound | Replayable | Lowest | Broad, reusable access tokens | ### Nonce Policy Options | Policy | Behavior | | ----------- | ---------------------------- | | `required` | Reject if no nonce (default) | | `optional` | Accept with or without nonce | | `forbidden` | Reject if nonce present | ### Best Practices 1. **Non-replayable is the default** — The baseline requires it, and all compliant verifiers must accept it 2. **Keep validity windows short** — For non-replayable signatures, 5–30 seconds is typical. For replayable signatures, set the window based on how long the signature should remain valid. The library defaults to 60 seconds (`ttlSeconds`), but adjust based on your use case 3. **Set `maxNonceWindowSec`** — For non-replayable signatures, limit how long nonces must be retained 4. **Implement early invalidation** — If accepting replayable signatures, provide signer-triggered revocation mechanisms 5. **Monitor replay patterns** — Log verification results for security analysis ## Request Binding Request binding determines which parts of the HTTP request are cryptographically protected by the signature. ### Request-Bound (Default) For request-bound signatures, the library automatically selects components based on the request: | Condition | Components Signed | | ---------------- | -------------------------------- | | All requests | `@authority`, `@method`, `@path` | | Has query string | `+ @query` | | Has body | `+ content-digest` | Example for `POST /orders?market=ETH` with body: ``` Signature-Input: eth=("@authority" "@method" "@path" "@query" "content-digest");... ``` ### Why These Components? #### @authority (Host) Prevents the signature from being used on a different server. Without `@authority`: ``` Attacker captures: POST https://api.example.com/transfer Replays to: POST https://malicious.com/transfer ❌ (would work!) ``` With `@authority`: ``` Signature binds to api.example.com Replay to malicious.com → verification fails ✓ ``` #### @method Prevents changing the HTTP method. Without `@method`: ``` Signed: GET /resource Attacker changes to: POST /resource ❌ (could create data!) ``` #### @path Prevents using the signature on a different endpoint. Without `@path`: ``` Signed: POST /api/v1/orders Attacker uses: POST /api/v1/admin/delete ❌ (different action!) ``` #### @query Prevents tampering with query parameters. Without `@query`: ``` Signed: GET /search?q=cats Attacker changes: GET /search?q=dogs&admin=true ❌ ``` #### content-digest Prevents tampering with the request body. Without `content-digest`: ``` Signed: POST /transfer with body {"amount": "10"} Attacker changes: POST /transfer with body {"amount": "1000000"} ❌ ``` ### Content-Digest Header For body integrity, ERC-8128 uses the `Content-Digest` header (RFC 9530): ```http Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: ``` The signature covers this header, and the server verifies that the header matches the actual body bytes. #### Automatic Handling When signing a request-bound request with a body, the `Content-Digest` header is computed and added automatically. ```typescript // Body present → Content-Digest added automatically const signedRequest = await signRequest( 'https://api.example.com/data', { method: 'POST', body: '{"value": 42}' }, signer ) // Content-Digest: sha-256=:hash: is added ``` #### Verification During verification, the server checks that the `Content-Digest` header matches the actual body bytes. A mismatch indicates tampering. ```typescript const result = await verifyRequest({ request, verifyMessage, nonceStore }) // If content-digest is in components: // 1. Header must exist // 2. Hash must match body bytes // Otherwise: result.reason === "digest_mismatch" or "digest_required" ``` ### Class-Bound Signatures A **class-bound** signature authorizes a *class of requests* rather than a single concrete request. Instead of binding the signature to all request components (method, path, query, body), only the explicitly specified components are signed. Any request that matches the signed components is authorized by the same signature. This is a signer-side decision: the signer chooses to produce a less specific signature. However, **it is entirely up to the verifier whether to accept class-bound signatures**. The ERC-8128 baseline requires every compliant verifier to accept request-bound signatures, but class-bound acceptance is optional — verifiers MAY reject them or require additional conditions. #### Why Use Class-Bound Signatures **From the signer's perspective:** * **Reduced overhead** — A single signature can authorize multiple requests (e.g., any endpoint on a domain), avoiding the cost of signing every request individually. * **Performance** — In high-frequency scenarios, computing and transmitting a new signature per request adds latency. A class-bound signature can be reused across requests that fall within the same class. * **Broad authorization patterns** — Useful for cases like "authorize any GET request to this API" without specifying each endpoint. **From the verifier's perspective:** * **Simpler validation for low-risk operations** — For idempotent read endpoints, verifying that the request comes from a specific address on a specific domain may be sufficient without checking the exact path or query. * **Reduced verification overhead** — Fewer components to reconstruct and verify can reduce CPU cost at scale, especially when combined with replayable signatures where verification results can be cached. * **Flexible authorization models** — Enables patterns where a single proof authorizes access to a set of resources rather than a single endpoint. :::warning Class-bound signatures weaken request integrity. A signature that only covers `@authority` could be used on *any* endpoint of that domain with *any* method, path, query, or body. Only accept class-bound signatures when you understand exactly which request properties are *not* protected. ::: #### Signing Class-Bound Requests When using `binding: 'class-bound'`, you must explicitly specify which components to sign via the `components` array. The library always includes `@authority` (required by the spec). ```typescript const signedRequest = await signRequest( request, signer, { binding: 'class-bound', components: ['@authority'], // Only the domain replay: 'replayable', // Often combined for performance } ) ``` #### Common Patterns | Components | Authorization Scope | Use Case | | ------------------------------------ | ------------------------------------------- | ------------------------------------------------------------ | | `['@authority']` | Any request to this domain | Domain-level access tokens | | `['@authority', '@method']` | Any request with this method on this domain | "Authorize all GETs" | | `['@authority', '@path']` | Any request to this specific endpoint | Endpoint access without method restriction | | `['@authority', '@method', '@path']` | Specific method + endpoint, any query/body | Read endpoints where query params don't affect authorization | #### Security Tradeoffs Each omitted component widens the class of requests the signature authorizes: | Omitted Component | Risk | When Acceptable | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------- | | `@method` | Signature valid for GET, POST, DELETE, etc. on the same target | All methods on the target have equivalent authorization | | `@path` | Signature valid for any endpoint on the domain | All endpoints share the same access level | | `@query` | Query parameters can be changed freely | Query params don't affect authorization or data access | | `content-digest` | Request body can be modified | No body, or body content doesn't affect authorization | ### Adding Extra Components Sign additional headers alongside the default set: ```typescript const signedRequest = await signRequest( request, signer, { components: ['x-idempotency-key', 'x-request-id'] } ) // Signature covers: @authority + @method + @path + @query + content-digest + x-idempotency-key + x-request-id ``` ### Server Enforcement with `additionalRequestBoundComponents` and `classBoundPolicies` Verification behavior is controlled by two policy inputs: * `additionalRequestBoundComponents` adds required components **in addition to** the default request-bound set. * `classBoundPolicies` opts into class-bound signatures by listing acceptable component sets. #### Default Behavior (no policy fields) When neither field is set, the verifier enforces the full **request-bound** check. This means the signature must cover the complete set of components that the ERC-8128 spec requires for the specific request: * `@authority`, `@method`, `@path` — always required * `@query` — required if the request has a query string * `content-digest` — required if the request has a body If the signature omits any of these (given the request shape), verification fails with `not_request_bound`. This is the strictest mode and the default — it **rejects all class-bound signatures**. ```typescript // Default: enforces full request-bound check const result = await verifyRequest({ request, verifyMessage, nonceStore }) // A class-bound signature (e.g. only @authority) would be rejected ``` #### Adding Extra Request-Bound Components Use `additionalRequestBoundComponents` to require additional components alongside the default set: ```typescript const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { additionalRequestBoundComponents: ['x-api-version', 'x-idempotency-key'], }, }) ``` The signature must now cover the request-bound set **plus** `x-api-version` and `x-idempotency-key`. Order does not matter; the verifier checks set inclusion. #### Accepting Class-Bound Signatures Use `classBoundPolicies` to opt in to class-bound signatures. Each policy list is an acceptable component set (order does not matter). If any policy is covered by the signed components, the signature can be accepted: ```typescript const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { classBoundPolicies: [ ['@authority', '@method'], ['@authority', '@path'], ], }, }) ``` Request-bound signatures are **always** accepted when valid, even if `classBoundPolicies` is set. The verifier also requires `@authority` in every class-bound policy (it is added if missing). ### Best Practices 1. **Use request-bound** as the default — it's the safest option 2. **Sign idempotency keys** — if your API uses them 3. **Be explicit about requirements** — configure `additionalRequestBoundComponents` and `classBoundPolicies` on the server 4. **Audit class-bound usage** — ensure you understand what's not being signed ## Security Model Understanding the security guarantees and limitations of ERC-8128. ### Threat Model ERC-8128 protects against: | Threat | Protection | | --------------------- | -------------------------------------- | | **Impersonation** | Signature proves key ownership | | **Request tampering** | Signed components detect modifications | | **Replay attacks** | Nonces and time bounds | | **Man-in-the-middle** | Request binding to specific authority | ERC-8128 does **not** protect against: | Threat | Why | Mitigation | | ------------------ | ------------------------ | ------------------------ | | Private key theft | Out of scope | Secure key storage | | TLS stripping | Transport security | Use HTTPS | | Response tampering | Only requests are signed | Sign responses too | | Timing attacks | Implementation detail | Constant-time comparison | ### Security Properties #### Authentication The signature proves the signer controls the private key for `address`: * **EOA**: ECDSA signature verification via `ecrecover` * **SCA**: Contract-defined via ERC-1271 `isValidSignature` #### Integrity Tampering with signed components causes verification failure: ``` Original: POST /transfer {"amount": "100"} Tampered: POST /transfer {"amount": "1000000"} ^^^^^^^^^^^^^^^^^^^ Different body → different content-digest → signature invalid ``` #### Non-Repudiation The signer cannot deny having signed a request (assuming key wasn't compromised). #### Freshness Time bounds ensure signatures aren't valid indefinitely: ``` created=1618884473 → signature starts being valid expires=1618884533 → signature stops being valid (60 second window) ``` ### Attack Scenarios #### Replay Attack **Attack:** Resend a captured signed request. **Defense:** 1. Nonce uniqueness — server rejects seen nonces 2. Time bounds — expired signatures rejected 3. Short TTL — narrow window for attack **Configuration:** ```typescript { replayable: false, maxValiditySec: 60, } ``` #### Parameter Tampering **Attack:** Modify query parameters or body. **Defense:** * Query signed via `@query` component * Body signed via `content-digest` component **If not using request-bound mode:** ```typescript // No request integrity opts: { binding: 'class-bound', components: ['@authority'] } // Request integrity opts: { binding: 'request-bound' } // Default ``` #### Path Confusion **Attack:** Use signature for different endpoint. **Defense:** `@path` component in signature. **Example:** ``` Signed for: POST /api/v1/orders Attacker uses: POST /api/v1/admin/delete ^^^^^^^^^^^^^^^^^^ Different path → signature invalid ``` #### Domain Confusion **Attack:** Use signature on malicious server. **Defense:** `@authority` component in signature. **Example:** ``` Signed for: POST https://api.example.com/transfer Attacker uses: POST https://evil.com/transfer ^^^^^^^^ Different authority → signature invalid ``` #### Clock Drift Exploitation **Attack:** Exploit clock differences between client and server. **Defense:** `clockSkewSec` policy. **Configuration:** ```typescript { clockSkewSec: 30, // Allow 30 second drift } ``` #### Nonce Prediction **Attack:** Guess nonces to pre-generate valid signatures. **Defense:** Use cryptographically random nonces. **Good:** ```typescript { nonce: crypto.randomUUID() } ``` **Bad:** ```typescript { nonce: `request-${counter++}` } // Predictable! ``` ### Verification Checklist A secure verifier should: 1. ✅ Require signatures on sensitive endpoints 2. ✅ Enforce request-bound components 3. ✅ Use a distributed nonce store 4. ✅ Set reasonable `maxValiditySec` 5. ✅ Allow minimal `clockSkewSec` 6. ✅ Verify `content-digest` for bodies 7. ✅ Support ERC-1271 for SCAs 8. ✅ Log verification failures ### Recommended Policies Choose a policy tier based on the sensitivity of the endpoint — tighter constraints for mutations, relaxed for idempotent reads. :::code-group ```typescript [High Security] // For payments and mutations { replayable: false, maxValiditySec: 60, maxNonceWindowSec: 60, clockSkewSec: 5, } ``` ```typescript [Standard API] { replayable: false, maxValiditySec: 300, clockSkewSec: 30, } ``` ```typescript [Read-Only / Idempotent] { replayable: true, maxValiditySec: 300, clockSkewSec: 60, } ``` ::: ### Implementation Considerations #### Signature Verification * Use constant-time comparison for signatures * Don't leak timing information on failures * Verify signature before processing request #### Nonce Storage * Use atomic operations (SET NX) * Include TTL for automatic cleanup * Scope nonces per keyid to prevent collision #### Error Messages * Don't reveal which check failed in production * Log detailed failures server-side * Return generic "authentication failed" to client #### Rate Limiting Even with valid signatures, apply rate limits: ```typescript const result = await verifyRequest({ request, verifyMessage, nonceStore, policy }) if (result.ok) { // Check rate limit for this address const allowed = await rateLimit(result.address) if (!allowed) { return { status: 429, error: 'Rate limited' } } } ``` ## Configuration Set defaults in a `.erc8128rc.json` file to avoid repeating options on every command. ### Config Resolution The CLI looks for configuration in this order: 1. `--config ` — explicit path, if provided 2. `./.erc8128rc.json` — current working directory 3. `~/.erc8128rc.json` — home directory ### Config File Format ```json { "chainId": 8453, "binding": "request-bound", "replay": "non-replayable", "ttl": 120, "keyfile": "/Users/you/.keys/bot.key", "keyid": "erc8128:8453:0xabc...", "headers": ["Content-Type: application/json"], "components": ["x-idempotency-key"] } ``` All fields are optional. CLI flags override config file values. ### Fields | Field | Type | Description | | ------------ | ---------- | ------------------------------------------ | | `chainId` | `number` | Default chain ID | | `binding` | `string` | `"request-bound"` or `"class-bound"` | | `replay` | `string` | `"non-replayable"` or `"replayable"` | | `ttl` | `number` | Signature TTL in seconds | | `keyfile` | `string` | Path to private key file | | `keyid` | `string` | Default key id (`erc8128:chainId:address`) | | `headers` | `string[]` | Default headers to include | | `components` | `string[]` | Default components to sign | ### Example: Bot Configuration A typical config for an automated agent on Base: ```json { "chainId": 8453, "ttl": 120, "keyfile": "/opt/bot/.keys/signer.key", "keyid": "erc8128:8453:0x1234...", "headers": ["Content-Type: application/json"] } ``` Then all you need is: ```bash erc8128 curl -X POST -d '{"action":"buy"}' https://api.example.com/orders ``` ## Examples Common usage patterns for the ERC-8128 CLI. ### GET Requests #### Simple authenticated GET ```bash erc8128 curl --keystore ./keyfile.json https://api.example.com/data ``` #### Verbose output ```bash erc8128 curl -v \ --keystore ./keyfile.json \ https://api.example.com/data ``` #### Include response headers ```bash erc8128 curl -i \ --keystore ./keyfile.json \ https://api.example.com/data ``` #### Save response to file ```bash erc8128 curl -o response.json \ --keystore ./keyfile.json \ https://api.example.com/data ``` ### POST Requests #### POST with JSON body ```bash erc8128 curl -X POST \ -H "Content-Type: application/json" \ -d '{"foo":"bar"}' \ --keystore ./keyfile.json \ https://api.example.com/submit ``` #### POST with body from file ```bash erc8128 curl -X POST \ -d @body.json \ --keyfile ~/.keys/bot.key \ https://api.example.com/orders ``` #### POST with keyid ```bash erc8128 curl -X POST \ -d @body.json \ --keyfile ~/.keys/bot.key \ --keyid erc8128:8453:0xabc... \ https://api.example.com/orders ``` ### Dry Run Sign a request without sending it — useful for debugging or generating signatures for other tools: ```bash erc8128 curl -X POST \ -d @body.json \ --keyfile ~/.keys/bot.key \ --dry-run \ https://api.example.com/orders ``` ### Advanced Signatures #### Custom chain, binding mode, and TTL ```bash erc8128 curl \ --chain-id 137 \ --binding class-bound \ --replay replayable \ --ttl 300 \ --keystore ./keyfile.json \ https://api.example.com/data ``` #### Interactive keystore password ```bash erc8128 curl \ --keystore ~/.ethereum/keystores/my-key \ --interactive \ https://api.example.com/data ``` ### How It Works Under the hood, `erc8128 curl`: 1. Creates an Ethereum signer from your wallet 2. Builds the HTTP request with your specified options 3. Signs the request according to [ERC-8128](https://eips.ethereum.org/EIPS/eip-8128) using [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) HTTP Message Signatures 4. Sends the signed request and displays the response ## CLI `@slicekit/erc8128-cli` is a command-line tool for signing HTTP requests with ERC-8128 — think of it as `curl` with built-in Ethereum authentication. ### Installation Install globally: :::code-group ```bash [npm] npm install -g @slicekit/erc8128-cli ``` ```bash [bun] bun install -g @slicekit/erc8128-cli ``` ::: Or run directly with npx: ```bash npx @slicekit/erc8128-cli curl ``` ### Basic Usage ```bash erc8128 curl [options] ``` If you have a [config file](/cli/configuration) set up, the minimal command is just: ```bash erc8128 curl https://api.example.com/orders ``` ### Next Steps * [Wallet Options](/cli/wallet-options) — Configure how you provide your Ethereum wallet * [Signature Options](/cli/signature-options) — Control chain ID, binding mode, replay protection, and more * [Configuration](/cli/configuration) — Set defaults with `.erc8128rc.json` * [Examples](/cli/examples) — Common usage patterns ## Signature Options Configure how ERC-8128 signatures are generated. ### HTTP Options Standard HTTP options that mirror `curl` behavior: | Option | Description | Default | | ------------------------ | -------------------------------------------- | ------------- | | `-X, --request ` | HTTP method (GET, POST, etc.) | `GET` | | `-H, --header
` | Add header (repeatable) | — | | `-d, --data ` | Request body (use `@file` or `@-` for stdin) | — | | `-o, --output ` | Write response to file | stdout | | `-i, --include` | Include response headers in output | `false` | | `-v, --verbose` | Show request details | `false` | | `--json` | Output response as JSON | `false` | | `--dry-run` | Sign only, do not send the request | `false` | | `--fail` | Exit non-zero for non-2xx responses | `false` | | `--config ` | Path to `.erc8128rc.json` | auto-detected | ### ERC-8128 Options Control the Ethereum signature parameters: | Option | Description | Default | | -------------------------- | ------------------------------------------- | ---------------- | | `--chain-id ` | Chain ID | `1` | | `--binding ` | `request-bound` or `class-bound` | `request-bound` | | `--replay ` | `non-replayable` or `replayable` | `non-replayable` | | `--ttl ` | Signature TTL in seconds | `60` | | `--components ` | Components to sign (repeatable) | — | | `--keyid ` | Expected key id (`erc8128:chainId:address`) | — | #### Binding Modes * **`request-bound`** (default) — The signature is tied to the specific request URL, method, and body. `--components` adds *additional* components to sign. * **`class-bound`** — The signature covers only the components you explicitly specify via `--components`. #### Replay Protection * **`non-replayable`** (default) — Each signed request includes a nonce and can only be used once. * **`replayable`** — The signature can be reused within the TTL window. #### Custom Chain ID ```bash erc8128 curl \ --chain-id 137 \ --binding class-bound \ --replay replayable \ --ttl 300 \ --keystore ./keyfile.json \ https://api.example.com/data ``` ## Wallet Options The CLI supports multiple ways to provide an Ethereum wallet for signing requests. ### Keystore (Recommended) The most secure option. Uses an encrypted keystore file: ```bash erc8128 curl --keystore ./keyfile.json https://api.example.com/data ``` For interactive password prompts: ```bash erc8128 curl \ --keystore ~/.ethereum/keystores/my-key \ --interactive \ https://api.example.com/data ``` You can also set the password via environment variable: ```bash export ETH_KEYSTORE_PASSWORD=your-password erc8128 curl --keystore ./keyfile.json https://api.example.com/data ``` ### Keyfile A file containing a raw private key: ```bash erc8128 curl --keyfile ~/.keys/bot.key https://api.example.com/data ``` Use `-` to read from stdin: ```bash cat ~/.keys/bot.key | erc8128 curl --keyfile - https://api.example.com/data ``` ### Private Key Pass a raw private key directly: ```bash erc8128 curl --private-key 0x... https://api.example.com/data ``` Or via environment variable: ```bash export ETH_PRIVATE_KEY=0x... erc8128 curl https://api.example.com/data ``` :::warning Using `--private-key` or `ETH_PRIVATE_KEY` is insecure — the key may be visible in shell history or process listings. Use `--keystore` for production workloads. ::: ### Options Reference | Option | Description | | --------------------- | ------------------------------------------------ | | `--keystore ` | Path to encrypted keystore file | | `--password ` | Keystore password | | `--interactive` | Prompt for keystore password interactively | | `--keyfile ` | Path to raw private key file (use `-` for stdin) | | `--private-key ` | Raw private key | | Environment Variable | Description | | ----------------------- | ----------------------------------- | | `ETH_KEYSTORE_PASSWORD` | Non-interactive keystore decryption | | `ETH_PRIVATE_KEY` | Raw private key | ## createSignerClient Create a client with a bound signer for convenient repeated use. The client provides `signRequest`, `signedFetch`, and `fetch` methods that automatically use the configured signer and default options. ### Usage Create a client by passing a signer and optional default configuration. The returned client exposes `signRequest`, `signedFetch`, and `fetch` methods. ```typescript import { createSignerClient } from '@slicekit/erc8128' const client = createSignerClient(signer, { ttlSeconds: 120, replay: 'non-replayable', }) // Use client.fetch (alias for signedFetch) 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' } ) // Sign without sending const signedRequest = await client.signRequest('https://api.example.com/orders') ``` ### Returns [`Client`](/api/types#client) An object with bound methods: * `signRequest` — Sign a request * `signedFetch` — Sign and send a request * `fetch` — Alias for `signedFetch` ### Parameters #### signer * **Type:** [`EthHttpSigner`](/api/types#ethhttpsigner) The signer to use for all requests. #### defaults (optional) * **Type:** [`ClientOptions`](/api/types#clientoptions) Default options for all requests. Can be overridden per-call. ```typescript const client = createSignerClient(signer, { ttlSeconds: 300, replay: 'non-replayable', fetch: customFetch, }) ``` ## createVerifierClient Create a client with bound verification dependencies for convenient repeated use. The client provides a `verifyRequest` method that automatically uses the configured `verifyMessage` function, `nonceStore`, and default policy. ### Usage Create a client by passing a verification function, a nonce store, and optional default policy. The returned client exposes `verifyRequest`. ```typescript import { createVerifierClient } from '@slicekit/erc8128' const verifier = createVerifierClient({ verifyMessage, nonceStore, defaults: { maxValiditySec: 120, replayable: false, }, }) const result = await verifier.verifyRequest({ request, policy: { strictLabel: true }, }) ``` ### Returns [`VerifierClient`](/api/types#verifierclient) An object with a bound method: * `verifyRequest` — Verify a signed request ### Parameters #### verifyMessage * **Type:** [`VerifyMessageFn`](/api/types#verifymessagefn) Function used to verify signatures (EOA/ERC-1271/6492/8010 depending on your implementation). #### nonceStore * **Type:** [`NonceStore`](/api/types#noncestore) Nonce store used for replay protection. #### defaults (optional) * **Type:** [`VerifyPolicy`](/api/types#verifypolicy) Default verification policy applied to all requests. Can be overridden per-call. ```typescript const verifier = createVerifierClient({ verifyMessage, nonceStore, defaults: { clockSkewSec: 10, maxValiditySec: 180, }, }) ``` ## formatKeyId Format a chain ID and address into an ERC-8128 keyid. ### Usage Pass a chain ID and Ethereum address to produce the canonical `erc8128::
` string. ```typescript import { formatKeyId } from '@slicekit/erc8128' const keyid = formatKeyId(1, '0xAbc123...') // "erc8128:1:0xabc123..." ``` ### Returns `string` The formatted keyid string in the format `erc8128::
`. ### Parameters #### chainId * **Type:** `number` The Ethereum chain ID. #### address * **Type:** [`Address`](/api/types#address) The Ethereum address. ## parseKeyId Parse an ERC-8128 keyid into chain ID and address. ### Usage Pass a keyid string to extract the chain ID and address. Returns `null` if the format is invalid. ```typescript import { parseKeyId } from '@slicekit/erc8128' const result = parseKeyId('erc8128:1:0xabc123...') // { chainId: 1, address: '0xabc123...' } const invalid = parseKeyId('invalid') // null ``` ### Returns `{ chainId: number; address: Address } | null` The parsed result, or `null` if the keyid is invalid. ### Parameters #### keyid * **Type:** `string` The keyid string to parse. ## signRequest Signs an HTTP Request and returns a new Request with [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) signature headers. ### Usage Sign a request by passing the URL (or `Request` object), an optional `RequestInit`, a signer, and optional sign options. The function returns a new `Request` with RFC 9421 signature headers attached. ```typescript import { signRequest } from '@slicekit/erc8128' // Simple: just input and signer const signedRequest = await signRequest( 'https://api.example.com/resource', signer ) // With RequestInit const signedRequest = await signRequest( 'https://api.example.com/resource', { method: 'POST', body: '{"data": "value"}' }, signer ) // With options const signedRequest = await signRequest( 'https://api.example.com/resource', { method: 'POST', body: '{"data": "value"}' }, signer, { ttlSeconds: 300 } ) ``` ### Returns `Promise` A new Request object with these headers added: * `Signature-Input` — Components and parameters per RFC 9421 * `Signature` — Base64-encoded Ethereum signature * `Content-Digest` — SHA-256 hash of body (if body is present) ### Parameters #### Overload 1: Simple When no `RequestInit` is needed, pass the input and signer directly. ```typescript signRequest(input: RequestInfo, signer: EthHttpSigner, opts?: SignOptions): Promise ``` #### Overload 2: With RequestInit When you need to specify method, headers, or body, pass a `RequestInit` as the second argument. ```typescript signRequest(input: RequestInfo, init: RequestInit | undefined, signer: EthHttpSigner, opts?: SignOptions): Promise ``` #### input * **Type:** `RequestInfo` URL string or Request object to sign. #### init (optional) * **Type:** `RequestInit` Optional RequestInit (method, headers, body, etc.). #### signer * **Type:** [`EthHttpSigner`](/api/types#ethhttpsigner) The signer that provides the Ethereum address and signing function. #### opts (optional) * **Type:** [`SignOptions`](/api/types#signoptions) Options for customizing the signature. ### Examples Common signing patterns showing TTL customization, custom nonces, and replayable signatures. :::code-group ```typescript [Custom TTL] const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { ttlSeconds: 300 } // Valid for 5 minutes ) ``` ```typescript [Custom Nonce] const signedRequest = await signRequest( 'https://api.example.com/resource', signer, { nonce: `request-${crypto.randomUUID()}` } ) ``` ```typescript [Replayable] const signedRequest = await signRequest( 'https://api.example.com/status', { method: 'GET' }, signer, { replay: 'replayable' } ) ``` ::: ## signedFetch Signs and sends an HTTP request in one call. Internally, `signedFetch` uses [signRequest](/api/signRequest) to create the signed request, then sends it using fetch. ### Usage Call `signedFetch` with the same arguments as `signRequest` — it signs the request and sends it in a single step, returning the `Response`. ```typescript import { signedFetch } from '@slicekit/erc8128' // Simple const response = await signedFetch( 'https://api.example.com/orders', signer ) // With RequestInit const response = await signedFetch( 'https://api.example.com/orders', { method: 'POST', body: JSON.stringify({ amount: '100' }) }, signer ) // With options const response = await signedFetch( 'https://api.example.com/orders', { method: 'POST', body: JSON.stringify({ amount: '100' }) }, signer, { ttlSeconds: 30 } ) const data = await response.json() ``` ### Returns `Promise` The fetch Response. ### Parameters #### Overload 1: Simple When no `RequestInit` is needed, pass the input and signer directly. ```typescript signedFetch(input: RequestInfo, signer: EthHttpSigner, opts?: SignOptions & { fetch?: typeof fetch }): Promise ``` #### Overload 2: With RequestInit When you need to specify method, headers, or body, pass a `RequestInit` as the second argument. ```typescript signedFetch(input: RequestInfo, init: RequestInit | undefined, signer: EthHttpSigner, opts?: SignOptions & { fetch?: typeof fetch }): Promise ``` #### input * **Type:** `RequestInfo` URL string or Request object. #### init (optional) * **Type:** `RequestInit | undefined` Optional RequestInit. #### signer * **Type:** [`EthHttpSigner`](/api/types#ethhttpsigner) The signer. #### opts (optional) * **Type:** [`SignOptions`](/api/types#signoptions) `& { fetch?: typeof fetch }` Sign options, plus an optional custom fetch implementation. ### Examples #### Custom Fetch Provide a custom fetch implementation (useful for testing or edge runtimes): ```typescript const response = await signedFetch( 'https://api.example.com/orders', { method: 'POST', body: JSON.stringify({ amount: '100' }) }, signer, { fetch: customFetch } ) ``` ## Types Glossary of types in `@slicekit/erc8128`. ### Hex Hex-encoded string with `0x` prefix. ```typescript type Hex = `0x${string}` ``` ### Address Ethereum address (40 hex characters with `0x` prefix). ```typescript type Address = `0x${string}` ``` *** ### EthHttpSigner Interface for Ethereum message signing. Used by [signRequest](/api/signRequest) and [signedFetch](/api/signedFetch). ```typescript interface EthHttpSigner { /** Ethereum address (EOA or smart contract account) */ address: Address /** Chain ID for the keyid */ chainId: number /** Sign the RFC 9421 signature base as an Ethereum message (EIP-191) */ signMessage: (message: Uint8Array) => Promise } ``` #### Example Here's how to create an `EthHttpSigner` using viem's `privateKeyToAccount`: ```typescript 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 } }) }, } ``` *** ### SignOptions Options for [signRequest](/api/signRequest) and [signedFetch](/api/signedFetch). ```typescript type SignOptions = { /** Signature label (default: "eth") */ label?: string /** Binding mode (default: "request-bound") */ binding?: BindingMode /** Replay mode (default: "non-replayable") */ replay?: ReplayMode /** Unix timestamp when signature becomes valid (default: now) */ created?: number /** Unix timestamp when signature expires (default: created + ttlSeconds) */ expires?: number /** Validity duration in seconds (default: 60) */ ttlSeconds?: number /** Custom nonce value or generator function */ nonce?: string | (() => Promise) /** Content-Digest handling (default: "auto") */ contentDigest?: ContentDigestMode /** Additional components to sign beyond the default set */ components?: string[] } ``` *** ### BindingMode Controls which request components are signed. ```typescript type BindingMode = "request-bound" | "class-bound" ``` | Value | Description | | --------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `request-bound` | Sign `@authority`, `@method`, `@path`, `@query` (if present), and `content-digest` (if body present). This is the secure default. | | `class-bound` | Sign only the components you explicitly specify. Requires `components` array. | *** ### ReplayMode Controls whether requests can be replayed. ```typescript type ReplayMode = "non-replayable" | "replayable" ``` | Value | Description | | ---------------- | --------------------------------------------------------------------------------------------------------------- | | `non-replayable` | Include a nonce (auto-generated if not provided). Default. | | `replayable` | No nonce. Signature can be reused within its validity window. Trades single-use guarantee for reduced overhead. | *** ### ContentDigestMode Controls Content-Digest header handling. ```typescript type ContentDigestMode = "auto" | "recompute" | "require" | "off" ``` | Value | Description | | ----------- | ------------------------------------------------------------------------- | | `auto` | Add Content-Digest if body is present and components include it. Default. | | `recompute` | Always recompute Content-Digest even if header exists. | | `require` | Require existing Content-Digest header. | | `off` | Don't add Content-Digest. | *** ### VerifyPolicy Policy for [verifyRequest](/api/verifyRequest). ```typescript type VerifyPolicy = { /** Preferred signature label (default: "eth") */ label?: string /** If true, require exact label match (default: false) */ strictLabel?: boolean /** * Extra components required in addition to the default request-bound set. * Use this for custom headers like 'x-idempotency-key'. */ additionalRequestBoundComponents?: string[] /** * Class-bound policies (one list or a list of lists). * If any policy list is covered by the signed components, the signature is accepted. * `@authority` is always required (added if missing). */ classBoundPolicies?: string[] | string[][] /** Allow replayable (nonce-less) signatures (default: false) */ replayable?: boolean /** * Optional replayable invalidation (per-keyid). * When set and a signature is replayable, requests with created < notBefore are rejected. */ replayableNotBefore?: (keyid: string) => number | null | undefined | Promise /** * Optional per-signature invalidation hook for replayable signatures. * Return true to mark the signature as invalidated. */ replayableInvalidated?: (args: { keyid: string created: number expires: number label: string signature: Hex signatureBase: Uint8Array signatureParamsValue: string }) => boolean | Promise /** Maximum number of signatures to verify (default: 3) */ maxSignatureVerifications?: number /** Current time function (default: Date.now() / 1000) */ now?: () => number /** Allowed clock skew in seconds (default: 0) */ clockSkewSec?: number /** Maximum validity window in seconds (default: 300) */ maxValiditySec?: number /** Maximum nonce validity window (optional) */ maxNonceWindowSec?: number /** Custom key generator for nonce storage */ nonceKey?: (keyid: string, nonce: string) => string } ``` *** ### NonceStore Interface for replay protection storage. See [NonceStore implementations](/api/types#noncestore). ```typescript interface NonceStore { /** * Atomically consume a nonce. * Returns true if newly stored (not seen before), false if already exists. */ consume(key: string, ttlSeconds: number): Promise } ``` *** ### VerifyMessageFn Function type for verifying Ethereum signatures. See [VerifyMessageFn with viem](/api/types#verifymessagefn). ```typescript type VerifyMessageFn = (args: { address: Address message: { raw: Hex } signature: Hex }) => boolean | Promise ``` *** ### VerifyResult Result of [verifyRequest](/api/verifyRequest). ```typescript type VerifyResult = | { ok: true address: Address chainId: number label: string components: string[] params: SignatureParams replayable: boolean binding: BindingMode } | { ok: false reason: VerifyFailReason detail?: string } ``` #### Success Properties | Property | Type | Description | | ------------ | ----------------- | ------------------------------------------------ | | `ok` | `true` | Verification succeeded | | `address` | `Address` | Verified Ethereum address | | `chainId` | `number` | Chain ID from keyid | | `label` | `string` | Signature label that was verified | | `components` | `string[]` | Components that were signed | | `params` | `SignatureParams` | Parsed signature parameters | | `replayable` | `boolean` | Whether the signature is replayable (nonce-less) | | `binding` | `BindingMode` | Binding mode used by the signature | #### Failure Properties | Property | Type | Description | | -------- | ------------------ | ----------------------- | | `ok` | `false` | Verification failed | | `reason` | `VerifyFailReason` | Why verification failed | | `detail` | `string?` | Optional detail message | *** ### SignatureParams Parsed signature parameters from `Signature-Input` header. ```typescript type SignatureParams = { created: number expires: number keyid: string nonce?: string tag?: string } ``` *** ### VerifyFailReason All possible verification failure reasons. See [Failure Reasons](/api/verifyRequest) for descriptions. ```typescript type VerifyFailReason = | "missing_headers" | "label_not_found" | "bad_signature_input" | "bad_signature" | "bad_keyid" | "bad_time" | "not_yet_valid" | "expired" | "validity_too_long" | "nonce_required" | "replayable_not_allowed" | "replayable_invalidation_required" | "replayable_not_before" | "replayable_invalidated" | "class_bound_not_allowed" | "nonce_window_too_long" | "replay" | "not_request_bound" | "digest_required" | "digest_mismatch" | "alg_not_allowed" | "bad_signature_bytes" | "bad_signature_check" ``` *** ### Client Return type of [createSignerClient](/api/createSignerClient). Provides bound methods for signing requests. ```typescript type Client = { signRequest: { (input: RequestInfo, opts?: SignOptions): Promise (input: RequestInfo, init: RequestInit | undefined, opts?: SignOptions): Promise } signedFetch: { (input: RequestInfo, opts?: ClientOptions): Promise (input: RequestInfo, init: RequestInit | undefined, opts?: ClientOptions): Promise } fetch: { (input: RequestInfo, opts?: ClientOptions): Promise (input: RequestInfo, init: RequestInit | undefined, opts?: ClientOptions): Promise } } ``` #### Methods | Method | Description | | ------------- | ------------------------------ | | `signRequest` | Sign a request without sending | | `signedFetch` | Sign and send a request | | `fetch` | Alias for `signedFetch` | *** ### ClientOptions Options for [createSignerClient](/api/createSignerClient) and client methods. Extends [SignOptions](#signoptions). ```typescript type ClientOptions = SignOptions & { fetch?: typeof fetch } ``` *** ### VerifierClient Return type of [createVerifierClient](/api/createVerifierClient). Provides a bound method for verifying requests. ```typescript type VerifierClient = { verifyRequest: (args: { request: Request policy?: VerifyPolicy setHeaders?: (name: string, value: string) => void }) => Promise } ``` #### Methods | Method | Description | | --------------- | ----------------------- | | `verifyRequest` | Verify a signed request | *** ### VerifierClientOptions Options for [createVerifierClient](/api/createVerifierClient) and client methods. Extends [VerifyPolicy](#verifypolicy). ```typescript type VerifierClientOptions = VerifyPolicy ``` *** ### SetHeadersFn Callback to set response headers. Used by `verifyRequest` to emit `Accept-Signature`. ```typescript type SetHeadersFn = (name: string, value: string) => void ``` *** ### VerifyRequestArgs Argument object for [verifyRequest](/api/verifyRequest). ```typescript type VerifyRequestArgs = { request: Request verifyMessage: VerifyMessageFn nonceStore: NonceStore policy?: VerifyPolicy setHeaders?: SetHeadersFn } ``` | Property | Type | Description | | --------------- | ------------------------------------- | ----------------------------------------------------------- | | `request` | `Request` | The HTTP request to verify | | `verifyMessage` | [`VerifyMessageFn`](#verifymessagefn) | Signature verification function | | `nonceStore` | [`NonceStore`](#noncestore) | Replay protection store | | `policy` | [`VerifyPolicy`](#verifypolicy) | Optional verification policy | | `setHeaders` | [`SetHeadersFn`](#setheadersfn) | Optional callback to set `Accept-Signature` response header | *** ### VerifierClientVerifyRequestArgs Argument object for `verifierClient.verifyRequest()`. The client already has `verifyMessage` and `nonceStore` bound, so only `request` and optional overrides are needed. ```typescript type VerifierClientVerifyRequestArgs = { request: Request policy?: VerifyPolicy setHeaders?: SetHeadersFn } ``` *** ### CreateVerifierClientArgs Argument object for [createVerifierClient](/api/createVerifierClient). ```typescript type CreateVerifierClientArgs = { verifyMessage: VerifyMessageFn nonceStore: NonceStore defaults?: VerifyPolicy } ``` | Property | Type | Description | | --------------- | ------------------------------------- | -------------------------------------- | | `verifyMessage` | [`VerifyMessageFn`](#verifymessagefn) | Signature verification function | | `nonceStore` | [`NonceStore`](#noncestore) | Replay protection store | | `defaults` | [`VerifyPolicy`](#verifypolicy) | Default policy applied to all requests | *** ### Erc8128Error Custom error class for ERC-8128 operations. ```typescript class Erc8128Error extends Error { code: Erc8128ErrorCode } ``` ### Erc8128ErrorCode Error codes for `Erc8128Error`. ```typescript type Erc8128ErrorCode = | "CRYPTO_UNAVAILABLE" // WebCrypto not available | "INVALID_OPTIONS" // Bad sign options | "UNSUPPORTED_REQUEST" // Can't sign this request | "BODY_READ_FAILED" // Can't read request body | "DIGEST_REQUIRED" // Content-Digest required but not present | "BAD_DERIVED_VALUE" // Can't derive component value | "BAD_HEADER_VALUE" // Invalid header format | "PARSE_ERROR" // Can't parse input ``` #### Example Catch `Erc8128Error` to handle specific error codes during signing or verification: ```typescript import { signRequest, Erc8128Error } from '@slicekit/erc8128' try { const signedRequest = await signRequest(input, signer) } catch (error) { if (error instanceof Erc8128Error) { console.error(`Error (${error.code}): ${error.message}`) } throw error } ``` ## verifyRequest Verify an ERC-8128 signed HTTP request. ### Usage Pass an argument object with `request`, `verifyMessage`, `nonceStore`, and optional `policy`/`setHeaders`. The function returns a `VerifyResult` indicating success or failure. ```typescript import { verifyRequest } from '@slicekit/erc8128' // Simple: request + required dependencies + policy const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { maxValiditySec: 300, }, }) if (result.ok) { console.log(`Authenticated: ${result.address} on chain ${result.chainId}`) } else { console.log(`Failed: ${result.reason}`) } ``` ### Returns [`VerifyResult`](/api/types#verifyresult) An object indicating success or failure: ```typescript if (result.ok) { // Success — access verified data result.address // Ethereum address result.chainId // Chain ID result.label // Signature label result.components // Signed components result.replayable // true if nonce-less result.binding // "request-bound" or "class-bound" } else { // Failure — check reason result.reason // VerifyFailReason result.detail // Optional detail message } ``` ### Parameters ```typescript verifyRequest({ request: Request, verifyMessage: VerifyMessageFn, nonceStore: NonceStore, policy?: VerifyPolicy, setHeaders?: (name: string, value: string) => void ): Promise ``` #### request * **Type:** `Request` The Request to verify. #### verifyMessage * **Type:** [`VerifyMessageFn`](/api/types#verifymessagefn) Signature verification function (e.g. viem-compatible). #### nonceStore * **Type:** [`NonceStore`](/api/types#noncestore) Replay protection store for non-replayable requests. #### policy (optional) * **Type:** [`VerifyPolicy`](/api/types#verifypolicy) Verification policy with rules for validation. Signatures are verified in the order they appear in `Signature-Input` after filtering to ERC-8128 keyIds and allowed policies. Use `maxSignatureVerifications` to cap how many candidates are tried (default: 3). If `replayable: true`, you must provide either `replayableNotBefore` or `replayableInvalidated`. #### setHeaders (optional) * **Type:** `(name: string, value: string) => void` Callback to set response headers. When provided, `verifyRequest` sets `Accept-Signature` with the required components for each supported policy. ### Examples These examples show strict, relaxed, and custom-component verification policies. :::code-group ```typescript [Strict Policy] const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { label: 'eth', strictLabel: true, replayable: false, maxValiditySec: 60, maxNonceWindowSec: 60, clockSkewSec: 5, }, }) ``` ```typescript [Relaxed Policy] const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { replayable: true, replayableNotBefore: async (keyid) => await getNotBeforeForKeyId(keyid), maxValiditySec: 300, clockSkewSec: 30, }, }) ``` ```typescript [Custom Components] const result = await verifyRequest({ request, verifyMessage, nonceStore, policy: { additionalRequestBoundComponents: ['x-custom-header'], classBoundPolicies: ['@authority', 'x-custom-header'], }, }) ``` :::