Security
Authentication and authorization
API keys
All SDK and server-to-server requests use scoped API keys prefixed vp_live_. Keys are stored as BLAKE3 hashes in the api_keys table — the plaintext is never persisted. A key can optionally be scoped to a single app, preventing cross-app access even within the same organisation.
OAuth 2.1 / OIDC 1.0
The admin panel authenticates via an embedded OAuth 2.1 server running inside the API process:
- PKCE (S256 only) — prevents authorization code interception
- Refresh token rotation — every refresh issues a new token pair; family tracking detects theft (entire family revoked on reuse)
- Short-lived access tokens — 1-hour expiry
- HS256 JWT signing —
JWT_SECRET, never stored in the DB
Password security
Passwords are hashed with Argon2id (memory-hard, GPU-resistant). No plaintext or reversible encoding is ever stored.
Multi-tenancy isolation
Every database row carries org_id. The auth middleware extracts it from the JWT or API key before every request, and every SQL query includes WHERE org_id = $1. Cross-tenant access is structurally impossible.
RBAC roles
| Role | Permissions |
|---|---|
| viewer | Read campaigns and analytics |
| developer | + Manage apps, devices, API keys |
| admin | + Manage team members, webhooks |
| owner | Full access including billing and org deletion |
Secret management
Local development
Secrets live in .env (gitignored). Never commit .env to git.
Kubernetes production
All secrets are stored in HashiCorp Vault at secret/visionpush/production and injected into pods via the External Secrets Operator. Sync runs every hour; secrets never touch the filesystem or git.
| Vault key | Required |
|---|---|
| DATABASE_URL | Yes |
| JWT_SECRET | Yes |
| CREDENTIAL_ENCRYPTION_KEY | Yes |
| AWS_ACCESS_KEY_ID | Yes |
| AWS_SECRET_ACCESS_KEY | Yes |
| S3_BUCKET | Yes |
| DB_PASSWORD | Yes |
| ALERTMANAGER_SMTP_PASSWORD | If alerting.enabled |
| ALERTMANAGER_SLACK_WEBHOOK_URL | If alerting.slack.enabled |
Push credentials encryption
APNs p8 keys and FCM service account JSON are AES-256-GCM encrypted at rest in platform_configs using CREDENTIAL_ENCRYPTION_KEY. The key is never stored in the database — only in Vault / environment.
Network security
NetworkPolicies (zero-trust)
| Pod | Allowed ingress | Allowed egress |
|---|---|---|
| api | Traefik ingress controller | Postgres:5432, NATS:4222, S3/external:443 |
| worker | — (no inbound) | Postgres:5432, NATS:4222, APNs/FCM/VAPID:443 |
| admin | Traefik ingress controller | — (nginx, static files only) |
| postgres | api, worker, migrations | — |
| nats | api, worker | — |
TLS everywhere
- External traffic — TLS terminated at Traefik with Let's Encrypt (cert-manager)
- APNs — TLS 1.2+ (HTTP/2 over TLS, required by Apple)
- FCM — HTTPS to
fcm.googleapis.com - Web Push — HTTPS to subscriber endpoints
- Database — TLS enforced by CloudNativePG in-cluster
- Vault — TLS for all ESO → Vault communication
Container security
All containers run with hardened security contexts:
Kubernetes securityContextsecurityContext: runAsNonRoot: true allowPrivilegeEscalation: false readOnlyRootFilesystem: true # api, worker capabilities: drop: ["ALL"]
NET_BIND_SERVICE and does not set readOnlyRootFilesystem (nginx writes to /var/cache/nginx). API and worker mount /tmp as emptyDir.Supply chain security
Dependency scanning
- Rust —
cargo auditin CI viarustsec/audit-check@v2, scanning the RustSec advisory database. A failed audit blocks the merge. - npm — Dependabot sends weekly PRs for outdated npm packages in
admin/. - Cargo — Dependabot sends weekly PRs for Rust crates, grouped by
minor-updates. - Docker — Dependabot monitors base image tags in
infra/docker/.
Container image scanning
Trivy scans API and worker images in CI for OS and library CVEs. Results are uploaded to the GitHub Security tab as SARIF. Scan findings are visible in the Security dashboard without blocking the build.
Image provenance
All production images are tagged with both the git SHA (sha-XXXXXXX) and the semver tag (v1.2.3). ArgoCD Image Updater pins the deployed version — no latest tag in production.
Rate limiting
Per-API-key rate limiting uses NATS JetStream KV as a distributed counter, ensuring consistent limits across all API replicas.
- Default: 1,000 requests/minute per API key
- Rate-limited requests return
HTTP 429with aRetry-Afterheader
Production hardening checklist
- Rotate all secrets from generated defaults
- Enable
CORS_ORIGINS— do not leave as* - Enable
serviceMonitor.enabled: trueand configure alert routing - Enable
networkPolicy.enabled: true(default in the chart) - Set
postgres.instances: 2for HA streaming replication - Configure WAL-G / CloudNativePG backup to an off-site S3 bucket
- Enable
alerting.enabled: trueand configure email/Slack receivers - Review Trivy scan results in GitHub Security tab
- Change the default admin password (
admin@example.com/changeme)
Incident response
JWT or encryption key compromised
Shell# 1. Generate new secrets NEW_JWT=$(openssl rand -base64 32) NEW_EK=$(openssl rand -base64 32) # 2. Update Vault vault kv put secret/visionpush/production \ JWT_SECRET="$NEW_JWT" \ CREDENTIAL_ENCRYPTION_KEY="$NEW_EK" # 3. Force ESO sync kubectl annotate externalsecret visionpush-secrets \ force-sync=$(date +%s) -n visionpush # 4. Restart pods (picks up new secret) kubectl rollout restart \ deployment/visionpush-api \ deployment/visionpush-worker \ -n visionpush
API key compromised
SQLDELETE FROM api_keys WHERE id = 'compromised-key-id';
The key is rejected immediately on next use — no JWT, no cached state.