| Tested on | SFTP server: RHEL 10.2 at 192.168.56.116Client: RHEL 10.2 at 192.168.56.220 |
|---|---|
| Package | openssh-server 9.9p1openssh-clients 9.9p1 |
| Applies to | RHEL, Rocky Linux, AlmaLinux, Fedora, Ubuntu, and Debian with OpenSSH internal-sftp |
| Privilege | sudo or root on the SFTP server to edit sshd config, create users, and set directory permissions |
| Scope | Chroot SFTP-only accounts with ChrootDirectory and ForceCommand internal-sftp, per-user upload directories, and public-key authentication. Does not cover ProFTPD or vsftpd, full SSH hardening, or SFTP umask details. |
| Related guides | Set SFTP umask OpenSSH authentication in sshd_config ssh command examples Add user to group chown command |
OpenSSH can jail an SFTP account inside one directory tree so the user never sees /etc, /home, or other paths on the server. You combine ChrootDirectory with ForceCommand internal-sftp in a Match block, keep the chroot path root-owned, and give the user a writable subdirectory for uploads.
This walkthrough was tested on two RHEL 10.2 hosts. Substitute your server hostname or IP wherever the examples use 192.168.56.116.
How SFTP chroot works
When a user matches a Match User or Match Group block, sshd authenticates the session first, then applies chroot() to ChrootDirectory before starting SFTP. The client sees / as that jail directory.
ForceCommand internal-sftp overrides the client command and runs OpenSSH's in-process SFTP server instead of an interactive shell. That is why ssh user@host fails while sftp user@host succeeds. You do not need to change the global Subsystem sftp line when the Match block already sets ForceCommand internal-sftp.
For chrooted accounts, internal-sftp is simpler than the standalone sftp-server binary because it does not require supporting files inside the jail.
Directory layout for an SFTP chroot
The ownership rule is the part most setups get wrong:
/opt/sftp-jails/ root:root 755
└── sftpdrop/ root:root 755 ← ChrootDirectory (not writable by sftpdrop)
└── upload/ sftpdrop:root 750 ← writable uploadsThe jail root must stay root:root and not group-writable. The SFTP user writes only in upload/ (or another subdirectory you create inside the jail).
-d / on useradd gives the account a home directory that exists inside the chroot. The -d /upload argument to internal-sftp then explicitly starts SFTP sessions in /upload. OpenSSH normally changes to the user's home after chroot; specifying an SFTP start directory overrides that default starting location for this setup.
Create the SFTP user
Create a group when you plan to jail several accounts with one Match Group block:
sudo groupadd -f sftpusersAdd the SFTP account with passwd home / so it does not point at a missing /home/username path inside the jail:
sudo useradd -M -d / -G sftpusers -s /sbin/nologin sftpdropThis example also assigns /sbin/nologin as an additional restriction. ForceCommand internal-sftp is what actually enforces SFTP-only access for this SSH configuration.
Set a password when you need password-based testing (skip for key-only accounts):
sudo passwd sftpdropConfirm the account:
id sftpdropuid=1014(sftpdrop) gid=1015(sftpdrop) groups=1015(sftpdrop),1014(sftpusers)Create the chroot and upload directory
Create the jail root and a writable upload directory:
sudo mkdir -p /opt/sftp-jails/sftpdrop/uploadEvery pathname component on the way to ChrootDirectory — including /, /opt, /opt/sftp-jails, and /opt/sftp-jails/sftpdrop — must be owned by root and must not be writable by group or other users:
sudo chown root:root /opt/sftp-jails /opt/sftp-jails/sftpdrop
sudo chmod 755 /opt/sftp-jails /opt/sftp-jails/sftpdropMode 755 is a convenient choice that satisfies that rule; the requirement is root ownership with no group or other write bit on any component.
Give the SFTP user write access only below the jail root:
sudo chown sftpdrop:root /opt/sftp-jails/sftpdrop/upload
sudo chmod 750 /opt/sftp-jails/sftpdrop/uploadBefore you edit sshd, walk the full path with namei and confirm every directory line shows root root and is not group-writable:
namei -l /opt/sftp-jails/sftpdropf: /opt/sftp-jails/sftpdrop
dr-xr-xr-x root root /
drwxr-xr-x root root opt
drwxr-xr-x root root sftp-jails
drwxr-xr-x root root sftpdropIf any component is group-writable or owned by the SFTP user, sshd logs fatal: bad ownership or modes for chroot directory and closes the session.
Configure sshd
RHEL 10 and most current distributions load drop-in files from /etc/ssh/sshd_config.d/. Confirm that /etc/ssh/sshd_config includes Include /etc/ssh/sshd_config.d/*.conf; if your system does not use drop-ins, place the Match block at the end of /etc/ssh/sshd_config instead.
Create a dedicated file — do not add another Subsystem sftp line if one is already defined in the main configuration. For this jailed account, ForceCommand internal-sftp is the important setting. Defining Subsystem sftp twice makes sshd -t fail with Subsystem 'sftp' already defined.
A Match block applies until the next Match line or end of file. End the drop-in with Match all so any later directives in the same file return to global context:
sudo tee /etc/ssh/sshd_config.d/sftp-chroot.conf <<'EOF'
Match User sftpdrop
ChrootDirectory /opt/sftp-jails/sftpdrop
ForceCommand internal-sftp -d /upload
AllowTcpForwarding no
X11Forwarding no
Match all
EOFWhat each line does:
Match User sftpdrop— applies the block only to that account.ChrootDirectory— path to the jail root on the host (/opt/sftp-jails/sftpdropbecomes/inside SFTP).ForceCommand internal-sftp -d /upload— SFTP only, starting in the writable directory.AllowTcpForwarding noandX11Forwarding no— disable those SSH features for this file-transfer-only account.Match all— closes the conditional block so later lines in this file are not scoped tosftpdrop.
ChrootDirectory restricts filesystem visibility. AllowTcpForwarding no and X11Forwarding no separately prevent TCP and X11 forwarding — chroot and forwarding are different controls. This article stops at chroot plus SFTP-only access rather than full SSH hardening.
Validate and reload sshd
Check syntax before you reload:
sudo sshd -tA silent exit means the file parsed correctly. sshd -t prints a line number and error text when a keyword is wrong.
Apply the configuration:
sudo systemctl reload sshdreload keeps existing SSH sessions up; use restart only when your distribution documents that requirement.
Test the restricted SFTP account
From a client host, interactive SSH should be blocked:
ssh sftpdrop@192.168.56.116This service allows sftp connections only.That message confirms ForceCommand internal-sftp is active. Open an SFTP session (replace the address with your server):
sftp sftpdrop@192.168.56.116With -d /upload, the session starts in the writable directory:
sftp> pwd
Remote working directory: /upload
sftp> mkdir labdir
sftp> put /etc/hostname lab-hostname.txt
sftp> ls -la
drwxr-x--- ? 1014 root 44 Aug 21 13:00 .
drwxr-xr-x ? root root 20 Aug 21 13:00 ..
-rw-r--r-- ? 1014 1015 16 Aug 21 13:00 lab-hostname.txt
drwxr-xr-x ? 1014 1015 6 Aug 21 13:00 labdir
sftp> cd ..
sftp> pwd
Remote working directory: /
sftp> mkdir testroot
remote mkdir "/testroot": Permission denied
sftp> quitUploads succeed under /upload. At the jail root /, mkdir fails because that directory is not writable.
Configure public-key authentication
Authentication happens before ChrootDirectory is applied. AuthorizedKeysFile may point to any host-side path sshd can read; the key file does not have to be visible inside the chroot after login.
Create a host-side key directory (outside the jail is fine):
sudo mkdir -p /etc/ssh/sftp-keys/sftpdrop
sudo chmod 755 /etc/ssh/sftp-keys /etc/ssh/sftp-keys/sftpdropOn the client, generate a key if you do not already have one:
ssh-keygen -t ed25519 -N '' -f ~/.ssh/sftpdrop_server -C 'sftpdrop@server'Install the public key on the server:
sudo tee /etc/ssh/sftp-keys/sftpdrop/authorized_keys < ~/.ssh/sftpdrop_server.pub
sudo chmod 644 /etc/ssh/sftp-keys/sftpdrop/authorized_keysAdd AuthorizedKeysFile to the same Match block:
Match User sftpdrop
ChrootDirectory /opt/sftp-jails/sftpdrop
AuthorizedKeysFile /etc/ssh/sftp-keys/%u/authorized_keys
ForceCommand internal-sftp -d /upload
AllowTcpForwarding no
X11Forwarding no
Match allWith %u, the same line resolves to /etc/ssh/sftp-keys/sftpdrop/authorized_keys for this user and scales to /etc/ssh/sftp-keys/alice/authorized_keys, /etc/ssh/sftp-keys/bob/authorized_keys, and so on when you add the Match Group template below.
Reload sshd after sshd -t succeeds. Connect with the dedicated key:
sftp -i ~/.ssh/sftpdrop_server -o IdentitiesOnly=yes sftpdrop@192.168.56.116When the client user is not the one who owns the key, pass -i /path/to/private_key or set IdentityFile in ~/.ssh/config for that host.
Configure multiple SFTP users
Use one Match Group block when every jailed account follows the same layout — do not combine this with separate Match User blocks for the same users:
Match Group sftpusers
ChrootDirectory /opt/sftp-jails/%u
AuthorizedKeysFile /etc/ssh/sftp-keys/%u/authorized_keys
ForceCommand internal-sftp -d /upload
AllowTcpForwarding no
X11Forwarding no
Match all%u expands to the login username, so alice is jailed under /opt/sftp-jails/alice, bob under /opt/sftp-jails/bob, and so on. Each tree needs the same root-owned parents and an upload/ subdirectory owned by that user.
Use individual Match User blocks only when different accounts need different ChrootDirectory paths or startup directories — not together with a group template that already covers them.
Default upload modes follow the server umask, not the user's .bashrc. To set SFTP upload permissions per user or group, see Set SFTP umask — add -u 0027 to ForceCommand internal-sftp inside the Match block when you need a specific mask.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Subsystem 'sftp' already defined on sshd -t |
Second Subsystem sftp line in config |
Remove the duplicate; ForceCommand internal-sftp in the Match block is enough for this user |
fatal: bad ownership or modes for chroot directory |
A pathname component not root-owned or group/world writable | Run namei -l on the ChrootDirectory path; fix every directory line through / |
packet_write_wait: Connection … Broken pipe after password |
Same permission issue on ChrootDirectory |
Check journalctl -u sshd on the server for the chroot fatal line |
This service allows sftp connections only on ssh |
Expected with ForceCommand internal-sftp |
Use sftp, not ssh, for file transfer |
Permission denied creating files at / in SFTP |
Write attempted outside the upload subtree | Use ForceCommand internal-sftp -d /upload or cd upload before put or mkdir |
| Passwordless SFTP fails, password works | Client not offering the right private key | Use sftp -i /path/to/key or IdentityFile in ~/.ssh/config |
| Public key rejected | Default AuthorizedKeysFile points at missing home .ssh |
Set AuthorizedKeysFile to a host-side path in the Match block |
| Config change has no effect | Syntax error or reload skipped | Run sudo sshd -t; then sudo systemctl reload sshd |
References
- sshd_config(5) — OpenBSD manual —
ChrootDirectory,Match,ForceCommand,AuthorizedKeysFile - Red Hat OpenSSH server configuration
- Ubuntu OpenSSH SFTP chroot guide — distribution-specific layout notes
Summary
Restricting SFTP to one directory is a three-part setup on the server: a dedicated account, a chroot tree where every path component is root-owned and not writable by group or others, and a Match block with ChrootDirectory and ForceCommand internal-sftp -d /upload. Writable work happens in upload/, not at the jail root.
Do not add another Subsystem sftp line if one is already defined — ForceCommand internal-sftp in the Match block is enough for jailed SFTP-only users. Public-key authentication runs before the chroot, so AuthorizedKeysFile /etc/ssh/sftp-keys/%u/authorized_keys can point to a host-side path for each account.
The permission rule is the usual source of failed logins: if any pathname component on the way to ChrootDirectory — not only the jail directory itself — is owned by the SFTP user or writable by group or other, authentication succeeds briefly and then sshd aborts with bad ownership or modes for chroot directory. After any change, run sshd -t and reload sshd, then confirm from a client that SFTP starts in /upload, the jail root is not writable, and ssh is rejected. For upload permission masks, configure umask in the same Match block using the dedicated SFTP umask guide.

