A Plaintext Password That Cost a Client $200K
Last year we inherited a managed environment from another vendor—a mid-size logistics company running 40+ scheduled PowerShell scripts across their domain controllers and file servers. During our first security review, we found hardcoded plaintext credentials in seventeen of those scripts. Service account passwords, SQL connection strings, even a domain admin credential sitting in a .ps1 file with read access for the entire IT OU.
Three months before we got involved, an attacker used T1552.001 (Credentials In Files) to harvest those passwords, pivot laterally, and deploy ransomware across the client’s backup infrastructure. The recovery cost them north of $200,000.
Encoding passwords securely in PowerShell isn’t optional. It’s a baseline control that every enterprise script should implement from day one. This walkthrough covers how we rebuilt that client’s scripting infrastructure from plaintext disaster to proper credential handling—mistakes included.
Why Plaintext Passwords Keep Showing Up in Production
I get it. You’re building a script in a test environment, you hardcode a password to get things working, and then the script goes to production with the credentials still embedded. It happens constantly.
But here’s the thing: it violates virtually every compliance framework you can name. ISO 27001 Annex A.9 explicitly requires protection of authentication information. PCI-DSS, HIPAA, SOC 2—they all flag plaintext credentials as a critical finding. And threat groups like APT29 and FIN7 actively hunt for credentials stored in scripts and config files during post-exploitation.
The dangerous precedent is real. If your team tolerates plaintext in test, it will leak into production. Every single time.
Step 1: Understanding SecureString and DPAPI
PowerShell’s SecureString is a datatype specifically designed for storing sensitive information—passwords, connection strings, tokens. Under the hood, it’s encrypted using the Windows Data Protection API (DPAPI), which employs AES encryption tied to both the user’s credentials and a machine-specific key.
This is critical: a SecureString can only be decrypted by the same user on the same machine that created it. If an attacker exfiltrates the encrypted string to another system, it’s useless without the original user context and machine key.
Here’s the basic conversion from plaintext to SecureString:
# Convert a plaintext string to a SecureString object
$securePassword = ConvertTo-SecureString -String "YourPasswordHere" -AsPlainText -Force
# Convert the SecureString to an encrypted standard string for storage
$encryptedPassword = ConvertFrom-SecureString $securePassword
# Output the encrypted string — safe to store in a file
Write-Host "Encrypted Password: $encryptedPassword"
The -Force parameter suppresses the confirmation prompt that PowerShell throws when you explicitly convert plaintext. The output from ConvertFrom-SecureString is a DPAPI-encrypted string you can safely write to a file or config.
One caveat: DPAPI encryption is tied to the Windows user profile. If your script runs as a scheduled task under a service account, you need to generate the encrypted string while logged in as that service account. We learned this the hard way during the logistics client rebuild—encrypted the passwords under an admin account, then watched every scheduled task fail at 2 AM because the service account couldn’t decrypt them.
Step 2: Building a Proper Credential File
The approach we standardized across that client’s environment uses Export-Clixml to serialize the entire credential object. This is cleaner than storing raw encrypted strings because it preserves both the username and the encrypted password in a single XML file.
# Create credential object interactively — password is masked on input
$cred = Get-Credential
# Export to encrypted XML — only this user on this machine can decrypt
$cred | Export-Clixml -Path "C:\Scripts\Credentials\svc-backup.xml"
# Later, in your automation script, import it back
$cred = Import-Clixml -Path "C:\Scripts\Credentials\svc-backup.xml"
# Use the credential object directly
New-PSSession -ComputerName "FS-PROD01" -Credential $cred
The XML file contains an encrypted standard string representation of the password. Open it in a text editor and you’ll see cipher text, not the original password. DPAPI handles the encryption and decryption transparently.
We locked down the credential files with NTFS ACLs—read permission only for the service account that needs them, deny for everyone else. This aligns with MITRE ATT&CK’s mitigation M1017 (User Training) and M1026 (Privileged Account Management).
Step 3: Handling Interactive Credential Input
For scripts that require manual execution—maintenance tasks, one-off migrations—prompting at runtime eliminates stored credentials entirely:
# Prompt for password with masked input
$SecurePassword = Read-Host -Prompt "Enter service account password" -AsSecureString
# Build a PSCredential object from the secure input
$Credential = New-Object System.Management.Automation.PSCredential(
"DOMAIN\svc-backup",
$SecurePassword
)
# Use the credential for a remote session
New-PSSession -ComputerName "PS-HOST01" -Credential $Credential
Read-Host -AsSecureString masks the input during typing and returns a SecureString directly. No plaintext variable ever exists in memory. This is the approach I recommend for any script that’s run interactively by an admin, especially in environments where IPsec policies enforce encrypted remote sessions.
Step 4: Symmetric Key Encryption for Multi-Server Environments
DPAPI’s machine-and-user binding is excellent for single-server scripts. But what about scripts that need to run on multiple servers—say, a concurrent network operations workflow that hits 50 endpoints?
This is where RijndaelManaged symmetric encryption comes in. You generate a shared AES key, use it to encrypt the credential, and distribute both the encrypted credential file and the key file to authorized servers.
# Generate a 256-bit AES key
$Key = New-Object Byte[] 32
[Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($Key)
# Save the key to a secure location with restricted ACLs
$Key | Set-Content -Path "C:\Scripts\Keys\aes-key.bin" -Encoding Byte
# Encrypt the password using the AES key instead of DPAPI
$securePassword = ConvertTo-SecureString "YourPasswordHere" -AsPlainText -Force
$encryptedPassword = ConvertFrom-SecureString $securePassword -Key $Key
$encryptedPassword | Set-Content -Path "C:\Scripts\Credentials\encrypted-pwd.txt"
# On any server with the key file, decrypt and use
$Key = Get-Content -Path "C:\Scripts\Keys\aes-key.bin" -Encoding Byte
$encryptedPassword = Get-Content -Path "C:\Scripts\Credentials\encrypted-pwd.txt"
$securePassword = ConvertTo-SecureString $encryptedPassword -Key $Key
The security of this method lives or dies with the key file. If an attacker gets the key, they can decrypt the credential on any machine. We store AES key files on a restricted network share with audit logging enabled—every read triggers a security event that feeds into the client’s SIEM.
My strong opinion here: if your environment is large enough to need cross-server credential sharing, you should be evaluating Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault instead of rolling your own key distribution. Symmetric key files in SMB shares are better than plaintext, but they’re not where you want to stay long-term.
Step 5: Base64 Is Not Encryption
I need to address something we see constantly during client audits. Teams encode credentials in Base64 and treat it as a security measure.
It is not.
Base64 encoding is a reversible transformation, not encryption. Anyone with access to the encoded string can decode it in seconds with [System.Convert]::FromBase64String() or a dozen free online tools. It provides obfuscation—the string isn’t human-readable at a glance—but zero actual security.
If your scripts contain Base64-encoded passwords, treat them as plaintext exposures during your next security review. Replace them with SecureString or credential file approaches immediately.
What We Deployed at the Client
After the logistics company incident, we built a standardized credential management framework across their environment:
- All scheduled tasks use
Export-Clixmlcredential files generated under the executing service account - Credential files stored in
C:\Scripts\Credentials\with NTFS ACLs restricting access to the specific service account - Multi-server scripts use AES-256 key encryption with keys stored on a restricted management share
- Interactive scripts use
Read-Host -AsSecureStringwith no credential persistence - Group Policy enforces PowerShell script block logging (Event ID 4104) to detect any plaintext credential usage that slips through
- Quarterly audits grep the entire script repository for patterns like
-AsPlainTextpaired with hardcoded strings
The migration took about two weeks. Seventeen scripts rewritten. Zero credential-related incidents in the twelve months since.
The Detection Side: Catching Plaintext Credential Abuse
Hunt for Hardcoded Credentials in Your Script Repos
Run this across your script directories to find potential plaintext password patterns:
# Search for common plaintext credential patterns in .ps1 files
# Detects hardcoded passwords that bypass SecureString protection
Get-ChildItem -Path "C:\Scripts" -Recurse -Filter *.ps1 |
Select-String -Pattern '(password|pwd|secret|token)\s*=\s*["''][^"'']+["'']' -AllMatches |
Select-Object Path, LineNumber, Line
If this returns results in your production script library, you have work to do. We run a variation of this as a scheduled scan for every managed customer environment as part of our enterprise backup and security monitoring service.
Monitor for Credential Access via Script Block Logging
Enable PowerShell script block logging via Group Policy and monitor Event ID 4104 for suspicious patterns. If someone is dumping SecureStrings back to plaintext using [Runtime.InteropServices.Marshal]::PtrToStringAuto(), you want to know about it. That’s T1059.001 in action.
Practical Takeaway
Every PowerShell script in your enterprise that touches credentials should follow a simple rule: never instantiate a plaintext password variable that persists beyond a single conversion operation. Use Get-Credential for interactive input, Export-Clixml for automated credential storage, and AES key encryption when DPAPI’s machine binding is too restrictive. Audit your script repositories quarterly.
If you’re inheriting an environment full of plaintext credentials in scripts—and statistically, you probably are—the remediation is straightforward but non-negotiable. Start with the service accounts that have the highest privilege and work down. If you need help assessing your exposure or rebuilding your credential management approach, reach out to our team. We’ve done this cleanup enough times to get it right the first pass.


