Questarr

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.

Variable Purpose Required?
JWT_SECRET Signs/verifies session JWTs No — auto-generated and persisted to the DB if unset (§2)
NEXUSMODS_API_KEY NexusMods mod lookups No — validated by envSchema; can be set later in Settings → Integrations (§3)
STEAM_API_KEY Steam achievements in a game’s Journal tab No — env-only; without it the achievements section is simply hidden (server/config.ts, GET /api/settings/steam)
IGDB_CLIENT_ID / IGDB_CLIENT_SECRET Twitch/IGDB OAuth for game metadata & discovery No env-wise, but one of env/DB must be set for discovery to work
PORT HTTP port No (default 5000)
HOST Bind address No — defaults to 0.0.0.0 (all interfaces) in every deployment mode, not just Docker (server/config.ts:52). Set HOST=127.0.0.1 explicitly if you don’t want the server reachable from other machines on the network.
NODE_ENV development | production | test No (defaults to production)
SQLITE_DB_PATH Path to the SQLite database file No (default sqlite.db)
CREDENTIALS_ENCRYPTION_KEY AES-256 key encrypting indexer/downloader credentials at rest No — auto-generated (32 random bytes) and persisted to the DB if unset; must be a 64-char hex string if provided (§4)
ARCHIVE_MAX_ENTRIES Max entries an archive may list before import extraction No (default 50000). Archives listing more entries are refused before extraction. See Archive extraction limits
ARCHIVE_MAX_EXPANDED_BYTES Max total declared uncompressed size of an archive, in bytes No (default 268435456000, 250 GiB). Archives declaring more are refused before extraction. See Archive extraction limits
LOG_LEVEL Pino log level (trace, debug, info, warn, error) No (default info in production, debug otherwise)
SSL_PORT HTTPS port when SSL is enabled in Settings No (default 9898); overrides the port saved in config.yaml
SEVENZIP_PATH Path to the 7-Zip binary used for archive extraction No; the Docker image ships one, other installs search the usual locations
UNRAR_PATH Path to the unrar binary used for RAR archives No; the Docker image ships one, other installs search the usual locations
APPRISE_CLI_PATH Path to the apprise CLI for notifications in CLI mode No; the Docker image ships one, other installs search PATH
TRUST_PROXY Trust X-Forwarded-For / X-Forwarded-Proto from a reverse proxy No (default off). Set 1 (hop count) or the proxy’s IP/subnet when Questarr sits behind a reverse proxy, so rate limits see real client IPs. See REVERSE_PROXY.md

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.

Archive extraction limits

When an import finds an archive (.rar, .zip, .7z, and so on), Questarr lists its contents and checks every entry before extracting anything. RAR archives are listed with unrar lt -v; other formats use 7-Zip. Questarr refuses the archive, and the import fails with an error, if any of these is true:

Check Error message
More entries than ARCHIVE_MAX_ENTRIES Archive contains too many entries (limit: N).
Total declared uncompressed size above ARCHIVE_MAX_EXPANDED_BYTES Archive expands beyond the configured size limit (N bytes).
An entry with an absolute path, a drive letter (C:), a .. segment, or more than 64 levels Archive contains an unsafe file path.
An entry that would resolve outside the extraction directory Archive contains a file path outside the extraction directory.
A symbolic link or hard link Archive contains a symbolic or hard link, which is not extracted.
An entry with a missing or invalid uncompressed size Archive contains an entry with an invalid uncompressed size.

The path and link checks always apply. If a legitimate release is refused for size or entry count, raise the matching limit and restart the server:

# .env
ARCHIVE_MAX_ENTRIES=200000
ARCHIVE_MAX_EXPANDED_BYTES=536870912000 # 500 GiB

Both values must be positive whole numbers. Questarr reads them at startup, outside the Zod schema above, so an empty, zero, negative, or non-numeric value is ignored and the default applies instead of stopping the server. See docs/SECURITY_ASSESSMENT.md for why these checks exist.

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

Service Where configured Storage Refresh/rotation
IGDB (via Twitch OAuth) .env (IGDB_CLIENT_ID/IGDB_CLIENT_SECRET) or Settings → Integrations DB system_config keys igdb.clientId / igdb.clientSecret take priority over env if both are present (server/igdb.ts:138-153) Twitch access token is fetched via client_credentials grant and cached in memory, auto-refreshed ~1 minute before expiry (server/igdb.ts:187-214). Client ID/secret themselves are user-rotated via the Settings UI.
NexusMods .env (NEXUSMODS_API_KEY) or Settings → Integrations DB system_config key nexusmods.apiKey; client reconfigured in-memory on save (server/nexusmods.ts:46-69,176-182) Manual — overwrite the key in Settings.
HowLongToBeat, PCGamingWiki, xREL.to N/A N/A These are unauthenticated public APIs; no credentials involved.
Steam wishlist import N/A (public Steam endpoints + user’s steamId64) N/A N/A
Steam Web API (achievements) .env (STEAM_API_KEY) only — no Settings UI Env-only; never persisted to the DB. GET /api/settings/steam returns only { apiKeyConfigured: boolean }, never the key itself (server/routes.ts) Manual — change the environment variable and restart the server.
Discord notification webhook Stats page DB system_config key discord.webhookUrl, plaintext (server/routes.ts:2769-2801) Manual — overwrite the URL on the Stats page.

Apprise API login

Settings → Notifications supports optional Apprise API username/password fields for servers with APPRISE_AUTH_REQUIRED=yes. The username is stored in system_config as apprise.username; apprise.password is encrypted with the same credential key as downloader/indexer passwords. GET settings masks a saved password as ********, including while CLI mode is active. Posting that placeholder preserves the password, an empty string clears it, and omitting it leaves it unchanged. Back up the encryption key along with the database.

Login uses HTTP Basic Auth. HTTPS requests carrying login credentials reject plaintext and cross-origin redirects. An explicitly configured HTTP API URL sends login credentials unencrypted and displays a warning; use HTTPS unless the connection is on a network you trust. CLI mode does not use the API login. See API.md for the settings contract.

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).

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)

Limiter Limit Applied to
authRateLimiter 20 requests / 15 min / IP POST /api/auth/login
sensitiveEndpointLimiter 30 requests / min / IP Indexer/downloader writes, password change, IGDB/NexusMods/Discord settings, SSL settings, Prowlarr sync
generalApiLimiter 600 requests / min / IP General fallback
scanRateLimiter 10 requests / min / user GET /api/games/:gameId/files (on-disk file scan for a game)

scanRateLimiter keys on the authenticated user ID. For unauthenticated requests it falls back to the client IP, grouping IPv6 addresses by subnet so a single client can’t bypass the limit by rotating addresses within its prefix.

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:

console.log(`Connecting to Postgres: ${pgUrl}`); // v1.1.0 – v1.3.1

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.

Tags Behavior
v1.1.0, v1.2.0, v1.2.1, v1.2.2, v1.3.0, v1.3.1 logs the full URL
v1.4.0, v1.4.1, v1.4.2 constant, no URL

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