Restrict SFTP Users to a Directory with OpenSSH Chroot

Deepak Prasad
Tested on SFTP server: RHEL 10.2 at 192.168.56.116
Client: RHEL 10.2 at 192.168.56.220
Package openssh-server 9.9p1
openssh-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:

text
/opt/sftp-jails/                 root:root 755
└── sftpdrop/                    root:root 755   ← ChrootDirectory (not writable by sftpdrop)
    └── upload/                  sftpdrop:root 750   ← writable uploads

The 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:

bash
sudo groupadd -f sftpusers

Add the SFTP account with passwd home / so it does not point at a missing /home/username path inside the jail:

bash
sudo useradd -M -d / -G sftpusers -s /sbin/nologin sftpdrop

This 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):

bash
sudo passwd sftpdrop

Confirm the account:

bash
id sftpdrop
output
uid=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:

bash
sudo mkdir -p /opt/sftp-jails/sftpdrop/upload

Every 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:

bash
sudo chown root:root /opt/sftp-jails /opt/sftp-jails/sftpdrop
sudo chmod 755 /opt/sftp-jails /opt/sftp-jails/sftpdrop

Mode 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:

bash
sudo chown sftpdrop:root /opt/sftp-jails/sftpdrop/upload
sudo chmod 750 /opt/sftp-jails/sftpdrop/upload

Before you edit sshd, walk the full path with namei and confirm every directory line shows root root and is not group-writable:

bash
namei -l /opt/sftp-jails/sftpdrop
output
f: /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 sftpdrop

If 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:

bash
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
EOF

What 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/sftpdrop becomes / inside SFTP).
  • ForceCommand internal-sftp -d /upload — SFTP only, starting in the writable directory.
  • AllowTcpForwarding no and X11Forwarding 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 to sftpdrop.

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:

bash
sudo sshd -t

A silent exit means the file parsed correctly. sshd -t prints a line number and error text when a keyword is wrong.

Apply the configuration:

bash
sudo systemctl reload sshd

reload 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:

bash
ssh sftpdrop@192.168.56.116
output
This service allows sftp connections only.

That message confirms ForceCommand internal-sftp is active. Open an SFTP session (replace the address with your server):

bash
sftp sftpdrop@192.168.56.116

With -d /upload, the session starts in the writable directory:

text
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> quit

Uploads 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):

bash
sudo mkdir -p /etc/ssh/sftp-keys/sftpdrop
sudo chmod 755 /etc/ssh/sftp-keys /etc/ssh/sftp-keys/sftpdrop

On the client, generate a key if you do not already have one:

bash
ssh-keygen -t ed25519 -N '' -f ~/.ssh/sftpdrop_server -C 'sftpdrop@server'

Install the public key on the server:

bash
sudo tee /etc/ssh/sftp-keys/sftpdrop/authorized_keys < ~/.ssh/sftpdrop_server.pub
sudo chmod 644 /etc/ssh/sftp-keys/sftpdrop/authorized_keys

Add AuthorizedKeysFile to the same Match block:

text
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 all

With %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:

bash
sftp -i ~/.ssh/sftpdrop_server -o IdentitiesOnly=yes sftpdrop@192.168.56.116

When 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:

text
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


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.


Frequently Asked Questions

1. Does SFTP chroot need a separate SFTP server package?

No on most Linux distributions. SFTP is part of OpenSSH. Install openssh-server on the SFTP host. On RHEL-family systems, the client is provided by openssh-clients; on Debian and Ubuntu, it is provided by openssh-client.

2. Why use internal-sftp instead of sftp-server with ChrootDirectory?

ForceCommand internal-sftp runs SFTP inside sshd and needs no extra files inside the chroot jail. For chrooted accounts, internal-sftp is simpler than the standalone sftp-server binary because it does not require supporting files inside the jail.

3. Why does sshd report bad ownership or modes for chroot directory?

Every pathname component on the way to ChrootDirectory, including directories such as /opt, must be owned by root and must not be writable by group or other users. Writable upload areas belong in a subdirectory inside the jail, owned by the SFTP user.

4. Can a chrooted SFTP user still get an interactive SSH shell?

No when ForceCommand internal-sftp is set in the Match block. ssh login is rejected with This service allows sftp connections only, while sftp still works.

5. Where do I put authorized_keys for a chrooted SFTP user?

Authentication happens before ChrootDirectory is applied, so AuthorizedKeysFile can point to any host-side path sshd can read, such as /etc/ssh/sftp-keys/%u/authorized_keys. The key file does not have to be visible inside the chroot after login.
Omer Cakmak

Linux Administrator

Highly skilled at managing Debian, Ubuntu, CentOS, Oracle Linux, and Red Hat servers. Proficient in bash scripting, Ansible, and AWX central server management, he handles server operations on OpenStack, KVM, Proxmox, and VMware.

  • Debian
  • Ubuntu
  • Linux
  • Red Hat Enterprise Linux
  • Shell Script
  • System Administration