Skip to main content

Secrets & Credentials Management

This document describes every place Questarr stores or handles sensitive values — environment configuration, third-party API credentials, and the indexer/downloader/user credentials that users enter through the app — how access to them is controlled, and how they get rotated. It reflects the current state of the code; gaps are called out explicitly rather than glossed over.

1. Environment variables

All configuration is optional; sensible defaults are used when a variable is unset. See .env.example for the canonical template. .env is loaded once via dotenv/config at server/index.ts:2 and parsed against a Zod schema in server/config.ts:10-59. If any variable fails validation, the server logs the error and exits (server/config.ts:64-80) rather than starting with an invalid configuration. A legacy hardcoded default, "questarr-default-secret-change-me", is explicitly rejected by a Zod .refine() (server/config.ts:18-24) so the app can never silently run with that well-known value. The .env file itself is git-ignored (see .gitignore) and must never be committed. docker-compose*.local.yml and gha-creds-*.json are ignored for the same reason.

2. Authentication secret (JWT_SECRET)

Session tokens are signed HS256 JWTs (jsonwebtoken), 7-day expiry (server/auth.ts:77-82), verified on every authenticated request by authenticateToken / optionalAuthenticateToken (server/auth.ts:88-126). Resolution order for the signing secret, getJwtSecret() (server/auth.ts:13-67):
  1. In-memory cache for the life of the process.
  2. JWT_SECRET environment variable.
  3. Value stored in the system_config table under key jwt_secret.
  4. If none of the above exist, generate 64 random bytes (crypto.randomBytes(64).toString("hex")) and persist them to system_config for future restarts.
If DB persistence fails (e.g. read-only filesystem), the generated secret is still used in memory for that process, but a warning is logged since it won’t survive a restart and will invalidate all sessions when it does. Rotation: there is no dedicated “rotate JWT secret” endpoint. To force all users to re-authenticate, either set/change the JWT_SECRET env var, or delete the jwt_secret row from system_config — a new one will be generated automatically on next use. Either action invalidates every existing session.

3. Third-party API credentials

All four of these settings endpoints follow the same pattern: GET never returns the real secret, and updating it without changing the non-secret part (if any) is done by sending the sentinel string "********" for the unchanged field. GET /api/settings/igdb returns the clientId but never the clientSecret (server/routes.ts:2709-2768 sends/accepts the sentinel). GET /api/settings/nexusmods returns only { configured, source } booleans (server/routes.ts:3311-3342). GET /api/settings/discord returns { configured, webhookUrl: "********" } when set, and POST treats the sentinel as “no change” (server/routes.ts:2769-2801). All four handlers sit behind sensitiveEndpointLimiter.

4. User-entered indexer & downloader credentials

