This document is for anyone running Drift in production or considering reselling access to the API. It covers what the codebase enforces today, what it explicitly does not, and the safe defaults to add if you go to market.
- All
/api/v1/*routes that touch tenant data require either a registered Drift JWT or a guest JWT (both HS256 signed withJWT_SECRET). middleware.RequireRegisteredrejects guest tokens with 403 on every write route (room creation, chat, gifts, payments, DMs, withdrawals, stage actions, moderation, ID verification).- Native auth providers issue Drift access + refresh tokens; refresh sessions are stored hashed in
auth_sessions. - Per-tenant livestream tokens (broadcast SFU,
drifttenant) are minted with the tenant's own SFU secret. One tenant cannot mint tokens for another. - Admin endpoints check server-side admin membership per tenant. There is no
is_adminclaim in the JWT.
- Every tenant-scoped query carries
app_id. There is no implicit cross-tenant access path. - Livestream webhooks recover
app_idfrom the room name ({app}::{host}::{ts}) and verify the webhook signature against that tenant's SFU secret. apps.Registrycaches per-tenant livestream and media-storage clients keyed byapps.id.usersremains tenant-scoped. Identities are isolated byapp_idand mapped to tenant-local user rows.
- Display names are trimmed and capped at 24 chars.
- Chat messages are capped at 300 chars.
- DM text is capped at 500 chars; media URLs must originate from the tenant's Niilox media CDN (URLs we issued).
- Avatar / DM media / ID-verification uploads are size-limited (5 MB / 8 MB / 8 MB each).
- Social link handles are sanitised: trimmed, length-capped (200), and
javascript:/data:/vbscript:URLs are rejected. - ID verification files are named with kind + timestamp (
verify/{user}/doc-20260521131500.jpg) sodocandselfiedon't collide. - Withdrawal amounts must be ≥100 tokens and cannot exceed the user's
earned_from_gifts - paid - pendingbalance (i.e. you cannot withdraw money you topped up).
payments.handler.handleWebhookupdatestoken_purchaseswithWHERE status='pending'so a replayed webhook never double-credits.- Card checkout webhooks are verified against an ops-provisioned signing secret per tenant.
- Bank checkout webhooks are verified against an ops-provisioned hash secret per tenant.
- Token balance updates run inside a transaction when withdrawing.
The API listens for card lifecycle events and resolves them through the same handler:
| Event | Effect |
|---|---|
checkout.session.completed |
Credit tokens. Stores payment reference on token_purchases so future dispute / refund webhooks can find the row. |
charge.dispute.created |
Soft clawback. Tokens revoked, users.dispute_count++. After two events the account is auto-suspended (suspended_at set, suspension_reason='chargeback_threshold'). |
charge.dispute.closed (status=lost) |
Permanent clawback. Marks purchase disputed. |
charge.dispute.closed (status=won / warning_closed) |
Decrements dispute_count and lifts the suspension if it was caused by chargebacks. Tokens are not restored — the user already lost them on dispute_open. |
charge.refunded |
Same as dispute_lost: clawback + flag, purchase marked refunded. |
Idempotency: every dispute write hits payment_disputes first, which has UNIQUE (provider, external_id, kind). A replayed webhook event is a no-op.
RequireActive(db) middleware blocks suspended users on /tokens/checkout and /rooms/{id}/gifts (the two laundering surfaces). The middleware caches the lookup for 5 s; InvalidateSuspensionCache(userID) forces an immediate re-read after an admin unsuspends.
| payment_intent.payment_failed | Marks the token_purchases row failed (card declined). No credit. |
Webhook destination configuration. Niilox ops configures inbound webhooks for your tenant to receive all six events above. Until configured, dispute clawbacks will silently no-op (the events never reach the server).
- Each WS connection authenticates via the
?token=query param parsed againstJWT_SECRET. - Slow clients are dropped from broadcasts (channel buffers of 64 / 128). They don't back-pressure the hub.
DisconnectUsercloses every connection a kicked user has in a room.
These are intentional gaps you should close before selling the API publicly.
| Concern | Today | Recommended hardening |
|---|---|---|
| Per-IP / per-app rate limiting | yes — Redis sliding window on /api/v1 (in-memory fallback without REDIS_URL) |
Tune RATE_LIMIT_*_PER_MIN; add WAF for edge DDoS. Per-user limits apply when JWT is present on the same request. |
| Tenant API keys + HMAC | yes — niilox_sk_… keys in app_api_keys; optional X-Drift-Timestamp + X-Drift-Signature |
Manage keys at /admin/api (UI) or GET/POST /api/v1/platform/keys. Video/join routes still require user JWT — keys are for integrator read/ping flows. |
| Orphan room reconcile | yes — background worker compares broadcast SFU room list vs DB | Tune RECONCILE_INTERVAL; does not delete SFU rooms. |
| PgBouncer / pool tuning | partial — DB_POOL_MAX_CONNS / DB_POOL_MIN_CONNS on pgxpool |
Deploy PgBouncer in transaction mode in front of Postgres (see DEPLOYMENT.md). |
| WebSocket origin checks | CheckOrigin returns true |
Replace with an allowlist (APP_URL + any partner origins). |
| Brute-force protection on auth routes | partial | Add per-IP limits / captcha on /auth/password/*, /auth/magic/send, and /auth/phone/send. |
| Login throttling / lockout | partial | Add progressive lockout policy for repeated password failures. |
| Server-side webhook IP filtering | none | Accept only ops-configured webhooks from documented IP ranges or via signed payload (the latter is already enforced). |
| Audit log | none | Add an audit_log table for admin actions (verifications, withdrawals, kicks). |
| Soft-delete of users | none — ON DELETE CASCADE removes follows, DMs, etc. |
Add a deleted_at column and switch foreign keys to ON DELETE SET NULL if you need data retention. |
| Encryption at rest of DM media | files are public CDN URLs | Use token-based signed CDN URLs for reads. |
| Anti-CSRF | the API is JWT-bearer only, not cookie-based, so CSRF is not directly applicable | Keep using Authorization: Bearer. Never store the Drift JWT in cookies. |
JWT_SECRET— 32+ random bytes. Rotating invalidates all sessions; users re-sign in.- Livestream SFU credentials — per tenant in
apps(provisioned by Niilox ops). Don't read from env in handlers — always go throughapps.Registry. - Payment signing secrets — process-scoped, ops-provisioned. Rotate via your secrets manager.
- Media storage credentials — writable per-tenant zone. Limit blast radius with zone-scoped passwords.
.envis git-ignored. The repo refuses to commit any.env*except.env.example.
| Adversary | What they can try | What stops them |
|---|---|---|
| Random internet user | Hit /api/v1/* without auth |
middleware.Auth returns 401. |
| Guest token holder | Send chat, buy tokens, create rooms | RequireRegistered returns 403. |
| Cross-tenant attacker | Send X-App-ID: rabbaly while signed into Drift |
The Drift JWT's sub is users.id for app_id='drift'. The Rabbaly handlers SELECT with WHERE app_id='rabbaly' AND id=$sub and get nothing. |
| Replayed webhook | Resubmit a payment webhook | UPDATE … WHERE status='pending' is idempotent. |
| Stolen Drift JWT | Impersonate the user for up to 7 days | Shorten access-token TTL and enforce refresh rotation/revocation. |
Underage publisher in adult room |
Bypass age check | apps.allow_adult must be true AND users.id_verified must be true. There is no way to bypass without an admin approving an ID submission. |
| Malicious upload (RCE via file content) | Upload a polyglot to /users/me/avatar |
Content-type and extension are restricted; files are served by the tenant CDN as static, never executed by this binary. Add server-side virus scanning if your tenant niche demands it. |
Email the maintainer listed on the GitHub repo, or open a private issue. Do not disclose publicly until a fix is shipped.