API Docs Security Widget Guide otp.royaltycoding.com
Security Reference · v1.4

Origin Control & DDoS Protection

All security settings are database-driven and hot-reload without a server restart. Configure CORS origin whitelisting and rate-limit policies directly from SQL — changes take effect within 30 seconds.

API Token Auth Origin Whitelist 5-Layer DDoS Hot-Reload Config Per-Layer Enable/Disable Widget Error Events

🔑 API Token Authentication

Every /auth/* request must include a valid Bearer token in the Authorization header. Tokens are scoped to a single origin — a token created for https://myapp.com will be rejected from any other origin even if that origin is in allowed_origins. Raw tokens are never stored — only their SHA-256 hash is persisted. The plaintext token is shown once on creation and cannot be recovered.

✅ Valid request
  • Origin header matches an active allowed_origins row
  • Authorization header: Bearer <token>
  • Token is active and scoped to this origin
  • Request proceeds to endpoint logic
🚫 Rejected (401)
  • No Authorization header or malformed value
  • Token hash not found in api_tokens table
  • Token is revoked (active = 0)
  • Token origin doesn't match the request's origin
Double guard: Requests pass through both origin validation and token validation. An attacker needs a valid token for the correct origin — stealing just one is not enough.

Managing tokens

Tokens are managed via the /admin/tokens endpoints, which require the X-Admin-Secret header matching the ADMIN_SECRET env var. Call these from your server or CI pipeline — never from a browser.

Admin endpoints

MethodPathDescription
POST /admin/tokens Create a token. Body: { origin, name }. Returns the raw token once.
GET /admin/tokens List all tokens across all origins. Hashes are never returned.
GET /admin/tokens/by-origin List tokens for one origin. Query: ?origin=https://myapp.com
DELETE /admin/tokens/:id/revoke Soft-revoke — sets active = 0. Token stops working within cache TTL (15 s default). Reversible.
PATCH /admin/tokens/:id/activate Re-activate a previously revoked token.
DELETE /admin/tokens/:id Permanently delete a token. Irreversible.
Create a token (curl)
curl -X POST https://otp.royaltycoding.com/admin/tokens \
  -H "Content-Type: application/json" \
  -H "X-Admin-Secret: your-admin-secret" \
  -d '{"origin":"https://myapp.com","name":"production-frontend"}'
Use the token in an API call
curl -X POST https://otp.royaltycoding.com/auth/totp/setup \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -H "Authorization: Bearer <your-token>" \
  -d '{"email":"user@example.com"}'
Revoke a token immediately
curl -X DELETE https://otp.royaltycoding.com/admin/tokens/1/revoke \
  -H "X-Admin-Secret: your-admin-secret"

Cache TTL: Token validation results are cached in-process for 15 seconds (configurable via TOKEN_CACHE_TTL env var in ms). A revoked token will stop working within that window — no restart needed.

🌐 Origin Whitelist

Every API request must carry an Origin header matching an active row in the allowed_origins table. Requests without an Origin header, or with an unknown origin, are rejected with 403 Forbidden before any endpoint logic runs. The same list drives CORS preflight (OPTIONS) responses and is the sole source of truth for multi-tenancy scoping.

Multi-tenancy: The origin field is stored on every user row. The same email address from two different origins is treated as two completely independent accounts with separate TOTP secrets.
✅ Allowed origin behaviour
  • CORS preflight returns 200 OK
  • Origin stored on the user record
  • Request proceeds to endpoint logic
  • Cache refreshed every 30 s automatically
🚫 Blocked origin behaviour
  • Returns 403 Forbidden immediately
  • No endpoint logic is executed
  • No DB user lookups performed
  • Widget fires otp-error with errorType: "cors"

Error responses

HTTP 403 Missing Origin header "error": "Forbidden", "message": "Missing Origin header. Direct API calls are not allowed."
HTTP 403 Unknown / inactive origin "error": "Forbidden", "message": "Origin \"https://unknown.com\" is not allowed."

Cache TTL: The allowed origin list is cached in-process for 30 seconds (configurable via the ORIGIN_CACHE_TTL env var in ms). Changes propagate within that window — no server restart required.

🛡️ DDoS Protection

All rate-limit and slow-down parameters are stored in the ddos_config table and hot-reloaded every 30 seconds (configurable via DDOS_CONFIG_CACHE_TTL). Each of the 5 layers can be individually enabled or disabled without a restart.

Protection Layers

global Global Rate Limit

Applied to all routes. First line of defence against volumetric floods. Default: 200 req / 15 min per IP.

auth Auth Rate Limit

Applied to all /auth/* routes. Limits enumeration and credential-stuffing attacks. Default: 20 req / 15 min.

verify Verify Rate Limit

Applied to /totp/verify and /totp/disable only. Smallest window — blocks 6-digit brute-force. Default: 10 req / 5 min.

slowdown Slow-Down Middleware

Applied to verify + disable routes. After delay_after free requests, each extra hit adds delay_ms latency up to max_delay_ms. Punishes bots without blocking humans.

dbrl DB-Backed Window

Persistent sliding-window check stored in rate_limit_log. Survives server restarts and works across multiple instances. Uses the verify row's window/max settings.

Default values (seeded on fresh install)

layerenabledwindow_minutesmax_requestsdelay_afterdelay_msmax_delay_ms
global115200
auth11520
verify1510
slowdown151055005000
Hot-reload: Changes take effect within 30 seconds (the cache TTL). Set DDOS_CONFIG_CACHE_TTL=5000 in your .env for faster propagation during an active incident. If the database is unreachable, the last cached config is used as a fail-safe. On first startup with no DB connection, hardcoded safe defaults apply.

🚨 Widget Security Error Events

When the widget encounters a security-related error (rate limit, origin block, or network failure), it fires an otp-error custom event on the component element. The event bubbles and is composed, so you can listen on document to catch errors from any widget. The detail object contains three fields for programmatic handling.

Event detail fields
  • message — human-readable error string
  • httpStatus — HTTP status code (0 for network errors)
  • errorType — one of the 4 types below
In-modal behaviour
  • Rate limit (429) → amber banner, Close only
  • Access denied (403) → red banner, Close only
  • Network failure → grey banner + Try Again
  • Wrong code → digit inputs shake and reset

errorType reference

errorTypehttpStatusTriggered byWidget behaviour
rate 429 DDoS rate limit hit — too many requests from this IP Shows amber "Too Many Requests" banner. Fatal — Close only, no retry.
cors 403 Origin not in allowed_origins table or active = 0 Shows red "Access Denied" banner with admin contact note. Fatal — Close only.
apierr 4xx / 5xx Wrong code, user not found, validation error, server error For digit flows: shakes inputs, shows inline error, lets user retry. For fatal flows: shows red banner.
network 0 No response from server — offline, DNS failure, or server down Shows grey "Network Error" banner with Try Again + Close buttons.
JavaScript — handle security errors programmatically
document.addEventListener('otp-error', (e) => {
  const { message, httpStatus, errorType } = e.detail;

  if (errorType === 'rate') {
    // IP is rate-limited — disable your login button temporarily
    disableLoginFor(60 * 1000); // 60 seconds
  }

  if (errorType === 'cors') {
    // Origin blocked — alert your ops team
    alertOps(`Widget origin blocked: ${location.origin}`);
  }

  if (errorType === 'network') {
    // Server unreachable — show system status banner
    showStatusBanner('Authentication service unavailable');
  }

  // Always log for monitoring
  console.error(`[otp-error] type=${errorType} status=${httpStatus}`, message);
});