What Grimoire does to protect a deployment out of the box, and what you should set when it is reachable from the internet.
The image does not run Grimoire as root. It starts as root only to hand DATA_PATH to an
unprivileged user, then drops to that user before Grimoire starts. Add-on scripts, the
PDF, image and archive parsers, and ffmpeg all run without root, so a flaw in one of
them can't reach beyond what that user can. By default the user is whoever owns the
mounted library; PUID/PGID choose it and --user (or Kubernetes runAsUser) skips the
switch entirely. See Which user Grimoire runs as.
The image's own code under /app stays owned by root, so the running app can't modify it.
The credential-checking endpoints - /api/auth/login, /api/auth/setup, and /api/auth/guest-login - are rate-limited per client IP to slow online password / invite-code guessing. Failed API key attempts count against the same limit on every endpoint; only failures count, and a working key is never refused, so an integration keeps working even from an address it shares with others. The default is 10/minute per IP; exceeding it returns HTTP 429. Tune it with AUTH_RATE_LIMIT (a limits string such as 20/minute or 100/hour), or turn it off with RATE_LIMIT_ENABLED=false. An IPv6 client is counted by its /64 network rather than its single address, since one host usually holds a whole /64 and could otherwise rotate through fresh buckets. Private and link-local IPv6 addresses are still counted one by one, as every device on a LAN segment shares one /64.
Per account and per link. Failed logins are also counted against the account being signed in to (by the username typed, whether or not it exists), and wrong share-link passwords against the link, however many addresses the attempts come from. The default is 20/hour; tune it with ACCOUNT_RATE_LIMIT. A browser that has signed in to an account before keeps its own count (it holds an HttpOnly grimoire_device cookie for that account), so failed attempts elsewhere don't stop the owner signing in from their usual browser. Single sign-on and existing sessions are not affected.
X-Forwarded-For is only read from connections that come from a trusted proxy, and it is read from the right: each proxy appends the address it received the request from, so the right-most address that isn't itself a trusted proxy is the client. The default trust list - loopback plus 172.16.0.0/12, the range Docker gives its bridge networks - covers a proxy or tunnel on the same host or in the same Docker Compose project, so nginx, Nginx Proxy Manager, SWAG, Traefik, Caddy, Cloudflare Tunnel and tailscale serve need no setting. A direct connection from anywhere else is keyed on its own address, whatever header it sends.
The list is matched on the connecting address, so any client connecting from those ranges is treated as a proxy and its X-Forwarded-For is believed: it can name any address in the header and is rate-limited and listed in Active Sessions under it. That is why the default stops at loopback and Docker's own networks rather than taking in your whole LAN. To narrow it further, set TRUSTED_PROXIES to just your proxy's address, or with no proxy set TRUST_FORWARDED_FOR=false.
- Proxy on another machine - another box on your LAN, a Kubernetes ingress, a Podman network, or a VPS reaching your server over the internet: add its address (or its network's range) to
TRUSTED_PROXIES. Until you do, every client behind it shares one bucket and shows up under the proxy's address, and Grimoire logs a line naming the address it ignored. - Cloudflare's proxy (orange cloud) in front of your own reverse proxy, without Cloudflare Tunnel: requests reach your proxy from Cloudflare's addresses, so add Cloudflare's IP ranges to
TRUSTED_PROXIES(keeping the default ranges). Until you do, clients share Cloudflare's addresses for rate limiting and Active Sessions shows them. Cloudflare Tunnel needs nothing. - No proxy at all: nothing has to be set; set
TRUST_FORWARDED_FOR=falseso no client can name its own address. - A port forwarder that hides client addresses - Docker Desktop, Docker's userland proxy, or rootless Podman or Docker using rootlesskit - exposed directly: the forwarder replaces each client's address with an internal one. If that address falls in the trust list (every client then shows up in Active Sessions under the same
172.xaddress), every client - including one reaching the published port from the internet - counts as a trusted proxy and can claim a fresh address on each request. SetTRUST_FORWARDED_FOR=falseon these setups. All clients then share one per-IP bucket and Active Sessions shows the internal address; the per-account and per-link limits above still apply. To see real addresses instead, put a reverse proxy or tunnel in front and keep the header trusted (ideally withTRUSTED_PROXIESnarrowed to the proxy), or on recent Podman setrootless_port_forwarder = "pasta"incontainers.conf, which keeps the client's own address.
Multiple replicas: when VALKEY_URL is set the limit counters are shared through Valkey so the limit is enforced consistently across all workers/replicas; without it each process keeps its own in-memory counters (and the limiter falls back to in-memory automatically if Valkey becomes unreachable).
Every response carries a Content-Security-Policy scoped to what the SPA actually loads (own scripts, inline styles used by React, Google Fonts, and data:/blob: images for rendered pages), plus X-Content-Type-Options: nosniff, X-Frame-Options: DENY (matching the CSP frame-ancestors 'none'), and Referrer-Policy: strict-origin-when-cross-origin. Strict-Transport-Security is emitted only when the request is HTTPS - either directly or via an X-Forwarded-Proto: https header from your TLS-terminating proxy - so it is never sent over plain HTTP.
Browser <img> and download requests can't send an Authorization header, so they authenticate via an HttpOnly, SameSite=Lax session cookie (grimoire_session) set at login rather than a token in the URL. This means the JWT no longer appears in image/download URLs, so a reverse proxy, CDN, or load balancer in front of Grimoire no longer records it in access logs (query-string tokens also leaked via Referer headers and browser history). Set BASE_URL to your https:// public URL so the cookie is marked Secure and only ever sent over TLS; without it, the cookie is still marked Secure on a request that arrived over HTTPS, or that a trusted proxy reports as X-Forwarded-Proto: https. A token in the URL (the old ?token= parameter) is no longer accepted at all.
The cookie only authenticates reads (GET/HEAD). A browser attaches the cookie on its own, and SameSite=Lax still lets a page on another subdomain of your domain send it with a form post, so anything that changes data needs the Authorization header the app always sends, which no other page can add.
Logging in issues a short-lived access token (30 minutes by default) plus a long-lived refresh token stored in an HttpOnly cookie. The browser refreshes in the background, so this is invisible in normal use - you stay logged in as before.
What it buys you is a kill switch. Previously a token was valid for 30 days and there was no way to revoke it short of rotating SECRET_KEY, which logged everyone out. Now every login is a session you can end individually.
Managing your sessions. Settings → Account → Active Sessions lists every device signed in to your account, with its browser, IP, and when it was last used. Revoke any one of them, or use Sign out all other devices to end every session but the one you're on - the thing to do if you've lost a device or think someone else has your password.
Sessions are also revoked automatically when an admin changes your role or resets your password, when you change your own password (all other devices), when a guest's invite code is regenerated or replaced (a merged guest removed from the campaign that held their code gets a fresh one), and when an account is deleted. This works the same for OIDC/SSO logins as for local ones.
One caveat worth understanding: revoking a session kills its refresh token immediately, but an access token already issued stays valid until it expires - up to ACCESS_TOKEN_EXPIRE_MINUTES (30 by default) - for ordinary requests. This is the trade for not hitting the database on every single request. Requests from GM and admin accounts are checked against the account's current role and whether their session has ended, every time, so a role change, a deleted account or a revoked session takes effect for those roles immediately. A guest's session also ends at its next refresh once guest access is turned off or its invite is removed - so a removed guest, like a player whose sessions you ended, can keep using the token they already hold for up to that long. If you want revocation to bite faster everywhere else, lower ACCESS_TOKEN_EXPIRE_MINUTES; the cost is more frequent background refreshes.
Refresh tokens are single-use and rotate on every refresh, and only a hash of each is stored in the database. If a refresh token is ever presented twice - the signature of a stolen token being replayed - Grimoire revokes that entire session rather than just refusing the request.
Dead sessions are cleared out automatically: a background job runs at startup and then daily, deleting sessions that expired or were revoked more than 7 days ago. The delay is deliberate - a refresh token replayed shortly after logout is still recognised as a reuse rather than looking like an unknown token. Nothing to configure, and it runs in only one worker regardless of WORKERS.
OPDS feed URLs, calendar subscription URLs and share links each carry a token that works on its own, with no login. None of them is stored as itself. The database keeps an HMAC-SHA256 of each token, keyed by SECRET_KEY, which requests are matched on, and a copy of the token encrypted under a second key derived from SECRET_KEY, so you can copy your feed URL or a share link again later. Guest invite codes are kept the same way. API keys and refresh tokens are stored as hashes too.
A backup therefore holds no usable feed or share URL: it contains grimoire.db but not secret_key. Backups are written with SQLite's VACUUM INTO, so the copy holds only live rows - nothing deleted or overwritten, which a page-by-page copy would carry along in the file's free space.
Two consequences of keying them on SECRET_KEY:
- Changing
SECRET_KEYends every feed and share link, and every guest invite code, just as it signs everyone out. OPDS and calendar settings offer a new URL, share links show as Lost - revoke or delete them and make new ones - and a GM regenerates each guest's code from the campaign's guest list. - Restoring a backup on a server with a different
SECRET_KEYhas the same effect. Keepsecret_key(or yourSECRET_KEYvalue) with your backups if you want feeds and links to survive a move - but store it separately from them, or a stolen backup becomes usable again.
The OIDC client secret entered in Settings → Sign-in is kept the same way: encrypted under a key derived from SECRET_KEY, and decrypted only when Grimoire presents it to your identity provider, so a backup does not carry it either. If SECRET_KEY changes, the stored secret can no longer be read; single sign-on stops until you enter the secret again, and the log says so. Setting OIDC_CLIENT_SECRET in the environment instead keeps it out of the database altogether and is the recommended setup.
Upgrading from a version that stored these tokens or the client secret in plaintext converts them in place, so every feed URL and share link already handed out keeps working. The first start after that upgrade also compacts the database once (a VACUUM), so the old plaintext does not linger in the file's free space. It needs free disk space about the size of the database for a moment; if it cannot run, the log says so and it tries again at the next start.
- Configuration - every variable named here
- Users and permissions - roles, restrictions, and pre-seeding
- OpenID Connect - delegating sign-in to your own identity provider