Authentication
Login flow, session cookies, the bearer-key alternative, the CSRF model, and rate limiting.
Mosaic accepts two forms of credential on every gated endpoint:
- Argon2id password with a server-issued session cookie.
- Bearer API key as
Authorization: Bearer <key>.
A request passes auth if either check succeeds.
The legacy
?key=<key>URL query-param form of the bearer key has been removed. URL params leak into Referer headers, reverse-proxy access logs, and browser history; passing a credential there is unsafe. Browser-side WebSocket clients that can’t setAuthorizationheaders should rely on the session cookie (auto-attached same-origin). Scripted clients should send the header explicitly.
On the headless mosaicd daemon, credentials belong to per-user accounts (see Daemon → Users & permissions): every request is resolved to an account and the response is scoped to it. The desktop app’s optional web server has a single account (admin). The flows below are identical either way — only the number of accounts differs.
Login flow (cookie sessions)
POST /api/login
Content-Type: application/json
{
"username": "admin",
"password": "<plaintext>"
}
Responses
| Status | Body | Notes |
|---|---|---|
| 200 | {"ok": true} |
Sets mosaic_session=<token> cookie. 12-hour TTL. |
| 400 | {"error": "<msg>"} |
Body could not be decoded as JSON. |
| 401 | {"error": "invalid credentials"} |
Username or password did not match. |
| 429 | {"error": "too many login attempts"} |
Per-IP rate limiter tripped. Retry-After: 12 set. |
The cookie is HttpOnly, SameSite=Strict, Secure (when HTTPS), Path=/. Subsequent requests over the same browser context attach it automatically; scripts can extract it from the Set-Cookie response header.
Logout
POST /api/logout
Deletes the session server-side and clears the cookie. Idempotent — calling it without a session is still 200.
Bearer API key
Every account has its own rotatable API key, and a bearer key authenticates as — and is scoped to — the account that owns it. Keys are stored hashed: only a SHA-256 digest and a 4-character display hint are persisted, so the cleartext is shown exactly once, at rotation time, and a leaked database does not leak usable keys.
Rotate your own account’s key:
POST /api/me/api_key/rotate
→ 200 {"api_key": "<new-token>"}
The desktop build’s single-login web server also exposes POST /api/settings/web/api_key/rotate, which rotates the admin account’s key (it is the same operation, kept for backwards compatibility).
The token is a 32-byte cryptographic random encoded with base64.RawURLEncoding (URL-safe alphabet, no padding) — characters come from [A-Za-z0-9-_].
Once rotated, the account’s previous key is immediately invalid. Every subsequent gated request must use the new key (or a still-valid cookie session).
Using the key
curl -k https://localhost:8080/api/torrents \
-H "Authorization: Bearer $MOSAIC_API_KEY"
For browser WebSocket clients, the standard WebSocket() constructor can’t set custom headers — use the session cookie (auto-attached when the page is loaded from the same origin):
// Same-origin only; the cookie attaches automatically.
const ws = new WebSocket(`wss://${location.host}/api/ws`);
Scripted clients (Node, Python) can set the Authorization header on the upgrade through their WebSocket library; see WebSocket → Upgrade.
Setting the password
An account’s password must be set before it can log in.
To change your own password (any account, daemon or desktop):
POST /api/me/password
Content-Type: application/json
{ "old_password": "<current>", "new_password": "<new-plaintext>" }
The desktop build’s single-login web server also exposes PUT /api/settings/web/password ({ "password": "<new>" }), which sets the admin account’s password without confirming the current one — first-time provisioning is done this way through the desktop UI.
On mosaicd, an admin can reset another account’s password with POST /api/users/{id}/password ({ "new_password": "<new>" }) — see the REST reference.
Side effect: changing an account’s password revokes that account’s active sessions, so any browser still holding a pre-change cookie is forced to re-log in.
CSRF model
Cookie-authed state-changing requests (POST/PUT/DELETE/PATCH) go through OriginGuard. The middleware:
- Skips requests with an
Authorization: Bearer …header entirely. Browsers don’t auto-attachAuthorizationheaders cross-origin, so bearer-keyed callers are not CSRF-vulnerable. (Bearer-detection is by header presence only — there’s no?key=URL form to consider.) - For everyone else (cookie-authed callers):
- If both
OriginandRefererare absent, rejects 403{"error": "missing origin"}. A legitimate browser fetch always sends at least one on POST/PUT/DELETE/PATCH; allowing the unset case would let an attacker bypass the guard via a referrer-policy trick combined with a request type that lacks Origin in older browsers. - If
Originis set, its host must match the request’sHost. Reject 403{"error": "origin mismatch"}otherwise. - If only
Refereris set, its host must matchHost. Reject 403{"error": "origin mismatch"}otherwise.
- If both
GET / HEAD / OPTIONS are never gated.
Practically:
- Browser SPA running same-origin: works. The browser auto-attaches
Origin: https://host:portmatchingHost. - curl/scripts with bearer auth: works. Skipped from the guard entirely.
- curl/scripts with cookie auth: works if you add a matching Origin header. The default curl invocation sends no Origin header at all and will be rejected on POST/PUT/DELETE/PATCH. Either add
-H "Origin: $MOSAIC"or switch to bearer auth. - A malicious cross-origin page: blocked. The browser’s auto-attached
Originis the attacker’s host, which won’t match Mosaic’s Host. SameSite=Strict prevents the cookie from being attached in the first place; this is defense in depth.
A 403 response means you fell into the cookie-authed bucket without a matching Origin/Referer header.
Rate limiting
POST /api/login is rate-limited per source IP:
| Parameter | Value |
|---|---|
| Refill interval | 12 seconds |
| Burst | 5 attempts |
| Idle bucket eviction | 10 minutes |
| Janitor period | 5 minutes |
When tripped, the response is 429 Too Many Requests with Retry-After: 12. No other route is currently rate-limited — the API key is your throughput control.
Session lifetime
| Property | Value |
|---|---|
| TTL | 12 hours from issue |
| Storage | In-memory (resets on process restart) |
| Cap | 100 sessions; oldest evicted on overflow |
| Binding | Each session is bound to one account |
| Revocation | Per-account: that account’s password, username, role or permissions change (other accounts’ sessions are untouched) |
| Token shape | 32-byte cryptographic random, base64.RawURLEncoding |
There is no refresh endpoint. Re-login when the cookie expires.
Putting it together
# 1) Login (one-shot, gets a cookie)
curl -k -c cookies.txt -X POST https://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"hunter2"}'
# 2) Subsequent calls reuse the cookie
curl -k -b cookies.txt https://localhost:8080/api/torrents
# 3) Or skip login entirely with a bearer key
curl -k https://localhost:8080/api/torrents \
-H "Authorization: Bearer $MOSAIC_API_KEY"