16 KiB
Security Architecture — Polymech
Polymech implements a layered security model that covers authentication, authorization, threat mitigation, and observability. Every layer is configurable via environment variables and manageable through admin APIs.
Table of Contents
- Authentication
- Authorization & Access Control
- Threat Mitigation
- Transport Security
- Observability & Auditing
- Admin API
- Configuration Reference
Authentication
JWT Bearer Tokens
All authenticated requests use Supabase-issued JWTs via the Authorization: Bearer <token> header. The server validates tokens through Supabase's auth.getUser(), with results cached in-memory to avoid repeated round-trips.
Three Authentication Modes
The server provides three middleware layers that can be composed per-route:
| Middleware | Behavior |
|---|---|
authMiddleware |
Strict — rejects any request without a valid Bearer token. Returns 401 immediately. |
optionalAuthMiddleware |
Flexible — resolves the user if a token is present, but allows unauthenticated access to public endpoints. Respects REQUIRE_AUTH env var for non-public routes. Also supports token via ?token= query param (for SSE streams). |
adminMiddleware |
Role-based — checks user_roles table for role = 'admin'. Returns 403 Forbidden if the user lacks admin privileges. Only applies to routes registered in AdminEndpointRegistry. |
Request Flow
Request → CORS → Blocklist → Auto-Ban → Analytics → optionalAuthMiddleware → adminMiddleware → Rate Limiter → Body Limit → Route Handler
- CORS validates origin against env-driven allowlist
- Blocklist checks manual blocklist (
config/blocklist.json) - Auto-Ban checks automatic ban list (
config/ban.json) - Analytics logs the request (non-blocking)
- Optional Auth resolves user identity if token present; validates JWT
expclaim and caches for 30s - Admin Check enforces admin-only on registered admin routes
- Rate Limiter enforces
RATE_LIMIT_MAXrequests perRATE_LIMIT_WINDOW_MSper IP/user - Body Limit enforces
MAX_UPLOAD_SIZE(default 10MB) on all API requests - Route Handler executes with
c.get('userId'),c.get('user'), andc.get('isAdmin')available
Authorization & Access Control
Route-Level Access Control
Routes are classified at definition time using decorators:
// In route definitions:
Public(route) // Registers in PublicEndpointRegistry → no auth required
Admin(route) // Registers in AdminEndpointRegistry → admin role required
The PublicEndpointRegistry and AdminEndpointRegistry use pattern matching (supporting :param and {param} styles) to determine access at runtime. This means authorization is declarative — defined alongside the route, not scattered across middleware.
Public Endpoints
All SEO and content delivery routes are public by default:
/feed.xml,/products.xml,/sitemap-en.xml,/llms.txt/post/:id.xhtml,/post/:id.pdf,/post/:id.md,/post/:id.json/user/:id/pages/:slug.xhtml,.html,.pdf,.md,.json,.email.html/api/posts/:id,/api/feed,/api/profiles,/api/media-items/embed/:id,/embed/page/:id
Admin-Only Endpoints
Privileged operations require both authentication and the admin role:
| Endpoint | Description |
|---|---|
POST /api/admin/system/restart |
Graceful server restart |
GET /api/admin/bans |
View current ban list |
POST /api/admin/bans/unban-ip |
Remove an IP ban |
POST /api/admin/bans/unban-user |
Remove a user ban |
GET /api/admin/bans/violations |
View violation statistics |
POST /api/flush-cache |
Flush all server caches |
GET /api/analytics |
View request analytics |
DELETE /api/analytics |
Clear analytics data |
GET /api/analytics/stream |
Live analytics stream (SSE) |
VFS (Virtual File System) ACL
The Storage product implements a full ACL system for its virtual file system:
- Mounts — isolated storage namespaces with per-mount access control
- Grants — explicit read/write permissions per user per mount
- Revocations — ability to revoke access without deleting the mount
- Glob-based queries — file listing supports
globpatterns, scoped to authorized mounts
Supabase RLS
Database-level security is enforced through PostgreSQL Row-Level Security:
user_roles— scoped byauth.uid() = user_iduser_secrets— API keys never exposed through public endpoints; accessed via/api/me/secretsproxy with masked GET and server-proxied PUT- Content tables — owner-based access with collaboration extensions
Secrets Management
API keys (OpenAI, Google, etc.) are stored in user_secrets and never returned in cleartext from any endpoint. The /api/me/secrets proxy returns masked values (last 4 characters only) with a has_key boolean indicator. Client code never accesses user_secrets directly.
CSRF Protection
Bearer token auth via Authorization header is inherently CSRF-proof — browsers cannot attach custom headers in cross-origin form submissions. No CSRF tokens are needed.
Threat Mitigation
Blocklist (Manual)
The blocklist.json file in /config/ provides static blocking of known bad actors:
{
"blockedIPs": ["203.0.113.50"],
"blockedUserIds": ["malicious-user-uuid"],
"blockedTokens": ["compromised-jwt-token"]
}
The blocklist is loaded on startup and checked for every API request. Blocked entities receive 403 Forbidden.
Auto-Ban (Automatic)
The auto-ban system tracks violations in-memory and automatically bans entities that exceed configurable thresholds:
How it works:
- Rate limit violations are recorded per IP or user key
- When violations exceed
AUTO_BAN_THRESHOLD(default: 5) withinAUTO_BAN_WINDOW_MS(default: 10s), the entity is permanently banned - Bans are persisted to
config/ban.jsonand survive server restarts - Old violation records are cleaned up periodically (
AUTO_BAN_CLEANUP_INTERVAL_MS)
What gets tracked:
- Repeated rate limit violations
- Repeated auth failures
- Suspicious request patterns
Ban types:
| Type | Scope |
|---|---|
| IP ban | Blocks all requests from the IP |
| User ban | Blocks all requests from the user ID |
| Token ban | Blocks requests with a specific JWT |
Rate Limiting
Rate limiting uses hono-rate-limiter with configurable windows and limits:
- Global API limiter —
RATE_LIMIT_MAXrequests perRATE_LIMIT_WINDOW_MS(applied to/api/*) - Custom per-endpoint limiters —
createCustomRateLimiter(limit, windowMs)for endpoints needing different thresholds - Key generation — rate limits are tracked per authenticated user (if token present) or per IP (fallback)
- Standard headers — responses include
RateLimit-*headers (draft-6 spec) - Violation escalation — rate limit violations are forwarded to the auto-ban system
Transport Security
Secure Headers
Applied globally via Hono's secureHeaders middleware:
| Header | Value | Rationale |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains |
1-year HSTS, enforces HTTPS |
| X-Frame-Options | SAMEORIGIN |
Clickjacking protection (relaxed for /embed/* routes) |
| Referrer-Policy | strict-origin-when-cross-origin |
Preserves analytics referrer data same-origin, protects privacy cross-origin |
| Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=(self) |
Restricts unused browser features; payment allowed for Stripe |
| Content-Security-Policy | See below | Full directive set protecting against XSS |
| Cross-Origin-Resource-Policy | Disabled | Media assets served cross-origin |
| Cross-Origin-Embedder-Policy | Disabled | Compatibility with external image/video sources |
| Cross-Origin-Opener-Policy | Disabled | No popup isolation needed |
Embed Route Override
Routes under /embed/* strip X-Frame-Options and widen frame-ancestors to *, allowing external sites to iframe embed widgets while keeping all other routes protected against clickjacking.
CSP Directives
| Directive | Value | Rationale |
|---|---|---|
default-src |
'self' |
Baseline deny-all |
script-src |
'self' 'nonce-<per-request>' cdn.jsdelivr.net |
Nonce-based inline script execution + Scalar UI |
style-src |
'self' 'unsafe-inline' fonts.googleapis.com cdn.jsdelivr.net |
Google Fonts CSS + Scalar UI (unsafe-inline required for dynamic styles) |
font-src |
'self' fonts.gstatic.com cdn.jsdelivr.net fonts.scalar.com |
Google Fonts + Scalar fonts |
img-src |
'self' data: blob: *.supabase.co *.polymech.info |
Supabase Storage + CDN assets |
connect-src |
'self' *.supabase.co wss://*.supabase.co api.openai.com assets.polymech.info cdn.jsdelivr.net proxy.scalar.com |
API, Realtime, AI, Scalar |
media-src |
'self' blob: *.supabase.co assets.polymech.info stream.mux.com |
Video/audio sources |
frame-src |
'self' *.supabase.co |
Supabase Auth popup |
frame-ancestors |
'self' |
Default: same-origin only (relaxed to * for /embed/*) |
object-src |
'none' |
Block Flash/Java |
base-uri |
'self' |
Prevent base-tag hijacking |
Compression
All responses are compressed with Brotli/gzip via hono/compress, reducing payload sizes and improving TTFB.
CORS
CORS origin validation is driven by CORS_ORIGINS env var:
# Production — only listed origins get Access-Control-Allow-Origin
CORS_ORIGINS=https://service.polymech.info,https://polymech.info,https://forum.polymech.info
# Development (unset / default) — falls back to origin: '*'
| Setting | Production | Development |
|---|---|---|
| Origin | Env-driven allowlist | * |
| Methods | GET, POST, PUT, DELETE, PATCH, OPTIONS | Same |
| Credentials | true |
false (browsers disallow credentials: true with *) |
| Max Preflight Cache | 600s (10 min) | Same |
Custom headers are whitelisted for client SDK compatibility (Stainless, etc.).
Observability & Auditing
Security Logging
All security events are logged via a dedicated securityLogger (Pino) with structured context:
- Auth failures with IP + user agent
- Admin actions with acting user ID
- Ban/unban events with target and outcome
- Rate limit violations with key and threshold
Analytics Middleware
Every request (except static assets, doc UIs, and widget paths) is tracked:
| Field | Source |
|---|---|
| Method + Path | Request |
| IP Address | Hardened extraction via getClientIpFromHono() — validates socket.remoteAddress against trusted proxy ranges before trusting X-Forwarded-For |
| User Agent | Request header |
| Session ID | pm_sid cookie (30-minute sliding expiry) |
| Geo Location | Background async lookup via BigDataCloud API |
| User ID | Resolved from JWT if present |
| Response Time | Measured end-to-end |
| Status Code | Response |
Geo-lookup resilience:
- Results cached in-memory + disk (
cache/geoip.json) - Non-blocking — resolved in background after response is sent
- Circuit breaker — after 3 consecutive failures, geo lookups are disabled for 30 seconds
- Timeout — individual lookups are capped at 2 seconds
- De-duplication — concurrent lookups for the same IP share a single request
Real-Time Streams
Security events and analytics are available as live Server-Sent Event (SSE) streams:
GET /api/logs/system/stream → Live system + security logs
GET /api/analytics/stream → Live request analytics
Admin API
All admin endpoints require authentication + admin role. Documented in OpenAPI and accessible via Swagger UI / Scalar.
Ban Management
GET /api/admin/bans → View all banned IPs, users, tokens
POST /api/admin/bans/unban-ip → { "ip": "203.0.113.50" }
POST /api/admin/bans/unban-user → { "userId": "user-uuid" }
GET /api/admin/bans/violations → View current violation tracking stats
System Operations
POST /api/admin/system/restart → Graceful restart (systemd re-spawns)
POST /api/flush-cache → Flush all in-memory + disk caches
POST /api/cache/invalidate → Selective cache invalidation by path/type
GET /api/cache/inspect → View cache state, TTLs, dependency graph
Analytics
GET /api/analytics → Historical request data
GET /api/analytics/stream → Real-time SSE stream
DELETE /api/analytics → Clear analytics data
Configuration Reference
All security settings are configurable via environment variables:
Authentication
| Variable | Default | Description |
|---|---|---|
REQUIRE_AUTH |
false |
When true, all non-public API routes require authentication |
CORS_ORIGINS |
* |
Comma-separated CORS allowed origins. Falls back to * if unset |
Rate Limiting
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_MAX |
1 |
Max requests per window |
RATE_LIMIT_WINDOW_MS |
50 |
Window duration in milliseconds |
Auto-Ban
| Variable | Default | Description |
|---|---|---|
AUTO_BAN_THRESHOLD |
5 |
Violations before auto-ban |
AUTO_BAN_WINDOW_MS |
10000 |
Violation counting window (ms) |
AUTO_BAN_CLEANUP_INTERVAL_MS |
60000 |
How often to clean up old violation records |
API Documentation
| Variable | Default | Description |
|---|---|---|
SCALAR_AUTH_TOKEN |
'' |
Pre-filled Bearer token for Scalar UI |
NODE_ENV |
— | When production, Swagger/Scalar UIs are disabled |
Files
| File | Description |
|---|---|
config/blocklist.json |
Manual IP/user/token blocklist |
config/ban.json |
Auto-generated ban list (persisted auto-bans) |
cache/geoip.json |
Geo-IP lookup cache |
TODO — Pending Improvements
High Priority
- Swagger/Scalar in production — Currently disabled entirely in production. Consider enabling at a protected
/admin/referencepath behind admin auth for debugging - [-] Audit logging — Admin actions (unban, restart, cache flush) log to Pino but should also persist to a dedicated
audit_logtable in the database
Medium Priority
- Page collaboration ACL — Implement
page_collaboratorsRLS so viewers cannot edit shared pages - Organization impersonation — Add
X-Org-Slugheader middleware to scope queries to organization context with role-based access (Admin reads all, Member reads own) - Per-route rate limiting — Apply stricter limits to expensive endpoints (
/api/search,/api/serving/site-info, image optimization proxy) usingcreateCustomRateLimiter - Redis-backed rate limiting — Current rate limiter is in-memory (per-instance). For multi-instance deploys, switch to a Redis-backed store via
hono-rate-limiter
Low Priority / Nice-to-Have
- API key authentication — Support
X-API-Keyheader as an alternative to Bearer tokens for third-party integrations - Webhook signature verification — For incoming webhooks (Stripe, etc.), verify HMAC signatures before processing
- Geo-blocking — Extend blocklist to support country-level blocking using the existing geo-IP cache
- Security headers audit — Run securityheaders.com and Mozilla Observatory checks on production and address any findings