Servers Security

Eliminating Static Service Credentials with gMSA and Windows LAPS

Two categories of credential leak the most attackers: the SQL service account whose password was set in 2017 and never rotated, and the local Administrator account that shares one password across 4,000 machines because someone baked it into the gold image. Both are static, both are reused, and both turn a single compromised host into domain-wide lateral movement. The good news is that Microsoft already ships the fix for each, free, in-box: group Managed Service Accounts (gMSA) for service identities, and the modern Windows LAPS for local admin passwords. This guide replaces both classes of static secret end to end, with the commands, the gotchas, and the verification that proves it worked.

1. Why static credentials are a breach multiplier

A static service password is not one risk, it is a chain. The account is usually over-permissioned, the password is known to whoever set it, it is frequently identical across environments, and because rotating it means coordinated service restarts, nobody ever does. Dump LSASS on one box and you have a credential that works on dozens. Worse, service accounts with an SPN are crackable offline via Kerberoasting: any authenticated user can request a service ticket, and a weak human-set password falls to a GPU in minutes.

gMSA closes this by moving password ownership to the domain. The password is 240 bytes, generated and rotated by AD itself (default every 30 days), never typed, never known to a human, and never the same on two services unless you deliberately share the account. Kerberoasting becomes pointless because the key has 2000+ bits of entropy.

Local Administrator reuse is the workstation-side equivalent. One shared local-admin password plus one harvested hash equals PsExec to every endpoint. LAPS gives every machine a unique, rotated local-admin password stored in the directory, readable only by the people you authorize, so a hash stolen from one machine unlocks exactly one machine.

The mental model: a credential a human can recite is a credential an attacker can steal, reuse, and crack. gMSA and LAPS remove the human from the loop for the two account types that get domains owned.

2. Creating the KDS root key and your first gMSA

gMSA passwords are derived by the Key Distribution Service from a forest-wide KDS root key. You create this once per forest. It must replicate to every DC and be at least 10 hours old before any gMSA password can be generated, because that interval guarantees all DCs have converged.

On a Domain Controller (or a host with the AD RSAT tools), as a Domain/Enterprise Admin:

# Production: create the key and wait the real 10 hours for replication safety.
Add-KdsRootKey -EffectiveImmediately

-EffectiveImmediately is a misnomer: it still enforces the 10-hour delay. Only in a single-DC lab should you fast-forward it:

# LAB ONLY. Backdates the key so gMSAs work immediately. Never do this in production.
Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-10))

Confirm the key exists and note its EffectiveTime:

Get-KdsRootKey | Select-Object KeyId, EffectiveTime, DomainController

Now create the account. The defining property is PrincipalsAllowedToRetrieveManagedPassword — only these principals can pull the password from AD, so the machines that run the service must be members. Best practice is to point this at a security group, not individual computers, so onboarding a new host is a group-add, not an account edit.

# A group that will hold every host allowed to use this gMSA.
New-ADGroup -Name "gMSA_SQL01_Hosts" -GroupScope DomainLocal `
  -Path "OU=Groups,OU=Tier1,DC=ad,DC=example,DC=com"

Add-ADGroupMember -Identity "gMSA_SQL01_Hosts" `
  -Members "SQLNODE01$","SQLNODE02$"

# Create the gMSA. DNSHostName must be unique and resolvable.
New-ADServiceAccount -Name "gmsa-sql01" `
  -DNSHostName "gmsa-sql01.ad.example.com" `
  -PrincipalsAllowedToRetrieveManagedPassword "gMSA_SQL01_Hosts" `
  -ManagedPasswordIntervalInDays 30 `
  -KerberosEncryptionType AES128,AES256

A few decisions that bite later:

Setting What it controls Gotcha
PrincipalsAllowedToRetrieveManagedPassword Who can read the password Computer must reboot, or its Kerberos ticket must refresh, before a newly added host sees the membership
ManagedPasswordIntervalInDays Rotation cadence Immutable after creation. To change it you delete and recreate the account
KerberosEncryptionType Allowed enc types Omit RC4. Set AES only, or you reintroduce a weak-crypto downgrade path
sAMAccountName The 15-char logon name Ends in $. Account names over 15 chars get truncated, plan short names

3. Installing and assigning the gMSA to services, IIS app pools, and tasks

Two distinct steps trip people up. Installing the gMSA on a host primes its local cache so it can fetch the password (one-time per machine). Assigning it is configuring a service to log on as that account.

On the target host, install RSAT-AD-PowerShell, then test and install:

Install-WindowsFeature RSAT-AD-PowerShell

# Must return True. If False, the host is not in the retrieval group
# or the ticket is stale (reboot or run: klist purge -li 0x3e7).
Test-ADServiceAccount "gmsa-sql01"

# Primes the local cache. No password is shown, the host fetches it on demand.
Install-ADServiceAccount "gmsa-sql01"

Assigning depends on the workload. The golden rule: the logon name is DOMAIN\name$ and the password field is left blank. The OS retrieves the secret itself.

Windows service — set the logon account, leave the password empty:

$svc = Get-CimInstance Win32_Service -Filter "Name='MSSQLSERVER'"
# Password is an empty string. The trailing $ on the account is required.
$svc | Invoke-CimMethod -MethodName Change `
  -Arguments @{ StartName = 'AD\gmsa-sql01$'; StartPassword = '' }

IIS application pool — set the identity to a custom account with a blank password:

Import-Module WebAdministration
Set-ItemProperty "IIS:\AppPools\AppPool1" -Name processModel.identityType -Value SpecificUser
Set-ItemProperty "IIS:\AppPools\AppPool1" -Name processModel.userName -Value 'AD\gmsa-sql01$'
Set-ItemProperty "IIS:\AppPools\AppPool1" -Name processModel.password -Value ''

Scheduled task — use the principal with LogonType Password; Task Scheduler recognizes the gMSA and supplies no credential:

$action  = New-ScheduledTaskAction -Execute 'C:\jobs\sync.exe'
$trigger = New-ScheduledTaskTrigger -Daily -At 2am
$principal = New-ScheduledTaskPrincipal -UserId 'AD\gmsa-sql01$' `
  -LogonType Password -RunLevel Limited
