Storing credentials with PowerShell
2024-02-20Storing 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:
- You can only use Windows built-in resources
- The scheduled task must run under an account with minimum permissions
- The task must be able to access resources where the executing user doesn't have access
- 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:
- We take a credential object as input
- We create a unique filename that includes both the stored username and the current Windows user context
- We use
Export-Clixmlwhich automatically encrypts the secure string using Windows DPAPI - The filename pattern
username_credentials_for_DOMAIN_USER.clixmlensures 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:
- Run a script as that user to create the encrypted file
- 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:
- Decrypts using the AES key
- Re-encrypts using their own DPAPI context
- 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:
-
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.
-
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
-
SYSTEM account power: When using
$ForSystem, remember that ANY code running as SYSTEM can decrypt the credential. Use this carefully. -
No network transmission: All credential handling happens locally. The encrypted password never leaves the machine.
-
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:
- DPAPI is powerful: Windows' built-in encryption ties credentials to specific users
- Scheduled tasks solve logon restrictions: They work where
Start-Processfails - Temporary encryption bridges the gap: AES encryption lets us securely pass credentials to other user contexts
- 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.