# Security

> Frame's security model for server operators: who is trusted, what the server protects, how the installer and Docker harden it, what Frame deliberately doesn't do, and a checklist for your server.

Source: https://crow.sg/apps/frame/docs/security/

Frame is built to be secure by being small: few moving parts, few dependencies, and nothing that parses untrusted media on the server. This page is the operator's summary; the full model is in [SECURITY.md](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/SECURITY.md).

## Who is trusted

| Party | Trusted? | What they can see or do |
|---|---|---|
| **You, the server operator** | Yes, fully | Everything stored on your server: photos, videos, captions, messages, reactions, friend lists, e-mail addresses, phone numbers and IP addresses. That's the self-hosting deal. |
| Other users on the same server | No | Only moments their friends addressed to them, chats with friends, and public profiles. |
| Network attackers | No | With HTTPS, endpoints, timing and sizes only. |
| The push relay (Crow Tech) | No | Opaque relay tokens, ciphertext sizes, timing and which server IPs push. No content, no user identities. |
| Apple and Google push services | No | Device tokens, timing, ciphertext and the generic text "Frame: New activity". |
| R2 or another bucket provider | No | Encrypted objects, their sizes and access times. |
| A tunnel or CDN (such as Cloudflare Tunnel) | Partially | It terminates TLS, so it can technically see the traffic. Built-in HTTPS avoids this. |

## What the server does

- **HTTPS** for anything on the internet, from Frame itself (Let's Encrypt), Caddy or a tunnel. The apps refuse plain `http://` except for local network addresses.
- **Passwords** are hashed with Argon2id. Sign-in is rate limited per IP and per account, and an unknown account takes as long to reject as a wrong password, so sign-in can't be used to find accounts.
- **Apple and Google sign-ins are bound to your server's name** (`FRAME_PUBLIC_URL`) and work once, so a token someone obtained for another Frame server can't be replayed against yours.
- **Sessions** are random tokens stored only as hashes, revocable per device. Changing a password signs out every other session.
- **Authorisation:** every object is checked against the requesting user. Things you can't see answer `404`, not `403`, so their existence isn't revealed.
- **No server-side media processing.** The server checks file types and sizes and stores the bytes; it never decodes images or video. Thumbnails are made on the phone, and the apps strip location and other metadata before upload.
- **Media URLs** are signed and expire after 24 hours, and responses tell shared caches not to keep copies.
- **Storage encryption:** with R2 or S3, every object is encrypted on the server before upload, with a key in `secrets.json`.
- **Push** payloads are end-to-end encrypted between your server and each phone ([Push notifications](https://crow.sg/apps/frame/docs/push/)).
- **Logs** never contain passwords, tokens or message text, and Frame's access log records route patterns, not paths or query strings. Invite codes are part of the path, so a reverse proxy's or tunnel's own access log does contain them.
- **Few dependencies:** the server uses the Go standard library plus a pure-Go SQLite and `golang.org/x/crypto`; no web framework, ORM or JWT library. Releases ship `SHA256SUMS`, and the installer refuses a file whose checksum doesn't match.

## Hardening

**Linux installer** (`frame.service`): a throwaway system user (`DynamicUser`) that can write only its data directory; `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`; `NoNewPrivileges` and no capabilities, except binding ports 80/443 when `FRAME_DOMAIN` needs them; `MemoryDenyWriteExecute`, restricted address families and namespaces, a system-call filter and `UMask=0077`. The config file is root-only and read by systemd before privileges are dropped.

**Docker:** a distroless image with no shell or package manager, running as uid 65532 on a read-only root filesystem with all capabilities dropped and `no-new-privileges`. In the Caddy setup, the Frame container publishes no ports.

## What Frame deliberately doesn't do

- **No end-to-end encryption of photos and messages against the operator.** You can read everything on your server. It keeps the system simple, fast and recoverable; your friends are trusting you.
- **Metadata is visible to the operator:** who is friends with whom, who posted when and to whom, reactions, views, read receipts and IP addresses.
- **Recipients can keep what you send:** screenshots, saved photos and widget caches can't be prevented.
- **A signed media URL works for anyone who has it** until it expires (24 hours). Deleting a moment deletes the file, which ends access immediately.
- **Deletion is best-effort beyond the server:** earlier backups still contain deleted content.
- **No automatic moderation or content scanning.** Members can block people and report content to you, the operator; nothing goes to Crow Tech or anyone else.
- **E-mail addresses and phone numbers aren't verified** (Frame sends no SMS). On an `open` server someone could register another person's number first; invite-only servers, the default, make this practically impossible.
- **A small server can be overwhelmed.** Rate limits protect against abuse, not volumetric attacks.
- **Servers don't talk to each other.** A user on one server can't see another server's content.

## Checklist for your server

- [ ] HTTPS works: `https://your-domain/healthz` shows a valid lock.
- [ ] `FRAME_PUBLIC_URL` is set to your address(es).
- [ ] `FRAME_TRUSTED_PROXIES` matches your setup: unset with built-in HTTPS, only your proxy's address otherwise.
- [ ] Registration is `invite` (the default) or `closed`, unless you really want an open server.
- [ ] You created your admin account with the setup link and turned on two-factor authentication in the panel (**Security**).
- [ ] Consider `FRAME_ADMIN=local` or `FRAME_ADMIN_LISTEN` with an SSH tunnel if you don't need the admin panel on the internet ([Keep the admin panel private](https://crow.sg/apps/frame/docs/private-admin/)). That also turns off Server admin in the apps, unless you set `FRAME_ADMIN_APP=on`.
- [ ] With `FRAME_ADMIN=local`, a reverse proxy or tunnel on the same host is listed in `FRAME_TRUSTED_PROXIES`.
- [ ] Only ports 22, 80 and 443 are open (or none, with a tunnel). You log in with SSH keys, not passwords.
- [ ] Automatic OS security updates are on (`sudo apt install unattended-upgrades`).
- [ ] Nightly backups of the database **and `secrets.json`** are copied off the server, and you've tried a restore once.
- [ ] `frame.conf` stays private (`chmod 600` with Docker) and is never committed or shared: it can contain R2 and SMTP credentials.
- [ ] You upgrade when a release is announced.

## Report a vulnerability

Please report security issues privately through **GitHub private vulnerability reporting** on [`Crow-Tech-Pte-Ltd/frame`](https://github.com/Crow-Tech-Pte-Ltd/frame/security/advisories/new) (Security tab → Report a vulnerability), or by e-mail to `hello@crow.sg` with the subject "Security". Don't open public issues for vulnerabilities. We aim to acknowledge reports within 3 working days. Test only against servers you run yourself.
