VerifyMX Docs

Security

Auth model, encryption, data privacy, rate limits, audit logging.

This document describes the security architecture of VerifyMX — authentication, authorization, encryption, data privacy, audit logging, and rate limiting.


API Authentication

VerifyMX uses a two-layer authentication model: API keys for long-lived server credentials and JWT bearer tokens for short-lived session access.

API Keys

Format: vmx_<48 hex characters> (52 chars total)

  • Generated with crypto.randomBytes(24) — 192 bits of entropy
  • The raw key is shown exactly once at creation and never stored
  • A bcrypt hash (cost factor 12) is stored in the database
  • Lookup uses the first 12 characters as a prefix index to avoid full-table scans
  • Keys can be revoked instantly via DELETE /v1/organizations/me/api-keys/:id
  • A key cannot revoke itself (the API prevents this)

JWT Bearer Tokens

Tokens are issued via POST /v1/auth/token (OAuth 2.0 client_credentials).

  • Algorithm: HS256 (HMAC-SHA256)
  • Expiry: 1 hour
  • Claims: sub (orgId), apiKeyId, role
  • Secret: JWT_SECRET environment variable — minimum 256-bit random value
  • Token validation on every request: signature + expiry + org existence

To obtain a token:

curl -X POST https://api.verifymx.xyz/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "org_01HXYZ...",
    "client_secret": "vmx_..."
  }'

OTP (No Auth Required)

OTP endpoints (/v1/otp/send, /v1/otp/verify) are intentionally unauthenticated because they are called from end-user browsers during a live verification session.

Security controls compensating for no auth:

  • verificationId acts as a capability token — it scopes the OTP to one session
  • OTP codes are stored bcrypt-hashed (never plaintext) in Redis
  • Rate limit: max 3 sends per destination per 10 minutes
  • Max 5 failed verify attempts before the code is invalidated
  • TTL: codes expire after 5 minutes (Redis EX 300)
  • Timing-safe comparison via bcrypt.compare prevents timing attacks

Authorization (RBAC)

Every authenticated request carries a role claim. Role hierarchy:

RoleCan do
VIEWERRead verifications, list API keys
REVIEWERAll VIEWER actions + manual status updates
ADMINAll REVIEWER actions + create/update/delete workflows, webhooks, API keys
OWNERAll ADMIN actions + manage org settings, billing

Role enforcement is applied in route handlers, not middleware, to provide precise per-endpoint error messages. The check uses a numeric rank map:

const RANK = { VIEWER: 0, REVIEWER: 1, ADMIN: 2, OWNER: 3 };
if (RANK[role] < RANK['REVIEWER']) throw forbidden('...');

Row-Level Security

All database queries are scoped to orgId from the JWT claims. There is no way for an authenticated request to access another organization's data — even if the resource ID is known. Example:

const verification = await db.verification.findById(id, orgId);
// orgId is always AND-ed into the WHERE clause

Webhook Signature Verification

Webhook payloads are signed with HMAC-SHA256. The signing secret is a 64-character random hex string (256 bits) auto-generated at endpoint creation.

Signature header: X-VerifyMX-Signature: sha256=<hex_digest>

Algorithm:

import { createHmac, timingSafeEqual } from 'node:crypto';

function signWebhookPayload(payload: string, secret: string): string {
  const digest = createHmac('sha256', secret).update(payload, 'utf8').digest('hex');
  return `sha256=${digest}`;
}

function verifyWebhookSignature(payload: string, secret: string, signature: string): boolean {
  const expected    = signWebhookPayload(payload, secret);
  const expectedBuf = Buffer.from(expected,   'utf8');
  const actualBuf   = Buffer.from(signature,  'utf8');
  if (expectedBuf.length !== actualBuf.length) return false;
  return timingSafeEqual(expectedBuf, actualBuf); // constant-time
}

Critical: always read the raw request body as a Buffer before any JSON parsing. Parsing then re-serializing changes whitespace and will break the signature comparison.

The signing secret is shown once at endpoint creation and is never returned in subsequent API responses. To rotate a secret, delete and recreate the endpoint.


Encryption

In Transit

All API traffic requires TLS 1.2+. HTTP requests are redirected to HTTPS. Webhook deliveries are only sent to HTTPS URLs (the url field is validated as a URI; plain HTTP webhook targets are rejected at endpoint creation).

At Rest

DataEncryption
Database volumesAES-256 at the storage layer (managed cloud disk encryption)
Document images/filesEncrypted at rest in object storage (AES-256)
API key secretsbcrypt (cost 12) — one-way, never decryptable
Webhook signing secretsStored encrypted in database with application-layer key
JWT secretNever persisted — loaded from environment variable only
OTP codesbcrypt (cost 10) in Redis — one-way

Passwords

User passwords are bcrypt-hashed (cost 12) before storage. Plain passwords are never logged or stored.


