Skip to content

Security & Authentication

Homepage includes optional password protection. It is fully GUI-driven — no environment variables are required to enable it.

First-run setup

When you open Homepage for the first time, a setup screen asks whether you want to enable password protection:

  • Yes, add a password — enter and confirm a password of at least 12 characters. All future visits will require login.
  • No, continue without — Homepage runs without any authentication. This can be enabled later.

Logging in

If password protection is enabled, you will see a login screen on every new session. Check Stay logged in to persist the session in localStorage (survives browser restarts). Leave it unchecked to use sessionStorage instead (cleared when the tab or browser is closed).

Sessions expire after 7 days.

Managing authentication

Go to Settings → Security to:

ActionDescription
Enable password protectionSet up a password on an existing no-auth install
Change passwordRequires your current password. Signs out every other device.
Sign out all devicesEnds every session, including this one
Disable password protectionRequires password confirmation; removes all auth requirements
Log outEnds the current session

How it works

  • The password is stored as a bcrypt hash (cost 12) in the SQLite database — never in plain text.
  • Auth material lives in a dedicated auth table that no general-purpose API endpoint reads or writes.
  • A JWT secret is generated on first use. You do not need to configure it.
  • Requests authenticate with a Bearer token. The same token is also set as an httpOnly, SameSite=Strict cookie, which is what authenticates the requests a browser makes on its own — <img src="/api/icons/…"> and backup downloads, neither of which can set a request header. The cookie is accepted for GET/HEAD only, so it cannot be used to change anything.
  • Login and setup endpoints are rate limited to 10 attempts per 15 minutes per IP.
  • Changing your password re-keys the server, so every previously issued token stops working immediately.

Widget credentials

Widgets talk to third-party services — Jellyfin, Umami, FreshRSS, Last.fm and so on — and need credentials for them. Unlike your dashboard password, these have to be replayed to the upstream service, so they are encrypted rather than hashed.

  • Credentials are stored AES-256-GCM encrypted in a widget_secrets table, separate from widget configuration.
  • They are never sent to the browser. The widget config API returns a placeholder for fields that have a stored value, so the settings form can show that a key is configured without being able to read it.
  • When a widget refreshes, the browser sends only the widget's id. The server looks the credential up and adds it to the upstream request.
  • Credentials are excluded from backup exports. Restoring a backup brings your layout back and asks you to re-enter keys.

The encryption key

By default the key is generated into your data directory as .encryption-key (mode 0600). That protects credentials in API responses, backups and git, but not against someone who can already read files on the host — the key sits next to the database it decrypts.

For a publicly reachable instance, set the key in the environment instead:

bash
openssl rand -hex 32
yaml
environment:
  - HOMEPAGE_SECRET_KEY=<the 64-character hex string>

With that set, a leaked database file is not enough on its own. Keep a copy — losing the key means re-entering every widget credential.

Exposing Homepage publicly

Enable password protection. Beyond that:

  • Put it behind HTTPS. HSTS is sent in production builds, but only TLS at the proxy makes it mean anything.
  • Set HOMEPAGE_SECRET_KEY so the credential encryption key is not stored beside the database.
  • Leave CORS_ORIGINS empty unless you genuinely serve the frontend from a different origin. Empty means same-origin only.
  • Keep TRUST_PROXY on (the default) when behind a reverse proxy, so rate limiting sees real client addresses rather than bucketing everyone under the proxy's IP.
  • Use the Docker socket proxy shipped in docker-compose.yml rather than mounting /var/run/docker.sock into the app. Socket access is equivalent to root on the host, and mounting it :ro does not restrict what the daemon will accept — the flag applies to the filesystem entry, not to the API.

No-auth installs on public URLs

If you chose no password at first-run setup and your instance is publicly reachable, any visitor could go to Settings → Security and enable a password themselves, locking you out. The no-auth mode is intended for use behind a firewall or VPN where you trust all visitors.

If your instance is on a public URL, always set a password.

Hardening notes

  • Security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) are applied by helmet.
  • Remote HTML — Mastodon posts, RSS article bodies — is sanitised with DOMPurify before rendering.
  • The host probe used by the PROBEd widget can reach private addresses, which is the point of the feature, but refuses cloud instance-metadata endpoints and is rate limited.
  • Uploaded app icons are restricted to raster formats. SVG is rejected because it can carry script and is served from the app's own origin.

Personal Homepage Dashboard