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

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:

LayerProtectionBypass Difficulty
Time boundsexpires parameterWait and retry
NonceUnique per requestRequires new nonce
Request bindingComponents in signatureRequires valid signature

Time Bounds

Every signature has created and expires timestamps:

Signature-Input: eth=(...);created=1618884473;expires=1618884533;...
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,
  }
)

Failure Reasons

  • not_yet_validnow < created (clock skew issue)
  • expirednow > expires
  • validity_too_longexpires - 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";...
Client: Auto Nonce
// Auto-generated (default)
const signedRequest = await signRequest(
  request,
  signer,
  { replay: 'non-replayable' }  // Default
)

Nonce Store

The nonce store tracks consumed nonces:

interface NonceStore {
  consume(key: string, ttlSeconds: number): Promise<boolean>
}
Requirements:
  • Atomic — no race conditions
  • TTL — automatic cleanup
Redis example:
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:

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.

PropertyNon-ReplayableReplayableBearer Token (JWT)
Authentication
Request integrity✅ (if request-bound)✅ (if request-bound)
Single-use
Requires server stateNonce storeNoSession/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.

Client
// Omit nonce
const signedRequest = await signRequest(
  'https://api.example.com/status',
  { method: 'GET' },
  signer,
  { replay: 'replayable' }
)

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.

const signedRequest = await signRequest(
  request,
  signer,
  {
    binding: 'class-bound',
    components: ['@authority'],
    replay: 'replayable',
  }
)

All four combinations are valid:

BindingReplaySecurity LevelUse Case
Request-boundNon-replayableStrongest (baseline)State changes, payments
Request-boundReplayableStrong integrity, no single-useHigh-frequency requests to specific endpoints
Class-boundNon-replayableSingle-use, broad scopeOne-time authorization for a class of requests
Class-boundReplayableLowestBroad, reusable access tokens

Nonce Policy Options

PolicyBehavior
requiredReject if no nonce (default)
optionalAccept with or without nonce
forbiddenReject 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