Admin console
The admin listener serves a web console next to the REST API and MCP. You sign in with a passkey; there are no passwords. Everything the console does goes through the same service layer as the API and MCP, so the audit log and error objects are the same.
| Section | What it shows or does |
|---|---|
| Storage | Disk usage of [server].root, tiered-storage state, per-user usage against quota |
| Users | Create, edit, and delete API-managed users; set passwords |
| Sessions | Live FTP, FTPS, and SFTP sessions; end one |
| Logs | The server log and the audit log, with filters; audit chain check |
| Admins | Invite and remove admins |
| Account | Your passkeys, personal access tokens, and connected apps |
| Connect an LLM | The MCP URL and how to sign a client in (Agents and MCP) |
Turn it on
The console needs an origin, which is a URL whose host the browser trusts for passkeys.
On the server itself. A loopback bind needs no extra settings. The console is at http://localhost:<port>:
[admin]
bind = "127.0.0.1:8080"
Reach it from your workstation with a port forward. Any local port works, so pick a free one:
ssh -L 8080:127.0.0.1:8080 files.example.com
aws ssm start-session --target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["8080"],"localPortNumber":["8080"]}'
Open http://localhost:8080. A request by IP address (127.0.0.1) is redirected to localhost, because passkeys cannot use IP addresses.
On a public host.
[admin]
bind = "0.0.0.0:443"
tls = true # uses the [ftps] certificate
public_url = "https://files.example.com" # a DNS name, never an IP address
| Setting | Why |
|---|---|
tls = true | Serves HTTPS with the [ftps] certificate. The certificate must name the host in public_url |
public_url | The console origin. Its host is the passkey relying-party id and the OAuth issuer |
| DNS name | Passkeys cannot be bound to an IP address |
With a non-loopback bind and no public_url, the console is off and only the bearer API is served. siftpd --check-config says so.
Changing the host in public_url invalidates every passkey. Recover each admin with admin-invite --recover (below).
[admin].ui = false turns the console off and keeps the API.
First admin
An invite link creates the first admin. Run this on the server:
sudo -u siftp siftpd admin-invite alice
{"admin":"alice","recover":false,"expires_unix":1761700000,"url":"https://files.example.com/admin/#/invite/..."}
Open the URL, then register a passkey. The link is valid for 24 hours and works once. It is stored as a hash, so a lost link cannot be shown again: run the command again, and the new link replaces the old one. (In the console, a second invite for a pending name is refused until the first is revoked.)
Run it as the siftp user, not as root. The command writes the accounts file, and a root-owned file is one the daemon cannot read. The command checks the owner of the accounts file's directory and refuses to run as anyone else.
Recovery
An admin who lost every passkey, or whose host changed, gets a new link:
sudo -u siftp siftpd admin-invite alice --recover
The link adds a passkey to the existing admin alice. Without --recover, an invite for a name that already exists is refused, and --recover for an unknown name returns not_found.
More admins
Any signed-in admin can invite another from Admins. The link is shown once. Removing an admin ends their sessions, tokens, connected apps, and pending invites. The last admin cannot be removed.
Sessions
| Limit | Value |
|---|---|
| Idle | 30 minutes |
| Absolute | 12 hours (2 hours on http://localhost) |
| Restart | Signs everyone out; sessions are in memory |
Sessions are cookies bound to the console origin: HttpOnly, SameSite=Strict, and __Host- prefixed on HTTPS. Changes need the console's exact Origin header.
The fresh passkey check
These actions need a passkey assertion from the last 5 minutes. A stale session gets 403 with X-Siftp-Step-Up: required, and the console asks for your passkey again.
| Action | Why |
|---|---|
| Invite or remove an admin | Hands out or takes away access |
| Add or remove a passkey | A stolen session could add its own passkey and outlast the session |
| Create a personal access token | A token outlives the session |
| Approve an OAuth client | Gives an outside service access |
An admin's last passkey cannot be removed. Removing a passkey ends the sessions it signed in.
Personal access tokens
Create one in Account. It is shown once and looks like siftp_pat_<id>_<secret>. Only its SHA-256 is stored.
| Field | Values |
|---|---|
| Scope | siftp:read (GET routes and read-only MCP tools, the default) or siftp:admin (also changes) |
| Lifetime | 1 to 365 days (default 90) |
Use it as a bearer token on /api/v1/*, /metrics, and /mcp:
curl -H "Authorization: Bearer $SIFTP_PAT" https://files.example.com/api/v1/storage
Tokens are the way for scripts and Prometheus to reach a public console. [admin].token still works, but only from loopback peers while the console is public. A wrong or expired token returns 401 and does not count toward login bans: tokens are 256-bit secrets, and an LLM provider's shared address must not get banned.
What tokens and LLM clients cannot do
Only a passkey session manages credentials. A personal access token, an OAuth token, and [admin].token get permission_denied for:
- admins, invites, and passkeys
- personal access tokens and connected apps
- a
virtual_rootoutside[server].root, or ans3://root (tokens and LLM clients only)
No credential can set a user's root to a directory that holds siftp's own files (config, accounts file, user store, revoked-key list, audit log, TLS and host keys), or one containing ... No MCP tool manages admins or credentials.
The accounts file
[admin].accounts_file (default /var/lib/siftp/admins.toml, mode 0600) holds admins, passkey public keys, invites, tokens, and OAuth grants. Codes and tokens are stored only as SHA-256 hashes.
- The daemon and
admin-invitelockadmins.toml.lock, re-read, and replace the file atomically, so neither loses the other's changes. - A file that does not parse turns off console sign-in, personal access tokens, and OAuth grants. It is never read as "no admins". Fix or restore the file, then reload (
systemctl reload siftpd). - Back it up with the user store (
users.toml). Restoring an old copy brings back revoked tokens and removed admins.
Audit actor
Admin changes (users, passwords, sessions, reload, and console accounts) carry an actor field. remote is the caller's address.
{"ts":1761600000,"user":"erin","action":"USER_CREATE","path":"","result":"ok","remote":"203.0.113.9:50122","actor":"ui:alice","seq":97,"chain":"4c1a…"}
actor | Who acted |
|---|---|
ui:<admin> | A passkey session in the console |
pat:<admin>/<token name> | A personal access token |
oauth:<admin>/<client host> | An OAuth client, such as oauth:alice/claude.ai |
token | [admin].token |
stdio | siftpd mcp-stdio |
cli | siftpd admin-invite; for a new admin, the creator (ui:<admin> or cli) |
Console sign-ins are ADMIN_LOGIN; a wrong [admin].token is ADMIN_AUTH. SPEC §8.5 lists every action. admin-invite itself writes no audit line (the chain has one writer). The invite appears as ADMIN_CREATE or PASSKEY_ADD with actor cli when it is used.
See also
- Agents and MCP: connect Claude, ChatGPT, Cursor, Codex
- Security: TLS, CSP, ALPN
- Operations: backups