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 1Defense 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;...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_valid—now < created(clock skew issue)expired—now > expiresvalidity_too_long—expires - created > maxValiditySecbad_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";...// 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>
}- Atomic — no race conditions
- TTL — automatic cleanup
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.
- No nonce state — Verifiers don't need to persist nonce state. The
nonceStoreparameter 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-
keyidnot-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.
// 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:
| 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
-
Non-replayable is the default — The baseline requires it, and all compliant verifiers must accept it
-
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 -
Set
maxNonceWindowSec— For non-replayable signatures, limit how long nonces must be retained -
Implement early invalidation — If accepting replayable signatures, provide signer-triggered revocation mechanisms
-
Monitor replay patterns — Log verification results for security analysis