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 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.
- Origin header matches an active
allowed_originsrow - Authorization header:
Bearer <token> - Token is active and scoped to this origin
- Request proceeds to endpoint logic
- No
Authorizationheader or malformed value - Token hash not found in
api_tokenstable - Token is revoked (
active = 0) - Token origin doesn't match the request's origin
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
| Method | Path | Description |
|---|---|---|
| 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. |
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"}'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"}'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.
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.
- CORS preflight returns
200 OK - Origin stored on the user record
- Request proceeds to endpoint logic
- Cache refreshed every 30 s automatically
- Returns
403 Forbiddenimmediately - No endpoint logic is executed
- No DB user lookups performed
- Widget fires
otp-errorwitherrorType: "cors"
Error responses
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
Applied to all routes. First line of defence against volumetric floods. Default: 200 req / 15 min per IP.
Applied to all /auth/* routes. Limits enumeration and credential-stuffing attacks. Default: 20 req / 15 min.
Applied to /totp/verify and /totp/disable only. Smallest window — blocks 6-digit brute-force. Default: 10 req / 5 min.
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.
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)
| layer | enabled | window_minutes | max_requests | delay_after | delay_ms | max_delay_ms |
|---|---|---|---|---|---|---|
| global | 1 | 15 | 200 | — | — | — |
| auth | 1 | 15 | 20 | — | — | — |
| verify | 1 | 5 | 10 | — | — | — |
| slowdown | 1 | 5 | 10 | 5 | 500 | 5000 |
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.
message— human-readable error stringhttpStatus— HTTP status code (0 for network errors)errorType— one of the 4 types below
- 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
| errorType | httpStatus | Triggered by | Widget 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. |
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);
});