Windows Server for beginners. Part 11: What is a read-only domain controller?

All parts of the series “Windows Server for beginners”

In the previous part the Branch site appeared, without a controller. Branch office users log on over the link to the head office: every logon is a Kerberos exchange with dc1 or dc2, every Group Policy refresh is a read of SYSVOL over the WAN. When the link goes down, the branch stops working; when it's merely slow, a logon takes a minute. The usual answer is to put a controller in the branch. But a regular controller holds the password hashes of every user in the domain, administrators included, and accepts changes that will spread across the whole forest. A server in a room without a lock, which the cleaning staff open with their own key, isn't the place for that.

For this, Windows Server 2008 introduced the RODC — the read-only domain controller. It does everything for the branch that a controller does: answers LDAP, issues Kerberos tickets, hands out policies, works as a DNS server. But it holds nothing that can't be lost, and can't change anything the others see.


How an RODC differs

Five differences, and each one is a separate mechanism.

A read-only database. A client that tries to write something to an RODC over LDAP, or to update a DNS record, gets a referral to a writable controller by default. ADSI and the ActiveDirectory module follow it by themselves, while a plain LDAP application has to be allowed to (LDAP_Write_Referral); if no writable controller is reachable, the write fails. A few operations the RODC doesn't refuse but forwards to a writable controller itself, and the main one is a password change: a change via Ctrl+Alt+Del or over Kerberos arrives at the RODC and goes on, while a change over LDAP goes straight to a writable controller, and the RODC never learns about it.

One-way replication. An RODC receives changes from writable controllers and gives nothing to anyone: in the KCC topology it has only inbound connections. If an RODC is compromised and something is written into its database, it stays there. Hence the consequence for applications: whatever writes to the directory must be able to follow referrals and look for a writable controller for writing — and preferably for reading back what it wrote too, or it will read the old value from the RODC until the change arrives. Exchange, according to Microsoft's documentation, doesn't use RODCs at all.

The filtered attribute set (FAS). Some attributes are never replicated to an RODC, whatever the password policy says. By default these are BitLocker recovery keys (ms-FVE-RecoveryPassword, ms-FVE-KeyPackage), TPM owner information (ms-TPM-OwnerInformation) and three credential roaming attributes (ms-PKI-DPAPIMasterKeys, ms-PKI-AccountCredentials, ms-PKI-RoamingTimeStamp); the schema marks the Windows LAPS passwords as filtered too. The marker is bit 0x200 in the attribute's searchFlags in the schema, and you hide your own attribute from RODCs the same way: set that bit, together with bit 0x80 "confidential", and remove the read permission from Authenticated Users. System-critical attributes can't be added to the set. The set protects only if the forest has no Windows Server 2003 controllers: a compromised RODC could request such attributes from them, and they would hand them over. In a forest with 2025 controllers there are none and can't be any.

The password replication policy (PRP). Password hashes are what all of this is about. By default an RODC holds none: every account is in the "don't cache" state. The administrator allows caching the passwords of the branch users and computers, and only those end up on the RODC. Other users can log on through the RODC too, but their password will be checked by a controller in the office every time. How that happens is below.

Its own krbtgt. Every RODC has its own krbtgt_12345 account with its own key. TGTs issued by the RODC are encrypted with that key. Writable controllers don't trust the RODC's tickets: they accept a TGT from an RODC presented to them, but throw away the group list in it (the PAC) and build it again from the directory. That's why a stolen krbtgt_12345 key opens neither other sites nor someone else's privileges, and administrators' credentials never reach the RODC at all. The reverse check works too: if Bob has a TGT from a writable controller and his password has meanwhile been cached on the RODC, the RODC answers that ticket with KDC_ERR_TGT_REVOKED, and the workstation requests a new TGT from the RODC itself.

And two more consequences. FSMO roles never live on an RODC. SYSVOL on it is a read-only DFS-R member: policies can be read, and DFS-R rolls back local changes; policies are edited on a writable controller (the Group Policy Management Console connects to the PDC emulator by default).


The password replication policy

The PRP is four attributes on the RODC's computer account and two domain groups created when the forest is prepared:

