Storing credentials with PowerShell

2024-02-20

Storing credentials with PowerShell

Storing any kind of credentials in clear text is a very bad idea.

But what can we do if we have a task that should run with minimum privileges because it doesn't require elevated rights all the time? Or if this task must use specific credentials to connect to remote systems?

I've seen many "solutions" over the years, some of which were, shall we say, creative but not secure. In this article, I'll show you how to build a practical solution using only Windows built-in resources, step by step.

Note: If you're not familiar with PowerShell's SecureString concept, check out my detailed article PowerShell and Secure Strings first.

The challenge

Let me outline the requirements that led to this solution:

  1. You can only use Windows built-in resources
  2. The scheduled task must run under an account with minimum permissions
  3. The task must be able to access resources where the executing user doesn't have access
  4. The privileged credential must only be readable and usable by the task-executing user

The first challenge is deciding how to store the credentials securely.

Why Export-Clixml is the answer

The initial idea might be to use ConvertFrom-SecureString and Out-File, but if we do this with a different account than the one that will use the credential, we must use a static key. This leads to a situation where the key might be hardcoded in the script, which is nearly the same as storing the password in clear text.

Fortunately, there's a better approach: Export-Clixml serializes .NET objects and stores them as XML. The key advantage is that secure strings will be encrypted using the executing user's account information via Windows DPAPI (Data Protection API). When we need the object back, we simply use Import-Clixml to retrieve the complete object.

This means the user running the scheduled task must store the credentials themselves, ensuring the password is encrypted using that user's information and cannot be used by another user.

The scheduled task user problem

Now we encounter another challenge: users running scheduled tasks typically aren't allowed to log on locally or via Remote Desktop (RDP). They only have the "Log on as a batch job" right (SeBatchLogonRight). Therefore, we need a creative solution that allows these users to create the credential file anyway.

Building the Set-CredentialsFile.ps1 script

Let's build our solution step by step, starting simple and adding features as we go.

Step 1: The basic version

Let's start with the simplest possible version - storing credentials for the current user:

[cmdletbinding()]
param(
    [Parameter(Mandatory)]
    [pscredential]$CredentialToStore
)

# Define the base path for our credential files
$basePath = $PSScriptRoot

# Create a unique filename based on username and current user context
$fileName = "$($CredentialToStore.UserName -replace '\\|/','_' -replace '@.*?$')_credentials_for_" + 
            "$($env:USERDOMAIN)_$($env:USERNAME).clixml"

$fullPath = Join-Path -Path $basePath -ChildPath $fileName

# Export the credential using the current user's encryption
$CredentialToStore | Export-Clixml -Path $fullPath -Force

Write-Host "Credential stored successfully at: $fullPath"

This basic version works perfectly when you're already running as the user who will later use the credentials. For example:

$cred = Get-Credential
.\Set-CredentialsFile.ps1 -CredentialToStore $cred

What's happening here:

  1. We take a credential object as input
  2. We create a unique filename that includes both the stored username and the current Windows user context
  3. We use Export-Clixml which automatically encrypts the secure string using Windows DPAPI
  4. The filename pattern username_credentials_for_DOMAIN_USER.clixml ensures we can identify which user can decrypt this file

Step 2: Adding support for different users

Now comes the tricky part - what if we want to store credentials for a scheduled task user who can't log in interactively? We need to:

  1. Run a script as that user to create the encrypted file
  2. Somehow pass the credentials to that script without exposing them in clear text

Here's the approach: we'll create a temporary script that the target user will execute. But we can't just pass the password in clear text to that script. Instead, we'll use an intermediate encryption with AES.

Let's add the necessary parameters first:

[cmdletbinding()]
param(
    [Parameter(Mandatory)]
    [pscredential]$CredentialToStore,
    [pscredential]$credentialsUsing
)

$basePath = $PSScriptRoot

Now we need to create two script blocks - one for the beginning of our temporary script and one for the end:

# This will be the parameter block of our temporary script
$TempScriptBlockHead = {
    [cmdletbinding()]
    param(
        [string]$UsernameToStore,
        [string]$passwordToStore
    )
}

