Skip to content

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

FileResponsibility
app/routes/api.plisio-webhook.tsPlisio entry point — orchestrates 6-layer security
app/routes/api.moonpay-webhook.tsMoonPay entry point — orchestrates 5-layer security
app/lib/webhook-extractor.server.tsMulti-format data extraction (URL params, JSON, FormData)
app/lib/webhook-validator.server.tsField validation, IP whitelisting, HMAC-SHA1 verification
app/lib/webhook-processor.server.tsBusiness logic: status mapping, amount/currency validation, DB save
app/lib/webhook-utils.server.tsShared types, response helpers, payload sanitization
app/lib/replay-protection.server.tsSHA-256 nonce generation and database-backed dedup (Plisio-specific checkWebhookNonce() + generic checkGenericWebhookNonce())
app/lib/rate-limiter.server.tsThree-tier in-memory rate limiting
app/lib/security-logger.server.tsGDPR-compliant event logging with PII masking
app/lib/security-types.tsCanonical type definitions, enums, and constants
app/lib/cors.server.tsShared 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:

CheckLimitFailure Response
IP rate limit100 req/min per IP429 with Retry-After header
Global circuit breaker1000 req/min total503 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:

MethodContent-TypeExtraction
GETURLSearchParams
POSTapplication/jsonrequest.json()
POSTmultipart/form-datarequest.formData()
POSTapplication/x-www-form-urlencodedrequest.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

typescript
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():

  1. Remove verify_hash from payload
  2. Sort keys alphabetically (PHP ksort equivalent)
  3. Handle special fields: expire_utc → string cast, tx_urls → HTML entity decode
  4. Serialize using PHP format (php-serialize library)
  5. Compute HMAC-SHA1 via Web Crypto API
  6. Compare using XOR-based constant-time function
typescript
// 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:

typescript
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 replay

Nonce 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:

typescript
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:

  1. Status Mapping: Plisio status → internal status (completedconfirmed, new/pendingpending, rest→failed)
  2. Currency Validation: Rejects non-USD source currencies; logs currency_mismatch event
  3. Amount Validation: Enhanced validation prevents precision exploits, overflow, NaN/Infinity
  4. Customer Extraction: Looks up email from payment_transactions pending record, falls back to webhook data
  5. 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

typescript
maskEmail('test@example.com')  // → "te***t@example.com"
maskEmail('a@example.com')     // → "a***@example.com"

IP Masking

typescript
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 PatternAction
*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_KEY to bypass RLS

Convenience Logger Functions

FunctionEvent TypeSeverity
logWebhookReceived()webhook_receivedinfo
logHMACFailure()hmac_failurecritical
logReplayAttack()replay_detectedcritical
logRateLimitViolation()rate_limit_violationwarning
logIPViolation()ip_whitelist_violationcritical
logPaymentSuccess()payment_successinfo
logPaymentFailure()payment_failureerror
logAmountValidationFailed()amount_validation_failedwarning
logCurrencyMismatch()currency_mismatchwarning

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

  1. Parse header — extract t=TIMESTAMP and s=SIGNATURE parts
  2. Timestamp freshness — reject if |now - timestamp| > 300 seconds (5-minute window)
  3. Construct signed payloadTIMESTAMP.BODY (timestamp + literal period + raw body)
  4. Compute HMAC-SHA256 — using MOONPAY_WEBHOOK_KEY via Web Crypto API
  5. Convert to hex — the signature is hex-encoded (NOT base64)
  6. Constant-time comparison — XOR-based timingSafeEqual() function
typescript
// 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

PropertyPlisioMoonPay
AlgorithmHMAC-SHA1HMAC-SHA256
Signed dataPHP-serialized sorted payloadtimestamp.body string
Signature encodingHexHex
Timestamp validationNoYes (5-min freshness window)
Header nameverify_hash field in payloadMoonpay-Signature-V2 header

Layer 4: Replay Prevention (Generic)

MoonPay uses the checkGenericWebhookNonce() function (not the Plisio-specific checkWebhookNonce()):

typescript
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:

  1. Look up by provider_txn_id — MoonPay's internal transaction ID; matches previously updated records
  2. Look up by order_number — the externalTransactionId from the MoonPay URL; matches pre-created pending records from the sign endpoint
  3. Create new — if neither lookup finds a match, creates a new transaction record

Status mapping:

MoonPay StatusInternal PaymentStatus
completedcompleted
failedfailed
waitingPayment, waitingAuthorizationprocessing
pendingpending

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

typescript
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

  1. HashSHA-256(rawNonceString) via Web Crypto API
  2. Check — Query webhook_nonces table for existing hash
  3. If exists → replay detected → log CRITICAL event → return { isValid: false, isDuplicate: true }
  4. If not exists → insert nonce with 5-minute TTL → return { isValid: true }
  5. Race condition — unique constraint violation (PostgreSQL error 23505) treated as replay

Comparison: Plisio vs Generic Nonce Functions

PropertycheckWebhookNonce()checkGenericWebhookNonce()
ProviderPlisio onlyAny provider
InputStructured WebhookDataForNonceRaw string via GenericWebhookNonceData
Nonce formulaSHA-256(txn_id:status:amount:order_number)SHA-256(rawNonceString)
Storage fieldstxn_id, status, amount, order_numbertxn_id only
Fail-openYesYes
Tablewebhook_nonceswebhook_nonces (shared)
TTL5 minutes5 minutes

Security Constants

Defined in app/lib/security-types.ts:

typescript
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,
};

UberLotto Technical Documentation