This is the largest surface of stored secrets: Torznab/Newznab indexer API keys, and usernames/passwords for download clients (qBittorrent, Transmission, rTorrent, sabnzbd, nzbget).
  • Storage: encrypted at rest. indexers.apiKey and downloaders.username / downloaders.password are AES-256-GCM encrypted before being written to SQLite and decrypted on read (server/credential-crypto.ts, wired into server/storage.ts’s addIndexer/updateIndexer/getIndexer/getAllIndexers/getEnabledIndexers/ syncIndexers and the equivalent downloader methods). Each encrypted value is prefixed enc:v1: and stores a random 12-byte IV + auth tag + ciphertext, base64-encoded — so two encryptions of the same plaintext never look alike at rest. Rows written before this feature existed are legacy plaintext; decryptCredential() detects the missing prefix and returns them unchanged (no migration required), and they get encrypted the next time they’re saved.
    • Encryption key: resolved the same way as JWT_SECRET (§2) — CREDENTIALS_ENCRYPTION_KEY env var, then the DB system_config key credentials_encryption_key, then auto-generated (32 random bytes) and persisted (server/credential-crypto.ts:getCredentialsEncryptionKey). Losing this key (e.g. wiping system_config without also setting the env var) makes previously encrypted rows undecryptable.
  • In transit to the indexer/download client: the storage layer decrypts transparently, so server/downloaders/*.ts and server/search.ts receive plaintext exactly as before — HTTP Basic Auth (base64, not encryption) for qBittorrent/Transmission-style clients, and RFC 2617 Digest Auth challenge-response for rTorrent (server/downloaders/rtorrent.ts:596-621).
    • MD5 fallback (accepted risk): RFC 2617’s classic Digest Auth only defines MD5; rtorrent.ts:596,599 uses SHA-256 whenever the rTorrent/ ruTorrent server’s challenge advertises algorithm=SHA-256, and falls back to MD5 only for servers that don’t (the common case, since most rTorrent/ruTorrent builds still only implement the original RFC 2617 MD5 scheme). This is an interoperability requirement, not a choice — there is no more-secure alternative that the target servers accept. Risk is limited: the digest response is an HMAC-style construction keyed by server-issued nonce/client cnonce per request (rtorrent.ts:608-621), not a bare hash of the credential, so MD5’s known collision weakness doesn’t directly expose the password — the exposure is the same one every RFC 2617 MD5 deployment has always carried. Mitigation: always prefer a downloader/network path that terminates in TLS between Questarr and the rTorrent host where possible, since Digest Auth (either hash) still doesn’t encrypt the request/response bodies themselves. No other code path in Questarr depends on MD5.
  • Access control / API exposure: every indexer/downloader route sits behind the global authenticateToken middleware (server/routes.ts:821-829). GET /api/indexers, GET /api/indexers/:id, GET /api/downloaders, and GET /api/downloaders/:id mask the secret field before responding — apiKey / password come back as "********" whenever a real value is set (maskIndexer/maskDownloader helpers, server/routes.ts). The same masking applies to the POST/PATCH responses. username is not treated as a secret and is still returned in full, matching how it’s used (a login name, not a token).
  • Rotation: PATCH /api/indexers/:id / PATCH /api/downloaders/:id follow the IGDB masked-sentinel convention — sending "********" for apiKey/password leaves the stored value unchanged (the sentinel is stripped from the update before it reaches storage); sending any other value overwrites and re-encrypts it. This is what lets the edit dialogs prefill the field with the mask without silently clobbering the real secret on save.

5. User account passwords

users.passwordHash (shared/schema.ts:6-11) never stores plaintext. Hashing uses bcryptjs with SALT_ROUNDS = 10 (server/auth.ts:1,10,69-75). Passwords are hashed on signup (server/routes.ts:309), verified on login (server/routes.ts:369-371), and the password-change endpoint requires the current password before accepting a new one (server/routes.ts:392-419).

6. Rate limiting around credentials (server/middleware.ts)

There is no account lockout beyond the IP-based authRateLimiter window for repeated failed logins.

7. Version control hygiene

Secret-bearing files are excluded via .gitignore: .env, sqlite.db*, data/*, data_test/, .sofa/ (SOFA agent credentials — see .claude/sofa-skill.md), and gha-creds-*.json. No .env or database file is currently tracked in git. Never commit real credentials in docker-compose*.yml — use .env or a local override file (docker-compose.*local.yml, also git-ignored) instead.

8. Credential exposure in operational scripts

Real in v1.1.0–v1.3.1. Fixed in v1.4.0. Rotate if you kept the logs. scripts/pg-to-sqlite.ts logged the full DATABASE_URL connection string before connecting:
Per standard postgresql:// URL convention that string embeds user:password@host, so the credential was printed in plaintext, where it could land in CI logs, container logs or shell history. Commit 99984867 (“Fix visible postgreSQL URL in migration log”) removed the interpolation. From v1.4.0 onward the line is a constant console.log(`Connecting to Postgres`) and the script logs nothing derived from DATABASE_URL — the only connection detail it prints is the SQLite path, which is not a credential. If you ran the migration on any of the six affected tags and still hold those logs, treat that Postgres password as exposed and rotate it. Purging the logs is not sufficient on its own if they were ever shipped to a log aggregator or a CI provider. This section previously read as an open, unfixed finding, because it was never updated when 99984867 landed. It also carried a line reference that by then pointed at the fixed line. The script has since been removed entirely, for unrelated reasons — it understood only 8 of the project’s 19 tables — see MIGRATION.md. The archived v1.4.2 tool that migration now points operators at is on the safe side of the fix.

9. Summary checklist for operators

  • Set JWT_SECRET explicitly in production so sessions survive restarts and DB resets.
  • Set CREDENTIALS_ENCRYPTION_KEY explicitly in production so stored indexer/downloader credentials stay decryptable across DB resets (openssl rand -hex 32).
  • Set IGDB and (optionally) NexusMods credentials via .env or Settings → Services.
  • Restrict who has login access to the app — Questarr has no per-user role scoping, so any account holder can use every configured indexer/downloader (though the API keys/passwords themselves are masked in responses and encrypted at rest, per §4).
  • Run behind HTTPS/a reverse proxy per docs/SECURITY.md.
  • Never commit .env, sqlite.db, or docker-compose.local.yml.
  • If you ran pg-to-sqlite on v1.1.0–v1.3.1 and kept the logs, rotate that Postgres password — those versions printed the full DATABASE_URL (§8).
  • If running the archived v1.4.2 pg-to-sqlite migration tool, set DATABASE_URL to your real source credentials and verify the row counts it reports — it continues past per-table failures and still reports success (see MIGRATION.md).