Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Auditing Active Directory Service Accounts with PowerShell (gMSAs Included)

One report for every kind of service account, gMSAs and old-school user accounts alike, showing password age, SPNs, delegation and who can read the gMSA password.

AT A GLANCEGet-ADServiceAccountReport.ps1
What it does
Reports on named service accounts, or discovers them all, covering group managed service accounts, standalone MSAs and regular user accounts used as service accounts. One row per account with password age, SPNs, delegation settings and gMSA password readers.
Requires
  • Windows PowerShell 5.1 or PowerShell 7 on Windows
  • ActiveDirectory module (RSAT)
Permissions
A normal domain user can read nearly all of this by default. Use -Credential if you need a different account; nothing is stored in the script.
Runs on
Windows 10/11 with RSAT, Windows Server 2016+
Tested
Parse-checked and dry-run with mocked AD cmdlets in PowerShell 7.4

Service accounts are where AD hygiene goes to die. There's the one that runs the backups, set up years ago with "password never expires" ticked. There's the SQL account with a handful of SPNs nobody remembers adding. And if you're lucky, there are some group managed service accounts (gMSAs) doing it properly, with AD rotating the password for you.

This script puts all of them in one report. Name the accounts you care about, or use -Discover to find every gMSA and standalone MSA plus every user account that either has an SPN or matches your service account naming convention. For each one you get the things that matter for security: how old the password is, whether it ever expires, which SPNs it holds, whether it's trusted for delegation, and, for gMSAs, which computers or groups are allowed to retrieve its password.

The original version of this post had a blank password variable sitting right in the script and wrapped everything in Invoke-Command, only to hand back the account name and domain. Please don't keep passwords in scripts, even blank ones waiting to be filled in. The AD cmdlets take -Credential directly, so if you need another account, pass a credential object and let PowerShell handle it.

Get-ADServiceAccountReport.ps1Download
<#
.SYNOPSIS
    Reports on Active Directory service accounts: gMSAs, standalone MSAs, and plain user accounts used as service accounts.
.DESCRIPTION
    Looks up the accounts you name, or finds them for you with -Discover, and returns one row
    per account: type, enabled state, password age, whether the password ever expires, SPNs,
    delegation settings, and for gMSAs which principals are allowed to retrieve the password.
    Credentials are never stored in the script. Pass -Credential if you need another account.
.PARAMETER Identity
    One or more account names (sAMAccountName, with or without the trailing $ for gMSAs).
.PARAMETER Discover
    Find service accounts instead of naming them: every gMSA/MSA, every user with an SPN, and
    every user whose sAMAccountName matches -NamePattern.
.PARAMETER NamePattern
    Naming convention for user-based service accounts, used with -Discover. Default: svc*.
.PARAMETER Server
    Domain or domain controller to query.
.PARAMETER Credential
    Alternate credentials for the AD queries. Leave off to use your current session.
.EXAMPLE
    .\Get-ADServiceAccountReport.ps1 -Identity gmsa-sqlprod, svc-backup
.EXAMPLE
    .\Get-ADServiceAccountReport.ps1 -Discover | Export-Csv .\service-accounts.csv -NoTypeInformation
#>
[CmdletBinding(DefaultParameterSetName = 'Identity')]
param(
    [Parameter(Mandatory, ParameterSetName = 'Identity', Position = 0, ValueFromPipeline)][string[]]$Identity,
    [Parameter(Mandatory, ParameterSetName = 'Discover')][switch]$Discover,
    [Parameter(ParameterSetName = 'Discover')][string]$NamePattern = 'svc*',
    [string]$Server,
    [pscredential]$Credential
)