What Attribute Default
Allowed to cache msDS-RevealOnDemandGroup the Allowed RODC Password Replication Group (empty)
Denied caching msDS-NeverRevealGroup the Denied RODC Password Replication Group (containing Domain Admins, Enterprise Admins, Schema Admins, Group Policy Creator Owners, Cert Publishers, krbtgt, Enterprise Domain Controllers, Enterprise Read-Only Domain Controllers) and, as separate entries, the built-in Administrators, Account Operators, Server Operators, Backup Operators
Already revealed msDS-RevealedList the list of accounts whose secrets this RODC has (built from msDS-RevealedUsers, which keeps the history of transfers)
Authenticated through it msDS-AuthenticatedToAccountList who has logged on through this RODC, regardless of caching

Deny beats allow: a user in both groups won't be cached. The default groups are shared by all RODCs in the forest, and each RODC can have its own: usually you create a "Branch Users" and a "Branch Computers" group and add them to the allowed list of the specific RODC. Computers must be on the list: a workstation also logs on to the domain with its own account, and if the user's password is cached but the workstation's isn't, the user won't log on when the link is down: the RODC can't issue a service ticket for that workstation. Logging on without the link also requires the RODC to be a global catalog, or universal group membership caching from the previous part to be enabled in the site.

What happens when the branch user Bob logs on through dc3:

 ws-branch              dc3 (RODC)                         dc1 (RW DC)
    │                       │                                    │
    ├─ AS-REQ bob ─────────►│                                    │
    │                       │ no secret for bob →                │
    │                       ├─ forward AS-REQ over RPC ─────────►│
    │                       │                                    │ check password, issue TGT
    │                       │◄── AS-REP ─────────────────────────┤
    │◄─ AS-REP ─────────────┤                                    │
    │                       │ bob allowed by the PRP? →          │
    │                       ├─ RSO request: bob's secrets ──────►│
    │                       │◄── single-object replication ──────┤  msDS-RevealedUsers += bob
    │                       │ bob's secret is now local          │
    │                       │                                    │
    ├─ AS-REQ bob (later) ─►│                                    │
    │◄─ AS-REP from dc3 ────┤  no trip to the office             │

The first logon goes through the office, with all the link's latency. Once it has the reply, the RODC passes it to the client and at the same time asks the writable controller to replicate Bob's credentials; that controller checks the PRP and, if the policy allows, sends them by replicate single object (RSO): the password hash and the Kerberos keys. All later logons are local, and Bob will log on when the link is down. A user not on the list (an administrator visiting the branch) goes through the office every time and can't log on when the link is down — that's by design.

The cache can be filled in advance rather than waiting for every user's first logon — this is done before the RODC goes into service, so that the first day isn't spent waiting. Fill in both users and their computers. repadmin /rodcpwdrepl takes the RODC, the writable controller that enforces the PRP, and the DNs of the accounts; for an account the policy doesn't allow to cache, the request fails:

PS C:\> 'Branch Users', 'Branch Computers' | ForEach-Object { Get-ADGroupMember $_ } | ForEach-Object { repadmin /rodcpwdrepl dc3.corp.example.com dc1.corp.example.com $_.DistinguishedName }

Installation in two stages

An RODC can be brought up with a single Install-ADDSDomainController -ReadOnlyReplica command as a domain administrator. But the point of an RODC in a branch is that the domain administrator doesn't travel there and their password isn't typed there. That's why there's a two-stage (staged) installation: a domain administrator in the office creates the account of the future RODC, and a branch employee with ordinary rights attaches the server to it.

Stage 1, in the office:

PS C:\> New-ADGroup -Name 'Branch Admins' -GroupScope Global -Path 'OU=Groups,DC=corp,DC=example,DC=com'
PS C:\> New-ADGroup -Name 'Branch Users' -GroupScope Global -Path 'OU=Groups,DC=corp,DC=example,DC=com'
PS C:\> New-ADGroup -Name 'Branch Computers' -GroupScope Global -Path 'OU=Groups,DC=corp,DC=example,DC=com'
PS C:\> Add-ADGroupMember 'Branch Admins' -Members bob
PS C:\> Add-ADDSReadOnlyDomainControllerAccount -DomainControllerAccountName dc3 -DomainName corp.example.com -SiteName Branch -DelegatedAdministratorAccountName 'CORP\Branch Admins' -InstallDns -AllowPasswordReplicationAccountName 'CORP\Allowed RODC Password Replication Group', 'CORP\Branch Users', 'CORP\Branch Computers', 'CORP\Branch Admins' -Credential (Get-Credential CORP\alice.admin)

