SFTPWindows ServerOpenSSHSecurity

Per-user SFTP chroot on Windows Server: why users can write to the jail root

You set ChrootDirectory on Windows OpenSSH, locked the NTFS ACLs, and the SFTP user can still drop files in the jail root and wander into other users' folders. Reproduced on Windows Server 2025: what the inherited ACL actually grants, the icacls fix, which ChrootDirectory tokens work on Windows, and the per-user Match block you end up with.
DocEvent Engineering·Oct 1, 2026·9 minute read

Short answer: the chroot is working. What lets the user write into the jail root is NTFS, specifically the BUILTIN\Users “create files” and “create folders” entries every folder inherits from the root of the volume, because on Windows the SFTP process runs as the user. Break inheritance on the jail and grant per folder. And you do not need one Match block per user: ChrootDirectory C:\SFTP_Root\%u works on Windows Server 2025.

The symptom

A post on r/OpenSSH describes a setup most Windows admins will recognise. Windows Server 2025, the built-in OpenSSH server, a jail for members of an Active Directory group, one subfolder per purpose, and NTFS permissions so that each user can write only to their own subfolder. The configuration, with the domain changed:

AllowGroups administrators contoso\sftpusers

Match Group contoso\sftpusers
  AllowTcpForwarding no
  ChrootDirectory D:\SFTP_Root
  ForceCommand internal-sftp

The jail part works: the user logs in and sees D:\SFTP_Root as /. Everything else does not. The user can upload into the root of the jail, which should be read-only, and can change into subfolders their group is not listed on and upload there too. The poster set the ACLs on the root and the subfolders and they were, as far as they could tell, “not being honored”. They also wanted what every Linux admin reaches for first, ChrootDirectory %h, and had been told it does not work on Windows.

Rather than answer from memory, we built it and watched what happened.

What we reproduced

A fresh Windows Server 2025 Datacenter instance (build 26100.33451, 24H2) with the in-box OpenSSH.Server capability, which on this build is OpenSSH for Windows 9.5p2. Two local users, sftpa and sftpb, in a local group sftpusers. A jail at C:\SFTP_Root with subfolders a and b, created with New-Item and left with the permissions Windows gave them. The poster’s Match Group block, verbatim apart from the path. Local users rather than domain users, which matters for one detail we will come to, and not for the result.

Then an SFTP session as each user, trying to upload a file to three places:

Logged in asUpload to /Upload to /aUpload to /bList /a and /b
sftpaOKOKOKOK
sftpbOKOKOKOK

Exactly the poster’s symptom. The chroot itself held: a request for /.. was refused, and sshd’s log recorded Changed root directory to "C:\SFTP_Root" for every session. The jail was fine. The jail was just writable everywhere.

Why the jail is writable

Two facts explain it, and the second is the one that gets missed.

First, on the build we tested the SFTP server runs as the logged-in user. While the sessions were open, Get-Process sftp-server -IncludeUserName showed one sftp-server.exe per session, owned by sftpa and sftpb respectively, not by SYSTEM. So every file operation is an ordinary NTFS access check against that user’s token. ChrootDirectory decides where / is. NTFS decides what the user can do there.

Second, the permissions the folder inherited are more generous than they look. Here is icacls C:\SFTP_Root before anything was changed:

C:\SFTP_Root BUILTIN\Administrators:(F)
             NT AUTHORITY\SYSTEM:(I)(OI)(CI)(F)
             BUILTIN\Administrators:(I)(OI)(CI)(F)
             BUILTIN\Users:(I)(OI)(CI)(RX)
             BUILTIN\Users:(I)(CI)(AD)
             BUILTIN\Users:(I)(CI)(WD)
             CREATOR OWNER:(I)(OI)(CI)(IO)(F)

The (I) marks an inherited entry, and these come from the root of the volume. The two that matter are (CI)(AD) and (CI)(WD) on BUILTIN\Users: “create folders / append data” and “create files / write data”, applied to this folder and every subfolder. Read them together with CREATOR OWNER full control and you have the default Windows behaviour that any member of Users can create a file in a folder like this and then owns it. Every subfolder under C:\SFTP_Root inherited the same set, which is why the poster’s per-folder ACLs seemed to be ignored. They were not ignored. They were being out-voted by an entry the poster was not looking for, because it names Users, not sftpusers.

