Harden an OpenSSH SFTP Subsystem with sftp-server

One misplaced chroot line in sshd_config and sftp-server locks every account out of the box. This walks through configuring it with a live SSH session kept open throughout: a deliberate starting directory, read-only mode or request limits, validate, reload, and test from a second terminal with a rollback path ready. Allow 20 to 30 minutes, including a configuration check and a separate login test. The examples use the external server from Ubuntu's openssh-sftp-server package, version 1:9.6p1-3ubuntu13.19 on the machine used for this guide.

1. Check the installed server and its current subsystem

Run these as an ordinary user first. The executable lives outside the usual command search path, so do not read a failed command -v sftp-server as proof it is missing:

$ dpkg-query -W -f='${Package} ${Version}\n' openssh-sftp-server
openssh-sftp-server 1:9.6p1-3ubuntu13.19
$ dpkg -L openssh-sftp-server | grep '/sftp-server$'
/usr/lib/openssh/sftp-server
$ /usr/lib/openssh/sftp-server -h
usage: sftp-server [-ehR] [-d start_directory] [-f log_facility] [-l log_level]
        [-P denied_requests] [-p allowed_requests] [-u umask]
       sftp-server -Q protocol_feature

Now check the active SSH configuration as an administrator. On this installation, the normal declaration is equivalent to Subsystem sftp /usr/lib/openssh/sftp-server. Included files can override an earlier declaration, so search the whole configuration rather than editing the first matching line you happen to see:

$ sudo sshd -T | grep '^subsystem '
subsystem sftp /usr/lib/openssh/sftp-server

If that output instead names internal-sftp, this guide's flags do not apply: stop here, or use the matching sshd_config(5) workflow for that in-process server instead.

2. Choose the boundary before changing configuration

Decide what the account actually needs to do. The safest small change for an export account is read-only mode: it rejects writes and anything else that changes filesystem state, while still letting the client read:

Subsystem sftp /usr/lib/openssh/sftp-server -R

This is a service-wide declaration unless you put it inside a conditional Match block alongside whatever other account restrictions you need. Do not assume -R replaces filesystem permissions, authentication policy or a chroot: it is one layer of control, not a complete access model.

For a writable account, set the starting directory explicitly. The %u token expands to the authenticated username and %d to that user's home directory:

Subsystem sftp /usr/lib/openssh/sftp-server -d /srv/sftp/%u -u 027

The -u value is an explicit umask for newly created files and directories. Check the target directory exists with the ownership and permissions your account model expects before restarting or reloading SSH.

3. Add a chroot only when its layout is ready

A chroot changes the filesystem view after authentication. It is an elevated, security-sensitive change: sshd requires every component of the ChrootDirectory path to be owned by root and not writable by other users or groups. The writable directory normally sits below that root-owned path.

Match User sftp-readonly
    ChrootDirectory /srv/sftp-jail/%u
    ForceCommand internal-sftp -R
    X11Forwarding no
    AllowTcpForwarding no
    PermitTunnel no

This example deliberately uses internal-sftp inside the conditional block because it needs no support files inside the chroot, so it is a different configuration from the external sftp-server examples above. Run the external binary inside a chroot instead, and logging may need a /dev/log socket inside the jail on systems that use one.

Warning: Do not create or change a jail from a copy-pasted command without checking the account, path and ownership first. A mistaken chroot can lock out users or expose the wrong directory. Keep an existing SSH session open while you test.

4. Apply request limits only after listing client behaviour

Request allow and deny lists are easy to make too strict. An SFTP client can issue requests implicitly, and an allow list rejects every request it doesn't name. So ask the installed binary what it supports before writing a policy:

$ /usr/lib/openssh/sftp-server -Q requests
open
close
read
write
lstat
fstat
setstat
fsetstat
opendir
readdir
remove
mkdir
rmdir
realpath
stat
rename
readlink
symlink
posix-rename
statvfs
fstatvfs
hardlink
fsync
lsetstat
limits
expand-path
copy-data
home-directory
users-groups-by-id

A narrow read-only policy, for example, can deny common mutation requests while leaving the rest of the protocol available:

Subsystem sftp /usr/lib/openssh/sftp-server -R -P write,remove,mkdir,rmdir,rename,symlink,posix-rename,hardlink,fsync

Here -R is the primary read-only control and -P documents the additional refusals. When both -P and -p are present, the denied list is applied first. Treat a client failure after filtering as a policy mismatch: check the logs and widen the policy only once you've identified the request it actually needed.

5. Save, validate and reload with a live-session safety net

Before editing anything, make a root-owned backup of the exact file you are about to change. This is the first command in this section that changes state, and it needs elevated privileges:

$ sudo cp --preserve=all /etc/ssh/sshd_config /etc/ssh/sshd_config.before-sftp-server
$ sudoedit /etc/ssh/sshd_config

Validate the syntax before asking the daemon to read the change:

$ sudo sshd -t
$ printf 'sshd configuration syntax: %s\n' "$?"
sshd configuration syntax: 0

A non-zero result means the daemon has not been safely validated. Fix the reported line and check again; do not reload a configuration that fails this test.

Once the check passes, reload rather than restart, so existing sessions get a chance to stay connected:

$ sudo systemctl reload ssh
$ sudo systemctl is-active ssh
active

Keep your original session open and test a new SFTP connection from a second terminal. For a read-only account, try listing and downloading first, then confirm a deliberately harmless write gets rejected. Do not test by overwriting a real file:

$ sftp ACCOUNT@HOST
sftp> ls
sftp> get remote-file /tmp/sftp-server-check
sftp> put /etc/hosts remote-file.test
Uploading /etc/hosts to /home/ACCOUNT/remote-file.test
remote open("/home/ACCOUNT/remote-file.test"): Permission denied

Exact wording varies by client and server. What matters is that reading works and the write does not create a remote file. Remove the local test download once you no longer need it.

Recovery: if a new login fails, use the still-open administrative session to restore the backup, run sudo sshd -t, and reload again:

$ sudo cp --preserve=all /etc/ssh/sshd_config.before-sftp-server /etc/ssh/sshd_config
$ sudo sshd -t && sudo systemctl reload ssh

6. Turn on diagnostics only for the investigation

The default sftp-server log level is ERROR. Use -e -l VERBOSE temporarily when you need transaction logging on standard error during a supervised test, or use the normal syslog path and facility for everyday service operation:

Subsystem sftp /usr/lib/openssh/sftp-server -e -l VERBOSE

-e is a debugging option. Remove it after the test: it changes where diagnostics go and can make production log collection misleading. A chrooted external server may also need the system logging socket available inside the jail.

Done means