Skip to main content

Secret Management

Proper secret management is critical for secure build automation. This guide shows you how to handle API keys, passwords, certificates, and other sensitive data in psake builds without exposing them in source control or logs.

Quick Start

Here's a basic secure approach using environment variables:

Properties {
# Never hardcode secrets!
# BAD: $ApiKey = "sk-1234567890abcdef"

# GOOD: Read from environment variables
$ApiKey = $env:API_KEY
$DatabasePassword = $env:DB_PASSWORD
}

Task Deploy {
# Validate secrets exist
if ([string]::IsNullOrEmpty($ApiKey)) {
throw "API_KEY environment variable is required"
}

if ([string]::IsNullOrEmpty($DatabasePassword)) {
throw "DB_PASSWORD environment variable is required"
}

# Expose the secret only through the child process environment.
exec {
$env:MYAPP_API_KEY = $ApiKey
try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY }
} -errorMessage "Publish failed (credentials redacted)"
}

Secret Management Patterns

Pattern 1: Environment Variables

The simplest and most common approach:

Properties {
$ProjectRoot = $PSScriptRoot

# API credentials
$NuGetApiKey = $env:NUGET_API_KEY
$DockerHubToken = $env:DOCKER_TOKEN
$GitHubToken = $env:GITHUB_TOKEN

# Database credentials
$DbUsername = $env:DB_USERNAME
$DbPassword = $env:DB_PASSWORD

# Cloud provider credentials
$AzureClientId = $env:AZURE_CLIENT_ID
$AzureClientSecret = $env:AZURE_CLIENT_SECRET
$AwsAccessKeyId = $env:AWS_ACCESS_KEY_ID
$AwsSecretAccessKey = $env:AWS_SECRET_ACCESS_KEY

# Certificate passwords
$SigningCertPassword = $env:SIGNING_CERT_PASSWORD
}

Task ValidateSecrets {
Write-Host "Validating required secrets..." -ForegroundColor Green

$requiredSecrets = @{
'NUGET_API_KEY' = $NuGetApiKey
'DB_PASSWORD' = $DbPassword
'AZURE_CLIENT_SECRET' = $AzureClientSecret
}

$missing = @()
foreach ($secret in $requiredSecrets.GetEnumerator()) {
if ([string]::IsNullOrEmpty($secret.Value)) {
$missing += $secret.Key
}
}

if ($missing.Count -gt 0) {
throw "Missing required secrets: $($missing -join ', ')"
}

Write-Host " All required secrets are present" -ForegroundColor Green
}

Task PublishPackage -depends Build, ValidateSecrets {
Write-Host "Publishing NuGet package..." -ForegroundColor Green

# Use the secret without logging it
$packages = Get-ChildItem "$BuildDir/*.nupkg"

foreach ($package in $packages) {
exec {
dotnet nuget push $package.FullName `
--api-key $NuGetApiKey `
--source https://api.nuget.org/v3/index.json
} -errorMessage "Failed to publish package (check API key)"
}
}

Pattern 2: Secure Strings (PowerShell)

For Windows-specific scenarios using PowerShell secure strings:

Properties {
$ProjectRoot = $PSScriptRoot
$CredentialsFile = Join-Path $ProjectRoot '.credentials/encrypted.xml'
}

Task SaveCredentials {
Write-Host "Saving encrypted credentials..." -ForegroundColor Green

# This only works on Windows with DPAPI
$credential = Get-Credential -Message "Enter deployment credentials"

$credentialsDir = Split-Path $CredentialsFile -Parent
if (-not (Test-Path $credentialsDir)) {
New-Item -ItemType Directory -Path $credentialsDir | Out-Null
}

# Export encrypted (only readable by current user on current machine)
$credential | Export-Clixml -Path $CredentialsFile

Write-Host "Credentials saved to: $CredentialsFile" -ForegroundColor Green
Write-Warning "Add .credentials/ to .gitignore!"
}

