Mosaic accepts two forms of credential on every gated endpoint:

  1. Argon2id password with a server-issued session cookie.
  2. 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 set Authorization headers 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.

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:

  1. Skips requests with an Authorization: Bearer … header entirely. Browsers don’t auto-attach Authorization headers 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.)
  2. For everyone else (cookie-authed callers):
    • If both Origin and Referer are 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 Origin is set, its host must match the request’s Host. Reject 403 {"error": "origin mismatch"} otherwise.
    • If only Referer is set, its host must match Host. Reject 403 {"error": "origin mismatch"} otherwise.

GET / HEAD / OPTIONS are never gated.

Practically:

  • Browser SPA running same-origin: works. The browser auto-attaches Origin: https://host:port matching Host.
  • 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 Origin is 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"