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_SECRETenvironment 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:
verificationIdacts 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.compareprevents timing attacks
Authorization (RBAC)
Every authenticated request carries a role claim. Role hierarchy:
| Role | Can do |
|---|---|
VIEWER | Read verifications, list API keys |
REVIEWER | All VIEWER actions + manual status updates |
ADMIN | All REVIEWER actions + create/update/delete workflows, webhooks, API keys |
OWNER | All 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
| Data | Encryption |
|---|---|
| Database volumes | AES-256 at the storage layer (managed cloud disk encryption) |
| Document images/files | Encrypted at rest in object storage (AES-256) |
| API key secrets | bcrypt (cost 12) — one-way, never decryptable |
| Webhook signing secrets | Stored encrypted in database with application-layer key |
| JWT secret | Never persisted — loaded from environment variable only |
| OTP codes | bcrypt (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:
| Action | Logged fields |
|---|---|
verification.status_updated | orgId, userId, previousStatus, newStatus, notes, ipAddress |
api_key.created | orgId, userId, keyName, keyPrefix |
api_key.revoked | orgId, userId, keyId, keyPrefix |
webhook_endpoint.created | orgId, userId, url, events |
webhook_endpoint.deleted | orgId, userId, endpointId |
org.settings_updated | orgId, 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_ORIGINSenvironment 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:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Cache-Control | no-store (for auth endpoints) |
Secrets Management
Never hardcode secrets. All sensitive values are loaded from environment variables:
| Variable | Description |
|---|---|
JWT_SECRET | HS256 signing key — minimum 256 bits, generated with openssl rand -hex 32 |
DATABASE_URL | Postgres connection string with credentials |
REDIS_URL | Redis connection string |
ALLOWED_ORIGINS | Comma-separated allowed CORS origins |
UPLOADS_DIR | Local 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 auditin CI - Dependabot or Renovate updates are reviewed before merge
@fastify/sensibleprovides safe defaults for HTTP error handling- No
eval,new Function, or dynamicrequirein 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:
- A description of the vulnerability
- Steps to reproduce
- 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.