Users and access
Where users come from
| Source | Managed by | Durable |
|---|---|---|
[[users]] in the config file | You, in the file | Yes; read-only through the API |
User store ([server].user_store) | Admin API and MCP | Yes; atomic writes, survives restart and crash |
| LDAP | Your directory | Session only; nothing is stored |
Lookup order at login: config file, then store, then LDAP.
Permissions
| Bit | Allows |
|---|---|
r | Download, SFTP read, WebDAV GET |
w | Upload, SFTP write, SETSTAT, MFMT, symlinks |
d | Delete files and directories |
n | Rename (needed on both ends) |
m | Create directories |
l | List, stat, WebDAV PROPFIND |
A missing bit is permission_denied before any file is touched.
Groups and directory rules
[[groups]]
name = "staff"
permissions = "rwdnml"
[[groups.directories]]
path = "/archive"
permissions = "rl"
[[users]]
name = "carol"
password_hash = "$argon2id$..."
groups = ["staff"]
[[users.directories]]
path = "/incoming"
permissions = "wl"
- Base bits: the user's
permissions; if absent, the union of their groups'permissions; if no groups,rl. - A rule covers its path and everything below it. The longest matching rule wins. At the same path a user rule beats group rules; group rules at the same path are combined.
- Rules replace the base for their subtree, so they can grant (
/incoming = "wl"for a read-only user) or restrict (/archive = "rl"). - Symlinks do not launder rights: an operation through a link is checked against both the link's path and the path it resolves to.
SSH keys
ssh_public_keys = [
'expiry-time="20271231" ssh-ed25519 AAAAC3... laptop',
"no-pty ecdsa-sha2-nistp256 AAAAE2... ci",
]
expiry-time(UTC,YYYYMMDD[HHMM[SS]]) stops the key at that time.restrict,no-pty,no-port-forwarding,no-agent-forwarding,no-x11-forwarding,no-user-rc,no-touch-requiredare accepted as no-ops (siftp never grants those). Any other option is a config error.- Revoke keys server-wide in
[server].revoked_keys_file: OpenSSH key lines orSHA256:fingerprints,#comments. It is re-read on reload.
Passwords
- argon2id only:
siftpd hash-password, or plaintext through the API (hashed server-side, never stored or echoed). - Rotation:
curl -X POST -H "Authorization: Bearer $SIFTP_ADMIN_TOKEN" \
http://127.0.0.1:8080/api/v1/users/bob/password
# {"name":"bob","generated":true,"password":"Xk4...","password_set_unix":1791849600}
Without a body a 24-character password is generated and shown once. With {"password":"..."} the given one is set.
- Expiry:
[server].password_max_age_daysrejects passwords older than that, counted frompassword_set_unix(set by the API; add it by hand for config users). Clients see the normalauth_failed.
Login protection
| Setting | Default | Effect |
|---|---|---|
max_failed_logins / ban_window_secs | 5 / 600 | Failures from one IP in the window ban it |
ban_duration_secs | 900 | Ban length; covers every listener and the admin API |
max_connections / max_connections_per_ip | 512 / 32 | Shared by FTP, FTPS, SFTP, WebDAV |
max_sessions (per user) | unlimited | Concurrent logins per user |
Wrong admin or MCP tokens count as failed logins.
LDAP
[ldap]
url = "ldaps://ldap.example.com"
bind_dn = "cn=siftp,ou=services,dc=example,dc=com"
bind_password = "${ENV:SIFTP_LDAP_PASSWORD}"
base_dn = "ou=people,dc=example,dc=com"
user_filter = "(uid={user})"
group_attribute = "memberOf"
require_group = true
[ldap.group_map]
"cn=ftp-staff,ou=groups,dc=example,dc=com" = "staff"
- Search-then-bind as above, or direct bind with
user_dn_template = "uid={user},ou=people,dc=example,dc=com". - Use
ldaps://, orldap://withstarttls = true;ca_fileadds a private CA. - Mapped groups give the user that group's permissions and directory rules; unmapped users get
default_permissions, or are refused withrequire_group = true. - Roots:
virtual_root_template({user}substituted), else<server.root>/<user>; same jail as everyone else. - Empty passwords are refused before contacting the directory. If the directory is unreachable, logins fail as
auth_failedand the audit log recordsinternal.