As organizations increasingly adopt Fine-Grained Password Policies (FGPP) to enforce stricter security standards for different user groups, a critical oversight often emerges: users slipping through the cracks and falling back to weaker default domain policies.
The FGPP Implementation Challenge
When you deploy Fine-Grained Password Policies in Active Directory, you're essentially creating a tiered security model where different user groups receive different password requirements. This is incredibly powerful for organizations that need to enforce stricter policies for privileged accounts while maintaining usability for standard users.
However, there's a fundamental flaw in how many organizations monitor FGPP compliance: they assume that once users are placed in the appropriate groups, they'll stay there.
Visual Runtime of the Script
This is the output on he console updates when the script is running:
What Happens When Users Fall Through the Cracks
Let me illustrate with a real-world scenario from our environment at bear.local:
# Example: Checking a user's effective password policy
Get-ADUserResultantPasswordPolicy -Identity "john.fluff"
# If this returns null, the user is subject to the default domain policy
# If it returns a policy object, they're covered by FGPP
Consider this: your company policy mandates that privileged accounts must change passwords every 30 days, implemented through FGPP. Your default domain policy, however, allows 100-day password changes for backwards compatibility with legacy systems.
What happens when a privileged user is accidentally removed from their FGPP group?
They silently fall back to the 100-day policy. No alerts, no notifications – just a critical compliance gap that could persist for months.
Understanding Fine-Grained Password Policies
Before diving into the monitoring solution, let's explore what FGPP can actually control and why these settings matter.
FGPP Configuration Options
Fine-Grained Password Policies offer granular control over multiple password and account lockout settings:
# Creating a secure FGPP for privileged accounts
New-ADFineGrainedPasswordPolicy -Name "SecurePrivilegedPolicy" `
-Precedence 10 `
-ComplexityEnabled $true `
-Description "High-security policy for privileged accounts" `
-DisplayName "Privileged Account Security Policy" `
-LockoutDuration "00:30:00" `
-LockoutObservationWindow "00:30:00" `
-LockoutThreshold 3 `
-MaxPasswordAge "30.00:00:00" `
-MinPasswordAge "1.00:00:00" `
-MinPasswordLength 14 `
-PasswordHistoryCount 24 `
-ReversibleEncryptionEnabled $false
Let's break down these critical settings:
Password Age Settings:
MaxPasswordAge
: Maximum time before password must be changed (30 days for privileged accounts)MinPasswordAge
: Minimum time before password can be changed again (prevents rapid cycling)
Complexity Requirements:
MinPasswordLength
: Minimum character count (14 for high-security environments)ComplexityEnabled
: Enforces Windows complexity rulesPasswordHistoryCount
: Prevents reusing recent passwords (24 previous passwords)
Account Lockout Protection:
LockoutThreshold
: Failed attempts before lockout (3 attempts)LockoutDuration
: How long accounts remain locked (30 minutes)LockoutObservationWindow
: Time window for counting failed attempts
When Users Fall Back to Default Policy
The real danger lies in the silent nature of FGPP failures. When a user is removed from their designated password policy group – whether through administrative error, group cleanup, or organizational changes – they don't receive a weaker password policy notification.
Domain Functional Level Limitation
Here's where the compliance gap becomes even more critical: traditional domain password policies in Windows Server 2012 R2 and earlier are limited to a maximum of 14 characters for minimum password length. This has been a longstanding limitation for many decades on all Microsoft platforms.
Consider this scenario in our bear.local environment:
- Your FGPP enforces 30-character minimum passwords for privileged accounts
- Your default domain policy can only enforce a maximum of 14 characters (due to Server 2012 R2 limitations)
- When a privileged user falls out of their FGPP group, they drop from 30-character requirements to just 14 characters
This creates a massive security gap – users aren't just falling back to a "slightly weaker" policy, they're potentially falling back to passwords that are less than half the required length.
Domain Functional Level vs. Password Length Capabilities
The password length limitations are not tied to domain functional level increases but rather to specific operating system versions and updates:
Windows Server 2012 R2 and Earlier:
- Maximum configurable minimum password length: 14 characters
- No workaround available through standard Group Policy
- FGPP is the only way to enforce longer passwords
Windows Server 2016/2019:
- Initially introduced support for longer passwords in 2018 updates
- Support was later removed in January 2019 updates due to compatibility issues
- Still limited to 14 characters in most deployments
Windows Server 2022 and Windows 10 2004+:
- Introduction of
RelaxMinimumPasswordLengthLimits
policy setting - Allows minimum password length up to 255 characters
- Requires three coordinated Group Policy settings to function
The Hidden Compliance Trap
# Example: What happens when FGPP fails in older domains
Get-ADUserResultantPasswordPolicy -Identity "privileged.admin"
# Returns: null (using default domain policy)
Get-ADDefaultDomainPasswordPolicy | Select-Object MinPasswordLength
# Returns: MinPasswordLength : 14 (maximum possible in Server 2012 R2)
# Meanwhile, your FGPP was enforcing:
Get-ADFineGrainedPasswordPolicy -Identity "BEAR-PrivilegedPolicy" |
Select-Object MinPasswordLength
# Returns: MinPasswordLength : 30
This creates a scenario where:
- FGPP enforces 30 characters for privileged accounts
- Default domain policy enforces only 14 characters (maximum possible)
- The gap is 16 characters – more than doubling the attack surface
Checking Current Policy Assignment
You can verify a user's current password policy assignment:
# Method 1: Check resultant password policy
$user = "privileged.admin"
$policy = Get-ADUserResultantPasswordPolicy -Identity $user
if ($policy) {
Write-Host "User $user is subject to FGPP: $($policy.Name)"
} else {
Write-Host "WARNING: User $user is using DEFAULT domain policy"
}
# Method 2: Check group membership
$policyGroups = @("BEAR-DomainAdmins-Policy", "BEAR-PrivilegedUsers-Policy")
$userGroups = (Get-ADUser -Identity $user -Properties MemberOf).MemberOf
foreach ($group in $policyGroups) {
$groupDN = (Get-ADGroup -Identity $group).DistinguishedName
if ($userGroups -contains $groupDN) {
Write-Host "User is member of policy group: $group"
}
Upgrading Domain Password Capabilities
For organizations running newer domain controllers, you can extend the default domain policy beyond 14 characters:
# Available in Windows Server 2022 and Windows 10 2004+
# Three coordinated settings required:
# 1. Enable relaxed length limits
Set-GPRegistryValue -Name "Default Domain Policy" -Key
"HKLM\System\CurrentControlSet\Control\SAM" -ValueName "RelaxMinimumPasswordLengthLimits"
-Type DWord -Value 1
# 2. Set audit length (for monitoring)
# Navigate to: Computer Configuration > Windows Settings > Security Settings
> Account Policies > Password Policy
# Set "Minimum password length audit" to desired value
# 3. Set actual minimum length
# Set "Minimum password length" to desired value (can now exceed 14)
Important caveat: Even with these newer capabilities, FGPP remains essential for:
- Granular group-based policies (different requirements for different user types)
- Backwards compatibility with mixed-version environments
- Centralized policy management for complex organizational structures
Automating Compliance
To address this challenge, I've developed a comprehensive PowerShell script that monitors FGPP group membership and generates detailed compliance reports.
The script follows this logical flow:
# 1. Load required modules and validate environment
Import-Module ActiveDirectory
# 2. Define policy group structure
$PolicyGroups = @(
"BEAR-ExecutivePolicy-Users",
"BEAR-AdminPolicy-Standard",
"BEAR-AdminPolicy-Privileged",
"BEAR-ServicePolicy-Critical",
"BEAR-UserPolicy-Marketing",
"BEAR-UserPolicy-Finance",
"BEAR-UserPolicy-IT",
"BEAR-UserPolicy-HR",
"BEAR-UserPolicy-Sales",
"BEAR-UserPolicy-Operations"
)
# 3. Query all enabled users
$AllUsers = Get-ADUser -Filter {Enabled -eq $true} -Properties MemberOf
# 4. Analyze group memberships
# 5. Generate compliance report
Key Monitoring Features
The script provides several critical monitoring capabilities that account for domain functional level limitations:
Domain Version Detection:
# Detect domain functional level and warn about password length limitations
$DomainLevel = (Get-ADDomain).DomainMode
$MaxDomainPolicyLength = switch ($DomainLevel) {
"Windows2012R2Domain" { 14 }
"Windows2016Domain" { 14 } # Still limited without RelaxMinimumPasswordLengthLimits
"Windows2019Domain" { 14 } # Still limited without RelaxMinimumPasswordLengthLimits
"Windows2022Domain" { 255 } # If RelaxMinimumPasswordLengthLimits is enabled
default { 14 }
}
Write-Warning "Domain functional level: $DomainLevel"
Write-Warning "Maximum enforceable password length in default domain policy:
$MaxDomainPolicyLength characters"
FGPP Gap Analysis:
# Compare FGPP requirements vs domain policy maximums
$FGPPPolicies = Get-ADFineGrainedPasswordPolicy -Filter *
foreach ($Policy in $FGPPPolicies) {
$FGPPLength = $Policy.MinPasswordLength
if ($FGPPLength -gt $MaxDomainPolicyLength) {
$SecurityGap = $FGPPLength - $MaxDomainPolicyLength
Write-Warning "CRITICAL: Policy '$($Policy.Name)' requires $FGPPLength chars,
but domain fallback only enforces $MaxDomainPolicyLength chars
(gap: $SecurityGap characters)"
}
}
Group Validation:
# Verify all policy groups exist before processing
foreach ($GroupName in $PolicyGroups) {
try {
$Group = Get-ADGroup -Identity $GroupName -ErrorAction Stop
Write-Host "✓ Policy group validated: $GroupName" -ForegroundColor Green
}
catch {
Write-Warning "✗ Missing policy group: $GroupName"
$MissingGroups += $GroupName
}
}
User Analysis:
# Check each user's policy group membership
foreach ($User in $AllUsers) {
$InPolicyGroup = $false
$UserGroups = $User.MemberOf
if ($UserGroups) {
foreach ($GroupDN in $UserGroups) {
$GroupName = (Get-ADGroup -Identity $GroupDN).Name
if ($PolicyGroups -contains $GroupName) {
$InPolicyGroup = $true
break
}
}
}
if (-not $InPolicyGroup) {
# User is non-compliant - add to report
$NonCompliantUsers += $User
}
}
Report Generation and Analysis
The script generates comprehensive CSV reports that include:
- User Identification: Username, display name, email
- Organizational Data: Department, job title
- Account Status: Enabled status, last logon date
- Technical Details: Distinguished name for easy AD management
Sample Report Output
Username,DisplayName,Email,Department,Title,Enabled,LastLogonDate,DistinguishedName
j.smith,John Smith,j.smith@bear.local,IT,System Administrator,True,2024-06-20,CN=John Smith,OU=IT,DC=bear,DC=local
m.jones,Mary Jones,m.jones@bear.local,Finance,Controller,True,2024-06-19,CN=Mary Jones,OU=Finance,DC=bear,DC=local
Scheduled Monitoring
Implement the script as a scheduled task for regular compliance checks:
# Create scheduled task to run weekly
$Action = New-ScheduledTaskAction -Execute 'PowerShell.exe' -Argument '-File
"C:\Scripts\FGPP-Compliance-Monitor.ps1"'
$Trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6:00AM
Register-ScheduledTask -Action $Action -Trigger $Trigger -TaskName
"FGPP Compliance Monitor"
Integration with SIEM/Logging
# Write compliance events to Windows Event Log
New-EventLog -LogName "Application" -Source "FGPP Monitor" -ErrorAction SilentlyContinue
if ($NonCompliantUsers.Count -gt 0) {
Write-EventLog -LogName "Application" -Source "FGPP Monitor" -EventId 1001 -EntryType
Warning -Message "FGPP Compliance Issue: $($NonCompliantUsers.Count) users not in
policy groups"
}
Script : FGPP-Compliance.ps1
# Fine-Grained Password Policy Compliance Monitor
# Domain: bear.local
# Purpose: Identify users not assigned to any password policy groups
# Import Active Directory module
try {
Import-Module ActiveDirectory -ErrorAction Stop
Write-Host "Active Directory module loaded successfully." -ForegroundColor Green
}
catch {
Write-Error "Failed to load Active Directory module. Please ensure
RSAT-AD-PowerShell feature is installed."
exit 1
}
# Define password policy groups for bear.local domain
$PasswordPolicyGroups = @(
"BEAR-ExecutivePolicy-Users",
"BEAR-AdminPolicy-Standard",
"BEAR-AdminPolicy-Privileged",
"BEAR-ServicePolicy-Critical",
"BEAR-UserPolicy-Marketing",
"BEAR-UserPolicy-Finance",
"BEAR-UserPolicy-IT",
"BEAR-UserPolicy-HR",
"BEAR-UserPolicy-Sales",
"BEAR-UserPolicy-Operations"
)
# Get script directory for output file
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$ReportPath = Join-Path $ScriptPath "FGPP_Compliance_Report_$
(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
Write-Host "Starting FGPP Compliance Audit for bear.local domain..." -ForegroundColor Yellow
Write-Host "Monitoring for users not assigned to password policy groups..."
-ForegroundColor Yellow
try {
# Retrieve all enabled user accounts
Write-Host "Querying enabled user accounts from bear.local..." -ForegroundColor Cyan
$AllUsers = Get-ADUser -Filter {Enabled -eq $true} -Properties MemberOf, Department,
Title, LastLogonDate, PasswordLastSet
Write-Host "Located $($AllUsers.Count) enabled user accounts." -ForegroundColor Cyan
# Validate password policy groups exist
Write-Host "Validating password policy group structure..." -ForegroundColor Cyan
$ValidatedGroups = @()
$MissingGroups = @()
foreach ($GroupName in $PasswordPolicyGroups) {
try {
$Group = Get-ADGroup -Identity $GroupName -ErrorAction Stop
$ValidatedGroups += $GroupName
Write-Host "✓ Validated policy group: $GroupName" -ForegroundColor Green
}
catch {
$MissingGroups += $GroupName
Write-Warning "✗ Policy group not found: $GroupName"
}
}
if ($MissingGroups.Count -gt 0) {
Write-Warning "ATTENTION: The following policy groups are missing from bear.local:"
$MissingGroups | ForEach-Object { Write-Warning " - $_" }
Write-Host "Continuing with available groups..." -ForegroundColor Yellow
}
# Analyze user compliance with password policy groups
Write-Host "Analyzing FGPP compliance for all users..." -ForegroundColor Cyan
$NonCompliantUsers = @()
$CompliantUsers = @()
$ProcessedCount = 0
foreach ($User in $AllUsers) {
$ProcessedCount++
if ($ProcessedCount % 50 -eq 0) {
Write-Progress -Activity "Analyzing FGPP Compliance" -Status
"Processed $ProcessedCount of $($AllUsers.Count) users" -PercentComplete
(($ProcessedCount / $AllUsers.Count) * 100)
}
# Determine if user belongs to any password policy group
$UserGroups = $User.MemberOf
$AssignedToPolicyGroup = $false
$PolicyGroupMembership = @()
if ($UserGroups) {
foreach ($GroupDN in $UserGroups) {
try {
$GroupName = (Get-ADGroup -Identity $GroupDN).Name
if ($ValidatedGroups -contains $GroupName) {
$AssignedToPolicyGroup = $true
$PolicyGroupMembership += $GroupName
}
}
catch {
# Skip groups that can't be resolved
continue
}
}
}
# Categorize user based on policy group assignment
if ($AssignedToPolicyGroup) {
$CompliantUsers += [PSCustomObject]@{
'Username' = $User.SamAccountName
'DisplayName' = $User.Name
'PolicyGroups' = ($PolicyGroupMembership -join "; ")
}
} else {
# User is non-compliant - not in any password policy group
$NonCompliantUsers += [PSCustomObject]@{
'Username' = $User.SamAccountName
'DisplayName' = $User.Name
'Email' = $User.UserPrincipalName
'Department' = $User.Department
'Title' = $User.Title
'Enabled' = $User.Enabled
'LastLogonDate' = $User.LastLogonDate
'PasswordLastSet' = $User.PasswordLastSet
'DistinguishedName' = $User.DistinguishedName
'ComplianceStatus' = 'NON-COMPLIANT: Using Default Domain Policy'
}
}
}
Write-Progress -Activity "Analyzing FGPP Compliance" -Completed
# Generate comprehensive compliance report
Write-Host "Generating FGPP compliance report..." -ForegroundColor Cyan
# Create summary report header
$ReportSummary = @"
# FGPP Compliance Report for bear.local Domain
# Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
#
# SUMMARY:
# Total Users Analyzed: $($AllUsers.Count)
# Compliant Users: $($CompliantUsers.Count)
# Non-Compliant Users: $($NonCompliantUsers.Count)
# Compliance Rate: $(if($AllUsers.Count -gt 0){[math]::Round(($CompliantUsers.Count / $AllUsers.Count) * 100, 2)}else{0})%
#
# POLICY GROUPS MONITORED:
$(foreach($group in $ValidatedGroups){"# - $group"})
#
# NON-COMPLIANT USERS (Subject to Default Domain Policy):
"@
if ($NonCompliantUsers.Count -gt 0) {
# Export non-compliant users to CSV
$NonCompliantUsers | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
# Display compliance summary
Write-Host "`n=== FGPP COMPLIANCE SUMMARY FOR BEAR.LOCAL ===" -ForegroundColor Yellow
Write-Host "Total enabled users analyzed: $($AllUsers.Count)" -ForegroundColor White
Write-Host "Users with FGPP assignment: $($CompliantUsers.Count)" -ForegroundColor Green
Write-Host "Users without FGPP assignment: $($NonCompliantUsers.Count)" -ForegroundColor Red
Write-Host "Compliance rate: $(if($AllUsers.Count -gt 0){[math]::Round(($CompliantUsers.Count / $AllUsers.Count) * 100, 2)}else{0})%" -ForegroundColor White
Write-Host "Detailed report saved to: $ReportPath" -ForegroundColor Green
# Show preview of non-compliant users
Write-Host "`n=== NON-COMPLIANT USERS (Preview) ===" -ForegroundColor Red
$NonCompliantUsers | Select-Object -First 10 | Format-Table -AutoSize Username, DisplayName, Department, LastLogonDate, PasswordLastSet
if ($NonCompliantUsers.Count -gt 10) {
Write-Host "... plus $($NonCompliantUsers.Count - 10) additional non-compliant users. See complete report: $ReportPath" -ForegroundColor Cyan
}
# Security recommendations
Write-Host "`n=== SECURITY RECOMMENDATIONS ===" -ForegroundColor Yellow
Write-Host "1. Review non-compliant users and assign to appropriate policy groups" -ForegroundColor White
Write-Host "2. Verify that high-privilege accounts are not using default domain policy" -ForegroundColor White
Write-Host "3. Implement automated monitoring to prevent future compliance gaps" -ForegroundColor White
Write-Host "4. Consider service accounts that may need specialized password policies" -ForegroundColor White
} else {
Write-Host "`n=== FGPP COMPLIANCE SUMMARY FOR BEAR.LOCAL ===" -ForegroundColor Yellow
Write-Host "Total enabled users analyzed: $($AllUsers.Count)" -ForegroundColor White
Write-Host "✓ EXCELLENT: All users are assigned to password policy groups!" -ForegroundColor Green
Write-Host "✓ No users are falling back to default domain policy" -ForegroundColor Green
# Create empty report file with summary
$ReportSummary | Out-File -FilePath $ReportPath -Encoding UTF8
Write-Host "Summary report saved to: $ReportPath" -ForegroundColor Green
}
# Display monitored policy groups
Write-Host "`n=== MONITORED PASSWORD POLICY GROUPS ===" -ForegroundColor Yellow
$ValidatedGroups | ForEach-Object {
$GroupMembers = (Get-ADGroupMember -Identity $_ | Measure-Object).Count
Write-Host "✓ $_ ($GroupMembers members)" -ForegroundColor Green
}
if ($MissingGroups.Count -gt 0) {
Write-Host "`n=== MISSING POLICY GROUPS ===" -ForegroundColor Red
$MissingGroups | ForEach-Object { Write-Host "✗ $_ (not found in bear.local)" -ForegroundColor Red }
}
# Log event for monitoring systems
try {
New-EventLog -LogName "Application" -Source "FGPP Monitor" -ErrorAction SilentlyContinue
$EventMessage = "FGPP Compliance Check: $($AllUsers.Count) users analyzed, $($NonCompliantUsers.Count) non-compliant"
$EventType = if ($NonCompliantUsers.Count -gt 0) { "Warning" } else { "Information" }
$EventID = if ($NonCompliantUsers.Count -gt 0) { 1001 } else { 1000 }
Write-EventLog -LogName "Application" -Source "FGPP Monitor" -EventId $EventID -EntryType $EventType -Message $EventMessage
Write-Host "Compliance event logged to Windows Event Log" -ForegroundColor Cyan
}
catch {
Write-Warning "Could not write to Windows Event Log: $($_.Exception.Message)"
}
} catch {
Write-Error "Critical error during FGPP compliance audit: $($_.Exception.Message)"
Write-Error "Stack trace: $($_.ScriptStackTrace)"
exit 1
}
Write-Host "`nFGPP Compliance Audit completed successfully!" -ForegroundColor Green
Write-Host "For ongoing compliance, consider scheduling this script to run weekly." -ForegroundColor Cyan
Conclusion
Fine-Grained Password Policies are only as strong as your ability to monitor and maintain them. Without proper oversight, users can silently fall back to weaker default policies, creating significant security gaps that persist undetected.
The domain functional level limitation makes this even more critical – in Windows Server 2012 R2 environments, users falling back from FGPP requirements of 30+ characters to domain policy maximums of just 14 characters represents security degradation of I am sure what is already a weak password.