Message                                       Context                 RebootRequired  Status
-------                                       -------                 --------------  ------
Operation completed successfully              DCPromo.General.1               False  Success

In the Domain Controllers OU a DC3 account appears, marked as an unoccupied domain controller account; in the Branch site, a server object; in the directory, a krbtgt_ account for it; and the branch groups are written into the PRP. -DelegatedAdministratorAccountName is the delegated administrator: a group whose members get local administrator rights on dc3 (installing updates, changing drivers, rebooting) without any rights in the domain. This is called administrator role separation, and a regular controller doesn't have it: there are no local administrators there. By default the delegate can't change the contents of SYSVOL. The delegate isn't on the PRP allowed list by default, and without a cached password can't log on even to dc3 when the link is down, which is why Branch Admins is in -AllowPasswordReplicationAccountName. The default group, Allowed RODC Password Replication Group, is also worth listing there explicitly, as the script saved by the installation wizard does.

Stage 2, in the branch, under the account of Bob — an ordinary domain user, a member of Branch Admins:

PS C:\> Install-WindowsFeature AD-Domain-Services -IncludeManagementTools
PS C:\> Install-ADDSDomainController -DomainName corp.example.com -UseExistingAccount -Credential (Get-Credential CORP\bob) -SafeModeAdministratorPassword (Read-Host -AsSecureString) -ReplicationSourceDC dc1.corp.example.com -Force

-UseExistingAccount means attaching to the pre-created account; the right to do that belongs to whoever is set as the delegated administrator, and to Domain Admins. Everything else — the site, DNS, global catalog, PRP — comes from the staged account, which is why this command has no such parameters. The server must be named dc3 and must not yet be joined to the domain. From here on it's the same as for a regular controller: replication of the partitions (without secrets and without the filtered attributes), SYSVOL, reboot, DNS registration. For a branch with a slow link IFM fits here too, and the media for an RODC is created with a separate command, ifm "create rodc C:\IFM" (or create sysvol rodc C:\IFM to include SYSVOL), which removes cached secrets from it; such media can be created on a writable controller or on another RODC.

Checking:

PS C:\> Get-ADDomainController dc3 | Select-Object HostName, Site, IsReadOnly, IsGlobalCatalog, OperatingSystem

HostName             Site   IsReadOnly IsGlobalCatalog OperatingSystem
--------             ----   ---------- --------------- ---------------
dc3.corp.example.com Branch       True            True Windows Server 2025 Standard

PS C:\> Get-ADDomainControllerPasswordReplicationPolicy -Identity dc3 -Allowed | Format-Table Name, ObjectClass -AutoSize

Name                                    ObjectClass
----                                    -----------
Allowed RODC Password Replication Group group
Branch Admins                           group
Branch Computers                        group
Branch Users                            group

PS C:\> Get-ADDomainControllerPasswordReplicationPolicy -Identity dc3 -Denied | Format-Table Name, ObjectClass -AutoSize

Name                                   ObjectClass
----                                   -----------
Denied RODC Password Replication Group group
Account Operators                      group
Administrators                         group
Backup Operators                       group
Server Operators                       group

PS C:\> Get-ADUser -Filter 'Name -like "krbtgt*"' | Select-Object Name, Enabled

Name        Enabled
----        -------
krbtgt        False
krbtgt_31337  False

To change the policy later: Add-ADDomainControllerPasswordReplicationPolicy -Identity dc3 -AllowedList 'Branch Printers' and -DeniedList (the cmdlet itself talks to a writable controller, and the RODC goes in -Identity). What results for a specific user, taking all groups into account, is shown by Get-ADAccountResultantPasswordReplicationPolicy -Identity bob -DomainController dc3: Allow, DenyExplicit (the account is on the denied list), DenyImplicit (on neither list) or Unknown.


DNS on an RODC

An RODC with -InstallDns is a DNS server with the same zones from the directory, but read-only: Get-DnsServerZone -ComputerName dc3 shows them with IsReadOnly = True. Branch clients ask dc3 for names, and it answers from its copy; recursion and forwarders are its own, as on any DNS server.

