Author: Mr. Watson 🦄 Date: 2026-02-15
- Goal
- What was configured
- Architecture
- Files
- Routes
- Secrets and keys
- Operations
- Add/rotate API keys
- Promote from free to paid roles
- Rollback
- Notes
Add a self-hosted JWT access layer in front of PostgREST for monetizable endpoints, without breaking existing routes.
- New local auth service validates
X-API-Keyand signs short-lived JWTs - Nginx uses
auth_requestto call that service - Protected routes inject
Authorization: Bearer <jwt>to PostgREST - Existing
beachlab.org/api/telemetry/...remains unchanged
Client
-> Nginx (api.beachlab.org)
-> /telemetry/public/* -> PostgREST (anon)
-> /telemetry/pro/* -> auth_request -> auth-jwt service
(if valid key => signed JWT)
-> PostgREST (JWT role)
/usr/local/bin/auth_jwt_service.py/etc/auth-jwt-service.env/etc/auth-jwt-keys.csv/etc/systemd/system/auth-jwt.service/etc/postgrest-telemetry.conf/etc/nginx/sites-available/api.beachlab.org
Public (no key):
https://api.beachlab.org/telemetry/public/telemetry_latest?limit=1
Protected (requires X-API-Key):
https://api.beachlab.org/telemetry/pro/telemetry_latest?limit=1
Auth health:
https://api.beachlab.org/auth/health
Current runtime secrets are stored in:
/etc/auth-jwt-service.env(JWT_SECRET, TTL, host/port)/etc/auth-jwt-keys.csv(api_key,role,plan,enabled,expires_at_utc)
Generate a strong JWT secret (example):
openssl rand -hex 32Permissions:
sudo chmod 600 /etc/auth-jwt-service.env /etc/auth-jwt-keys.csv# service health
systemctl status auth-jwt postgrest-telemetry nginx
# auth service logs
journalctl -u auth-jwt -n 80 --no-pager
# local tests (force host header to local nginx)
curl -ks -H 'Host: api.beachlab.org' https://127.0.0.1/auth/health
curl -ks -o /dev/null -w '%{http_code}\n' \
-H 'Host: api.beachlab.org' \
https://127.0.0.1/telemetry/public/telemetry_latest?limit=1
curl -ks -o /dev/null -w '%{http_code}\n' \
-H 'Host: api.beachlab.org' \
https://127.0.0.1/telemetry/pro/telemetry_latest?limit=1
curl -ks -o /dev/null -w '%{http_code}\n' \
-H 'Host: api.beachlab.org' \
-H 'X-API-Key: <YOUR_KEY>' \
https://127.0.0.1/telemetry/pro/telemetry_latest?limit=1Expected:
- public:
200 - protected without key:
401 - protected with valid key:
200
Current plan mapping in this setup:
free->web_anonfree_trial(30 days) ->web_trialpro->web_paid
Append keys:
# free
FREE_KEY=$(openssl rand -hex 24)
echo "$FREE_KEY,web_anon,free,1," | sudo tee -a /etc/auth-jwt-keys.csv
# trial (30 days)
TRIAL_KEY=$(openssl rand -hex 24)
TRIAL_EXP=$(date -u -d '+30 days' '+%Y-%m-%dT%H:%M:%SZ')
echo "$TRIAL_KEY,web_trial,free_trial,1,$TRIAL_EXP" | sudo tee -a /etc/auth-jwt-keys.csv
# paid
PAID_KEY=$(openssl rand -hex 24)
echo "$PAID_KEY,web_paid,pro,1," | sudo tee -a /etc/auth-jwt-keys.csv
sudo systemctl restart auth-jwtDisable a key by setting enabled to 0.
This host already has role skeletons:
web_anonweb_trialweb_paid
Next hardening step (when monetization starts):
- Restrict paid objects to
web_paid - Keep free objects on
web_anon/web_trial - Add RLS if tenant/data partitioning is needed
Restore backups and reload:
# restore previous nginx file (pick your timestamp)
sudo cp /etc/nginx/sites-available/api.beachlab.org.bak.<timestamp> /etc/nginx/sites-available/api.beachlab.org
# restore previous PostgREST config (pick your timestamp)
sudo cp /etc/postgrest-telemetry.conf.bak.<timestamp> /etc/postgrest-telemetry.conf
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart postgrest-telemetry
sudo systemctl disable --now auth-jwt- This design is fully self-hosted, no SaaS/free-tier dependency.
- Keep PostgREST bound to localhost and expose only through Nginx.
- Add rate limits in Nginx as next step if needed (
limit_req).