Task LoadCredentials {
if (-not (Test-Path $CredentialsFile)) {
throw "Credentials file not found: $CredentialsFile. Run SaveCredentials task first."
}

# Import encrypted credentials
$script:DeploymentCredential = Import-Clixml -Path $CredentialsFile

Write-Host "Loaded credentials from: $CredentialsFile" -ForegroundColor Green
}

Task Deploy -depends Build, LoadCredentials {
Write-Host "Deploying with saved credentials..." -ForegroundColor Green

$arguments = @(
'-verb:sync'
"-source:package=$BuildDir\package.zip"
'-dest:auto,computerName=https://server.example.com:8172/msdeploy.axd'
'-allowUntrusted'
)
$process = Start-Process msdeploy `
-ArgumentList $arguments `
-Credential $DeploymentCredential `
-Wait -PassThru -NoNewWindow

if ($process.ExitCode -ne 0) {
throw "Deployment failed with exit code $($process.ExitCode)"
}
}

Pattern 3: Azure Key Vault

For Azure-hosted secrets:

Properties {
$ProjectRoot = $PSScriptRoot
$KeyVaultName = $env:AZURE_KEYVAULT_NAME
}

Task AzureLogin {
Write-Host "Authenticating with Azure..." -ForegroundColor Green

if ($env:CI) {
# CI authenticates before psake with azure/login or an equivalent workload identity.
exec { az account show --output none }
} else {
exec { az login }
}

Write-Host "Azure authentication successful" -ForegroundColor Green
}

Task GetSecretsFromKeyVault -depends AzureLogin {
Write-Host "Retrieving secrets from Azure Key Vault: $KeyVaultName" -ForegroundColor Green

if ([string]::IsNullOrEmpty($KeyVaultName)) {
throw "AZURE_KEYVAULT_NAME environment variable is required"
}

# Retrieve secrets from Key Vault
$script:DatabasePassword = az keyvault secret show `
--name "DatabasePassword" `
--vault-name $KeyVaultName `
--query value -o tsv

$script:ApiKey = az keyvault secret show `
--name "ApiKey" `
--vault-name $KeyVaultName `
--query value -o tsv

$script:CertificatePassword = az keyvault secret show `
--name "SigningCertPassword" `
--vault-name $KeyVaultName `
--query value -o tsv

# Validate retrieved secrets
if ([string]::IsNullOrEmpty($DatabasePassword)) {
throw "Failed to retrieve DatabasePassword from Key Vault"
}

Write-Host " Retrieved secrets successfully" -ForegroundColor Green
}