begin {
    Import-Module ActiveDirectory -ErrorAction Stop
    $ad = @{ ErrorAction = 'Stop' }
    if ($Server)     { $ad.Server = $Server }
    if ($Credential) { $ad.Credential = $Credential }

    $msaProps  = 'Description', 'Enabled', 'LastLogonDate', 'PasswordLastSet', 'servicePrincipalName', 'TrustedForDelegation', 'msDS-AllowedToDelegateTo', 'PrincipalsAllowedToRetrieveManagedPassword', 'msDS-ManagedPasswordInterval'
    $userProps = 'Description', 'Enabled', 'LastLogonDate', 'PasswordLastSet', 'PasswordNeverExpires', 'servicePrincipalName', 'TrustedForDelegation', 'msDS-AllowedToDelegateTo', 'MemberOf'
    $now = Get-Date

    function ConvertTo-Row($Account, [string]$Type) {
        $isManaged = $Type -ne 'User account'
        [pscustomobject]@{
            Name                 = $Account.SamAccountName
            Type                 = $Type
            Enabled              = $Account.Enabled
            PasswordLastSet      = $Account.PasswordLastSet
            PasswordAgeDays      = if ($Account.PasswordLastSet) { [int]($now - $Account.PasswordLastSet).TotalDays } else { $null }
            PasswordNeverExpires = if ($isManaged) { 'n/a (managed by AD)' } else { $Account.PasswordNeverExpires }
            RotationDays         = if ($isManaged) { $Account.'msDS-ManagedPasswordInterval' } else { $null }
            LastLogonDate        = $Account.LastLogonDate
            SPNs                 = @($Account.servicePrincipalName) -join '; '
            UnconstrainedDelegation = [bool]$Account.TrustedForDelegation
            ConstrainedDelegationTo = @($Account.'msDS-AllowedToDelegateTo') -join '; '
            PasswordRetrievableBy   = if ($isManaged) { (@($Account.PrincipalsAllowedToRetrieveManagedPassword) | ForEach-Object { ($_ -split '(?<!\\),')[0] -replace '^CN=' }) -join '; ' } else { $null }
            GroupCount           = if ($isManaged) { $null } else { @($Account.MemberOf).Count }
            Description          = $Account.Description
            Domain               = (($Account.DistinguishedName -split ',') -match '^DC=' -replace '^DC=') -join '.'
            DistinguishedName    = $Account.DistinguishedName
        }
    }

    function Get-MsaType($Account) {
        if ($Account.ObjectClass -eq 'msDS-GroupManagedServiceAccount') { 'gMSA' }
        elseif ($Account.ObjectClass -eq 'msDS-DelegatedManagedServiceAccount') { 'dMSA' }
        else { 'Standalone MSA' }
    }
}