# This will be the main logic of our temporary script
$TempScriptBlockFoot = {
    $basePath = $PSScriptRoot
    
    # Decrypt the password using the AES key (embedded in the script)
    $password = ConvertTo-SecureString -String $passwordToStore -Key $key
    
    # Clear the plain password variable for security
    $passwordToStore = ""
    
    # Recreate the credential object
    $CredentialToStore = [pscredential]::new($UsernameToStore, $password)
    
    # Export using THIS user's DPAPI encryption
    $fileName = "$($CredentialToStore.UserName -replace '\\|/','_' -replace '@.*?$')_credentials_for_" + 
                "$($env:USERDOMAIN)_$($env:USERNAME).clixml"
    $fullPath = Join-Path -Path $basePath -ChildPath $fileName
    
    $CredentialToStore | Export-Clixml -Path $fullPath -Force
}

Now let's generate a random AES key and build the complete temporary script:

# Generate a random 32-byte (256-bit) AES key
[byte[]]$Key = 1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }

# Build the complete script by combining all parts
# The key is embedded as an array in the middle
[string]$TempScriptBlock = @(
    $TempScriptBlockHead.ToString()
    '$key = @(' + ($Key -join ',') + ')'
    $TempScriptBlockFoot.ToString()
) -join "`r`n"

Understanding the security approach:

  • We encrypt the password with a random AES key we control
  • We embed this key in the temporary script
  • The target user runs the temporary script, which:
    1. Decrypts using the AES key
    2. Re-encrypts using their own DPAPI context
    3. Saves the file (which only they can decrypt later)
  • We then delete the temporary script (and with it, the AES key)

Now let's add the logic to either store directly or use the temporary script approach:

if ($credentialsUsing) {
    # Mode: Store for a different user
    
    # Create a unique temporary script name
    $tempScriptGUID = [guid]::NewGuid().Guid
    $tempScript = Join-Path -Path $PSScriptRoot -ChildPath "$tempScriptGUID.ps1"
    
    # Write the temporary script to disk
    $TempScriptBlock | Out-File -FilePath $tempScript -Encoding utf8 -Force
    
    # Encrypt the password with our AES key
    $encryptedPassword = ConvertFrom-SecureString -SecureString $CredentialToStore.Password -Key $Key
    
    # Build the command to execute the temporary script
    $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -UsernameToStore "{1}" -passwordToStore "{2}"' -f 
                 $tempScript, $CredentialToStore.UserName, $encryptedPassword
    
    # Execute the temporary script as the target user
    Start-Process -FilePath "powershell.exe" `
                  -ArgumentList $arguments `
                  -Credential $credentialsUsing `
                  -Wait `
                  -NoNewWindow
    
    # Clean up the temporary script
    Remove-Item $tempScript -Force -Confirm:$false
    
    Write-Host "Credential stored successfully for user: $($credentialsUsing.UserName)"
}
else {
    # Mode: Store for current user (simple mode from Step 1)
    $fileName = "$($CredentialToStore.UserName -replace '\\|/','_' -replace '@.*?$')_credentials_for_" + 
                "$($env:USERDOMAIN)_$($env:USERNAME).clixml"
    $fullPath = Join-Path -Path $basePath -ChildPath $fileName
    
    $CredentialToStore | Export-Clixml -Path $fullPath -Force
    
    Write-Host "Credential stored successfully at: $fullPath"
}

Now we can use it like this:

# Store credential for a different user
$credToStore = Get-Credential -Message "Enter credentials to store"
$targetUser = Get-Credential -Message "Enter the user who will use these credentials"

.\Set-CredentialsFile.ps1 -CredentialToStore $credToStore -credentialsUsing $targetUser

Step 3: Adding the ForScheduledTask switch

There's a problem with using Start-Process for scheduled task users: they often don't have the right to launch new processes in this way. The solution? Use a scheduled task instead! Let's add a switch parameter:

[cmdletbinding()]
param(
    [Parameter(Mandatory)]
    [pscredential]$CredentialToStore,
    [pscredential]$credentialsUsing,
    [switch]$ForScheduledTask
)

Now let's modify our execution logic to use a scheduled task when the switch is set:

if ($credentialsUsing) {
    # ... (temporary script creation code stays the same) ...
    
    # Encrypt the password with our AES key
    $encryptedPassword = ConvertFrom-SecureString -SecureString $CredentialToStore.Password -Key $Key
    
    # Build the PowerShell command
    $psCommand = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -UsernameToStore "{1}" -passwordToStore "{2}"' -f 
                 $tempScript, $CredentialToStore.UserName, $encryptedPassword
    
    if ($ForScheduledTask) {
        # Use scheduled task method
        $taskName = "StoreCredential_$tempScriptGUID"
        
        # Create the scheduled task action
        $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $psCommand
        
        # Create a trigger that runs on registration (immediately)
        $trigger = Get-CimClass "MSFT_TaskRegistrationTrigger" -Namespace "Root/Microsoft/Windows/TaskScheduler"
        
        # Configure task settings
        $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -Compatibility Vista
        
        # Extract username and password for task registration
        $taskUser = $credentialsUsing.UserName
        $taskPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
            [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($credentialsUsing.Password)
        )
        
        # Register the task
        $registeredTask = Register-ScheduledTask -TaskName $taskName `
                                                  -Action $action `
                                                  -Trigger $trigger `
                                                  -Settings $settings `
                                                  -User $taskUser `
                                                  -Password $taskPassword `
                                                  -Force
        
        # Wait for the task to complete (with timeout)
        $startTime = [datetime]::Now
        $timeout = $false
        
        do {
            $taskInfo = $registeredTask | Get-ScheduledTaskInfo
            Start-Sleep -Milliseconds 100
            
            $currentTime = [datetime]::Now
            $elapsedSeconds = ($currentTime - $startTime).TotalSeconds
            
            if ($elapsedSeconds -ge 30) {
                $timeout = $true
                Write-Warning "Task execution timed out after 30 seconds"
            }
        } until ($taskInfo.LastTaskResult -eq 0 -or $timeout)
        
        # Clean up the scheduled task
        Unregister-ScheduledTask -InputObject $registeredTask -Confirm:$false
        
        Write-Host "Credential stored successfully for user: $taskUser"
    }
    else {
        # Use Start-Process method (original approach)
        Start-Process -FilePath "powershell.exe" `
                      -ArgumentList $psCommand `
                      -Credential $credentialsUsing `
                      -Wait `
                      -NoNewWindow
        
        Write-Host "Credential stored successfully for user: $($credentialsUsing.UserName)"
    }
    
    # Clean up the temporary script
    Remove-Item $tempScript -Force -Confirm:$false
}

Why scheduled tasks work better:

  • Scheduled tasks can run under users with only SeBatchLogonRight
  • They don't require interactive logon rights
  • The task runs on registration (immediately) and we can wait for completion
  • We clean up the task after it completes

Usage:

$credToStore = Get-Credential -Message "Enter credentials to store"
$taskUser = Get-Credential -Message "Enter scheduled task user credentials"

.\Set-CredentialsFile.ps1 -CredentialToStore $credToStore `
                           -credentialsUsing $taskUser `
                           -ForScheduledTask

Step 4: Adding SYSTEM account support

Sometimes you need to store credentials for tasks running as the SYSTEM account. Let's add that capability:

[cmdletbinding()]
param(
    [Parameter(Mandatory)]
    [pscredential]$CredentialToStore,
    [pscredential]$credentialsUsing,
    [switch]$ForScheduledTask,
    [switch]$ForSystem
)

Now we need to modify the scheduled task registration to handle SYSTEM:

if ($credentialsUsing -or $ForSystem) {
    # ... (temporary script creation code stays the same) ...
    
    if ($ForScheduledTask -or $ForSystem) {
        # Use scheduled task method
        $taskName = "StoreCredential_$tempScriptGUID"
        
        $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $psCommand
        $trigger = Get-CimClass "MSFT_TaskRegistrationTrigger" -Namespace "Root/Microsoft/Windows/TaskScheduler"
        $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -Compatibility Vista
        
        # Prepare task registration parameters
        $scheduledTaskParams = @{
            TaskName = $taskName
            Action   = $action
            Trigger  = $trigger
            Settings = $settings
            Force    = $true
        }
        
        if ($ForSystem) {
            # Register to run as SYSTEM
            $scheduledTaskParams.User = "SYSTEM"
        }
        else {
            # Register to run as specified user
            $taskUser = $credentialsUsing.UserName
            $taskPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($credentialsUsing.Password)
            )
            
            $scheduledTaskParams.User = $taskUser
            $scheduledTaskParams.Password = $taskPassword
        }
        
        # Register the task
        $registeredTask = Register-ScheduledTask @scheduledTaskParams
        
        # Wait for completion (same as before)
        $startTime = [datetime]::Now
        $timeout = $false
        
        do {
            $taskInfo = $registeredTask | Get-ScheduledTaskInfo
            Start-Sleep -Milliseconds 100
            
            $currentTime = [datetime]::Now
            $elapsedSeconds = ($currentTime - $startTime).TotalSeconds
            
            if ($elapsedSeconds -ge 30) {
                $timeout = $true
                Write-Warning "Task execution timed out after 30 seconds"
            }
        } until ($taskInfo.LastTaskResult -eq 0 -or $timeout)
        
        # Clean up
        Unregister-ScheduledTask -InputObject $registeredTask -Confirm:$false
        
        $userContext = if ($ForSystem) { "SYSTEM" } else { $taskUser }
        Write-Host "Credential stored successfully for user: $userContext"
    }
    else {
        # Start-Process method for non-scheduled task scenarios
        # ... (same as before) ...
    }
    
    # Clean up the temporary script
    Remove-Item $tempScript -Force -Confirm:$false
}
else {
    # Simple mode: store for current user
    # ... (same as Step 1) ...
}

Usage for SYSTEM:

$credToStore = Get-Credential -Message "Enter credentials to store"

.\Set-CredentialsFile.ps1 -CredentialToStore $credToStore -ForSystem

The complete script

Here's the final, complete version of Set-CredentialsFile.ps1:

[cmdletbinding()]
param(
    [Parameter(Mandatory)]
    [pscredential]$CredentialToStore,
    [pscredential]$credentialsUsing,
    [switch]$ForScheduledTask,
    [switch]$ForSystem
)

$basePath = $PSScriptRoot

# Template for temporary script - parameter block
$TempScriptBlockHead = {
    [cmdletbinding()]
    param(
        [string]$UsernameToStore,
        [string]$passwordToStore
    )
}

# Template for temporary script - main logic
$TempScriptBlockFoot = {
    $basePath = $PSScriptRoot
    $password = ConvertTo-SecureString -String $passwordToStore -Key $key
    $passwordToStore = ""
    $CredentialToStore = [pscredential]::new($UsernameToStore, $password)
    $CredentialToStore | Export-Clixml -Path $($basePath + "\$($CredentialToStore.UserName -replace '\\|/','_' -replace '@.*?$')_credentials_for_" + $($env:USERDOMAIN)+'_'+$($env:USERNAME) + ".clixml") -Force
}

# Generate random AES key
[byte[]]$Key = 1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }

# Build complete temporary script
[string]$TempScriptBlock = @(
    $TempScriptBlockHead.ToString()
    '$key = @(' + ($Key -join ',') + ')'
    $TempScriptBlockFoot.ToString()
) -join "`r`n"

if ($credentialsUsing -or $ForSystem) {
    # Create temporary script
    $tempScriptGUID = [guid]::NewGuid().Guid
    $tempScript = Join-Path -Path $PSScriptRoot -ChildPath "$tempScriptGUID.ps1"
    
    $TempScriptBlock | Out-File -FilePath $tempScript -Encoding utf8 -Force
    
    # Encrypt password with AES key
    $encryptedPassword = ConvertFrom-SecureString -SecureString $CredentialToStore.Password -Key $Key
    
    # Build PowerShell command
    $psCommand = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -UsernameToStore "{1}" -passwordToStore "{2}"' -f 
                 $tempScript, $CredentialToStore.UserName, $encryptedPassword
    
    if ($ForScheduledTask -or $ForSystem) {
        # Scheduled task method
        $taskName = "StoreCredential_$tempScriptGUID"
        
        $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $psCommand
        $trigger = Get-CimClass "MSFT_TaskRegistrationTrigger" -Namespace "Root/Microsoft/Windows/TaskScheduler"
        $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -Compatibility Vista
        
        $scheduledTaskParams = @{
            TaskName = $taskName
            Action   = $action
            Trigger  = $trigger
            Settings = $settings
            Force    = $true
        }
        
        if ($ForSystem) {
            $scheduledTaskParams.User = "SYSTEM"
        }
        else {
            $taskUser = $credentialsUsing.UserName
            $taskPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($credentialsUsing.Password)
            )
            $scheduledTaskParams.User = $taskUser
            $scheduledTaskParams.Password = $taskPassword
        }
        
        $registeredTask = Register-ScheduledTask @scheduledTaskParams
        
        # Wait for task completion
        $startTime = [datetime]::Now
        $timeout = $false
        
        do {
            $taskInfo = $registeredTask | Get-ScheduledTaskInfo
            Start-Sleep -Milliseconds 100
            
            $currentTime = [datetime]::Now
            $elapsedSeconds = ($currentTime - $startTime).TotalSeconds
            
            if ($elapsedSeconds -ge 30) {
                $timeout = $true
            }
        } until ($taskInfo.LastTaskResult -eq 0 -or $timeout)
        
        Unregister-ScheduledTask -InputObject $registeredTask -Confirm:$false
    }
    else {
        # Start-Process method
        Start-Process -FilePath "powershell.exe" `
                      -ArgumentList $psCommand `
                      -Credential $credentialsUsing `
                      -Wait `
                      -NoNewWindow
    }
    
    # Clean up temporary script
    Remove-Item $tempScript -Force -Confirm:$false
}
else {
    # Simple mode: store for current user
    $CredentialToStore | Export-Clixml -Path $($basePath + "\$($CredentialToStore.UserName -replace '\\|/','_' -replace '@.*?$')_credentials_for_" + $($env:USERDOMAIN)+'_'+$($env:USERNAME) + ".clixml") -Force
}

