Fingerprinting
Ensure idempotency keys are used correctly with request fingerprints.
What is Fingerprinting?
A fingerprint is a hash of the request that uniquely identifies its content. It prevents accidental reuse of idempotency keys with different request data.
Why Fingerprint?
Problem Without Fingerprinting
// Request 1
POST /payments
Idempotency-Key: payment-123
Body: { amount: 100, currency: "USD" }
→ Creates payment for $100
// Request 2 (Bug: same key, different data!)
POST /payments
Idempotency-Key: payment-123
Body: { amount: 500, currency: "EUR" }
→ Returns $100 payment (WRONG!)Solution With Fingerprinting
// Request 1
POST /payments
Idempotency-Key: payment-123
Body: { amount: 100 }
→ Fingerprint: abc123
→ Creates payment
// Request 2 (Different body)
POST /payments
Idempotency-Key: payment-123
Body: { amount: 500 }
→ Fingerprint: def456
→ ❌ Error: Fingerprint mismatch!How Fingerprints Are Generated
Default Implementation
import { createHash } from 'crypto';
function generateFingerprint(request: Request): string {
const data = [
request.method, // POST
request.path, // /payments
canonicalStringify(request.body), // {"amount":100} — keys sorted recursively
].join('|');
return createHash('sha256')
.update(data)
.digest('hex');
}
// Result: "a1b2c3d4e5f6..." (64 hex chars)The body (and query, when included) is serialized canonically: object keys are sorted recursively at every nesting level. Two semantically identical bodies that differ only in key order — re-serialized by a proxy, built by a different client, or generated by an LLM — produce the same fingerprint, so a legitimate retry is never rejected as a mismatch because of field ordering. Array order is preserved (it is significant).
What Gets Hashed
By default:
method | path | body
POST | /payments | {"amount":100,"currency":"USD"}
→ SHA256
→ a1b2c3d4e5f6...Configuration
Include Query Parameters
new IdempotencyPlugin({
fingerprintFields: ['method', 'path', 'body', 'query'],
})// Now this affects fingerprint:
POST /payments?source=stripe
Body: { amount: 100 }
→ Fingerprint: xyz789
POST /payments?source=paypal
Body: { amount: 100 }
→ Fingerprint: abc456 // Different!Only Path and Body
new IdempotencyPlugin({
fingerprintFields: ['path', 'body'],
})// Method doesn't matter:
POST /payments
Body: { amount: 100 }
→ Fingerprint: aaa111
PUT /payments
Body: { amount: 100 }
→ Fingerprint: aaa111 // Same!Custom Fingerprint Generator
Ignore Certain Fields
new IdempotencyPlugin({
fingerprintGenerator: async (context) => {
const req = context.switchToHttp().getRequest();
// Ignore timestamp in body
const { timestamp, ...relevantData } = req.body;
return createHash('sha256')
.update(`${req.method}|${req.path}|${JSON.stringify(relevantData)}`)
.digest('hex');
},
})// These are considered the same:
Body: { amount: 100, timestamp: 1706123456 }
Body: { amount: 100, timestamp: 1706123999 }
→ Same fingerprint (timestamp ignored)Include Headers
new IdempotencyPlugin({
fingerprintGenerator: async (context) => {
const req = context.switchToHttp().getRequest();
const data = [
req.method,
req.path,
JSON.stringify(req.body),
req.headers['x-tenant-id'], // Include tenant
].join('|');
return createHash('sha256').update(data).digest('hex');
},
})Key Order Is Already Handled
You do not need a custom generator to normalize key order — the default fingerprint canonicalizes the body (recursive key sort):
// These produce the same fingerprint out of the box:
Body: { amount: 100, currency: "USD", meta: { b: 2, a: 1 } }
Body: { meta: { a: 1, b: 2 }, currency: "USD", amount: 100 }
→ Same fingerprint (keys sorted recursively)Do not use the replacer-array trick
JSON.stringify(body, Object.keys(body).sort()) looks like a sort but is actually an allow-list of property names: any nested key whose name does not also appear at the top level is silently dropped from the output — so genuinely different bodies can produce the same fingerprint. If you write a custom fingerprintGenerator, sort keys recursively instead.
Fingerprint Validation
Strict Validation (Default)
new IdempotencyPlugin({
validateFingerprint: true, // Default
})// Request 1
POST /payments
Idempotency-Key: pay-123
Body: { amount: 100 }
→ Success
// Request 2 (different body)
POST /payments
Idempotency-Key: pay-123
Body: { amount: 200 }
→ Error: Fingerprint mismatch (surfaces as HTTP 422)Disable Validation
new IdempotencyPlugin({
validateFingerprint: false, // No checking
})Not Recommended
Disabling fingerprint validation can lead to incorrect behavior if clients reuse keys inappropriately.
Handling Mismatches
Default Error Response
On a fingerprint mismatch the plugin throws IdempotencyFingerprintMismatchError. This extends RedisXError (a plain Error), not HttpException, but the plugin registers a built-in exception filter that maps it to HTTP 422 Unprocessable Entity automatically — no extra configuration required:
HTTP/1.1 422 Unprocessable EntityIf you want a different status or a custom response shape, register your own exception filter to override the built-in one, as shown next.
Custom Error Handler (optional — override the built-in 422)
import { IdempotencyFingerprintMismatchError } from '@nestjs-redisx/idempotency';
@Catch(IdempotencyFingerprintMismatchError)
export class FingerprintMismatchFilter implements ExceptionFilter {
catch(exception: IdempotencyFingerprintMismatchError, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
response.status(422).json({
error: 'Invalid Request',
message: 'This idempotency key was already used with different data',
idempotencyKey: exception.idempotencyKey,
suggestion: 'Use a new idempotency key for different request data',
});
}
}Best Practices
Do
// ✅ Include all relevant data
fingerprintFields: ['method', 'path', 'body']
// ✅ Exclude volatile fields
const { timestamp, requestId, ...data } = req.body;
// ✅ Use SHA-256 for hashing
createHash('sha256')Don't
// ❌ Include changing fields that don't affect operation
fingerprintGenerator: (ctx) => {
return createHash('sha256')
.update(`${req.body.timestamp}`) // Changes every time!
.digest('hex');
}
// ❌ Use weak hashing
createHash('md5') // Not secure enough
// ❌ Disable validation without good reason
validateFingerprint: falseDebugging Fingerprints
Log Fingerprints
new IdempotencyPlugin({
fingerprintGenerator: async (context) => {
const req = context.switchToHttp().getRequest();
const data = `${req.method}|${req.path}|${JSON.stringify(req.body)}`;
const fingerprint = createHash('sha256').update(data).digest('hex');
console.log('Fingerprint:', fingerprint);
console.log('Data:', data);
return fingerprint;
},
})Compare Requests
# Request 1
curl -X POST http://localhost:3000/payments \
-H "Idempotency-Key: pay-123" \
-d '{"amount": 100}'
# Server logs:
# Data: POST|/payments|{"amount":100}
# Fingerprint: a1b2c3d4...
# Request 2
curl -X POST http://localhost:3000/payments \
-H "Idempotency-Key: pay-123" \
-d '{"amount": 200}'
# Server logs:
# Data: POST|/payments|{"amount":200}
# Fingerprint: e5f6g7h8...
# Error: Mismatch! (a1b2c3d4 != e5f6g7h8)Next Steps
- Concurrent Requests — Handle concurrent requests
- Troubleshooting — Debug fingerprint issues