An RODC can't accept dynamic updates. When a branch workstation tries to register its A record, dc3 answers its SOA query with the name of a writable controller, the client sends the update there, and dc3, having seen the attempt, itself requests RSO replication of that record from that controller — by default after 30 seconds and polling every three minutes, so the new record appears on it within three and a half minutes at most, without waiting for the site link schedule. The RODC registers its own records through a writable controller too, and by default only its site's records and CNAMEs: it doesn't register NS records or delegations.

That's why in the branch's DHCP option 6, dc3 must come first and an office controller second: when the link is down the branch keeps resolving names, and when it's up, updates reach the right place.


If the RODC is stolen

The scenario all this was built for. The server was carried off with its disk; on the disk is a database with the password hashes of those in msDS-RevealedList. Those secrets can't be erased, only made worthless by changing the passwords, so Microsoft advises resetting all cached passwords and rebuilding the RODC. Removing users from the allowed list isn't enough: what has already reached the RODC stays there. The order of actions:

  1. Save the list of revealed accounts: once the RODC account is deleted, it can't be retrieved.
  2. Delete the RODC account in Active Directory Users and Computers connected to a writable controller: the Domain Controllers OU, dc3, Delete. The deletion wizard offers to reset the passwords of all user accounts cached on it and to export their list to a file — both boxes are ticked by default — and, with a separate box you have to tick yourself, to reset the passwords of cached computer accounts. There's no ready-made PowerShell equivalent of this wizard; if you reset the passwords with commands, it goes like this:
PS C:\> Get-ADDomainControllerPasswordReplicationPolicyUsage -Identity dc3 -RevealedAccounts | Select-Object Name, ObjectClass, DistinguishedName | Export-Csv C:\Temp\dc3-revealed.csv -NoTypeInformation -Encoding UTF8   # step 1, before deletion
PS C:\> $revealed = Import-Csv C:\Temp\dc3-revealed.csv
PS C:\> $revealed | Where-Object ObjectClass -eq 'user' | ForEach-Object { Set-ADAccountPassword $_.DistinguishedName -Reset -NewPassword (ConvertTo-SecureString (New-Guid).Guid -AsPlainText -Force); Set-ADUser $_.DistinguishedName -ChangePasswordAtLogon $true }
PS C:\> $cred = Get-Credential CORP\alice.admin
PS C:\> $revealed | Where-Object { $_.ObjectClass -eq 'computer' -and $_.Name -ne 'DC3' } | ForEach-Object { Invoke-Command -ComputerName $_.Name -ScriptBlock { Reset-ComputerMachinePassword -Server dc1.corp.example.com -Credential $Using:cred } }

Reset-ComputerMachinePassword changes the computer password on the machine where it runs, so it's run on each workstation through Invoke-Command, and the password changes on both sides. Computers whose passwords were reset by the wizard can't be fixed this way: they have to be rejoined to the domain.

  1. Give users new passwords: after the reset they can't log on until an administrator sets one.
  2. Make sure the RODC's krbtgt_31337 account is gone along with it (Get-ADUser -Filter 'Name -like "krbtgt*"'), and rebuild the RODC.

The revealed list is worth reviewing regularly, with no theft involved: if a domain administrator turns up on it, someone added them to the allowed group, and that's a reason to look into it.


A hands-on look

Inbound replication without outbound:

PS C:\> repadmin /showrepl dc3

Branch\DC3
DSA Options: IS_GC DISABLE_OUTBOUND_REPL
Site Options: (none)
...
==== INBOUND NEIGHBORS ======================================

DC=corp,DC=example,DC=com
    Office\DC1 via RPC
        DSA object GUID: 2f3e4d5c-6b7a-4899-8a7b-6c5d4e3f2a1b
        Last attempt @ 2026-10-15 12:30:04 was successful.
...

The options say DISABLE_OUTBOUND_REPL: an RODC has no outbound replication. Writable controllers won't take it as a source either: the KCC on them skips NTDS Settings objects with the NTDS-DSA-RO category, and that's exactly the RODC's. How a client sees it:

PS C:\> nltest /dsgetdc:corp.example.com /site:Branch
           DC: \\dc3.corp.example.com
      Address: \\192.168.2.10
 Dc Site Name: Branch
Our Site Name: Branch
        Flags: GC DS LDAP KDC TIMESERV DNS_DC DNS_DOMAIN DNS_FOREST CLOSE_SITE SELECT_SECRET WS DS_8 DS_9 DS_10 DS_13 KEYLIST

