This document describes security features, authentication flows, and known limitations.
All sensitive data is encrypted using AES-256-GCM. The encryption key can be set via the ENCRYPTION_KEY environment variable or auto-generated on first start.
Key sources (in priority order):
ENCRYPTION_KEYenvironment variable (if set)/data/.encryption_keyfile (if exists)- Auto-generated and saved to
/data/.encryption_key(first start)
Requirements:
- Minimum 32 characters (256 bits)
- Generate manually with:
openssl rand -hex 32 - If auto-generated, ensure
/datais included in backups — losing the key means encrypted data cannot be recovered
What's encrypted:
- AI provider API keys (
ai_providers.api_key_encrypted) - GitHub tokens (
settings.github_token) - TOTP secrets (
users.totp_secret) - TOTP recovery code hashes (
users.totp_recovery_codes)
Key derivation: The master key is stretched into separate keys for different purposes:
- Encryption key:
SHA256(master + ":encryption") - HMAC key:
SHA256(master + ":hmac") - JWT signing key:
SHA256(master + ":jwt")
Admin users authenticate via:
- OAuth2 (Google, GitHub) - preferred
- Password - fallback
Access is controlled by the ADMIN_EMAILS environment variable (comma-separated list).
Facet supports optional TOTP-based two-factor authentication. When enabled, users must enter a 6-digit code from their authenticator app after logging in.
Implementation: Application-layer gate (not native PocketBase MFA, which only supports email OTP). The existing PocketBase auth flow is completely unchanged; the TOTP check happens after successful PocketBase authentication in the admin layout.
Auth flow with 2FA enabled:
Login → PocketBase auth → authRefresh → checkDefaultPassword →
→ check2FAStatus() →
if API error → retry/logout screen (fail-closed)
if 2FA enabled && session not verified → TwoFactorModal (blocks access)
if 2FA not enabled or already verified → normal admin flow
| Aspect | Implementation |
|---|---|
| TOTP algorithm | SHA1, 6 digits, 30-second period (RFC 6238) |
| Clock drift tolerance | ±1 step (±30 seconds) via ValidateCustom(Skew: 1) |
| TOTP secrets | AES-256-GCM encrypted at rest using app encryption key |
| Recovery codes | 8 codes, XXXX-XXXX format (hex), bcrypt hashed (cost 10), stored encrypted |
| Recovery code comparison | Lowercased before bcrypt compare (case-insensitive input) |
| Session duration | 24 hours after successful TOTP verification |
| Session storage | totp_session_nonce + totp_session_expires on user record |
| QR code generation | Client-side only (qrcode npm library) — secret never sent to third-party services |
| Rate limiting | Strict tier on all 6 TOTP endpoints |
| Library | github.com/pquerna/otp v1.5.0 |
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/totp/status |
Check if 2FA is enabled + session verified |
POST |
/api/totp/begin-setup |
Generate TOTP secret and otpauth URL |
POST |
/api/totp/confirm-setup |
Validate code, enable 2FA, return recovery codes |
POST |
/api/totp/verify |
Validate TOTP/recovery code, create session |
POST |
/api/totp/disable |
Disable 2FA (requires valid code) |
POST |
/api/totp/regenerate-codes |
Generate new recovery codes (requires valid code) |
All endpoints require PocketBase authentication (apis.RequireAuth()).
If a user is locked out (lost authenticator + no recovery codes), an admin can reset 2FA via CLI:
./facet reset-2fa user@example.comThis clears all TOTP fields and disables 2FA for that user.
- Fail-closed: If the 2FA status API is unreachable, access is blocked (retry/logout screen shown, not the TOTP modal)
- No bypass via password change: Password change modal does not skip the 2FA gate
- Recovery code race protection: Recovery code verification runs inside
app.RunInTransaction()with a fresh user fetch to prevent double-consumption - No secret leakage: QR codes generated client-side;
session_noncenot returned in API responses - Mutual exclusivity: Frontend enforces that the error screen and TOTP modal cannot display simultaneously
- Single-device sessions: TOTP session is stored on the user record, so verifying on one device/browser updates the nonce for all sessions
- No re-auth on sensitive actions: Enabling 2FA or changing settings does not require re-entering the TOTP code (protected by rate limiting)
- 24-hour fixed window: Session duration is not configurable
| Visibility | Access Control | HTTP Response |
|---|---|---|
public |
Anyone | 200 OK |
unlisted |
Valid share token required | 200 with token, 404 without |
password |
Valid password JWT required | 200 with JWT, password prompt without |
private |
Admin only | 404 (not 401 - prevents discovery) |
Facet uses LinkedIn-style canonical URLs:
| Route | Purpose | Notes |
|---|---|---|
/ |
Default public profile | Renders view with is_default=true |
/<slug> |
Named view (canonical) | e.g., /recruiter, /investor |
/s/<token> |
Share link entry | Sets cookie, redirects to /<slug> |
/v/<slug> |
Legacy route | 301 redirects to /<slug> |
View slugs cannot collide with system routes. These are protected:
admin, api, s, v, projects, posts, talks,
_app, _, assets, static,
favicon.ico, robots.txt, sitemap.xml,
health, healthz, ready, login, logout,
auth, oauth, callback, home, index, default, profile
Enforcement layers:
- Frontend param matcher:
src/params/slug.ts- invalid slugs don't route - Backend hook: Returns HTTP 400 when creating/updating views with reserved slugs
Password-protected views use signed JWTs for access control.
1. Client: POST /api/view/{slug}/access
Response: { "requires_password": true, "id": "..." }
2. Client: POST /api/password/check
Body: { "view_id": "...", "password": "..." }
Response: { "access_token": "<JWT>", "expires_in": 3600 }
3. Client: GET /api/view/{slug}/data
Header: Authorization: Bearer <JWT>
Response: { view data }
Algorithm: HS256
Claims:
| Claim | Description |
|---|---|
vid |
View ID |
iss |
Issuer: facet |
aud |
Audience: view-access |
iat |
Issued at timestamp |
exp |
Expiration timestamp |
jti |
Unique token ID (for audit) |
Lifetime: 1 hour
Tokens can be sent via:
Authorization: Bearer <token>(preferred)X-Password-Token: <token>(legacy/UI convenience)
- Tokens are signed with HMAC-SHA256
- Signature validation is required
- Expiry is enforced
- Issuer and audience are validated
- View ID in token must match requested view
- Tokens cannot be used for a different view than issued
- No revocation: Tokens are valid until expiry
- No refresh: Client must re-authenticate after expiry
- Stateless: Server doesn't track issued tokens
Share tokens provide access to unlisted views. They are required for any visibility=unlisted view.
The recommended way to share unlisted views is via /s/<token> URLs:
1. Admin: POST /api/share/generate (authenticated)
Body: { "view_id": "...", "name": "For recruiters", "expires_at": null, "max_uses": 0 }
Response: { "id": "...", "token": "<raw-token>", "name": "..." }
⚠️ Raw token is returned ONLY ONCE - store it securely
2. Admin shares URL: https://example.com/s/<token>
3. User visits /s/<token>:
- Server validates token (POST /api/share/validate)
- Sets httpOnly cookie (me_share_token, SameSite=Lax)
- 302 redirect to /<slug> (canonical URL)
- Token is NOT in the final URL
4. User's browser requests /<slug>:
- SvelteKit reads token from cookie
- Sends X-Share-Token header to backend
- Backend validates and returns view data
This flow ensures:
- Token never appears in browser history
- Token never leaks via Referer headers
- Clean canonical URLs are displayed
- Cookie is httpOnly (no JavaScript access)
1. GET /api/view/{slug}/access
Response: { "requires_token": true, "id": "..." }
2. GET /api/view/{slug}/data
Header: X-Share-Token: <raw-token> (RECOMMENDED)
-- or --
Header: Authorization: Bearer <raw-token> (alternative)
Response: { view data }
Share tokens use a two-part storage strategy for O(1) lookup:
- token_prefix (first 12 chars): Stored in plaintext for indexed queries
- token_hash: HMAC-SHA256 of the full token
Lookup algorithm:
1. Extract prefix from provided token (first 12 chars)
2. Query: SELECT * FROM share_tokens WHERE token_prefix = ? AND is_active = true
3. For each candidate (typically 1), verify full HMAC
4. Validate: view_id matches, not expired, under max_uses
This achieves O(1) database lookup instead of O(n) scanning, while maintaining security.
- HMAC storage: Raw tokens never stored; DB leak doesn't reveal usable tokens
- Constant-time comparison: Prevents timing attacks on HMAC verification
- Prefix is non-secret: The 12-char prefix is a lookup optimization only; security relies entirely on HMAC verification of the full token and the underlying 256-bit token randomness
- View-bound: Each token is tied to a specific view ID
- Expiry support: Tokens can have optional expiration dates
- Usage limits: Tokens can have optional max usage counts
- Revocation: Admin can deactivate tokens at any time
- Non-leaky errors: All validation failures return the same generic error to prevent oracle attacks
| Property | Description |
|---|---|
| Length | 32 bytes, URL-safe base64 encoded (~43 chars) |
| Prefix | First 12 characters stored for indexed lookup |
| HMAC | Full token hashed with server's HMAC key |
| Expiry | Optional, enforced server-side |
| Max uses | Optional, 0 = unlimited |
| Use count | Tracked per-token |
Tokens can be sent via:
Authorization: Bearer <token>— RECOMMENDED for API clientsX-Share-Token: <token>— Alternative header for programmatic access?token=<token>— LEGACY/COMPAT for shareable links only
⚠️ Security Warning: Query parameter tokens (?token=...) are logged in server access logs, stored in browser history, and may leak via HTTP Referer headers. Use header-based transport whenever possible. Consider the query parameter method only for human-shareable links where header transport is impractical.
- No token refresh: Expired tokens require admin to generate new one
- Prefix collision: Rare but possible; mitigated by HMAC verification
- No per-use logging: Usage count tracked, but not individual accesses
- URL token leakage: Tokens in query strings may leak (see warning above)
All PocketBase collections require authentication for direct access via /api/collections/{name}/records. This prevents bypassing visibility and draft rules.
Public data flows through custom API endpoints:
/api/view/{slug}/access— Returns view metadata (visibility, requirements)/api/view/{slug}/data— Returns view content with visibility rules enforced
These endpoints use server-side database calls that bypass collection rules, allowing them to serve public content while maintaining access control.
| Category | Collections | Direct Access |
|---|---|---|
| Content | profile, experience, projects, education, certifications, skills, posts, talks, views | Auth required |
| Sensitive | share_tokens, sources, ai_providers, import_proposals, settings | Auth required |
| Auth | users | Managed by PocketBase |
Without these restrictions, an attacker could:
- Enumerate all records via
/api/collections/projects/records - Access draft content (
is_draft=true) - Access private content (
visibility=private) - Bypass share token requirements for unlisted views
With deny-by-default:
- Public visitors use
/api/view/{slug}/datawhich enforces visibility - Only authenticated admins can access raw collection data
- Visibility and draft filtering is guaranteed
PocketBase collection API rules protect HTTP access only. Internal application queries (via app.FindRecordsByFilter() and similar methods) run with server authority and bypass these rules by design.
Custom API endpoints (e.g., /api/view/{slug}/data) are responsible for enforcing visibility and draft rules in application code. This separation is intentional: collection rules block external enumeration, while application code handles business logic.
Authenticated users (admin OAuth allowlist) can still:
- Use the admin dashboard to manage content
- Access collections directly via PocketBase API
- Use the
/_/admin UI (if enabled)
Facet implements per-IP rate limiting using the token bucket algorithm to protect against brute force and abuse.
| Tier | Limit | Burst | Endpoints |
|---|---|---|---|
| Strict | 5/min | 3 | POST /api/password/check, all /api/totp/* endpoints |
| Moderate | 10/min | 5 | POST /api/share/validate |
| Normal | 60/min | 10 | GET /api/view/{slug}/access, GET /api/view/{slug}/data |
When rate limited, the server returns:
- Status:
429 Too Many Requests - Headers:
Retry-After: <seconds>— Time until next request allowedX-RateLimit-Limit: <rate>— The rate limit for this endpointX-RateLimit-Remaining: 0— No requests remaining
- Body:
{"error": "too many requests"}(uniform, non-leaky)
Environment Variables:
| Variable | Default | Description |
|---|---|---|
TRUST_PROXY |
false |
Set to true to trust proxy headers for client IP |
Client IP Detection (in order of priority when TRUST_PROXY=true):
CF-Connecting-IP— Cloudflare's original client IP headerX-Real-IP— Common proxy header (nginx, etc.)X-Forwarded-For— Leftmost IP from comma-separated listRemoteAddr— Direct connection IP (fallback)
Security Warning: Only set TRUST_PROXY=true if:
- Traffic arrives exclusively through a trusted proxy (Cloudflare, nginx, etc.)
- The proxy is configured to set/overwrite these headers
- Direct connections to the server are blocked
Without proper proxy configuration, attackers can spoof their IP address.
When using Cloudflare Tunnel or proxy:
- Set
TRUST_PROXY=true - Ensure Cloudflare IP ranges are the only allowed source IPs
- The server will use
CF-Connecting-IPfor rate limiting
- In-memory storage: Rate limit state does not persist across restarts
- Single-instance: Each server instance has independent rate limit state
- No distributed coordination: In multi-instance deployments, limits apply per-instance
For production at scale, consider implementing Redis-backed rate limiting (Step 6B).
# Test rate limiting on password endpoint (strict tier)
for i in {1..6}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8090/api/password/check \
-H "Content-Type: application/json" \
-d '{"view_id":"test","password":"wrong"}'
done
# Expected: 400, 400, 400, 429, 429, 429 (first 3 allowed, then rate limited)
# Check Retry-After header
curl -s -I -X POST http://localhost:8090/api/password/check \
-H "Content-Type: application/json" \
-d '{"view_id":"test","password":"wrong"}' | grep -i retry-afterNo explicit CORS configuration - all endpoints are same-origin behind the Caddy reverse proxy.
Facet implements security headers in two phases. Phase 5A is deployed by default; Phase 5B requires manual configuration after testing.
These headers are safe for all deployments and applied via docker/Caddyfile:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing attacks |
X-Frame-Options |
DENY |
Prevents clickjacking by blocking iframes |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer leakage on cross-origin requests |
Permissions-Policy |
geolocation=(), microphone=()... |
Disables unnecessary browser APIs |
Server |
(removed) | Hides server software identity |
Intentionally Omitted:
| Header | Reason |
|---|---|
X-XSS-Protection |
Deprecated since 2023; can introduce vulnerabilities in modern browsers |
Strict-Transport-Security |
TLS terminates at edge proxy (Cloudflare), not at Caddy |
These require testing before deployment and may break functionality:
| Header | Recommendation |
|---|---|
Content-Security-Policy |
Start with Content-Security-Policy-Report-Only to identify violations before enforcing. SvelteKit may require 'unsafe-inline' for styles. |
Content-Security-Policy: frame-ancestors 'none' |
Supersedes X-Frame-Options; add when CSP is configured |
Strict-Transport-Security |
Only if Caddy terminates TLS directly (not behind proxy). Use max-age=31536000; includeSubDomains |
Cross-Origin-Opener-Policy |
May break OAuth popups; test thoroughly |
Cross-Origin-Embedder-Policy |
May break external image loading; test thoroughly |
References:
If deploying behind Cloudflare Tunnel, configure these at Cloudflare instead of Caddy:
- HSTS: SSL/TLS → Edge Certificates → Enable "Always Use HTTPS" and configure HSTS
- Security Headers: Rules → Transform Rules → Modify Response Headers
- CSP: Consider Cloudflare's CSP reporting if using their proxy
Test that security headers are applied (requires Docker deployment):
# Frontend routes
curl -sI http://localhost:8080/ | grep -E '^(X-Content-Type|X-Frame|Referrer|Permissions)'
# API endpoints
curl -sI http://localhost:8080/api/health | grep -E '^(X-Content-Type|X-Frame|Referrer|Permissions)'
# PocketBase admin (when ADMIN_ENABLED=true)
curl -sI http://localhost:8080/_/ | grep -E '^(X-Content-Type|X-Frame|Referrer|Permissions)'Expected output for each:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()
Verify Server header is removed:
curl -sI http://localhost:8080/ | grep -i '^server:'
# Should return nothing (header removed)Files are served via PocketBase at /api/files/{collectionId}/{recordId}/{filename}.
Current state: Files follow the collection's access rules.
Planned: Signed URLs with expiration for private files.
| Aspect | Development | Production |
|---|---|---|
| Encryption key | Dev-only key in docker-compose.dev.yml | Auto-generated or set via ENCRYPTION_KEY |
| PocketBase Admin | Enabled at /_/ |
Disabled by default (ADMIN_ENABLED=false) |
| Seed data | Created automatically | Not created |
| Debug logging | Enabled | Disabled |
If you discover a security vulnerability, please report it responsibly by opening a private issue or contacting the maintainers directly.
Do not open public issues for security vulnerabilities.