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

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:

import { verifyRequest } from '@slicekit/erc8128'
 
const result = await verifyRequest({
  request,
  verifyMessage,
  nonceStore
})
 
if (result.ok) {
  console.log(`Authenticated: ${result.principal.address}`)
  console.log(`Chain: ${result.principal.chainId}`)
} else {
  console.log(`Failed: ${result.reason}`)
}

Using createVerifierClient

For repeated verification with the same dependencies, use createVerifierClient to bind verifyMessage and nonceStore:

import { createVerifierClient } from '@slicekit/erc8128'
 
const verifier = createVerifierClient({
  verifyMessage,
  nonceStore,
  defaults: {
  maxValiditySec: 120,
  },
})
 
const result = await verifier.verifyRequest({
  request,
  policy: { maxValiditySec: 60 },
})

Required Setup

Nonce Store

For replay protection, you need a store that tracks consumed nonces.

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'
  },
}

Policy Configuration

Choose the appropriate policy based on your endpoint's security requirements:

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
  },
})

Label Correlation

RFC 9421 labels only correlate Signature-Input and Signature members. ERC-8128 candidates are selected by their signed tag and CAIP-10 keyid, never by label.

Component Policies

By default, the verifier enforces that signatures are request-bound — covering @scheme, @authority, @method, @path, and @query unconditionally. A received non-empty body also requires a covered, recomputed content-digest, and a received content-type is always covered. This rejects all class-bound signatures unless a route explicitly enables a weaker policy. Other cases depend on the configured replay, timing, and nonce policies.

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):

// 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 for details on default behavior, custom components, and the difference between request-bound and class-bound verification.

Set classBoundPolicies: [] to explicitly disable class-bound signatures. An empty list is equivalent to leaving the field unset; it does not create an @authority-only policy.

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.

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.principal.address,
    chainId: result.principal.chainId,
  })
 
  await next()
})

Handling Failures

Use formatErc8128ProblemDetails so malformed requests, insufficient permissions, authentication failures, and temporary verifier outages receive their specified status codes.

import { formatErc8128ProblemDetails } from '@slicekit/erc8128'
 
const result = await verifyRequest({
  request,
  verifyMessage: verifyMessageFn,
  nonceStore,
  policy,
})
 
if (!result.ok) {
  const problem = formatErc8128ProblemDetails(result)
  return { status: problem.status, body: problem }
}

Infrastructure may reject a request before ERC-8128 verification runs. For example, the playground worker's body-size limit returns a generic RFC 9457 Problem Details response with status 413. Such responses intentionally do not contain a VerifyFailReason; use parseErc8128ProblemDetails only for ERC-8128 verification failures.

Multi-Chain Support

The principal identity identifies the authenticated delegation root or base-profile account:

if (result.ok) {
  const { address, chainId } = result.principal
 
  // 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. Document a route maximum and keep signer windows short — a baseline route must accept windows up to 60 seconds, while signers should choose the shortest delivery-safe window. Longer windows increase replay exposure and nonce-storage time.

  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.