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.address}`)
console.log(`Chain: ${result.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: { strictLabel: true },
})Required Setup
Nonce Store
For replay protection, you need a store that tracks consumed nonces.
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:
// 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 Selection
When multiple signatures exist on a request:
// Prefer "eth" label, fall back to first valid
const result = await verifyRequest({
request,
verifyMessage: verifyMessageFn,
nonceStore,
policy: {
label: 'eth',
},
})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):
// 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.
Signature Selection Order
When a request contains multiple signatures (multiple labels in the headers), the verifier:
- Filters to ERC-8128 keyIds
- Filters to signatures allowed by your request-bound/class-bound policies
- 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.
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()
})Handling Failures
When verification fails, inspect result.reason to return appropriate HTTP status codes and error messages.
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:
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
-
Always use nonce stores in production — In-memory stores don't work across server restarts or multiple instances.
-
Keep validity windows short — 60-300 seconds is reasonable. Longer windows increase replay risk.
-
Use strict label matching when you expect a specific signature source.
-
Log verification failures — They can indicate attacks or client bugs.
-
Consider clock skew — Allow 5-30 seconds for network latency and clock drift.
-
Use Redis or a distributed store — Essential for multi-instance deployments.