This page describes the platform's security posture as implemented today. We do not mention certifications we do not hold: the following is the code's real behaviour, not aspirations.

Authentication

API key

Your API key is a brand-prefixed string (default mxcep_) followed by 64 hexadecimal characters generated with random_bytes(32). On the server, it is stored in three forms:

  • Hash (api_key_hash): SHA-256 hex. This is what we use to authenticate each request — we compare hash to hash.
  • Encrypted (api_key_encrypted): the full key, encrypted with AES-256-GCM (12-byte nonce + 16-byte tag). Decrypted only when the owner explicitly requests the value from the panel.
  • Prefix (api_key_prefix): the first 8 characters in cleartext. Used for fast lookup against the ambiguous-prefix set.

The encryption key lives in an environment variable (API_KEY_ENCRYPTION_KEY), never in the database. To rotate, regenerate the key from the /api panel. The old key stops working immediately.

Web session (console)

The web console uses JWT-in-cookie:

  • Cookie app_tokenHttpOnly, Secure (in production), SameSite=Lax. Browser JavaScript cannot read it.
  • Cookie app_csrf — 32-byte CSRF token, readable by JS, required on mutations to /admin and /v1/auth/*.
  • The domain is set from COOKIES_DOMAIN to enable SSO across subdomains (api, app, docs).

The public API (/v1/* for M2M clients) accepts API keys exclusively. JWTs are not accepted on public endpoints.

Step-up HMAC

Destructive admin operations (plan updates, subscriber migration between Stripe prices, price sync) use a two-phase flow:

  1. Prepare: the client sends the intent; the server validates and returns an HMAC token carrying the computed delta and a nonce.
  2. Execute: the client sends the token back; the server verifies the HMAC and nonce, then applies.

This prevents a hijacked session from triggering irreversible changes without explicit confirmation.

Handling of sensitive data

Account numbers (CLABE, card, DiMo phone)

Beneficiary accounts are stored in cleartext in user_beneficiaries.account_number and inside the request_data JSON of each validation. We do not encrypt them at the column level — access control depends on app auth and per-user_id isolation.

Wherever accounts might appear in textual logs (error messages, audit log with request bodies), they pass through AuditLogger::redactPanInMessage(), which masks any 13–19 digit run with •••• except the last 4 — for CLABEs (18 digits) it preserves the value for operational compatibility.

Receipt images

Images uploaded via POST /v1/validate-ocr go through a sanitization pipeline before touching disk:

  1. Size validation (40 bytes minimum, 12 MB maximum).
  2. Format detection via magic bytes (JPEG/PNG/WebP only).
  3. MIME cross-check with fileinfo.
  4. Structural parse with getimagesizefromstring + dimension caps (12000 px per axis, 60 megapixels total) to prevent decompression bombs.
  5. Polyglot scan — rejects payloads containing markers of PDF, ZIP, PHP, HTML, SVG, PE/ELF executables, RAR, or gzip.
  6. Metadata strip: EXIF, XMP, ICC profiles, text chunks (PNG), VP8X chunks (WebP). GPS, device model, software, and original date are removed.
  7. Re-validation of the sanitized output.

For images fetched via image_url, the downloader hardens the HTTP egress: SSRF gate with pinned DNS resolution (CURLOPT_RESOLVE), redirects capped at 3, HTTPS-only protocol in production, strict certificate verification (VERIFYPEER=true, VERIFYHOST=2), and an in-stream size cap.

Audit log

Every request to /v1/* and to the admin console is persisted to the audit_log table with:

  • method, endpoint, status, IP, user-agent, request_id
  • request body (with secrets redacted: API keys, JWTs, Stripe keys via SecretsRedactor::redact)
  • acting user (when applicable)

Critical events (failed login, key regenerated, permission changes, plan mutations, verified OTPs) get a second write to security_events, indexed by severity and category. The /admin/security panel reads from both tables.

For inbound Stripe webhooks the original body lives in webhook_ingress_events.raw_payload (not in audit_log) to avoid duplication.

Outgoing webhooks

The events we send to your endpoints are signed with HMAC-SHA256 over the JSON body, using a per-endpoint secret that you generate and rotate. The signature ships as:

X-Platform-Signature: sha256=<hex>

(The header name is white-label-configurable; the default is X-Platform-Signature.)

We also send:

  • X-{Brand}-Event: <event_type>
  • X-{Brand}-Delivery-Id: <id>
  • X-{Brand}-Timestamp: <ISO 8601 UTC>
  • User-Agent: Platform-Webhook/1.0

To mitigate replay attacks, verify on your side that the timestamp falls within a reasonable window (5 minutes) in addition to validating the signature. More detail in Webhook architecture.

Retention

Data does not live forever. These are the automatic timers, all running via Symfony Messenger Scheduler:

  • OCR receipts (storage/comprobantes/): 30 days by default, configurable via COMPROBANTES_RETENTION_DAYS. Orphan files are swept periodically.
  • idempotency_keys: hourly purge of expired rows (per-scope TTL; 24h for validations).
  • otp_codes: hourly removal of expired OTPs.
  • notifications and notification_deliveries: daily cleanup with configurable retention_days.
  • telegram_link_tokens: hourly.
  • webhook_ingress_events.raw_payload: 30 days.
  • audit_log: 1 year.
  • Messenger DLQ (dead-letter queue): auto-purge after 30 days by default (DLQ_AUTO_PURGE_DAYS).
  • Banxico health checks: 90 days.

Validations (validations) and beneficiaries (user_beneficiaries) have no automatic retention — that is your history and stays put until you explicitly delete it. Soft delete (deleted_at) and hard delete are available from the console.

Compliance support

If your team needs to coordinate a security review, a vendor assessment, a data processing agreement, or an end-user erasure request, contact the security team through the channel listed in your contract.

We do not publish certifications we do not audit. If your process requires SOC 2, ISO 27001, or another formal attestation, ask about the current status before integrating.