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):
- In-memory cache for the life of the process.
JWT_SECRETenvironment variable.- Value stored in the
system_configtable under keyjwt_secret. - If none of the above exist, generate 64 random bytes
(
crypto.randomBytes(64).toString("hex")) and persist them tosystem_configfor future restarts.
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.apiKeyanddownloaders.username/downloaders.passwordare AES-256-GCM encrypted before being written to SQLite and decrypted on read (server/credential-crypto.ts, wired intoserver/storage.ts’saddIndexer/updateIndexer/getIndexer/getAllIndexers/getEnabledIndexers/syncIndexersand the equivalent downloader methods). Each encrypted value is prefixedenc: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_KEYenv var, then the DBsystem_configkeycredentials_encryption_key, then auto-generated (32 random bytes) and persisted (server/credential-crypto.ts:getCredentialsEncryptionKey). Losing this key (e.g. wipingsystem_configwithout also setting the env var) makes previously encrypted rows undecryptable.
- Encryption key: resolved the same way as
- In transit to the indexer/download client: the storage layer decrypts
transparently, so
server/downloaders/*.tsandserver/search.tsreceive 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,599uses SHA-256 whenever the rTorrent/ ruTorrent server’s challenge advertisesalgorithm=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-issuednonce/clientcnonceper 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.
- MD5 fallback (accepted risk): RFC 2617’s classic Digest Auth only
defines MD5;
- Access control / API exposure: every indexer/downloader route sits
behind the global
authenticateTokenmiddleware (server/routes.ts:821-829).GET /api/indexers,GET /api/indexers/:id,GET /api/downloaders, andGET /api/downloaders/:idmask the secret field before responding —apiKey/passwordcome back as"********"whenever a real value is set (maskIndexer/maskDownloaderhelpers,server/routes.ts). The same masking applies to thePOST/PATCHresponses.usernameis 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/:idfollow the IGDB masked-sentinel convention — sending"********"forapiKey/passwordleaves 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:
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_SECRETexplicitly in production so sessions survive restarts and DB resets. - Set
CREDENTIALS_ENCRYPTION_KEYexplicitly in production so stored indexer/downloader credentials stay decryptable across DB resets (openssl rand -hex 32). - Set IGDB and (optionally) NexusMods credentials via
.envor 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, ordocker-compose.local.yml. - If you ran
pg-to-sqliteon v1.1.0–v1.3.1 and kept the logs, rotate that Postgres password — those versions printed the fullDATABASE_URL(§8). - If running the archived v1.4.2
pg-to-sqlitemigration tool, setDATABASE_URLto your real source credentials and verify the row counts it reports — it continues past per-table failures and still reports success (see MIGRATION.md).