Security
Dubby is designed to be self-hosted on private networks. Security is layered across authentication, authorization, cryptography, input validation, container hardening, and automated CI checks.
Authentication
Section titled “Authentication”Password hashing
Section titled “Password hashing”Passwords are hashed with Argon2id via a native binding (@node-rs/argon2):
| Parameter | Value |
|---|---|
| Memory cost | 64 MB |
| Time cost (iterations) | 3 |
| Parallelism | 4 threads |
| Output length | 32 bytes |
Password hashes are stored in a dedicated account table, not on the users table — querying a user profile never exposes the hash.
Password policy
Section titled “Password policy”Registration and password changes enforce:
- Minimum 8 characters, maximum 128
- At least one lowercase letter, one uppercase letter, and one digit
The login endpoint intentionally uses a relaxed validation schema (min(1) only) to avoid leaking the password policy on failed attempts.
Session management
Section titled “Session management”Sessions are database-backed (not JWT), stored in the session table:
- Lifetime: 7 days
- Auto-renewal: Extended by 24 hours on each activity
- Revocation: Immediate — deleting the session row from the database
- Cascade: All sessions are deleted when a user is deleted
Every request checks isActive on the user record. Deactivated accounts are rejected with 401 even if the session token is still valid.
Signing secret
Section titled “Signing secret”The BETTER_AUTH_SECRET environment variable is used for session signing and stream token HMAC. It must be at least 32 characters — the server exits at startup if this requirement isn’t met.
Authorization
Section titled “Authorization”Role-based access control
Section titled “Role-based access control”Four roles with immutable capability maps:
| Capability | Owner | Admin | Member | Guest |
|---|---|---|---|---|
| Manage server / settings | Yes | Yes | — | — |
| Invite / manage users | Yes | Yes | — | — |
| Create / delete libraries | Yes | Yes | — | — |
| Scan libraries, manage metadata | Yes | Yes | — | — |
| View audit logs | Yes | Yes | — | — |
| Browse and play media | Yes | Yes | Yes | Yes |
Role mutations are guarded at the service layer:
- Users cannot change their own role
- The
ownerrole cannot be reassigned - Only the owner can promote users to admin
- The owner account cannot be deactivated or deleted
Library permissions
Section titled “Library permissions”Per-user, per-library access controls:
- Owners and admins bypass library permissions (they see everything)
- Members and guests only see libraries they’ve been explicitly granted access to
- Permissions support
can_view,can_watch,can_download, content rating restrictions, and expiration dates - Content from ungranted libraries is invisible in search, recommendations, and API responses
Encryption
Section titled “Encryption”Config value encryption
Section titled “Config value encryption”Sensitive configuration values (e.g., API keys) stored in the database are encrypted with AES-256-GCM:
| Component | Detail |
|---|---|
| Algorithm | AES-256-GCM |
| Key derivation | HKDF-SHA256 with context dubby-config-encryption |
| IV | Random 12 bytes per encryption |
| Auth tag | 16 bytes (tamper detection) |
| Storage format | enc:v1:<iv>:<tag>:<ciphertext> (base64-encoded) |
The encryption key is resolved in order:
DUBBY_ENCRYPTION_KEYenvironment variable- File at
DUBBY_DATA_DIR/.encryption-key - Auto-generated with
crypto.randomBytes(32), written to disk with mode0600(owner-read-only)
Stream token signing
Section titled “Stream token signing”HLS streaming endpoints use HMAC-SHA256 tokens separate from the session Bearer token. This limits blast radius if a segment URL is intercepted:
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Key | BETTER_AUTH_SECRET |
| TTL | 4 hours |
| Format | userId:sessionId:expiresAt:hmac |
| Comparison | crypto.timingSafeEqual (prevents timing side-channels) |
Stream tokens are scoped to a specific user and session. They can be passed as a ?token= query parameter for native video players that cannot set HTTP headers on segment requests.
Input validation
Section titled “Input validation”All inputs are validated with Zod schemas shared by both tRPC and REST APIs:
| Input | Validation |
|---|---|
| IDs | String, 1–50 characters |
| Valid email, lowercased, trimmed | |
| Display name | 1–100 characters, trimmed |
| Search queries | 1–200 characters, trimmed |
| Pagination | Limit 1–100, default 20 |
Body size limits
Section titled “Body size limits”| Context | Max size |
|---|---|
| API mutations | 1 MB |
| Upload endpoints | 2 GB |
Requests exceeding the limit return 413 Payload Too Large. Content-Type is enforced on mutations — only application/json and multipart/form-data are accepted (returns 415 otherwise).
SQL injection protection
Section titled “SQL injection protection”All database queries use Drizzle ORM’s parameterized query builder. No raw string interpolation reaches the database — all user inputs are bound as parameters.
Rate limiting
Section titled “Rate limiting”In-memory, per-IP rate limiting with fixed-window algorithm:
| Endpoint | Limit | Window |
|---|---|---|
Login (POST /api/auth/sign-in/*) | 5 attempts | 15 minutes |
Registration (POST /api/auth/sign-up/*) | 3 attempts | 1 hour |
General API (/v1/*) | 100 requests | 1 minute |
Rate-limited responses return 429 Too Many Requests with a Retry-After header.
Client IP extraction uses the rightmost value from X-Forwarded-For (not the leftmost, which is attacker-controlled), falling back to X-Real-IP. This correctly handles reverse-proxy deployments.
Security headers
Section titled “Security headers”API server (Hono)
Section titled “API server (Hono)”Applied globally on every response:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | SAMEORIGIN |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
Cache-Control | no-store (default for API responses) |
Web client (Nginx)
Section titled “Web client (Nginx)”The containerized web client adds a Content Security Policy:
default-src 'self';script-src 'self' 'unsafe-inline';style-src 'self' 'unsafe-inline';img-src 'self' data: https://image.tmdb.org https://artworks.thetvdb.com;media-src https: http:;connect-src 'self';object-src 'none';frame-ancestors 'self';base-uri 'self';| Environment | Behavior |
|---|---|
| Development | All origins allowed |
| Production | Strict allowlist from ALLOWED_ORIGINS. If empty, no cross-origin requests are accepted |
Credentials are enabled for cross-origin cookie auth. Only the Authorization, Content-Type, Range, and X-Dubby-Client headers are allowed.
Error handling
Section titled “Error handling”Unhandled errors in production return a generic Internal Server Error message — actual error details and stack traces are never exposed to clients. Domain-specific errors (not found, forbidden, validation) surface their messages in all environments since they are intentionally user-facing.
Secrets are stripped from the tRPC context object to prevent accidental leakage in error payloads. The excluded variables include BETTER_AUTH_SECRET, REDIS_URL, DATABASE_AUTH_TOKEN, SENTRY_DSN, and DUBBY_ENCRYPTION_KEY.
Log redaction
Section titled “Log redaction”Pino’s built-in redaction replaces sensitive values with [REDACTED] in all log output:
password,passwordHashtoken,authorization,accessToken,refreshToken- Nested fields:
*.password,*.token
Additional privacy-controlled masking (configurable at runtime):
| Setting | Effect |
|---|---|
| Mask file paths | Filesystem paths redacted from API responses and logs |
| Mask media titles | Titles redacted in external request logs |
| Mask user info | User details redacted in logs |
| Mask IP addresses | Last octet replaced with xxx in audit logs |
Config values displayed in admin settings are redacted to show only the last 4 characters: ****abcd.
Audit logging
Section titled “Audit logging”Security-relevant events are recorded to the audit_logs table:
| Event | What’s recorded |
|---|---|
login_attempt | Email, success/failure, IP, user-agent |
user_create | New user ID, assigned role |
user_delete | Deleted user ID |
config_change | Config key changed (values intentionally omitted to avoid logging sensitive data) |
privacy_change | Which privacy settings were modified |
external_request | Outbound calls to external services |
Audit log failures are swallowed and logged to stderr — they never break the triggering operation. Retention is configurable and cleaned up automatically via a cron job every 6 hours.
Only owner and admin roles can view audit logs.
Privacy controls
Section titled “Privacy controls”Four configurable privacy presets control external data exposure:
| Level | External services | TMDB images | Data masking |
|---|---|---|---|
| Maximum | Blocked | Blocked | All fields masked |
| Private | Allowed | Proxied through server | All fields masked |
| Balanced | Allowed | Proxied through server | Paths, user info, IPs masked |
| Open | All enabled | Direct CDN | No masking |
When image proxying is enabled, TMDB CDN images are fetched by the server and served to clients locally — the client’s IP address never reaches TMDB’s CDN.
Container security
Section titled “Container security”Docker image hardening
Section titled “Docker image hardening”| Measure | Detail |
|---|---|
| Non-root user | All production stages run as UID 1000 (node). Nginx runs as nginx |
| Restricted permissions | Data and cache directories created with chmod 700 (owner-only) |
| Source map stripping | .js.map files deleted from production images |
| Production-only deps | Each stage installs only runtime dependencies |
| npm/npx removed | Removed from base image to reduce attack surface |
| Multi-stage builds | Separate stages with minimal filesystem footprints |
| Alpine CVE patching | Web stage explicitly upgrades libpng and zlib |
| SBOM + provenance | Docker images include SLSA provenance attestations and Software Bill of Materials |
| Health checks | All server stages include HEALTHCHECK directives |
Docker Compose defaults
Section titled “Docker Compose defaults”- Media volumes mounted read-write by default for subtitle downloads and podcast storage. Add
:roif write access is not needed - Valkey/Redis is internal-only (no port exposed to the host)
BETTER_AUTH_SECRETis never hardcoded — must be set as an environment variable
Automated security scanning
Section titled “Automated security scanning”Every release is automatically scanned before it ships:
| Check | What it catches |
|---|---|
| Secret detection | Accidentally committed API keys, passwords, and tokens |
| Static analysis (SAST) | OWASP Top 10 vulnerabilities and language-specific security patterns |
| Dependency audit | Known CVEs in npm dependencies (moderate severity and above) |
| Container image scanning | OS-level vulnerabilities in the Docker image’s system packages |
| Supply chain protections | Pinned CI dependencies, disabled install scripts, forced security patches |
Graceful degradation
Section titled “Graceful degradation”When Valkey/Redis is unavailable:
- The server logs a warning and continues — it does not crash
- Browsing, metadata, and playback configuration continue working
- Endpoints requiring the job queue return
412 Precondition Failed - Real-time SSE events degrade gracefully
- Core functionality is preserved; only background jobs (scanning, metadata refresh, subtitles) are paused