By default Windows adds Domain Users to the local Users group when a computer joins a domain, so on a member server this is normally the same story with Active Directory accounts; check the server’s local group membership and any Group Policy that changes it. “My group is not even on the ACL” is true and beside the point.

There is a third fact worth knowing, because it is where Windows differs from the Linux you learned chroot on. The OpenBSD sshd_config manual requires every component of the chroot path to be root-owned and not writable by any other user, and Unix sshd refuses to start the session otherwise (“bad ownership or modes for chroot directory”). Windows OpenSSH applied no such check in our testing. Later in the lab we pointed ChrootDirectory at the user’s own profile folder and it chrooted there without a complaint. There is no rail. The ACLs have to be right because nothing will tell you when they are wrong.

The fix: break inheritance, grant per folder

Remove the inherited entries from the jail root and from each subfolder, then grant exactly what you mean. Three commands, run elevated, for the two-user lab:

icacls C:\SFTP_Root   /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "sftpusers:(RX)"
icacls C:\SFTP_Root\a /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "sftpa:(OI)(CI)M"
icacls C:\SFTP_Root\b /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "sftpb:(OI)(CI)M"

/inheritance:r strips the inherited entries. /grant:r replaces rather than adds. The group gets read-and-execute on the root only, with no (OI)(CI), so it does not flow down. Each user gets Modify on their own folder and nothing on anyone else’s. For a domain group and users, write them as contoso\sftpusers and contoso\alice. No change to sshd_config and no restart. Same two sessions again:

Logged in asUpload to /Upload to /aUpload to /bList the other folder
sftpaPermission deniedOKPermission deniedRefused
sftpbPermission deniedPermission deniedOKRefused

Two things to expect. Users still see the other subfolders’ names in the root listing, because listing the root is the read-and-execute you granted the group; they cannot enter them. And the refusal on listing someone else’s folder reaches the client as “Bad message” rather than “Permission denied”, which is a Windows OpenSSH wording quirk, not a sign the deny is partial. If you would rather users not see each other’s folder names at all, give each user their own jail, which brings us to the part the poster was told was impossible.

Per-user jails: %u works on Windows

The folklore is that Windows OpenSSH does not expand tokens in ChrootDirectory, so a per-user jail means one Match User block per user, maintained by hand forever. On this build that is not true. With a folder per user under the jail root and this single block:

Match Group sftpusers
  AllowTcpForwarding no
  ChrootDirectory C:\SFTP_Root\%u
  ForceCommand internal-sftp

sftpa logged in and saw only the contents of C:\SFTP_Root\sftpa as /; sftpb saw only C:\SFTP_Root\sftpb. The log confirmed it: Changed root directory to "C:\SFTP_Root\sftpa". The same worked inside a Match User block, and with forward slashes (C:/SFTP_Root/%u). The one-block-per-user workaround also works. It is just unnecessary.

%h expands too, which is probably where the folklore comes from. On Linux, %h is the user’s home directory, which you set to the jail. On Windows it is the user’s profile folder, so ChrootDirectory %h dropped sftpa into C:\Users\sftpa: a root listing of AppData, Desktop, NTUSER.DAT and the rest, fully writable by the user, with no objection from the server. It works. It is the wrong token. Use %u and build the path yourself.

One more thing we went looking for and did not find. A 2024 report against an older Windows build, Win32-OpenSSH issue 2212, describes Match Group blanking the username so that tokens and later matches break. On Server 2025’s 9.5p2 the debug log read user sftpa matched group list sftpusers every time and the %u expansion was correct. If you are on an older in-box version and %u misbehaves inside Match Group, that issue is the first thing to check, and Match User is the fallback.

The complete recipe

