I was evaluating a client’s Microsoft tenant for HIPAA compliance requirements. I needed to pull detailed data from their environment – user attributes, security settings, device compliance status, all the things that matter for a HIPAA assessment. That meant running scripts with their credentials. I didn’t want those credentials sitting around in plaintext while I’m digging through their infrastructure. I also didn’t want to ask them to set up a whole separate authentication system just so I could run some audit scripts on my machine.
That’s when the credential problem became real. I needed a way to keep secrets secure while running one-off PowerShell scripts locally, without the overhead of full secret management infrastructure.
You’re writing automation scripts that call APIs. Your scripts look clean. Your logic is solid. And then you realize your API credentials are sitting in plaintext in a file that syncs to OneDrive. Someone with five minutes of poking around gets your API keys.
The obvious fix is Windows Credential Manager. It’s the same encrypted vault that stores your browser passwords. But getting it to work reliably in PowerShell 7 is anything but obvious. I spent today fighting this with Claude Code trying to set up secure credential handling for one of our client automations, and I want to save you the afternoon I just lost.
Here’s what actually works.
Why the Standard Solutions Fail
Everyone recommends the CredentialManager module. It wraps Windows Credential Manager with clean PowerShell cmdlets and it works great in Windows PowerShell 5.1. The problem: PowerShell 7 runs on .NET Core, and the module depends on System.Web.Security.Membership, a .NET Framework class that doesn’t exist anymore. You get:
Could not load type 'System.Web.Security.Membership' from assembly 'System.Web, Version=4.0.0.0'
Dead on arrival. If you already stored credentials using this module from PS 5.1, don’t panic. Those credentials are still in Windows Credential Manager at the OS level. You just can’t read them with the module from PowerShell 7.
Microsoft knows this is a problem, so they built SecretManagement and SecretStore as the official PowerShell 7 solution. Setup looks reasonable:
Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser
Install-Module Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name "LocalStore" -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false
Set-Secret -Name "MyAPI-ClientId" -Secret "your-client-id"
This should work. It doesn’t work in production. When SecretStore creates its vault, it requires an interactive password prompt even though you configured it for no authentication. There’s no way around it. You can’t automate this in CI/CD pipelines or non-interactive environments. You can’t use it anywhere there’s no way to respond to:
Creating a new Microsoft.PowerShell.SecretStore vault.
Enter password:
You could run the setup interactively once, but now you’ve got another secret to manage. The vault password itself. That’s friction nobody needs.
Honest take on the tradeoffs: There’s no perfect solution here. We tried all three approaches. SecretStore actually works fine if you’re willing to do one-time interactive setup in your environment or container image – after that, it’s non-interactive everywhere. CredentialManager is clean if you’re still on PS5.1. The problem is we needed something that works consistently across both versions, requires zero external modules, and runs in non-interactive environments without any first-run friction. We didn’t care if it was calling native Windows APIs instead of using PowerShell patterns. For our use case – one-off PowerShell scripts running on my machine to offboard users, audit client tenancies, or automate routine IT tasks – cmdkey + P/Invoke won out. This isn’t for building applications or services. It’s for scripts I’m running locally or on my own machines. If you’re building something customers will use, or anything that runs as a service account across infrastructure, use Azure Key Vault instead. This approach works for your own IT automation. Your mileage may vary depending on what you’re actually building and how sensitive the data is.
The Approach That Actually Works
The solution is simple. Use two Windows built-ins that have worked the same way for years. cmdkey.exe is a Windows command that writes to Credential Manager. And .NET P/Invoke lets you call the native Windows API directly to read them back out. No external modules. No compatibility issues. No prompts. No friction.
To store your secrets, use cmdkey.exe:
cmdkey /generic:Graph-ClientId /user:Graph-ClientId /pass:"your-client-id-here"
cmdkey /generic:Graph-ClientSecret /user:Graph-ClientSecret /pass:"your-secret-here"
cmdkey /generic:Graph-TenantId /user:Graph-TenantId /pass:"your-tenant-id"
Each call creates a Generic Credential entry in Windows Credential Manager. Verify they’re there by opening Control Panel > Credential Manager > Windows Credentials. The /pass: value gets encrypted using DPAPI (Data Protection API), tied to your Windows user profile. Only that user can decrypt it. If someone copies your files, they get nothing.
To retrieve credentials in PowerShell 7, you need a small C# class compiled at runtime. Drop this in your script:
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class CredManager {
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredReadW(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr credential);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL {
public int Flags;
public int Type;
public string TargetName;
public string Comment;
public long LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
public static string GetSecret(string target) {
IntPtr credPtr;
if (CredReadW(target, 1, 0, out credPtr)) {
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
string secret = Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / 2);
CredFree(credPtr);
return secret;
}
return null;
}
public static string GetUser(string target) {
IntPtr credPtr;
if (CredReadW(target, 1, 0, out credPtr)) {
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
string user = cred.UserName;
CredFree(credPtr);
return user;
}
return null;
}
}
"@
Now retrieval is one line:
$clientId = [CredManager]::GetSecret("Graph-ClientId")
$secret = [CredManager]::GetSecret("Graph-ClientSecret")
$tenantId = [CredManager]::GetSecret("Graph-TenantId")
Use them like this:
# Pull credentials at runtime
$clientId = [CredManager]::GetSecret("Graph-ClientId")
$clientSecret = [CredManager]::GetSecret("Graph-ClientSecret")
$tenantId = [CredManager]::GetSecret("Graph-TenantId")
# Make API calls with zero secrets in the script
$headers = @{
'Authorization' = "Bearer $token"
'Content-Type' = 'application/json'
}
$users = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users" -Headers $headers
If someone gets your script, they get nothing. The credentials stay encrypted in the Windows vault.
Cross-Version Bonus
Windows Credential Manager is an OS-level store, not a PowerShell store. Credentials stored any way work with the P/Invoke approach. If you stored credentials using the CredentialManager module in Windows PowerShell 5.1, they’re already in Windows Credential Manager. The P/Invoke approach reads them without migration:
$clientId = [CredManager]::GetUser("AbilityFirst-GraphAPI")
$secret = [CredManager]::GetSecret("AbilityFirst-GraphAPI")
| Stored From | Works in PS 5.1 | Works in PS 7 |
|---|---|---|
| cmdkey.exe | Yes | Yes |
| CredentialManager module (PS 5.1) | Yes | Yes |
| SecretStore module (PS 7) | No | Yes |
Practical Details
Use a naming convention that makes sense for your environment. Something like <Service>-<Field> works well. Graph-ClientId, Graph-ClientSecret, Azure-SubscriptionId, ServiceAccount-Password – pick something and stick with it.
If you want to store multiple values together, use the UserName and Password fields. Store the client ID in UserName and the secret in Password:
cmdkey /generic:MyAPI /user:client-id /pass:"client-secret"
# Then retrieve both
$clientId = [CredManager]::GetUser("MyAPI")
$secret = [CredManager]::GetSecret("MyAPI")
Don’t paste the Add-Type block in every script. Save it as CredHelper.ps1 and dot-source it:
# CredHelper.ps1
if (-not ([System.Management.Automation.PSTypeName]'CredManager').Type) {
Add-Type -TypeDefinition @"
// ... (full CredManager class)
"@
}
Then in your scripts:
. "$PSScriptRoot\CredHelper.ps1"
$secret = [CredManager]::GetSecret("MyAPI-Secret")
You can verify what’s stored three ways. GUI: Control Panel > Credential Manager > Windows Credentials. Command line: cmdkey /list (shows targets, not passwords). PowerShell: [CredManager]::GetSecret("target-name") (shows the value). Remove credentials with cmdkey /delete:Graph-ClientId.
Why This Works
The cmdkey + P/Invoke approach is the only method that works across both PowerShell versions, requires nothing extra to install, never prompts for input, and uses the native Windows encryption stack. It’s not the most elegant solution, but it’s the one that actually works without requiring AI minions to take over your automation infrastructure while you sit in too many meetings explaining why credentials are leaking.
(My AI minions are planning world domination anyway, but at least when they succeed, it won’t be because someone found plaintext API keys in a PowerShell script.)
Now go secure those secrets and get back to building something useful.