Register-ScheduledTask -TaskName 'NightlySync' `
  -Action $action -Trigger $trigger -Principal $principal

The gMSA also needs Log on as a service (and Log on as a batch job for tasks) on the host. Grant it through the relevant User Rights Assignment GPO scoped to the server OU rather than editing local policy on each box.

4. Migrating existing services and tasks to gMSAs

You rarely build greenfield. The realistic job is converting a service that already runs under AD\svc-sql with a password in a vault. Sequence it so a failed cutover is a one-line revert.

First, inventory what runs under human-set service accounts across the fleet:

# Per host: services not using built-in accounts.
Get-CimInstance Win32_Service |
  Where-Object { $_.StartName -notmatch 'LocalSystem|NetworkService|LocalService|NT AUTHORITY|NT SERVICE' } |
  Select-Object SystemName, Name, StartName

For each, the migration is mechanical, but permissions do not follow the account. The new gMSA must be granted everything the old account had: SQL logins, share ACLs, registry rights, database roles, folder permissions. Map those first, or the service starts and then fails on first data access.

# 1. Add the gMSA to the same groups the legacy account was in.
Get-ADPrincipalGroupMembership "svc-sql" |
  Where-Object Name -ne 'Domain Users' |
  ForEach-Object { Add-ADGroupMember $_.Name -Members "gmsa-sql01$" }

# 2. For SQL specifically, create the login and replicate role membership.
#    Run against the instance:
#    CREATE LOGIN [AD\gmsa-sql01$] FROM WINDOWS;
#    ALTER SERVER ROLE [sysadmin] ADD MEMBER [AD\gmsa-sql01$];  -- match prior, scope down later

# 3. Stop, repoint, start, verify, THEN disable the old account.
Stop-Service MSSQLSERVER
$svc = Get-CimInstance Win32_Service -Filter "Name='MSSQLSERVER'"
$svc | Invoke-CimMethod -MethodName Change -Arguments @{ StartName='AD\gmsa-sql01$'; StartPassword='' }
Start-Service MSSQLSERVER

# 4. Only after the service is verified healthy:
Disable-ADAccount "svc-sql"   # disable, do not delete, until you are sure

Keep the legacy account disabled rather than deleted for at least one full business cycle. If a forgotten dependency surfaces, re-enabling beats a restore. Delete only after the account shows zero authentications in the logs.

5. Deploying modern Windows LAPS via Group Policy and Intune

The service-account problem is solved. Now the local-admin problem. Use Windows LAPS, the in-box capability shipped in the April 2023 cumulative updates and built into Windows 11 24H2 and Server 2025. Do not deploy the legacy MSI “Microsoft LAPS” on anything new; the modern stack is a superset and can interop during migration.

On-prem AD backend. First extend the schema (idempotent, run once per forest as Schema Admin), then delegate write permission on the OUs holding your machines so the machines can self-update their password attributes:

Update-LapsADSchema

# Let computers in this OU write their own LAPS attributes.
Set-LapsADComputerSelfPermission -Identity "OU=Workstations,DC=ad,DC=example,DC=com"

Then drive policy. The Windows LAPS settings live under Computer Configuration > Administrative Templates > System > LAPS. The non-negotiable settings:

Policy Recommended value Why
BackupDirectory 2 (AD) or 1 (Entra ID) 0 disables backup entirely
PasswordComplexity 4 (upper+lower+digit+special) Maximum character set
PasswordLength 20+ Long enough to resist offline cracking
PasswordAgeDays 30 Rotation cadence
PostAuthenticationActions 3 (reset password + logoff) Kills standing sessions after admin use
ADPasswordEncryptionEnabled 1 Encrypt the stored password (Server 2016 FL+)

Intune / Entra-joined. For cloud-managed devices, skip GPO. Enable LAPS in Entra ID first (Entra admin center > Devices > Device settings > Enable Local Administrator Password Solution = Yes), then push the Account protection > Local admin password solution (Windows LAPS) policy from Intune. The CSP settings mirror the GPO names one-to-one. Point BackupDirectory at Azure AD (Entra ID).

One subtlety regardless of backend: LAPS manages one account. If it is the built-in Administrator (RID 500), set AdministratorAccountName empty and LAPS finds RID 500 by SID even if renamed. If you use a custom managed admin, that account must already exist on the machine — LAPS does not create it. Provision it via your image or a separate policy.

6. Storing LAPS secrets in Entra ID versus on-prem AD

Where the password lands changes who can read it and how it is protected.

On-prem AD. The password is written to confidential computer-object attributes: msLAPS-Password (or msLAPS-EncryptedPassword when encryption is on). With ADPasswordEncryptionEnabled=1, the value is encrypted to a principal (default: Domain Admins) so a DC-database reader without that key sees ciphertext, not the password. This is a meaningful upgrade over legacy LAPS, where the attribute was cleartext-readable by anyone with the (often over-delegated) read ACL.

Entra ID. The password is stored against the device object and protected by the directory’s access model; retrieval is gated by the microsoft.directory/deviceLocalCredentials/password/read action, carried by roles like Cloud Device Administrator. Retrieval is logged in the Entra audit log, which is the cleanest audit trail of the two.

Decision shortcut:
  Domain-joined, on-prem GP-managed servers/workstations -> AD backend, encryption ON.
  Entra-joined / hybrid cloud-managed endpoints          -> Entra ID backend.
  Do not point one machine at both. Pick the backend that matches its join state.

Encryption on the AD backend requires Windows Server 2016 Domain Functional Level or higher because it relies on KDS — the same service gMSA uses. If your DFL is lower, you either raise it or accept cleartext storage; do not pretend the attribute is protected when it is not.

7. Rotation, retrieval auditing, and access control

A rotated secret nobody can audit is half a control. Three things must be true: it rotates, retrieval is logged, and only the right people can read it.

Rotation happens automatically at PasswordAgeDays, and on-demand after use via PostAuthenticationActions=3, which resets the password and logs off the admin a set time after they authenticate — so a harvested LAPS password has a short, bounded life. You can also force a rotation:

# Expire the current password now, forcing the client to rotate at next cycle.
Reset-LapsPassword -ComputerName "WKS-014"

Auditing retrieval (AD backend). Reading a LAPS password is a directory read of a confidential attribute. Turn on Directory Service Access auditing via the Audit Directory Service Access advanced audit policy and a SACL on the OU for the LAPS attributes, then read who retrieved what:

# Event 4662 fires on access to the LAPS password attribute.
# Filter on the msLAPS-Password schema GUID once you have it from your schema.
Get-WinEvent -FilterHashtable @{ LogName='Security'; Id=4662 } -MaxEvents 200 |
  Where-Object { $_.Message -match 'msLAPS' } |
  Select-Object TimeCreated, @{n='Reader';e={$_.Properties[1].Value}}, Message

Auditing retrieval (Entra). Cleaner — query the audit log directly:

AuditLogs
| where OperationName == "Recover device local administrator password"
| project TimeGenerated, InitiatedBy.user.userPrincipalName, TargetResources[0].displayName, Result
| order by TimeGenerated desc

Access control. Default LAPS read in AD often inherits to broader groups than you think. Lock it to a named, just-in-time admin group and verify what is actually delegated:

# Show every principal currently delegated to read LAPS passwords on this OU.
Find-LapsADExtendedRights -Identity "OU=Workstations,DC=ad,DC=example,DC=com"

Anything in that list beyond your intended retrieval group is an over-delegation to fix today.

Enterprise scenario

A retail platform team running ~600 Windows Server hosts inherited an estate where a single domain account, svc-batch, ran scheduled jobs and a Windows service on roughly 90 servers. The password was 12 characters, set in 2019, stored in a wiki, and the account was (of course) a member of Domain Admins “to make it work.” A purple-team exercise Kerberoasted it offline in under an hour. Rotating it manually was the blocker: 90 services across four datacenters, no maintenance window long enough, and nobody confident which jobs would break.

The constraint was that the account could not simply be replaced wholesale — several jobs hard-coded svc-batch in connection strings the team could not all locate, and a hard cutover risked a silent batch failure that would only surface at month-end close.

They solved it with gMSA plus a phased, group-scoped migration. A retrieval group gMSA_Batch_Hosts was created and the 90 computer objects added in three waves of 30. They built gmsa-batch01 with AES-only Kerberos, granted it the specific rights svc-batch actually used (two SQL logins scoped to db_datawriter, not sysadmin, and three share ACLs) rather than copying its Domain Admin membership. Each wave repointed services and tasks, ran a canary job, and only then disabled nothing yet — svc-batch stayed enabled but had its password rotated to a random 64-char value held in the vault, so any missed dependency would fail loudly on the next run instead of silently succeeding under the old creds. Two stragglers surfaced exactly that way in wave two and were fixed in place.

# The wave pattern that made the cutover safe: add hosts, prove retrieval, repoint, verify.
Add-ADGroupMember "gMSA_Batch_Hosts" -Members (Get-Content .\wave2-hosts.txt | ForEach-Object { "$_$" })

Invoke-Command -ComputerName (Get-Content .\wave2-hosts.txt) -ScriptBlock {
    klist purge -li 0x3e7 | Out-Null      # refresh the machine ticket so new membership is seen
    Install-ADServiceAccount "gmsa-batch01"
    Test-ADServiceAccount  "gmsa-batch01"  # must return True before repointing anything
}

After the final wave, svc-batch showed zero authentications for a full month-end cycle and was disabled, then deleted. The Kerberoasting finding closed permanently: the new account’s key is uncrackable, and there is no longer a password in the wiki to leak.

Verify

Prove each control independently. Do not trust that configuration equals enforcement.

# gMSA: does the host hold the account and can it retrieve the password?
Test-ADServiceAccount "gmsa-sql01"            # True = retrievable on this host

# gMSA: confirm AD actually rotated the password recently.
Get-ADServiceAccount "gmsa-sql01" -Properties PasswordLastSet, msDS-ManagedPasswordInterval |
  Select-Object Name, PasswordLastSet, msDS-ManagedPasswordInterval

# Service is genuinely running under the gMSA, not the old account.
(Get-CimInstance Win32_Service -Filter "Name='MSSQLSERVER'").StartName  # AD\gmsa-sql01$

For LAPS, read the stored password back as an authorized admin and confirm it is present, encrypted (AD), and recently rotated:

# Returns the current managed password, its source, and expiry.
Get-LapsADPassword -Identity "WKS-014" -AsPlainText |
  Select-Object Account, Password, PasswordExpirationTimestamp

# Client-side: confirm the machine processed LAPS and which account it manages.
Get-WinEvent -LogName "Microsoft-Windows-LAPS/Operational" -MaxEvents 20 |
  Select-Object TimeCreated, Id, Message | Format-Table -Wrap

Healthy looks like: Test-ADServiceAccount returns True, the service StartName shows the $-suffixed gMSA, PasswordLastSet is within the rotation interval, Get-LapsADPassword returns a long random value with a future expiry, and the LAPS operational log shows event IDs in the 10xx range for successful policy processing and rotation.

Checklist

Pitfalls and decommissioning legacy LAPS

The failures that recur in real migrations:

To decommission legacy LAPS cleanly: confirm full modern-LAPS coverage, unlink and delete the legacy LAPS GPO, uninstall the legacy CSE from clients (it is no longer needed — modern LAPS is in-box), and leave the old ms-Mcs-AdmPwd schema attributes in place (schema attributes cannot be removed) but ensure nothing reads them. The single source of truth becomes Windows LAPS, with rotation, encryption, and retrieval auditing all enforced by the platform rather than by a human remembering to act.

windows-serveractive-directorygmsalapssecurity

Comments

Keep Reading