How to Programmatically Create a Windows User Profile for Service Accounts


2 views

When creating local service accounts in Windows, you'll notice the user profile directory (typically under C:\Users) isn't automatically created. This becomes problematic when:

  • Services require configuration files in their profile (SSH keys, Git configs, etc.)
  • Tools expect Unix-style home directory locations
  • You need predictable paths for configuration management

The typical net user command only creates the account without building the profile structure. If you manually create the directory first, Windows will append random characters to the profile path during actual usage, breaking any pre-configured paths.

Here's a PowerShell function that reliably creates both the user account and its profile:

function Create-ServiceUserWithProfile {
    param (
        [string]$Username,
        [string]$Password,
        [string]$HomeDirectory = $null
    )
    
    # Create the user account
    $userParams = @{
        Name        = $Username
        Password    = (ConvertTo-SecureString -String $Password -AsPlainText -Force)
        Description = "Service account"
    }
    New-LocalUser @userParams
    
    # Trigger profile creation by simulating a login
    $credential = New-Object System.Management.Automation.PSCredential($Username, (ConvertTo-SecureString $Password -AsPlainText -Force))
    
    Start-Process -FilePath "cmd.exe" -Credential $credential -ArgumentList "/c exit" -WindowStyle Hidden -Wait
    
    # Optional: Set home directory if specified
    if ($HomeDirectory) {
        Set-LocalUser -Name $Username -HomeDirectory $HomeDirectory
    }
    
    # Wait for profile to be fully created
    while (-not (Test-Path "C:\Users\$Username")) {
        Start-Sleep -Seconds 1
    }
}

For more control, you can use Windows API calls through PowerShell:

Add-Type @'
using System;
using System.Runtime.InteropServices;

public class UserProfile {
    [DllImport("userenv.dll", CharSet = CharSet.Unicode, ExactSpelling = false, SetLastError = true)]
    public static extern bool CreateProfile(
        [MarshalAs(UnmanagedType.LPWStr)] string pszUserSid,
        [MarshalAs(UnmanagedType.LPWStr)] string pszUserName,
        [Out][MarshalAs(UnmanagedType.LPWStr)] out string pszProfilePath,
        uint cchProfilePath
    );
}
'@

$user = Get-LocalUser -Name "foobar"
$sid = $user.SID.Value
$profilePath = [System.Text.StringBuilder]::new(260)

[UserProfile]::CreateProfile($sid, "foobar", [ref] $profilePath, $profilePath.Capacity)

Once the profile exists, you can populate it with required files:

# Create .ssh directory and set permissions
$sshDir = "C:\Users\foobar\.ssh"
New-Item -ItemType Directory -Path $sshDir -Force
icacls $sshDir /grant "foobar:(OI)(CI)F"

# Create git config
@"
[user]
    name = Service Account
    email = service@domain.com
"@ | Out-File -FilePath "C:\Users\foobar\.gitconfig" -Encoding UTF8
  • Profile creation requires admin privileges
  • The simulated login approach may trigger security alerts in some environments
  • For production systems, consider using Group Policy Preferences instead of hard-coded passwords
  • Always secure sensitive files with proper NTFS permissions

When provisioning Windows service accounts programmatically, we often encounter the profile directory creation timing problem. Unlike interactive logins, service accounts don't automatically generate their profile directories at account creation time.

Here's a PowerShell function that forces profile creation for a local user account:


function Create-UserProfile {
    param(
        [string]$Username,
        [string]$Password
    )
    
    # Load required assembly
    Add-Type -AssemblyName System.DirectoryServices.AccountManagement
    
    # Create context for local machine
    $context = New-Object System.DirectoryServices.AccountManagement.PrincipalContext([System.DirectoryServices.AccountManagement.ContextType]::Machine)
    
    # Validate credentials to trigger profile creation
    $valid = $context.ValidateCredentials($Username, $Password)
    
    if ($valid) {
        # Use Windows API to load user profile
        $pinvokeCode = @"
using System;
using System.Runtime.InteropServices;

public class UserProfileHelper {
    [DllImport("userenv.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern bool LoadUserProfile(IntPtr hToken, ref PROFILEINFO lpProfileInfo);

    [DllImport("userenv.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern bool UnloadUserProfile(IntPtr hToken, IntPtr hProfile);

    [StructLayout(LayoutKind.Sequential)]
    public struct PROFILEINFO {
        public int dwSize;
        public int dwFlags;
        [MarshalAs(UnmanagedType.LPTStr)]
        public string lpUserName;
        [MarshalAs(UnmanagedType.LPTStr)]
        public string lpProfilePath;
        [MarshalAs(UnmanagedType.LPTStr)]
        public string lpDefaultPath;
        [MarshalAs(UnmanagedType.LPTStr)]
        public string lpServerName;
        [MarshalAs(UnmanagedType.LPTStr)]
        public string lpPolicyPath;
        public IntPtr hProfile;
    }
}
"@
        Add-Type -TypeDefinition $pinvokeCode
        
        # Impersonate user and load profile
        $logonToken = [System.Security.Principal.WindowsIdentity]::GetCurrent().Token
        $profileInfo = New-Object UserProfileHelper+PROFILEINFO
        $profileInfo.dwSize = [System.Runtime.InteropServices.Marshal]::SizeOf($profileInfo)
        $profileInfo.lpUserName = $Username
        
        $success = [UserProfileHelper]::LoadUserProfile($logonToken, [ref]$profileInfo)
        
        if ($success) {
            [UserProfileHelper]::UnloadUserProfile($logonToken, $profileInfo.hProfile)
            return $true
        } else {
            throw "Failed to load user profile"
        }
    } else {
        throw "Invalid credentials"
    }
}

# Usage example:
# Create-UserProfile -Username "foobar" -Password "Abcd123!"

Another reliable method is to create a temporary scheduled task that runs as the target user:


$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument "/c exit 0"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date)
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -DontStopOnIdleEnd
$task = Register-ScheduledTask -TaskName "TempProfileCreator" -Action $action -Trigger $trigger -Settings $settings -User "foobar" -Password "Abcd123!"
Start-ScheduledTask -TaskName "TempProfileCreator"
Start-Sleep -Seconds 5
Unregister-ScheduledTask -TaskName "TempProfileCreator" -Confirm:$false

Once the profile directory exists, you can populate it with required configuration files:


$userProfile = "C:\Users\foobar"
New-Item -Path "$userProfile\.ssh" -ItemType Directory -Force
New-Item -Path "$userProfile\.gitconfig" -ItemType File -Force

# Example SSH config
@"
Host *
    StrictHostKeyChecking no
    UserKnownHostsFile ~/.ssh/known_hosts
"@ | Out-File -FilePath "$userProfile\.ssh\config" -Encoding ASCII

# Example Git config
@"
[user]
    name = Service Account
    email = service@example.com
[core]
    autocrlf = false
"@ | Out-File -FilePath "$userProfile\.gitconfig" -Encoding ASCII
  • Profile creation requires administrative privileges
  • The password must meet Windows complexity requirements
  • Some Windows versions may have slightly different profile directory naming conventions
  • Consider using the SYSTEM account temporarily if you encounter permission issues

When configuring your service with NSSM, ensure proper environment variable setup:


nssm set MyService AppEnvironmentExtra "USERPROFILE=C:\Users\foobar"
nssm set MyService AppParameters "--config=C:\Users\foobar\.serviceconfig"