Skip to content
🚧 These docs are a work in progress and may contain inaccuracies. Content is being actively reviewed and validated.

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.

Passwords are hashed with Argon2id via a native binding (@node-rs/argon2):

ParameterValue
Memory cost64 MB
Time cost (iterations)3
Parallelism4 threads
Output length32 bytes

Password hashes are stored in a dedicated account table, not on the users table — querying a user profile never exposes the hash.

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.

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.

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.

Four roles with immutable capability maps:

CapabilityOwnerAdminMemberGuest
Manage server / settingsYesYes
Invite / manage usersYesYes
Create / delete librariesYesYes
Scan libraries, manage metadataYesYes
View audit logsYesYes
Browse and play mediaYesYesYesYes

Role mutations are guarded at the service layer:

  • Users cannot change their own role
  • The owner role cannot be reassigned
  • Only the owner can promote users to admin
  • The owner account cannot be deactivated or deleted

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

Sensitive configuration values (e.g., API keys) stored in the database are encrypted with AES-256-GCM:

ComponentDetail
AlgorithmAES-256-GCM
Key derivationHKDF-SHA256 with context dubby-config-encryption
IVRandom 12 bytes per encryption
Auth tag16 bytes (tamper detection)
Storage formatenc:v1:<iv>:<tag>:<ciphertext> (base64-encoded)

The encryption key is resolved in order:

  1. DUBBY_ENCRYPTION_KEY environment variable
  2. File at DUBBY_DATA_DIR/.encryption-key
  3. Auto-generated with crypto.randomBytes(32), written to disk with mode 0600 (owner-read-only)

HLS streaming endpoints use HMAC-SHA256 tokens separate from the session Bearer token. This limits blast radius if a segment URL is intercepted:

PropertyValue
AlgorithmHMAC-SHA256
KeyBETTER_AUTH_SECRET
TTL4 hours
FormatuserId:sessionId:expiresAt:hmac
Comparisoncrypto.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.

All inputs are validated with Zod schemas shared by both tRPC and REST APIs:

InputValidation
IDsString, 1–50 characters
EmailValid email, lowercased, trimmed
Display name1–100 characters, trimmed
Search queries1–200 characters, trimmed
PaginationLimit 1–100, default 20
ContextMax size
API mutations1 MB
Upload endpoints2 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).

All database queries use Drizzle ORM’s parameterized query builder. No raw string interpolation reaches the database — all user inputs are bound as parameters.

In-memory, per-IP rate limiting with fixed-window algorithm:

EndpointLimitWindow
Login (POST /api/auth/sign-in/*)5 attempts15 minutes
Registration (POST /api/auth/sign-up/*)3 attempts1 hour
General API (/v1/*)100 requests1 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.

Applied globally on every response:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsSAMEORIGIN
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=()
Cache-Controlno-store (default for API responses)

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';
EnvironmentBehavior
DevelopmentAll origins allowed
ProductionStrict 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.

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.

Pino’s built-in redaction replaces sensitive values with [REDACTED] in all log output:

  • password, passwordHash
  • token, authorization, accessToken, refreshToken
  • Nested fields: *.password, *.token

Additional privacy-controlled masking (configurable at runtime):

SettingEffect
Mask file pathsFilesystem paths redacted from API responses and logs
Mask media titlesTitles redacted in external request logs
Mask user infoUser details redacted in logs
Mask IP addressesLast octet replaced with xxx in audit logs

Config values displayed in admin settings are redacted to show only the last 4 characters: ****abcd.

Security-relevant events are recorded to the audit_logs table:

EventWhat’s recorded
login_attemptEmail, success/failure, IP, user-agent
user_createNew user ID, assigned role
user_deleteDeleted user ID
config_changeConfig key changed (values intentionally omitted to avoid logging sensitive data)
privacy_changeWhich privacy settings were modified
external_requestOutbound 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.

Four configurable privacy presets control external data exposure:

LevelExternal servicesTMDB imagesData masking
MaximumBlockedBlockedAll fields masked
PrivateAllowedProxied through serverAll fields masked
BalancedAllowedProxied through serverPaths, user info, IPs masked
OpenAll enabledDirect CDNNo 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.

MeasureDetail
Non-root userAll production stages run as UID 1000 (node). Nginx runs as nginx
Restricted permissionsData and cache directories created with chmod 700 (owner-only)
Source map stripping.js.map files deleted from production images
Production-only depsEach stage installs only runtime dependencies
npm/npx removedRemoved from base image to reduce attack surface
Multi-stage buildsSeparate stages with minimal filesystem footprints
Alpine CVE patchingWeb stage explicitly upgrades libpng and zlib
SBOM + provenanceDocker images include SLSA provenance attestations and Software Bill of Materials
Health checksAll server stages include HEALTHCHECK directives
  • Media volumes mounted read-write by default for subtitle downloads and podcast storage. Add :ro if write access is not needed
  • Valkey/Redis is internal-only (no port exposed to the host)
  • BETTER_AUTH_SECRET is never hardcoded — must be set as an environment variable

Every release is automatically scanned before it ships:

CheckWhat it catches
Secret detectionAccidentally committed API keys, passwords, and tokens
Static analysis (SAST)OWASP Top 10 vulnerabilities and language-specific security patterns
Dependency auditKnown CVEs in npm dependencies (moderate severity and above)
Container image scanningOS-level vulnerabilities in the Docker image’s system packages
Supply chain protectionsPinned CI dependencies, disabled install scripts, forced security patches

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