Using stored credentials

Once you've stored credentials using any of the methods above, retrieving them is straightforward:

# Find credential file for current user
$currentUser = "$env:USERDOMAIN`_$env:USERNAME"
$credentialFile = Get-ChildItem -Path $PSScriptRoot -Filter "*_credentials_for_$currentUser.clixml" | 
                  Select-Object -First 1

if ($credentialFile) {
    # Import and use the credential
    $credential = Import-Clixml -Path $credentialFile.FullName
    
    # Example: Remote command execution
    Invoke-Command -ComputerName "RemoteServer" -Credential $credential -ScriptBlock {
        Get-Service | Where-Object Status -eq 'Running'
    }
}
else {
    Write-Error "No credential file found for current user"
}

Security considerations

Understanding what we've built:

  1. User-bound encryption: The final credential file uses Windows DPAPI with the target user's profile. Only that specific user on that specific machine can decrypt it.

  2. Temporary key exposure: The AES key exists briefly in:

    • Memory during script execution
    • The temporary script file on disk (for a few seconds)
    • These are both cleaned up, but ensure your script directory has appropriate permissions
  3. SYSTEM account power: When using $ForSystem, remember that ANY code running as SYSTEM can decrypt the credential. Use this carefully.

  4. No network transmission: All credential handling happens locally. The encrypted password never leaves the machine.

  5. Scheduled task audit trail: Scheduled tasks leave entries in Windows Event Logs, which can be useful for auditing.

When to use this solution

This approach is ideal for:

  • Small to medium organizations without dedicated secrets management
  • Scenarios requiring only Windows built-in tools
  • Development and testing environments
  • Single-server automation tasks
  • Legacy systems where modern solutions aren't available

When to use something else

Consider dedicated secrets management when:

  • Managing credentials across multiple servers
  • Handling highly sensitive credentials (production databases, etc.)
  • Requiring audit trails and automatic rotation
  • Operating in regulated environments (PCI DSS, HIPAA, SOX, etc.)
  • Managing dozens or hundreds of credentials

Modern alternatives include:

  • Azure Key Vault
  • AWS Secrets Manager
  • HashiCorp Vault
  • CyberArk or similar PAM solutions
  • Windows Credential Manager API (for simpler local scenarios)

Conclusion

We've built a complete solution that starts simple and grows to handle complex scenarios. The key insights are:

  1. DPAPI is powerful: Windows' built-in encryption ties credentials to specific users
  2. Scheduled tasks solve logon restrictions: They work where Start-Process fails
  3. Temporary encryption bridges the gap: AES encryption lets us securely pass credentials to other user contexts
  4. Cleanup is essential: Always remove temporary scripts and tasks

This solution demonstrates that you don't always need enterprise tools to solve security challenges - sometimes, creative use of built-in features is enough.

For more information about the underlying SecureString technology, see my article on PowerShell and Secure Strings.