There's no WRITABLE flag, and instead of the writable controller's FULL_SECRET there's SELECT_SECRET: per the specification, that is the RODC marker. For clients that need to write (nltest /dsgetdc:corp.example.com /writable), the Locator will return a controller from Office.

Who is already cached and who has logged on:

PS C:\> Get-ADDomainControllerPasswordReplicationPolicyUsage -Identity dc3 -RevealedAccounts | Select-Object Name, ObjectClass

Name      ObjectClass
----      -----------
Bob Jones user
WS-BRANCH computer
DC3       computer

PS C:\> Get-ADDomainControllerPasswordReplicationPolicyUsage -Identity dc3 -AuthenticatedAccounts | Select-Object Name, ObjectClass

Name        ObjectClass
----        -----------
Bob Jones   user
WS-BRANCH   computer
Alice Admin user

Alice logged on through the branch, but she isn't among the revealed: the policy denied it. The same with the utility: repadmin /prp view dc3 reveal and repadmin /prp view dc3 auth2, and the allowed and denied lists with repadmin /prp view dc3 allow and deny. The first argument is always the RODC.

Who issued a ticket is visible in the Security log: event 4768 "TGT requested" is written by the controller that handled the request. Bob's tickets, with his secret cached, are issued by dc3 itself, and 4768 events with his name appear in the dc3 log, with none on dc1. Alice's request is forwarded by dc3 to the office, and the 4768 event for it is written by dc1. The documentation defines the "Client Address" field as the address of the computer the request was received from, so it should hold the address of dc3, not the workstation's; for forwarded requests this isn't stated explicitly, and it's worth checking on your own lab:

PS C:\> Get-WinEvent -ComputerName dc1 -FilterHashtable @{LogName='Security'; Id=4768} -MaxEvents 50 | Where-Object Message -match 'alice.admin' | Select-Object -First 1 -ExpandProperty Message

The workstation itself knows less: klist tgt shows the ticket and its key but not the controller that issued it; which controllers the machine has contacted recently is shown by klist query_bind.

Which attributes an RODC never receives:

PS C:\> $schema = (Get-ADRootDSE).schemaNamingContext
PS C:\> Get-ADObject -SearchBase $schema -LDAPFilter '(&(objectClass=attributeSchema)(searchFlags:1.2.840.113556.1.4.803:=512))' -Properties lDAPDisplayName | Sort-Object lDAPDisplayName | Select-Object -ExpandProperty lDAPDisplayName

msFVE-KeyPackage
msFVE-RecoveryPassword
msLAPS-EncryptedDSRMPassword
msLAPS-EncryptedDSRMPasswordHistory
msLAPS-EncryptedPassword
msLAPS-EncryptedPasswordHistory
msLAPS-Password
msPKI-AccountCredentials
msPKI-DPAPIMasterKeys
msPKI-RoamingTimeStamp
msTPM-OwnerInformation

Bit 512 (0x200) means RODC filtered. Next to it, bit 128 (0x80) marks confidential attributes, which even on a regular controller are read only with an explicit permission. The LAPS passwords have searchFlags equal to 904: that's 0x200 and 0x80 plus 0x100 (don't write values to the audit) and 0x8 (preserve on deletion). The LAPS attributes appear in the schema after Update-LapsADSchema; without it the list is five lines shorter.

And a check of the main promise — the branch works without the link. Block traffic to the office on dc3 (New-NetFirewallRule -DisplayName 'Test WAN down' -Direction Outbound -RemoteAddress 192.168.1.0/24 -Action Block), and on a branch workstation:

PS C:\> runas /user:CORP\bob cmd           ← logs on: the password is cached on dc3
PS C:\> runas /user:CORP\alice.admin cmd   ← "There are currently no logon servers available"

Don't forget to remove the rule: Remove-NetFirewallRule -DisplayName 'Test WAN down'.


Sources


Three controllers in two sites, and each of them checks the time in tickets to within five minutes. Whose time is considered correct, where it comes from, and what happens to a domain whose clocks have drifted apart is the topic of the next part, the last one in the domain block.

Next — part 12: how does time work in the domain. The W32Time hierarchy from the PDC emulator down to a workstation, how domain members find a time source and why it's signed, configuring an external source on the PDC, virtual controllers and the hypervisor's clock, what breaks when clocks drift apart and how that looks in the logs.

Only registered users can leave a comment.

Sign in or register to leave a comment.