Webhook Security Deep Dive
UberLotto's webhook handlers implement defense-in-depth security pipelines with multi-layer validation, replay protection via database-backed nonces, and GDPR-compliant audit logging with PII masking. Both Plisio and MoonPay webhooks are protected by independent security stacks.
Involved Files
| File | Responsibility |
|---|---|
app/routes/api.plisio-webhook.ts | Plisio entry point — orchestrates 6-layer security |
app/routes/api.moonpay-webhook.ts | MoonPay entry point — orchestrates 5-layer security |
app/lib/webhook-extractor.server.ts | Multi-format data extraction (URL params, JSON, FormData) |
app/lib/webhook-validator.server.ts | Field validation, IP whitelisting, HMAC-SHA1 verification |
app/lib/webhook-processor.server.ts | Business logic: status mapping, amount/currency validation, DB save |
app/lib/webhook-utils.server.ts | Shared types, response helpers, payload sanitization |
app/lib/replay-protection.server.ts | SHA-256 nonce generation and database-backed dedup (Plisio-specific checkWebhookNonce() + generic checkGenericWebhookNonce()) |
app/lib/rate-limiter.server.ts | Three-tier in-memory rate limiting |
app/lib/security-logger.server.ts | GDPR-compliant event logging with PII masking |
app/lib/security-types.ts | Canonical type definitions, enums, and constants |
app/lib/cors.server.ts | Shared CORS origin validation |
Plisio Webhook Security
Step-by-Step Validation Pipeline
The webhook handler (api.plisio-webhook.ts) supports both GET (query params) and POST (JSON or FormData) requests. Both paths follow the same security pipeline:
Layer 1: IP Whitelisting
Request arrives
│
▼
┌────────────────────────────┐
│ Read PLISIO_WEBHOOK_IPS │ ← Comma-separated env var
│ from environment │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ Extract client IP: │
│ cf-connecting-ip │ ← Cloudflare (priority 1)
│ x-forwarded-for (first) │ ← Proxy (priority 2)
│ x-real-ip │ ← Nginx (priority 3)
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ IP in whitelist? │
│ YES → continue │
│ NO → 403 Forbidden │ ← Logs ip_whitelist_violation (CRITICAL)
└────────────────────────────┘If PLISIO_WEBHOOK_IPS is not configured, this layer is skipped (fail-open).
Layer 2: Rate Limiting
Two rate limit checks run sequentially:
| Check | Limit | Failure Response |
|---|---|---|
| IP rate limit | 100 req/min per IP | 429 with Retry-After header |
| Global circuit breaker | 1000 req/min total | 503 Service Unavailable |
Both use the shared RateLimiter class with sliding window algorithm. See Rate Limiting for details.
Layer 3: Data Extraction + HMAC Verification
Data Extraction
The extractWebhookData() function auto-detects the format:
| Method | Content-Type | Extraction |
|---|---|---|
| GET | — | URLSearchParams |
| POST | application/json | request.json() |
| POST | multipart/form-data | request.formData() |
| POST | application/x-www-form-urlencoded | request.formData() |
All formats use a unified WebhookDataSource interface with adapters (URLSearchParamsWrapper, JSONDataWrapper, FormDataWrapper).
HMAC-Critical Detail
The extractor only adds fields that Plisio actually sent. Empty strings are preserved exactly as-is for HMAC computation. Adding default empty strings for missing fields would break the HMAC signature.
Required Fields
const requiredFields = ['txn_id', 'status', 'order_number'];Missing required fields result in a 400 error.
HMAC-SHA1 Verification
The validateWebhook() function delegates to PlisioClient.verifyWebhookSignature():
- Remove
verify_hashfrom payload - Sort keys alphabetically (PHP
ksortequivalent) - Handle special fields:
expire_utc→ string cast,tx_urls→ HTML entity decode - Serialize using PHP format (
php-serializelibrary) - Compute HMAC-SHA1 via Web Crypto API
- Compare using XOR-based constant-time function
// Step 5: HMAC computation
const cryptoKey = await crypto.subtle.importKey(
'raw', encoder.encode(secretKey),
{ name: 'HMAC', hash: 'SHA-1' },
false, ['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(serialized));
// Step 6: Constant-time comparison
private constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}Failed HMAC verification returns 401 Unauthorized.
Layer 4: Replay Attack Prevention
The checkWebhookNonce() function in replay-protection.server.ts implements database-backed nonce tracking:
Nonce Hash Formula
SHA-256( txn_id : status : amount : order_number )Fields are joined with colon separators and hashed via Web Crypto API:
const nonceString = [
webhookData.txn_id,
webhookData.status,
webhookData.amount,
webhookData.order_number,
].join(':');
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(nonceString));Algorithm
1. Generate SHA-256 nonce from webhook fields
2. Query webhook_nonces table for existing nonce
3. If EXISTS → replay detected → log CRITICAL event → return 409
4. If NOT EXISTS → insert nonce with 5-minute TTL → continue
5. Handle unique constraint violation (race condition) as replayNonce TTL
Nonces expire after 5 minutes. Expired nonces are cleaned up by:
- A scheduled Supabase function (runs every 5 minutes)
- Manual cleanup via
cleanupExpiredNonces()function
Fail-Open Design
Fail-Open Philosophy
Replay protection fails open on all errors (network failures, database errors, missing credentials). The rationale: HMAC verification provides the primary security guarantee. Blocking legitimate webhooks due to infrastructure issues is worse than allowing a rare duplicate through (which is caught by Layer 6 anyway).
Error scenarios that fail open:
- Missing Supabase credentials
- Network errors during database query
- Database insert failures (non-duplicate)
- Unexpected exceptions
Layer 5: Transaction Rate Limiting
Per-transaction rate limit: 10 requests per minute per txn_id.
This prevents rapid-fire webhook spam targeting the same transaction, even if each webhook has a unique nonce (e.g., different status values).
Layer 6: Duplicate Transaction Check
Database-level deduplication checks if a transaction with the same txn_id already exists in the payment_transactions table:
const alreadyExists = await checkTransactionExists(payload.txn_id, context);
if (alreadyExists) {
return createSuccessResponse('Already processed');
}Note: returns 200 OK (not an error) since the transaction was already successfully processed.
Processing: Amount & Currency Validation
After passing all security layers, the webhook is processed:
- Status Mapping: Plisio status → internal status (
completed→confirmed,new/pending→pending, rest→failed) - Currency Validation: Rejects non-USD source currencies; logs
currency_mismatchevent - Amount Validation: Enhanced validation prevents precision exploits, overflow, NaN/Infinity
- Customer Extraction: Looks up email from
payment_transactionspending record, falls back to webhook data - Database Save: Creates or updates transaction record in
payment_transactions
GDPR-Compliant Security Logging
The security-logger.server.ts module automatically masks PII before writing to the database.
Email Masking
maskEmail('test@example.com') // → "te***t@example.com"
maskEmail('a@example.com') // → "a***@example.com"IP Masking
maskIP('192.168.1.100') // → "192.168.xxx.xxx"
maskIP('2001:0db8:85a3:...') // → "2001:0db8:85a3:0000:xxxx:xxxx:xxxx:xxxx"Recursive Sensitive Data Masking
The maskSensitiveData() function recursively scans JSONB event_data fields and masks:
| Field Pattern | Action |
|---|---|
*email* | Email masking |
*ip* | IP masking |
password, secret, token, key, api_key, api_secret, private_key, secret_key | ***REDACTED*** |
Logging Behavior
- Never throws — logging failures are caught and logged to console as fallback
- Never blocks — application flow continues even if database insert fails
- Console fallback — masked events are written to console if DB is unavailable
- Service role — uses
SUPABASE_SERVICE_ROLE_KEYto bypass RLS
Convenience Logger Functions
| Function | Event Type | Severity |
|---|---|---|
logWebhookReceived() | webhook_received | info |
logHMACFailure() | hmac_failure | critical |
logReplayAttack() | replay_detected | critical |
logRateLimitViolation() | rate_limit_violation | warning |
logIPViolation() | ip_whitelist_violation | critical |
logPaymentSuccess() | payment_success | info |
logPaymentFailure() | payment_failure | error |
logAmountValidationFailed() | amount_validation_failed | warning |
logCurrencyMismatch() | currency_mismatch | warning |
MoonPay Webhook Security
File: app/routes/api.moonpay-webhook.ts
MoonPay webhooks use a 5-layer security pipeline. Unlike Plisio (which has IP whitelisting and per-transaction rate limiting), MoonPay relies on HMAC-SHA256 with timestamp freshness as its primary authentication mechanism.
Pipeline Overview
MoonPay Webhook POST
│
▼
┌─────────────────────────────────┐
│ Layer 1: IP Rate Limiting │
│ 100 req/min per IP │
│ Failure → 429 Too Many Requests │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 2: Global Rate Limiting │
│ 1000 req/min total │
│ Failure → 503 Service Unavail. │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 3: HMAC-SHA256 Signature │
│ Timestamp freshness (5-min) │
│ Constant-time comparison │
│ Failure → 401 Unauthorized │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 4: Replay Prevention │
│ Nonce via checkGenericWebhook │
│ Nonce() — shared webhook_nonces │
│ Duplicate → 200 Acknowledged │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 5: Payload Validation │
│ Required fields: type, data.id │
│ Handle data as JSON string │
│ Security event logging │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Transaction Processing │
│ Find-or-create in │
│ payment_transactions │
└─────────────────────────────────┘Layer 3: HMAC-SHA256 Signature Verification
MoonPay sends the signature in the Moonpay-Signature-V2 header with the format:
Moonpay-Signature-V2: t=1492774577,s=abc123def456...Verification Steps
- Parse header — extract
t=TIMESTAMPands=SIGNATUREparts - Timestamp freshness — reject if
|now - timestamp| > 300 seconds(5-minute window) - Construct signed payload —
TIMESTAMP.BODY(timestamp + literal period + raw body) - Compute HMAC-SHA256 — using
MOONPAY_WEBHOOK_KEYvia Web Crypto API - Convert to hex — the signature is hex-encoded (NOT base64)
- Constant-time comparison — XOR-based
timingSafeEqual()function
// Signed payload construction
const signedPayload = `${timestamp}.${body}`;
// HMAC-SHA256 computation
const cryptoKey = await crypto.subtle.importKey(
'raw', encoder.encode(webhookKey),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(signedPayload));
// Hex encoding (NOT base64)
const computedSignature = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Constant-time comparison
timingSafeEqual(computedSignature, receivedSignature);Key Differences from Plisio
| Property | Plisio | MoonPay |
|---|---|---|
| Algorithm | HMAC-SHA1 | HMAC-SHA256 |
| Signed data | PHP-serialized sorted payload | timestamp.body string |
| Signature encoding | Hex | Hex |
| Timestamp validation | No | Yes (5-min freshness window) |
| Header name | verify_hash field in payload | Moonpay-Signature-V2 header |
Layer 4: Replay Prevention (Generic)
MoonPay uses the checkGenericWebhookNonce() function (not the Plisio-specific checkWebhookNonce()):
const nonceCheck = await checkGenericWebhookNonce(
{
rawNonceString: `${payload.data.id}:${payload.data.status}:${payload.type}`,
txn_id: payload.data.id,
provider: 'moonpay',
},
securityContext
);Nonce string: SHA-256(moonpay_txn_id:status:event_type) — e.g., SHA-256(txn_abc:completed:transaction_updated)
This uses the same webhook_nonces table and 5-minute TTL as Plisio, with the same fail-open behavior.
Transaction Processing
After security validation, the webhook processes transactions into the payment_transactions table:
- Look up by
provider_txn_id— MoonPay's internal transaction ID; matches previously updated records - Look up by
order_number— theexternalTransactionIdfrom the MoonPay URL; matches pre-created pending records from the sign endpoint - Create new — if neither lookup finds a match, creates a new transaction record
Status mapping:
| MoonPay Status | Internal PaymentStatus |
|---|---|
completed | completed |
failed | failed |
waitingPayment, waitingAuthorization | processing |
pending | pending |
Payload handling: MoonPay may send the data field as a JSON string. The handler detects this and double-parses it before validation.
TIP
Returns 200 OK even on internal errors to prevent MoonPay from retrying, per MoonPay's best practices documentation.
Generic Replay Protection Function
File: app/lib/replay-protection.server.ts
The checkGenericWebhookNonce() function provides provider-agnostic replay protection, complementing the Plisio-specific checkWebhookNonce().
Interface
interface GenericWebhookNonceData {
/** Raw string to hash (e.g. "txn_id:status:event_type") */
rawNonceString: string;
/** Transaction ID for logging and storage */
txn_id: string;
/** Provider name for logging context */
provider: string;
}
async function checkGenericWebhookNonce(
data: GenericWebhookNonceData,
context: SecurityLoggerContext
): Promise<NonceCheckResult>Algorithm
- Hash —
SHA-256(rawNonceString)via Web Crypto API - Check — Query
webhook_noncestable for existing hash - If exists → replay detected → log
CRITICALevent → return{ isValid: false, isDuplicate: true } - If not exists → insert nonce with 5-minute TTL → return
{ isValid: true } - Race condition — unique constraint violation (PostgreSQL error 23505) treated as replay
Comparison: Plisio vs Generic Nonce Functions
| Property | checkWebhookNonce() | checkGenericWebhookNonce() |
|---|---|---|
| Provider | Plisio only | Any provider |
| Input | Structured WebhookDataForNonce | Raw string via GenericWebhookNonceData |
| Nonce formula | SHA-256(txn_id:status:amount:order_number) | SHA-256(rawNonceString) |
| Storage fields | txn_id, status, amount, order_number | txn_id only |
| Fail-open | Yes | Yes |
| Table | webhook_nonces | webhook_nonces (shared) |
| TTL | 5 minutes | 5 minutes |
Security Constants
Defined in app/lib/security-types.ts:
export const SECURITY_CONSTANTS = {
NONCE_TTL_MINUTES: 5,
NONCE_CLEANUP_INTERVAL_MINUTES: 5,
SECURITY_EVENTS_RETENTION_DAYS: 365,
DEFAULT_RATE_LIMIT_WINDOW_MINUTES: 1,
DEFAULT_RATE_LIMIT_MAX_REQUESTS: 10,
DEFAULT_MIN_AMOUNT_USD: 1,
DEFAULT_MAX_AMOUNT_USD: 10000,
DEFAULT_MAX_DECIMAL_PLACES: 2,
};