Task Deploy -depends Build, AzureLogin {
$databaseSecretUri = az keyvault secret show `
--name DatabasePassword --vault-name $KeyVaultName --query id -o tsv
$apiKeySecretUri = az keyvault secret show `
--name ApiKey --vault-name $KeyVaultName --query id -o tsv
$packageFile = Join-Path $BuildDir 'package.zip'

exec {
az webapp config appsettings set `
--resource-group $AzureResourceGroup `
--name $AzureWebAppName `
--settings `
"ConnectionStrings__DefaultConnection=@Microsoft.KeyVault(SecretUri=$databaseSecretUri)" `
"ExternalServices__ApiKey=@Microsoft.KeyVault(SecretUri=$apiKeySecretUri)"
}
exec {
az webapp deploy `
--resource-group $AzureResourceGroup `
--name $AzureWebAppName `
--src-path $packageFile `
--type zip
}
}

Pattern 4: AWS Secrets Manager

For AWS-hosted secrets:

Properties {
$ProjectRoot = $PSScriptRoot
$AwsRegion = if ($env:AWS_REGION) { $env:AWS_REGION } else { 'us-east-1' }
$SecretsPath = 'myapp/prod' # Path in Secrets Manager
}

Task VerifyAwsCli {
try {
$awsVersion = aws --version
Write-Host "AWS CLI: $awsVersion" -ForegroundColor Gray
}
catch {
throw "AWS CLI is not installed. Install from https://aws.amazon.com/cli/"
}
}

Task GetSecretsFromAWS -depends VerifyAwsCli {
Write-Host "Retrieving secrets from AWS Secrets Manager..." -ForegroundColor Green

# Get secret from AWS Secrets Manager
$secretJson = aws secretsmanager get-secret-value `
--secret-id $SecretsPath `
--region $AwsRegion `
--query SecretString `
--output text

if ([string]::IsNullOrEmpty($secretJson)) {
throw "Failed to retrieve secrets from AWS Secrets Manager: $SecretsPath"
}

# Parse secrets JSON
$secrets = $secretJson | ConvertFrom-Json

# Extract individual secrets
$script:DatabasePassword = $secrets.DatabasePassword
$script:ApiKey = $secrets.ApiKey
$script:EncryptionKey = $secrets.EncryptionKey

# Validate
if ([string]::IsNullOrEmpty($DatabasePassword)) {
throw "DatabasePassword not found in secrets"
}

Write-Host " Retrieved secrets successfully" -ForegroundColor Green
}

Task SetAwsSecret {
param(
[string]$SecretName = 'myapp/prod',
[SecureString]$SecretValue
)

if ($null -eq $SecretValue) {
$SecretValue = Read-Host "Enter secret value" -AsSecureString
}

Import-Module AWS.Tools.SecretsManager
$credential = [PSCredential]::new('secret', $SecretValue)
$plainText = $credential.GetNetworkCredential().Password

try {
$existingSecret = Get-SECSecretList |
Where-Object Name -eq $SecretName |
Select-Object -First 1

if ($existingSecret) {
Update-SECSecret -SecretId $SecretName -SecretString $plainText
Write-Host "Secret updated: $SecretName" -ForegroundColor Green
} else {
New-SECSecret -Name $SecretName -SecretString $plainText
Write-Host "Secret created: $SecretName" -ForegroundColor Green
}
}
finally {
$plainText = $null
$credential = $null
$SecretValue.Dispose()
}
}

Task Deploy -depends Build, GetSecretsFromAWS {
Write-Host "Deploying with AWS Secrets..." -ForegroundColor Green

# Use secrets in deployment
exec {
aws deploy create-deployment `
--application-name MyApp `
--deployment-config-name CodeDeployDefault.OneAtATime `
--deployment-group-name Production `
--s3-location bucket=my-deployments,key=app.zip,bundleType=zip
}
}

Pattern 5: HashiCorp Vault

For HashiCorp Vault integration:

Properties {
$VaultAddr = $env:VAULT_ADDR # e.g., https://vault.example.com:8200
$VaultToken = $env:VAULT_TOKEN
$VaultSecretsPath = 'secret/data/myapp/prod'
}

Task GetSecretsFromVault {
Write-Host "Retrieving secrets from HashiCorp Vault..." -ForegroundColor Green

if ([string]::IsNullOrEmpty($VaultAddr)) {
throw "VAULT_ADDR environment variable is required"
}

if ([string]::IsNullOrEmpty($VaultToken)) {
throw "VAULT_TOKEN environment variable is required"
}

# Retrieve secret from Vault
$headers = @{
'X-Vault-Token' = $VaultToken
}

try {
$response = Invoke-RestMethod `
-Uri "$VaultAddr/v1/$VaultSecretsPath" `
-Method Get `
-Headers $headers

$secretData = $response.data.data

# Extract secrets
$script:DatabasePassword = $secretData.database_password
$script:ApiKey = $secretData.api_key
$script:EncryptionKey = $secretData.encryption_key

Write-Host " Retrieved secrets successfully" -ForegroundColor Green
}
catch {
throw "Failed to retrieve secrets from Vault: $_"
}
}

Security Best Practices

Never Commit Secrets

Always add these to .gitignore:

# Secrets and credentials
*.key
*.pem
*.pfx
*.p12
*.cer
*.crt
.env
.env.local
.env.*.local
secrets.json
appsettings.*.json
*.credentials
.credentials/
*.secret

# Configuration with secrets
config/prod.ps1
config/staging.ps1
**/appsettings.Production.json
**/appsettings.Staging.json

