Security & privacy¶
Proton Faces is privacy-first by design: no telemetry, no cloud APIs, and the only network calls go to Proton's servers via the bridge container. All ML runs locally through ONNX Runtime on CPU.
What's on disk¶
| Path | Content | Sensitivity |
|---|---|---|
DATA_DIR/thumbs/ |
512px WebP thumbnails | Low. These are downscaled previews; not full-res. |
DATA_DIR/index.sqlite3 |
The local index (photos, faces, clips, people, users, tokens) | Medium. Photo metadata + your local edits + bcrypt password hashes + bearer tokens (random hex). |
DATA_DIR/crops/ |
Face-crop JPEGs ({face_id}.jpg) |
Low. Cached subset of the thumbs. |
DATA_DIR/_backups/ |
index-<UTC-stamp>.sqlite3 snapshots |
Same as the live DB. |
DATA_DIR/gps_sha1_cache.json |
sha1 → (lat, lng) map |
Low. |
DATA_DIR/admin_config.json |
Auto-backup schedule | None. |
DATA_DIR/models/ |
InsightFace buffalo_l + CLIP ViT-B/32 | None. Public ML model weights. |
credentials/auth-session.json |
Your Proton session | High. Treat like a password. |
Nothing else is written to disk. No temporary caches, no logs of photo content, no analytics files.
What's NOT on disk¶
- Full-resolution photos. The bridge downloads them on demand when you click View full resolution; the bytes stream through the FastAPI response and are discarded by the client. The fullres loop downloads HEIC / videos once, generates a 512px thumbnail, and discards the full-res bytes immediately.
- Uploaded face-search images. The face-search-by-example endpoint reads the multipart upload into memory, embeds it, and discards it. The image is never written to disk or sent to Proton.
- Telemetry, analytics, or any outbound calls other than Proton. There are no analytics SDKs, no Sentry/Datadog calls, no remote logs.
Network surface¶
| From | To | What |
|---|---|---|
Browser → app:8080 |
localhost / your reverse proxy | HTTPS / JSON / static files |
app:8080 → proton-bridge:8090 |
compose internal network | timeline, timeline_ids, nodes, albums, thumbnails, photo/{uid}/full |
app:8080 → indexer:8091 |
compose internal network (no host port mapping) | /status (live state for the footer) |
proton-bridge → Proton Drive |
HTTPS to drive-api.proton.me |
Auth + timeline + thumbnail fetch (read-only) |
The bridge's only outbound destination is Proton's API. Nothing else leaves the host.
If you put a reverse proxy in front of :8080 (Traefik, Caddy, nginx), it's your responsibility to terminate TLS and gate the public side.
The bridge is read-only¶
proton-bridge is built inside the Proton Drive SDK monorepo and pins a specific release tag. It exposes only GET /timeline, POST /nodes, GET /albums, POST /thumbnails, GET /photo/{uid}/full, GET /health.
There is no POST/PUT/DELETE for write operations. There is no upload, no delete, no move. The session file is mounted read-only. The data dir is shared with the indexer for thumbnail caching only — not for write-back.
Multi-user auth¶
Proton doesn't publish an OAuth/OIDC provider, so each family member gets a local account:
- Username + bcrypt password. Cost 12, ~250 ms per hash on a modern CPU.
- Opaque bearer tokens — 32 random bytes, hex-encoded. Not JWT.
- Access tokens — TTL
AUTH_ACCESS_TTL(default 8 h). - Refresh tokens — TTL
AUTH_REFRESH_TTL(default 30 d). - Tokens are stored in
auth_tokens(SQLite). The short-lived access token is kept inlocalStorageunderpf.auth; the refresh token is held in the HttpOnlySameSite=Strictpf_refreshcookie (FP-1) so page-level XSS cannot exfiltrate it. The cookie'sSecureflag is controlled byAUTH_COOKIE_SECURE. - Failed username probes run a dummy bcrypt hash to keep wall time comparable to a real failure (defense against username enumeration).
- Optional per-user 2FA (TOTP, authenticator app). Each user can enroll from the account menu (click your username, bottom-left → Enable 2FA; QR code or manual secret); a 6-digit code is then required at every login. The TOTP secret is encrypted at rest (AES-256-GCM, key derived from
SIGNING_SECRET), and the pending-login token expires afterAUTH_2FA_PENDING_TTL(default 5 minutes). Recovery paths: an admin can force-disable 2FA for a user from the UI, or anyone with host access can runpython main.py --disable-2fa <username>from the CLI (e.g. when the only admin is locked out). - Password reset (
--reset-password) revokes every active token for that user.
Roles¶
| Role | Can browse | Can edit metadata | Admin area |
|---|---|---|---|
read |
✅ | ❌ | ❌ |
write |
✅ | ✅ | ❌ |
admin |
✅ | ✅ | ✅ |
A write user inherits everything a read user can do. An admin inherits everything a write user can do.
What can be edited by write+ users¶
- Tag, untag, archive, unarchive, hide, unhide, favorite, unfavorite any photo.
- Rename a person / merge two people.
- Assign a face to a person / create a new named person.
- Unassign a face.
- Run face search by example.
What only admin can do¶
- Create / edit / delete users.
- Reset another user's password.
- Sign another user out of every device.
- Read the admin overview, run health checks.
- Create / delete / prune backups, change the auto-backup schedule.
Bearer token validation¶
Every API request (except the auth-free ones listed in the API reference) requires Authorization: Bearer <token>. The server:
- Looks up the token in
auth_tokens. - Checks it's an
accesstoken (refresh tokens only work at/api/auth/refresh). - Checks it's not expired.
- Checks the user isn't disabled.
- Returns a 401 otherwise — with a
WWW-Authenticate: Bearerheader.
There is no automatic token rotation; the access token stays valid until expiry or explicit revocation (POST /api/auth/logout or POST /api/admin/users/{id}/logout).
CORS / CSRF¶
There is no CORS layer in the app — the web UI is served from the same origin as the API. If you put a reverse proxy in front, configure it to not add CORS headers; the same-origin model is intentional.
Security headers¶
The app ships a SecurityHeadersMiddleware (app/src/security_headers.py) that adds
defense-in-depth headers to every response (HTML, JSON and binary alike):
Content-Security-Policy—default-src 'self', withimg-srcallowingdata:/blob:and OSM tiles,media-srcallowingblob:, andscript-src/style-srcallowing'unsafe-inline'plus the jsDelivr CDN (Leaflet).frame-ancestors 'none'blocks embedding. Tighteningscript-srcto a nonce or external file is a follow-up.X-Content-Type-Options: nosniff— no MIME sniffing.X-Frame-Options: DENY— clickjacking protection (belt-and-braces withframe-ancestors).Referrer-Policy: same-origin— no referrer leakage to third parties.
If you run a reverse proxy in front, its own security headers (see
SECURITY_HARDENING.md §9) are additive; the app-level headers are harmless duplicates.
Threat model — what you should worry about¶
| Threat | Mitigation |
|---|---|
| Someone reads your disk | The DB has bcrypt password hashes + opaque bearer tokens. Photos aren't on disk; only thumbs. Revoke all tokens + change passwords if you suspect compromise. |
| Someone sniffs your network | All traffic to Proton is TLS. The compose-internal traffic is plaintext HTTP but on a private network. Put a TLS-terminating reverse proxy in front of :8080 for remote access. |
Someone accesses localhost:8080 while you're away |
Bearer tokens are required for every API endpoint — including the binary media endpoints (/thumb, /full, /cover, /crop), which additionally accept short-lived signed URLs from /api/sign. These are world-readable only when you explicitly set DEMO_ALLOW_PUBLIC_THUMBS=1. The ? overlay shows who's signed in. Sign out from another device via the admin Users tab. |
| Bug in the bridge | The bridge is read-only by construction; even a bug can't write back to Proton. Pin the SDK tag in your fork if you're paranoid. |
| Malicious auth-session.json | Only the bridge reads it. It authenticates to Proton and streams the encrypted timeline — it can't be used to delete or upload. Revoke the session on Proton's website to invalidate. |
| Compromised or broken image publish | compose.yml defaults to :latest, which is mutable. Pin images to a versioned tag or a content digest (image@sha256:...) for an immutable deploy — see Pinning images to a version or digest. |
What we don't do¶
- We do not upload anything to Proton (read-only bridge).
- We do not upload anything to any third party (no telemetry).
- We do not store full-resolution photos.
- We do not log photo bytes to disk (the
processed_atfield is the only timestamp). - We do not require GPU access.
- We do not require any cloud account other than your Proton Drive session.
Reporting a vulnerability¶
Open an issue on GitHub, or email the maintainer (see the GitHub profile). Please don't disclose security issues publicly until a fix is out.
Next: FAQ answers the common questions.