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 invalidNon-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:- Nonce uniqueness — server rejects seen nonces
- Time bounds — expired signatures rejected
- Short TTL — narrow window for attack
{
replayable: false,
maxValiditySec: 60,
}Parameter Tampering
Attack: Modify query parameters or body.
Defense:- Query signed via
@querycomponent - Body signed via
content-digestcomponent
// No request integrity
opts: { binding: 'class-bound', components: ['@authority'] }
// Request integrity
opts: { binding: 'request-bound' } // DefaultPath Confusion
Attack: Use signature for different endpoint.
Defense: @path component in signature.
Signed for: POST /api/v1/orders
Attacker uses: POST /api/v1/admin/delete
^^^^^^^^^^^^^^^^^^
Different path → signature invalidDomain Confusion
Attack: Use signature on malicious server.
Defense: @authority component in signature.
Signed for: POST https://api.example.com/transfer
Attacker uses: POST https://evil.com/transfer
^^^^^^^^
Different authority → signature invalidClock Drift Exploitation
Attack: Exploit clock differences between client and server.
Defense: clockSkewSec policy.
{
clockSkewSec: 30, // Allow 30 second drift
}Nonce Prediction
Attack: Guess nonces to pre-generate valid signatures.
Defense: Use cryptographically random nonces.
Good:{ nonce: crypto.randomUUID() }{ nonce: `request-${counter++}` } // Predictable!Verification Checklist
A secure verifier should:
- ✅ Require signatures on sensitive endpoints
- ✅ Enforce request-bound components
- ✅ Use a distributed nonce store
- ✅ Set reasonable
maxValiditySec - ✅ Allow minimal
clockSkewSec - ✅ Verify
content-digestfor bodies - ✅ Support ERC-1271 for SCAs
- ✅ Log verification failures
Recommended Policies
Choose a policy tier based on the sensitivity of the endpoint — tighter constraints for mutations, relaxed for idempotent reads.
// For payments and mutations
{
replayable: false,
maxValiditySec: 60,
maxNonceWindowSec: 60,
clockSkewSec: 5,
}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:
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' }
}
}