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> (or ?key=<key> for WebSocket upgrades).

A request passes auth if either check succeeds.

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

The API key is a single rotatable token stored in the SQLite settings table. View it in Settings → Web Interface in the desktop UI, or rotate via:

POST /api/settings/web/api_key/rotate
→ 200 {"api_key": "<new-token>"}

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 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 WebSocket upgrades that can’t set headers (browser WebSocket() constructor):

const ws = new WebSocket(`wss://localhost:8080/api/ws?key=${encodeURIComponent(key)}`);

Setting the password

The password must be set before you can log in.

PUT /api/settings/web/password
Content-Type: application/json

{ "password": "<new-plaintext>" }

Side effect: every active session is revoked, so any browser still holding a pre-change cookie is forced to re-log in.

This route is itself gated — first-time provisioning is done through the desktop UI, which calls the same Service method without going through HTTPS.

CSRF model

Cookie-authed state-changing requests (POST/PUT/DELETE/PATCH) go through OriginGuard. The middleware:

  1. Skips bearer-keyed requests entirely. A Authorization: Bearer ... header (or ?key=...) means the caller is a script, not a browser auto-attaching a cookie — there’s no CSRF vector to defend against.
  2. For everyone else, requires the Origin header’s host to match the Host header. Falls back to Referer if Origin is absent. If both are absent, allows the request — SameSite=Strict on the cookie means a real browser-mediated CSRF wouldn’t have the cookie attached anyway, and the absence of Origin is what most non-browser clients send.

Practically:

  • Browser SPA running same-origin: works. The browser auto-attaches Origin: https://host:port matching Host.
  • curl/scripts with bearer auth: works. Bypasses the guard.
  • curl/scripts with cookie auth: works as long as Origin/Referer are absent or matching. Don’t forge a mismatched Origin.
  • 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 cross-origin bucket: {"error": "origin mismatch"}.

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
Revocation Set new password, change username, or RevokeAll()
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"