Putting it together for an Active Directory group. Microsoft’s OpenSSH server configuration page says domain principals are resolved to domain\name (the short NetBIOS domain, not the DNS name) and that names in AllowGroups and Match must be lower case. Use the contoso\sftpusers form and check the server’s short domain name before anything else; a DNS-style contoso.example\sftpusers is not what the documentation describes.

  1. In %ProgramData%\ssh\sshd_config:
    AllowGroups administrators contoso\sftpusers
    
    Match Group contoso\sftpusers
      AllowTcpForwarding no
      ChrootDirectory D:\SFTP_Root\%u
      ForceCommand internal-sftp
  2. Create the jail root and one folder per user, named exactly as %u expands for that account. We tested local accounts only, where it is the plain account name, and we did not test what happens when the %u folder is missing at login. For domain accounts, validate the expanded path and the ACL layout in a non-production domain environment before using this recipe. Two placeholders below: <expanded-%u-path> is the folder name %u actually expanded to, relative to the jail root, and <account-name> is the account part only, such as alice; the command adds the short domain prefix itself. They are not necessarily the same string.
    New-Item -ItemType Directory -Force D:\SFTP_Root, "D:\SFTP_Root\<expanded-%u-path>"
  3. Set the ACLs, root first, then each user folder:
    icacls D:\SFTP_Root /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F"
    icacls "D:\SFTP_Root\<expanded-%u-path>" /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "contoso\<account-name>:(OI)(CI)M"
    With per-user jails the group needs no entry on the root at all: a user never sees it.
  4. Restart-Service sshd, then test as a real user: upload to / should succeed (that is now their own folder), and /.. should go nowhere.

Two limits to know. Keep the chroot on a local volume: a direct UNC path to a file share is reported not to work (Win32-OpenSSH issue 1258, which also notes key-authentication constraints), and we did not test UNC paths on Server 2025. And we tested local accounts on one build of Server 2025 with the in-box server; if you installed OpenSSH from the GitHub MSI or are on 2019 or 2022, re-check the %u behaviour before relying on it.

What it costs to keep

The recipe works, in the lab above, and there is no reason to expect otherwise on a patched server, as long as someone re-tests it after Windows and OpenSSH updates. What it asks of you is the part people under-count. Every new user is a folder, an icacls line and a login test, done by someone with the rights to do it. Every ACL mistake is silent, because the server will not refuse a bad jail; you find out when a user reports they can see a folder they should not, or you do not find out. Nothing in this setup records what a user did once they were in: sshd logs the login, and a per-file record, if you want one, is yours to add with internal-sftp’s logging option or NTFS object auditing, and lives on that one box. And the whole thing lives on a server that needs patching, SSH host-key management, a backup and an owner, which was true before you added SFTP to it and is now a little more true.

The managed alternative

If the goal was never “run an SFTP server” but “let each client or system upload into its own folder, and nowhere else”, the per-user jail is a setting, not a procedure. On DocEvent’s Simple FTP Service, capabilities as of October 2026:

  • The home directory is the jail. Each user gets a home directory such as /acme inside the bucket. It is a hard boundary: every path is cleaned before it is joined to the prefix, so ../ collapses back inside the user’s own folder, for listing and stat as well as reads and writes. There is no ACL to get wrong and no root folder for anyone to write into.
  • The folder is in your storage. The service fronts an S3 bucket, Azure Blob or Azure Files, Azure Data Lake Gen2, Google Cloud Storage, or any S3-compatible store. If the files need to end up on a Windows share, point the service at an Azure Files share and mount the same share on the server, and the files are there with no sync step.
  • The controls you were building by hand are per-user settings. SSH authorized keys, a per-service IP allow and deny list, read-only users, and an audit log of every login, upload, download and delete with the user, source address, path and bytes.
  • Nothing to patch. There is no server. If policy says the bytes must not transit a vendor, the same server runs self-hosted in your own account, with the console holding configuration only.
A folder per user, without the icacls
Managed SFTP and FTPS over your own S3, Azure or Google Cloud storage. Set a home directory per user and that is the jail. Keys, IP allowlists and a per-operation log included.
Tested 1 October 2026 on Windows Server 2025 Datacenter build 26100.33451 with the in-box OpenSSH 9.5p2 and local accounts. Behaviour on other builds, on the separately installed OpenSSH releases, or with domain accounts may differ; the references below are the authoritative sources for each claim.

References