Input Validation and Sanitization

All request bodies are validated against JSON Schema using Fastify's built-in AJV integration. Validation runs before any business logic executes.

Configuration:

ajv: {
  customOptions: {
    strict: false,        // allows format keywords
    allErrors: true,      // collects all errors before rejecting
    coerceTypes: 'array', // coerces query string values to declared types
    useDefaults: true,    // applies schema defaults
  },
}

Validation failures return HTTP 422 with a structured error listing all invalid fields — no partial processing occurs.

SQL injection prevention: all database queries use parameterized statements via Prisma's query builder. Raw SQL is never constructed from user input.

XSS prevention: the API returns JSON only. There is no server-rendered HTML. All string fields in JSON Schema have maxLength constraints to prevent oversized payloads from reaching business logic.


Rate Limiting

Default limit: 10 requests per second per organization. The rate-limit key is the orgId from the JWT claim when authenticated, or the client IP for unauthenticated requests.

The key generator:

keyGenerator(request) {
  return request.auth?.orgId ?? request.ip;
}

Responses on rate-limit breach:

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Try again in 100ms",
    "details": {
      "limit": 10,
      "remaining": 0,
      "resetAfter": "100ms"
    }
  }
}

The /health endpoint is exempt from rate limiting (config: { rateLimit: false }). The /v1/openapi.json endpoint has a relaxed limit (60 req/min) to accommodate CI tooling that fetches the spec on every build.


Audit Logging

Security-sensitive actions are written to an immutable audit log table:

ActionLogged fields
verification.status_updatedorgId, userId, previousStatus, newStatus, notes, ipAddress
api_key.createdorgId, userId, keyName, keyPrefix
api_key.revokedorgId, userId, keyId, keyPrefix
webhook_endpoint.createdorgId, userId, url, events
webhook_endpoint.deletedorgId, userId, endpointId
org.settings_updatedorgId, userId, changedFields

Audit log entries are append-only. No update or delete operation exists on the audit log table.


Data Privacy and Retention

Data Retention

Each organization configures its own data retention period (dataRetentionDays, 30–3650 days). A background job permanently deletes verifications, documents, and associated data older than the configured period.

The retention period defaults to 365 days and can be updated via PATCH /v1/organizations/me.

Mexican Data Privacy Law (LFPDPPP)

The Ley Federal de Protección de Datos Personales en Posesión de los Particulares requires:

  • Explicit consent before collecting biometric data
  • Data minimization — collect only what is required for verification
  • Right of access, rectification, cancellation, and opposition (ARCO rights)
  • Designation of a data privacy officer (DPO)
  • Security measures appropriate to the sensitivity of the data

VerifyMX stores raw document images and extracted biometric data (facial embeddings). Both are classified as sensitive personal data under LFPDPPP. Your privacy notice must include VerifyMX as a data processor.

GDPR

For EU customers or customers processing EU citizen data, VerifyMX operates as a data processor under Article 28 GDPR. A Data Processing Agreement (DPA) is available on request.


CORS

Cross-Origin Resource Sharing is configured per environment:

  • Development: all origins allowed (origin: true)
  • Production: only origins listed in the ALLOWED_ORIGINS environment variable (comma-separated list of exact URLs) — e.g. https://app.acme.mx,https://admin.acme.mx

Credentials are allowed (credentials: true) so browser-based SDK calls can send cookies alongside the Authorization header.


Security Headers

All API responses include:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Cache-Controlno-store (for auth endpoints)

Secrets Management

Never hardcode secrets. All sensitive values are loaded from environment variables:

VariableDescription
JWT_SECRETHS256 signing key — minimum 256 bits, generated with openssl rand -hex 32
DATABASE_URLPostgres connection string with credentials
REDIS_URLRedis connection string
ALLOWED_ORIGINSComma-separated allowed CORS origins
UPLOADS_DIRLocal uploads path (dev only; use object storage in production)

In production, inject secrets via your cloud provider's secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, etc.) rather than .env files on disk.

The server exits at startup (process.exit(1)) if JWT_SECRET is missing. This prevents accidentally running without authentication.


Dependency Security

  • All npm dependencies are pinned and audited via npm audit / pnpm audit in CI
  • Dependabot or Renovate updates are reviewed before merge
  • @fastify/sensible provides safe defaults for HTTP error handling
  • No eval, new Function, or dynamic require in production code paths
  • File uploads are validated by MIME type before storage; filenames are never used directly in filesystem paths (a random UUID is used instead)

Responsible Disclosure

If you find a security vulnerability, email security@verifymx.xyz with:

  1. A description of the vulnerability
  2. Steps to reproduce
  3. Potential impact

We acknowledge reports within 24 hours and aim to patch critical issues within 72 hours. We do not currently offer a bug bounty program but will publicly credit reporters who request it.

On this page