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-sftpThe 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 as | Upload to / | Upload to /a | Upload to /b | List /a and /b |
|---|---|---|---|---|
sftpa | OK | OK | OK | OK |
sftpb | OK | OK | OK | OK |
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 as | Upload to / | Upload to /a | Upload to /b | List the other folder |
|---|---|---|---|---|
sftpa | Permission denied | OK | Permission denied | Refused |
sftpb | Permission denied | Permission denied | OK | Refused |
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-sftpsftpa 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.
- In
%ProgramData%\ssh\sshd_config:AllowGroups administrators contoso\sftpusers Match Group contoso\sftpusers AllowTcpForwarding no ChrootDirectory D:\SFTP_Root\%u ForceCommand internal-sftp - Create the jail root and one folder per user, named exactly as
%uexpands for that account. We tested local accounts only, where it is the plain account name, and we did not test what happens when the%ufolder 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%uactually expanded to, relative to the jail root, and<account-name>is the account part only, such asalice; 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>" - Set the ACLs, root first, then each user folder:
With per-user jails the group needs no entry on the root at all: a user never sees it.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" 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
/acmeinside 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.
References
- SSH chroot jail with AD accounts on Win 2025 - r/OpenSSH, the thread this article answers.
- OpenSSH Server configuration for Windows - Microsoft Learn.
ChrootDirectoryis SFTP-only and supported from 7.7.0.0; domain principals resolve todomain\name; account names inAllowGroupsandMatchmust be lower case. - sshd_config(5), ChrootDirectory - OpenBSD manual. The token list (
%%,%h,%u) and the Unix requirement that every path component be root-owned and not writable by other users. - icacls - Microsoft Learn.
/inheritance:r,/grant:r, and the(OI)(CI)inheritance flags. - “Match Group” together with “ChrootDirectory” breaks SFTP - Win32-OpenSSH issue 2212 (2024). Not reproduced on Server 2025’s 9.5p2.
- CHRoot doesn’t work with network shares - Win32-OpenSSH issue 1258. Reports a direct UNC-path chroot failure and key-authentication constraints; UNC paths were not tested in this lab.