# Build artifacts that may contain secrets
publish/
deploy/

Avoid Logging Secrets

Bad - Secret appears in logs:

Task Deploy {
Write-Host "Using API key: $ApiKey" -ForegroundColor Gray # NEVER DO THIS!
exec { dotnet publish }
}

Good - Secrets redacted:

Task Deploy {
Write-Host "Using API key: [REDACTED]" -ForegroundColor Gray

exec {
$env:MYAPP_API_KEY = $ApiKey
try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY }
} -errorMessage "Publish failed (check API key configuration)"
}

Secret Validation

Always validate secrets exist before using them:

function Test-SecretExists {
param(
[string]$SecretName,
[string]$SecretValue
)

if ([string]::IsNullOrEmpty($SecretValue)) {
throw "Required secret '$SecretName' is not set"
}

# Optionally validate format
if ($SecretName -like '*API_KEY' -and $SecretValue.Length -lt 20) {
Write-Warning "Secret '$SecretName' appears to be invalid (too short)"
}
}

Task ValidateSecrets {
Write-Host "Validating secrets..." -ForegroundColor Green

Test-SecretExists -SecretName 'NUGET_API_KEY' -SecretValue $env:NUGET_API_KEY
Test-SecretExists -SecretName 'DB_PASSWORD' -SecretValue $env:DB_PASSWORD
Test-SecretExists -SecretName 'SIGNING_CERT_PASSWORD' -SecretValue $env:SIGNING_CERT_PASSWORD

Write-Host " All secrets validated" -ForegroundColor Green
}

Secure Certificate Handling

For code signing certificates:

Properties {
$CertificateThumbprint = $env:SIGNING_CERT_THUMBPRINT
}

Task SignAssemblies -depends Build {
Write-Host "Signing assemblies..." -ForegroundColor Green

if ([string]::IsNullOrEmpty($CertificateThumbprint)) {
throw "SIGNING_CERT_THUMBPRINT environment variable is required"
}

$assemblies = Get-ChildItem "$BuildDir/*.dll" -Recurse
foreach ($assembly in $assemblies) {
exec {
signtool sign /sha1 $CertificateThumbprint `
/tr http://timestamp.digicert.com `
/td SHA256 `
/fd SHA256 `
$assembly.FullName
} -errorMessage "Failed to sign $($assembly.Name)"

Write-Host " Signed: $($assembly.Name)" -ForegroundColor Gray
}
}

Minimize Secret Lifetime

Strings cannot be reliably erased from managed memory. Prefer APIs that accept SecureString, scope secrets narrowly, and dispose sensitive objects deterministically:

Task InstallSigningCertificate {
$password = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force
$certificate = $null

try {
$certificate = Import-PfxCertificate `
-FilePath $CertificatePath `
-CertStoreLocation Cert:\CurrentUser\My `
-Password $password

exec { signtool sign /sha1 $certificate.Thumbprint /fd SHA256 $PackagePath }
}
finally {
if ($certificate) {
Remove-Item $certificate.PSPath -Force
}
$password.Dispose()
}
}

CI/CD Integration

GitHub Actions

name: Deploy with Secrets

on:
push:
branches: [main]

jobs:
deploy:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- name: Install psake
shell: pwsh
run: Install-Module -Name psake -Force

- name: Deploy
shell: pwsh
run: |
Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy
env:
# Pass secrets as environment variables
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}

Azure Pipelines

trigger:
- main

pool:
vmImage: 'windows-latest'

variables:
- group: production-secrets # Variable group with secrets

steps:
- task: PowerShell@2
displayName: 'Install psake'
inputs:
targetType: 'inline'
script: 'Install-Module -Name psake -Force'

- task: PowerShell@2
displayName: 'Deploy'
inputs:
targetType: 'inline'
script: 'Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy'
env:
NUGET_API_KEY: $(NuGetApiKey)
DB_PASSWORD: $(DatabasePassword)
AZURE_CLIENT_SECRET: $(AzureClientSecret)

Complete Secure Build Example

psakefile.ps1:

Properties {
$ProjectRoot = $PSScriptRoot
$BuildDir = Join-Path $ProjectRoot 'build/output'

# Secrets from environment variables
$NuGetApiKey = $env:NUGET_API_KEY
$AzureKeyVaultName = $env:AZURE_KEYVAULT_NAME

# Flags
$UseKeyVault = -not [string]::IsNullOrEmpty($AzureKeyVaultName)
}

Task ValidateSecrets {
Write-Host "Validating secrets configuration..." -ForegroundColor Green

if ($UseKeyVault) {
Write-Host " Using Azure Key Vault: $AzureKeyVaultName" -ForegroundColor Gray

if ([string]::IsNullOrEmpty($env:AZURE_CLIENT_SECRET)) {
throw "AZURE_CLIENT_SECRET is required for Key Vault access"
}
} else {
Write-Host " Using environment variables" -ForegroundColor Gray

if ([string]::IsNullOrEmpty($NuGetApiKey)) {
throw "NUGET_API_KEY environment variable is required"
}
}

Write-Host " Secrets validation passed" -ForegroundColor Green
}

Task GetSecrets -depends ValidateSecrets, GetSecretsFromKeyVault {
if (-not $UseKeyVault) {
Write-Host "Using secrets from environment variables" -ForegroundColor Gray
}
}

Task GetSecretsFromKeyVault -precondition { $UseKeyVault } {
Write-Host "Retrieving secrets from Azure Key Vault..." -ForegroundColor Green

# CI authenticates before psake with azure/login or an equivalent workload identity.
$script:NuGetApiKey = az keyvault secret show `
--name "NuGetApiKey" `
--vault-name $AzureKeyVaultName `
--query value -o tsv

Write-Host " Secrets retrieved successfully" -ForegroundColor Green
}

Task Build {
Write-Host "Building project..." -ForegroundColor Green
exec { dotnet build -c Release -o $BuildDir }
}

Task Pack -depends Build {
Write-Host "Creating NuGet packages..." -ForegroundColor Green
exec { dotnet pack -c Release -o $BuildDir --no-build }
}

Task Publish -depends Pack, GetSecrets {
Write-Host "Publishing packages to NuGet..." -ForegroundColor Green

try {
$packages = Get-ChildItem "$BuildDir/*.nupkg"
foreach ($package in $packages) {
Write-Host " Publishing: $($package.Name)" -ForegroundColor Gray
exec {
dotnet nuget push $package.FullName `
--api-key $NuGetApiKey `
--source https://api.nuget.org/v3/index.json
} -errorMessage "Failed to publish package (credentials redacted)"
}
Write-Host "Publishing complete" -ForegroundColor Green
}
finally {
$script:NuGetApiKey = $null
}
}

Troubleshooting

Secret Not Found

Problem: Environment variable not set

Solution:

Task Debug:ShowSecrets {
Write-Host "Secret Status:" -ForegroundColor Yellow
Write-Host " NUGET_API_KEY: $(if ($env:NUGET_API_KEY) { '[SET]' } else { '[NOT SET]' })"
Write-Host " DB_PASSWORD: $(if ($env:DB_PASSWORD) { '[SET]' } else { '[NOT SET]' })"
Write-Host " AZURE_CLIENT_SECRET: $(if ($env:AZURE_CLIENT_SECRET) { '[SET]' } else { '[NOT SET]' })"
}

Secrets Appearing in Logs

Problem: Sensitive data in build output

Solution: Use -errorMessage with exec and avoid Write-Host with secret values

Key Vault Access Denied

Problem: Cannot access Azure Key Vault

Solution: Check service principal permissions:

Task GrantKeyVaultAccess {
$servicePrincipalId = $env:AZURE_CLIENT_ID

exec {
az keyvault set-policy `
--name $env:AZURE_KEYVAULT_NAME `
--spn $servicePrincipalId `
--secret-permissions get list
}
}

See Also