-
Notifications
You must be signed in to change notification settings - Fork 0
Security
English · Tiếng Việt · 中文
This page covers five mechanisms specifically — the first-run claim code, the
screen lock, trusted proxies, OAuth sign-in, and encryption at rest — and is
deliberately honest about what each one does not cover. For the full posture (headers,
rate-limiting, the audit log, SSRF guards) see SECURITY.md in the
repository; to report a vulnerability, use that file's instructions rather
than a public issue.
Threat model, stated plainly. These mechanisms defend against an
attacker who reaches your instance over the network, or who obtains a copy of
your files — a stolen disk, a backup, a leaked DATA_DIR. They do not
defend against an attacker who already has root on the machine while it's
running: that attacker can read process memory, including any unlock secret
currently in use.
Before anyone has signed in, SkimMail's login screen would otherwise create a credential for whoever gets there first — the very first passphrase or account is created by the first request that asks. On a network of any size, that's a race between you and everyone else who can reach the port between "container started" and "you sat down and opened the browser".
Since 1.9.0, a fresh instance prints a one-time claim code
(SKIM-XXXX-XXXX-XXXX) to its log at first start and refuses to create the
first credential without it. Only the code's SHA-256 hash is ever stored —
not the plaintext — so once it's printed, the only record of it is whatever
captured that log line. That also means SkimMail can never show it to you
again: there is no "reveal" feature, by design, and claim-code --show
exists only to explain that and point you at --rotate instead.
Read it (see Installation for the exact command per install method):
sudo journalctl -u skimmail | grep 'claim code' # apt — sudo is required
docker compose logs skimmail 2>&1 | grep 'claim code' # Docker Compose — the container is not named skimmail
docker logs skimmail 2>&1 | grep 'claim code' # docker run --name skimmailBefore 1.17.0 this page (and the wizard's own first-run screen) showed only
the docker logs form. It fails under Compose: the published
docker-compose.yml names the project skimmail with no container_name, so
the container is really skimmail-skimmail-1, not skimmail. docker compose logs <service> looks the service up by its compose-file name regardless,
which is why it is the form to use for a Compose deployment. Fixed in 1.17.0
(SKIMMAIL-190). See Installation for the full explanation and
the exact command for every install method.
Lost it, or the log rotated past it? Mint a replacement — the old one stops working the instant you do:
sudo -u skimmail skimmail --data-dir /var/lib/skimmail claim-code --rotateRestoring a backup onto an instance that reopens first-run setup (one with no credential in it, or a fresh install target) mints and prints a new code automatically — a spent code is never treated as "already claimed" the way an earlier release did, which would otherwise let anyone create the admin account during that window.
On a genuinely closed network — one where nobody untrusted can reach the port
before you do — you can skip the gate entirely with SKIMMAIL_SKIP_CLAIM=1.
Do this deliberately: it removes the one control standing between "container
started" and "instance belongs to whoever asks first".
The idle screen lock is a real access boundary, not a drawing on top of the app. Before 1.9.0 the lock was rendered entirely in the browser: the underlying session stayed fully valid the whole time, so the API answered normally regardless, and reloading the locked tab made the lock disappear with the data still on screen.
Since 1.9.0, locking a session is something the server does. A locked session:
- keeps its identity (it's still you, still signed in) but loses its reach:
every API route answers
423 Lockedexcept the handful the lock screen itself needs (/api/auth/unlock,/api/auth/logout,/api/auth/me,/api/server-info); - survives a reload — there is no client-side state to lose, because the server is the thing enforcing it;
- does not pause your mail. Background sync keeps running and new mail keeps arriving while a session is locked — the lock is a boundary on the person at the keyboard, not a pause button for the service.
Unlocking re-verifies your current passcode (and your 2FA code, if you have
two-factor enabled) through the same brute-force guards as signing in — it
doesn't mint a new session, so your original sign-in's expiry is untouched.
The lock isn't offered at all under AUTH_MODE=none: with no credential to
lock behind, there would be nothing to unlock with.
Two settings control it, both in Settings ▸ Security ▸ Timeouts and both Plane 2 (see Configuration):
| Setting | Environment default | Meaning |
|---|---|---|
| Auto-lock after |
AUTO_LOCK_MINUTES (0 = never) |
Idle minutes before the client locks itself |
| Session expires after |
SESSION_TTL_HOURS (720 = 30 days) |
How long a sign-in lasts before you must sign in again |
Since 1.16.2, the lock holds across every open tab, not just the one that
triggered it. The lock has always lived on the session row, so it was
already shared — but before 1.16.2, whether a given tab drew the overlay
was separate, per-tab state, so a second tab open on the same session kept
showing a fully rendered mailbox with no overlay at all. That defeats most of
what an idle lock is for. Tabs now exchange a hint through localStorage on
every lock and unlock, and the two directions are treated asymmetrically on
purpose — this asymmetry is the actual security property, not an
implementation detail:
- a locked hint draws the overlay in every other tab immediately — fail-closed, so the worst a forged hint can do is show the passcode prompt to someone who then still has to type the real passcode;
- an unlocked hint never lifts the overlay by itself. It only makes the
tab ask the server again (
/api/auth/me), and the overlay lifts only when the server's own answer says the session is unlocked.
Without that asymmetry, a same-origin script could dismiss the lock screen
just by writing a localStorage key — which is exactly the defect 1.9.0
fixed, back when the lock was still an overlay drawn over a workspace that
never unmounted.
Also since 1.16.2: a refusal is no longer recorded as an empty result.
Before this release, a page that reloaded while the lock was up asked for
your mailbox list, was refused with 423 because the session was locked, and
wrote that refusal down as "you have no mailboxes yet" — drawing the
first-run "Your first mailbox" screen underneath the lock overlay, on an
instance that had mail the whole time. Typing your passcode revealed that
screen instead of your inbox; only a full page reload recovered. The same
class of bug showed up as a failed message load rendering "Inbox Zero", a
failed folder list disappearing instead of reporting an error, and the
sessions list above being able to say "no active sessions" while read from
inside one. None of these were a lock-specific fix — a 423 from any locked
session now always routes to the lock overlay instead of an error screen,
loading and empty are no longer the same state, and only a genuine 401
is treated as being signed out. See
Troubleshooting
for the exact symptom this replaces.
X-Forwarded-For is a header the client starts and each proxy in front of
your instance appends to — so the address the client wrote is always the
leftmost entry, and the address your nearest proxy actually observed is
the rightmost. SkimMail reads it from the right, walking left, and stops
at the first hop that isn't recognised as one of your own proxies. That
matters because a client can put anything at all in the header — an earlier
version of this code trusted the leftmost entry, which meant an attacker
could set a fresh X-Forwarded-For on every request and land on a new
rate-limit bucket every time, defeating the login lockout and the per-IP
limiter, and writing whatever address they liked into the audit log.
Two settings control it:
-
TRUST_PROXY(Plane 1,trust_proxy) — off by default. With it off,X-Forwarded-Foris ignored entirely and the direct TCP peer is the client's address, full stop. -
TRUSTED_PROXIES— a comma-separated CIDR list of hops that count as your infrastructure rather than the client. Left empty, loopback and private-range addresses are assumed to be proxies (a sidecar or a same-host nginx), which is the right default for the common case and wrong for nothing else.
The consequence of getting this wrong runs in both directions. Turning on
TRUST_PROXY only makes sense when SkimMail itself is not directly
reachable by anyone except your real reverse proxy — the code has no way to
tell "this connection came through my proxy" from "this connection is a
stranger pretending to be my proxy" except by checking where it actually came
from. Concretely:
- If your reverse proxy sits at a public address (a CDN edge, a tunnel
exit) and you leave
TRUSTED_PROXIESempty, SkimMail won't recognise it as a proxy at all, and the address it resolves for rate-limiting, lockout and the audit log will be wrong. - If SkimMail's own port is reachable by anyone other than your proxy —
or if you set
TRUSTED_PROXIEStoo broadly — an attacker who can reach that port directly can hand-write their ownX-Forwarded-Forand have SkimMail believe whatever address they choose, which is exactly the spoofing hole the read-from-the-right fix closed for the default case.
The rule in one sentence: only turn TRUST_PROXY on when the port is
otherwise unreachable except through your real proxy, and set
TRUSTED_PROXIES to that proxy's actual address whenever it isn't already
loopback or a private range.
Since 1.18.0, both ways of finishing a Google or Microsoft sign-in — the ordinary redirect and the manual paste flow — use PKCE. This matters most for the paste flow, where the sign-in result passes through your clipboard on its way back into SkimMail. Two independent locks protect it: your client secret, which the provider requires before it will exchange a code for a token and which never leaves your server, and PKCE, a one-time value your server keeps to itself and the provider checks. The secret alone already made a copied line useless to anyone else who saw it; PKCE keeps that true even if a provider ever handled its own side of PKCE poorly. Neither lock has to be perfect on its own. Full procedure: Accounts.
The internal ticket that carries a sign-in through the flow now states what it is for. Two unrelated short-lived tickets — the one held during a Google or Microsoft sign-in, and the one held between your password and your two-factor code — were sealed the same way, with overlapping field names, so one could in principle have been read as the other. Nothing was ever exploited this way; it is now impossible by construction rather than by luck.
SkimMail encrypts data at rest in two independent layers, and they cover different things. Knowing which layer a setting belongs to tells you exactly what it does and doesn't protect — and one thing changed in 1.11.0: cached message bodies are now encrypted at rest, and nothing else about your mail is.
| Layer | Covers | How |
|---|---|---|
| 1 — Secret wrap | Account credentials, OAuth tokens, TOTP secrets, proxy/tunnel secrets, the S3 backup secret key | Always AES-256-GCM, under a 32-byte master key. KEY_PROVIDER decides how that key is stored. |
| 2 — Content | Cached message bodies (DATA_DIR/blobs/) |
AES-256-GCM since 1.11.0, under a key derived from the master key. On unless BLOB_ENCRYPT is explicitly set to false, 0, no or off. |
| 2 — Content | Message subjects, senders/recipients, the skimmail.db file, the full-text search index |
Not encrypted by SkimMail. Encrypt the volume underneath instead. |
-
file(default) — the master key is 32 raw bytes atDATA_DIR/master.key, mode0600. Protection is filesystem permissions only: anyone who copiesDATA_DIRhas the key and the ciphertext side by side, and can decrypt every stored credential. Safe only when the volume itself is encrypted (Layer 2). -
passphrase(recommended) — the master key is generated once and then stored wrapped inDATA_DIR/keyring.json, encrypted with a key-encryption-key derived (Argon2id) from a separate unlock secret that lives outside the data directory. A copiedDATA_DIRis then useless without that secret. It is deliberately not your login password — sync keeps running unattended across restarts, which needs a secret the background process can read on its own. Rotate it any time withskimmail rekey, which re-wraps the same key without re-encrypting any data.
Combining passphrase with an encrypted volume means a stolen disk or backup
exposes neither your credentials nor your mail. file on a plain,
unencrypted volume — the zero-config default — exposes both.
Cached message bodies are encrypted since 1.11.0 — AES-256-GCM, under a key derived from the master key. There is no new secret to back up: the key is derived, never stored. Bodies written by earlier releases stay readable, so upgrading needs no migration, and turning the setting off never strands an existing cache. Full behaviour: Message body cache.
BLOB_ENCRYPT is on unless you turn it off. Only false, 0, no and
off disable it; every other value, 1 and TRUE included, leaves it on.
1.11.0 had this backwards. It accepted only the exact word
true, soBLOB_ENCRYPT=1— oryes, oron, orTRUE— silently switched encryption off. Fixed in 1.11.1. If you set it to one of those on 1.11.0, the bodies cached since are not encrypted and are not rewritten in place; evict them by lowering the size or age limit in Settings ▸ Security ▸ Message body cache, and they come back sealed.
Everything else about your mail is still plaintext on disk, by design:
subjects, addresses, dates, snippets and the full-text search index live in
the database, and encrypting them in-app would break the pure-Go build and
search itself. The documented mitigation for those is to encrypt the
volume, not the content — LUKS/dm-crypt or an encrypted ZFS dataset under
DATA_DIR, encrypted cloud disks (EBS, GCP/Azure disk encryption) under a
container's volume, or S3 server-side encryption (SSE-S3/SSE-KMS) if your blob
backend is S3. Any of these covers the database file, the cached bodies, and
the plaintext-key file together, at the storage layer rather than inside the
app.
What a stolen DATA_DIR exposes:
| Setup | Credentials / tokens | Cached bodies | Subjects, addresses, search index |
|---|---|---|---|
file + plain volume (default) |
exposed | exposed* | exposed |
passphrase + plain volume |
protected | protected* | still exposed |
file + encrypted volume |
protected (by the volume) | protected | protected (by the volume) |
passphrase + encrypted volume |
protected twice over | protected twice over | protected (by the volume) |
* Body encryption is only as good as the master key it derives from. With
KEY_PROVIDER=file that key sits in the same directory, so whoever copied
DATA_DIR can decrypt the cache — it raises the bar against a casual grep,
nothing more. With passphrase the key is not on the disk at all, and the
cached bodies really are unreadable without the unlock secret.
-
Per-user access control exists as of 1.10.0, and reached every surface in 1.13.0. Every account, group, message, attachment and sender flag belongs to exactly one user, enforced in the data layer rather than the interface, and a request across that boundary is answered "not found" rather than "forbidden" — because refusing a specific row confirms it exists. Roles are
owner,operatorandviewer; the boundary between them is a single authorization step in the middleware, so a route cannot be added without deciding who may call it. That describes the design from 1.10.0 on; it did not describe every query until 1.13.0 — sender flags in particular were instance-wide the whole time. On any version BEFORE 1.10.0 a second sign-in was a second administrator, not a separate mailbox owner. Full detail, including the role table and theskimmail usercommand: Users and roles. -
A link in a message can no longer act on your own instance. Making the message frame same-origin — the change 1.18.0 needed so that remote images could load with your session at all, see Remote images in mail — also meant an ordinary-looking link in a stranger's mail could point back at SkimMail's own address and be followed while you are signed in, turning one click into a request made as you. Since 1.18.0 (SKIMMAIL-209), a link in a message that points anywhere other than out to the web is inert. An anchor to a place within the same message still works, and so does
mailto:. -
Opening a message can still tell the sender. If you press Show images, SkimMail fetches them from the sender's server — through that account's egress, with no cookies and no
Referer, but the fetch itself tells the sender the message was opened and when. Images are withheld by default, a sender can be refused permanently, and the whole feature can be switched off for the instance from Settings ▸ Security ▸ Remote images (owner only, a screen since 1.11.1). What it cannot do is make a fetched image invisible. See Remote images in mail. -
Upgrade to 1.14.0 if more than one person signs in. Thirteen operations shipped between 1.10.0 and 1.13.0 were gated by role but not by owner, so they read or wrote across the user boundary they were supposed to enforce. 1.12.0 closed five of them: Reset groups deleted every user's groups instance-wide, snooze and pin could be applied to another person's message, a push-notification device could be deleted out of another person's account, and split-inbox counts were computed over everyone's mail. 1.13.0 closed six more, and two of them were reachable by a plain
viewerthrough a screen the product actually ships:What it did Search returned matches from every account on the instance — subjects, senders, preview lines and cached AI summaries The AI "catch me up" digest read the opening lines of everybody's unread mail and posted them to the instance's AI vendor Archive / Move / Delete moved another user's message on their real mail server before the ownership check ran, then reported success AI summaries were served from the result cache, which answered before the ownership check OAuth accounts were saved with no owner: they belonged to nobody, vanished from the account list, and a second person connecting the same mailbox overwrote the first Muting a sender applied to the whole instance, while the mutes and VIPs you set were written where nothing read them back The digest only runs on an instance where AI is enabled and the digest feature is switched on. Note what that does not mean: the AI tab is hidden in this release, and hiding a tab is a display decision, not a boundary — the routes stay registered and answer anything that can reach the port. Treat
GET /api/digestas live.1.14.0 closed the last two, and they are a different shape: they did not read another user's rows, they acted as another user. Server-side search and the account Test connection button each took an account id straight off the request and opened that account's IMAP connection with its saved password and its network route — searching the live mailbox and writing the fetched headers into the database. Nothing came back to the caller; the connection was opened and the rows written all the same, and a
viewercould do it. Both had been flagged by the standing guard test and waved through by an exemption note that was untrue.Single-user instances — the default — were never exposed, because there was no second user to reach across to. Details, and what the upgrade does to your muted senders: Users and roles.
1.18.0 closed one more, found by an internal audit rather than a report.
SyncAccount— the function behind the Sync now button — never checked who owned the account id it was given, even though its caller,POST /api/accounts/{id}/sync, is an operator-level route with a real cross-user caller: the sidebar's sync button, pressed on any account an operator can see. Naming another user's account id made the server dial their mailbox with their decrypted credentials through their egress, report back how much mail it found, surface the mail server's own error text on failure, and — for a Gmail or Outlook account — force a token refresh that rotates their stored sign-in. The same call could also drive up another user's failure counter until SkimMail's automatic cut-off switched their mailbox off, with an error message of the caller's choosing left on that person's Accounts screen. This was the third function of exactly this shape:TestAccountConnectionandWatchAccountwere both given the same ownership check in 1.14.0, andSyncAccountwas missed — it is the one of the three with a live, user-scoped caller. It was present in every tagged release from 1.10.0 through 1.17.0, eleven tags, the whole span since per-user ownership existed as a concept at all. A single-user instance was never exposed, because there is no second account to name. If you run SkimMail for more than one person, read the Upgrading section of this release's ownCHANGELOG.mdentry for what to check on your instance — it is written for that, and this page will not repeat it. -
The AI endpoints have no licence or tier check, and hiding their button is not a boundary. Six routes — summarise, classify, translate, extract action items, thread summary, and an inbox digest — are registered in every release and reachable by anyone holding a session, whatever the interface shows next to them. Each sends the relevant message body to whichever AI provider you configured; with none configured, every one of them errors and nothing leaves your server. This was described incorrectly from 1.10.0 through 1.17.0, which claimed the capability needed a Pro licence and did not exist at all on Community or Sponsor. See
PRIVACY.md's "AI features" section for the full correction, and treat every one of these routes as live regardless of which tab is visible. -
A rooted, running host is out of scope for every mechanism above. The claim code, the screen lock, and both encryption layers all assume the attacker doesn't already have that level of access to the live process.
SkimMail · skimmail@base101.app · 2026-09-18 · commit f525934