process {
    if ($Discover) {
        foreach ($msa in Get-ADServiceAccount -Filter * -Properties $msaProps @ad) { ConvertTo-Row $msa (Get-MsaType $msa) }
        $ldap = "(&(objectCategory=person)(objectClass=user)(!(sAMAccountName=krbtgt))(|(servicePrincipalName=*)(sAMAccountName=$NamePattern)))"
        foreach ($user in Get-ADUser -LDAPFilter $ldap -Properties $userProps @ad) { ConvertTo-Row $user 'User account' }
        return
    }

    foreach ($name in $Identity) {
        # Try it as a managed service account first, then as a regular user.
        try {
            $msa = Get-ADServiceAccount -Identity ($name.TrimEnd('$')) -Properties $msaProps @ad
            ConvertTo-Row $msa (Get-MsaType $msa)
            continue
        }
        catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { Write-Verbose "$name isn't a managed service account; trying it as a user." }
        catch { Write-Warning "${name}: $($_.Exception.Message)"; continue }

        try {
            $user = Get-ADUser -Identity $name -Properties $userProps @ad
            ConvertTo-Row $user 'User account'
        }
        catch { Write-Warning "${name}: not found as a service account or user. $($_.Exception.Message)" }
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-Identitystring[]—Accounts to look up. For gMSAs the trailing $ is optional. Takes pipeline input.
-Discoverswitch—Find service accounts instead of naming them. Every gMSA and MSA, plus users with an SPN or a name matching -NamePattern.
-NamePatternstringsvc*Your naming convention for user-based service accounts. Only used with -Discover.
-Serverstring—Domain name or domain controller to query. Handy for reporting on another domain in the forest.
-Credentialpscredential—Run the queries as someone else. Use Get-Credential; never put a password in the script.

Run it

Check a couple of accounts you already know about.

.\Get-ADServiceAccountReport.ps1 -Identity gmsa-sqlprod, svc-backup

Find everything and save it for review.

.\Get-ADServiceAccountReport.ps1 -Discover | Export-Csv .\service-accounts.csv -NoTypeInformation

User accounts with SPNs and old passwords: the ones worth fixing first.

.\Get-ADServiceAccountReport.ps1 -Discover | Where-Object { $_.Type -eq 'User account' -and $_.SPNs -and $_.PasswordAgeDays -gt 365 }

Report on another domain with a different account.

.\Get-ADServiceAccountReport.ps1 -Discover -NamePattern 'sa-*' -Server emea.contoso.com -Credential (Get-Credential)

What you'll see

Example outputvalues are illustrative
Name                    : gmsa-sqlprod$
Type                    : gMSA
Enabled                 : True
PasswordAgeDays         : 12
PasswordNeverExpires    : n/a (managed by AD)
RotationDays            : 30
SPNs                    : MSSQLSvc/sql01.contoso.com:1433; MSSQLSvc/sql01.contoso.com
UnconstrainedDelegation : False
PasswordRetrievableBy   : SQL-Servers
Domain                  : contoso.com

Name                    : svc-backup
Type                    : User account
Enabled                 : True
PasswordAgeDays         : 1462
PasswordNeverExpires    : True
SPNs                    :
UnconstrainedDelegation : True
GroupCount              : 3
Domain                  : contoso.com

How it works

  1. Set up the connection once. -Server and -Credential are collected into a splat that every AD query uses. If you don't pass them, it uses your current session and domain.
  2. Look up each account the right way. A named account is tried as a managed service account with Get-ADServiceAccount first. If AD says it isn't one, the script tries Get-ADUser. Anything else, like an access error, is reported as a warning and the script moves on to the next account.
  3. Or discover them. -Discover pulls every managed service account, then runs one LDAP query for user accounts that have any SPN or match your naming pattern. krbtgt has an SPN too, so it's explicitly left out.
  4. Flatten the answers. SPNs and the gMSA password readers become semicolon-separated strings, the password age is worked out in days, and the domain comes from the account's DN. The result exports to CSV cleanly.

The gMSA-specific columns are the interesting ones. PasswordRetrievableBy is the list of principals that can pull the current password from AD, which is effectively the list of machines that can run as that account. If there's a group in there, check who's in the group.

Take it further

  • Fix what you find. For each user-based service account, check whether the service supports gMSAs. SQL Server, IIS app pools, scheduled tasks and most Windows services do.
  • Check the local side. Pair this with a report of which services run as which account on your servers (Get-CimInstance Win32_Service and the StartName property) to find where each account is actually used.
  • Put it on a schedule. A monthly run with a diff against last month catches new SPNs and new service accounts before they become permanent fixtures.

Things that'll trip you up

  • User accounts with SPNs are Kerberoastable. Any domain user can request a service ticket for an account with an SPN and try to crack its password offline. If it's a short or ancient password, that's a real risk. Move the service to a gMSA, or at least give the account a long random password.
  • Unconstrained delegation is a red flag. An account trusted for unconstrained delegation can impersonate anyone who authenticates to it. There are very few good reasons for it in 2026. Constrained or resource-based constrained delegation almost always does the job.
  • gMSAs need a KDS root key. If Get-ADServiceAccount finds nothing and you expected gMSAs, check that the domain has a KDS root key (Get-KdsRootKey). Without one, nobody's been able to create gMSAs yet.
  • The name pattern is a guess. -Discover finds user accounts by SPN or by name, so a service account named like a person and without an SPN will slip through. And a person named Svc-something will show up. Review the list, don't trust it blindly.
  • Last logon lags. LastLogonDate is replicated lazily and can be up to two weeks behind. It's good for spotting accounts that haven't been used in months, not for yesterday.