Azure Key Vault Testing: Secrets Management in CI/CD Pipelines
Azure Key Vault centralizes secret management, certificate storage, and cryptographic key operations. Testing applications that depend on Key Vault requires careful strategy: you don't want to skip Key Vault in tests (then you're not testing real behavior) but you also can't hardcode production secrets. This guide covers the right approach for each scenario.
The Testing Strategy
Four approaches, in order of preference for different scenarios:
- Local secret provider — replace Key Vault with environment variables or a local mock during unit tests
- Azure Key Vault test instance — a dedicated Key Vault in a non-production subscription for integration tests
- Managed Identity in CI — GitHub Actions or Azure DevOps using OIDC to authenticate to a test Key Vault
- Mock with
@azure/identitytest credentials — for testing the SDK interaction without a real vault
Abstracting Key Vault Behind an Interface
The most maintainable approach is hiding Key Vault behind an interface your code depends on:
// secrets.interface.ts
export interface SecretsProvider {
getSecret(name: string): Promise<string>;
setSecret(name: string, value: string): Promise<void>;
}
// azure-keyvault.provider.ts
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';
export class AzureKeyVaultProvider implements SecretsProvider {
private client: SecretClient;
constructor(vaultUrl: string) {
this.client = new SecretClient(vaultUrl, new DefaultAzureCredential());
}
async getSecret(name: string): Promise<string> {
const { value } = await this.client.getSecret(name);
if (!value) throw new Error(`Secret ${name} is empty`);
return value;
}
async setSecret(name: string, value: string): Promise<void> {
await this.client.setSecret(name, value);
}
}
// env.provider.ts — for local development and testing
export class EnvironmentSecretsProvider implements SecretsProvider {
private prefix: string;
constructor(prefix = 'SECRET_') {
this.prefix = prefix;
}
async getSecret(name: string): Promise<string> {
const envKey = `${this.prefix}${name.toUpperCase().replace(/-/g, '_')}`;
const value = process.env[envKey];
if (!value) throw new Error(`Secret ${name} not found in environment (${envKey})`);
return value;
}
async setSecret(name: string, value: string): Promise<void> {
const envKey = `${this.prefix}${name.toUpperCase().replace(/-/g, '_')}`;
process.env[envKey] = value;
}
}Unit tests use the environment provider:
// database.service.test.ts
import { DatabaseService } from './database.service';
import { EnvironmentSecretsProvider } from './env.provider';
describe('DatabaseService', () => {
const secrets = new EnvironmentSecretsProvider('TEST_SECRET_');
beforeEach(() => {
process.env.TEST_SECRET_DB_PASSWORD = 'test-password-123';
process.env.TEST_SECRET_DB_CONNECTION_STRING = 'postgresql://test:test@localhost/testdb';
});
test('connects using secret from provider', async () => {
const service = new DatabaseService(secrets);
await service.connect();
expect(service.isConnected()).toBe(true);
});
test('throws if secret missing', async () => {
delete process.env.TEST_SECRET_DB_PASSWORD;
const service = new DatabaseService(secrets);
await expect(service.connect()).rejects.toThrow('Secret db-password not found');
});
});Integration Tests Against Real Key Vault
For integration tests, use a dedicated test Key Vault with Managed Identity:
// keyvault.integration.test.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential, EnvironmentCredential } = require('@azure/identity');
const VAULT_URL = process.env.AZURE_KEY_VAULT_URL;
const isIntegration = !!VAULT_URL;
// Skip if no Key Vault URL provided
const describeIntegration = isIntegration ? describe : describe.skip;
describeIntegration('Key Vault integration', () => {
let client;
const testPrefix = `test-${Date.now()}`;
beforeAll(() => {
client = new SecretClient(VAULT_URL, new DefaultAzureCredential());
});
afterAll(async () => {
// Clean up test secrets
const secrets = client.listPropertiesOfSecrets();
for await (const secret of secrets) {
if (secret.name.startsWith(testPrefix)) {
await client.beginDeleteSecret(secret.name);
}
}
});
test('stores and retrieves a secret', async () => {
const secretName = `${testPrefix}-test-secret`;
const secretValue = `value-${Date.now()}`;
await client.setSecret(secretName, secretValue);
const { value } = await client.getSecret(secretName);
expect(value).toBe(secretValue);
});
test('retrieves a specific version', async () => {
const secretName = `${testPrefix}-versioned`;
// Create two versions
const { properties: v1 } = await client.setSecret(secretName, 'version-1');
const { properties: v2 } = await client.setSecret(secretName, 'version-2');
// Retrieve specific version
const { value: v1Value } = await client.getSecret(secretName, {
version: v1.version,
});
const { value: v2Value } = await client.getSecret(secretName, {
version: v2.version,
});
expect(v1Value).toBe('version-1');
expect(v2Value).toBe('version-2');
// Latest (no version) should be v2
const { value: latest } = await client.getSecret(secretName);
expect(latest).toBe('version-2');
});
test('disabled secret cannot be retrieved', async () => {
const secretName = `${testPrefix}-disabled`;
await client.setSecret(secretName, 'should-not-be-readable');
await client.updateSecretProperties(secretName, '', {
enabled: false,
});
await expect(client.getSecret(secretName)).rejects.toMatchObject({
statusCode: 403,
});
});
test('secret with expiry is auto-disabled after expiry', async () => {
const secretName = `${testPrefix}-expiring`;
const expiresOn = new Date(Date.now() + 2000); // 2 seconds
await client.setSecret(secretName, 'expiring-value', {
expiresOn,
});
// Should be retrievable now
const { value } = await client.getSecret(secretName);
expect(value).toBe('expiring-value');
// After expiry, should not be retrievable
await new Promise(resolve => setTimeout(resolve, 3000));
await expect(client.getSecret(secretName)).rejects.toMatchObject({
statusCode: 403,
});
}, 10000);
});GitHub Actions with OIDC Authentication
The recommended CI approach — no stored credentials:
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
integration:
runs-on: ubuntu-latest
environment: test # Uses environment for Key Vault access
steps:
- uses: actions/checkout@v4
- name: Login to Azure via OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Get secrets from Key Vault
uses: azure/get-keyvault-secrets@v1
with:
keyvault: my-test-key-vault
secrets: 'db-password, api-key, jwt-secret'
id: kv-secrets
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run integration tests
run: npm run test:integration
env:
# Secrets injected from Key Vault
DB_PASSWORD: ${{ steps.kv-secrets.outputs.db-password }}
API_KEY: ${{ steps.kv-secrets.outputs.api-key }}
JWT_SECRET: ${{ steps.kv-secrets.outputs.jwt-secret }}
# Key Vault URL for tests that use DefaultAzureCredential
AZURE_KEY_VAULT_URL: https://my-test-key-vault.vault.azure.netTesting Secret Rotation
Applications must handle secret rotation gracefully:
// rotation.test.js
class CachingSecretsProvider {
constructor(client, ttlMs = 300000) {
this.client = client;
this.cache = new Map();
this.ttlMs = ttlMs;
}
async getSecret(name) {
const cached = this.cache.get(name);
if (cached && Date.now() - cached.timestamp < this.ttlMs) {
return cached.value;
}
const { value } = await this.client.getSecret(name);
this.cache.set(name, { value, timestamp: Date.now() });
return value;
}
invalidate(name) {
this.cache.delete(name);
}
}
describe('Secret rotation handling', () => {
test('caches secrets within TTL', async () => {
const mockClient = {
getSecret: jest.fn().mockResolvedValue({ value: 'secret-value' }),
};
const provider = new CachingSecretsProvider(mockClient, 60000);
await provider.getSecret('api-key');
await provider.getSecret('api-key');
await provider.getSecret('api-key');
// Should only call Key Vault once (cached)
expect(mockClient.getSecret).toHaveBeenCalledTimes(1);
});
test('re-fetches after cache invalidation', async () => {
const mockClient = {
getSecret: jest.fn()
.mockResolvedValueOnce({ value: 'old-secret' })
.mockResolvedValueOnce({ value: 'new-secret' }),
};
const provider = new CachingSecretsProvider(mockClient, 60000);
const first = await provider.getSecret('api-key');
expect(first).toBe('old-secret');
// Simulate rotation — invalidate cache
provider.invalidate('api-key');
const second = await provider.getSecret('api-key');
expect(second).toBe('new-secret');
expect(mockClient.getSecret).toHaveBeenCalledTimes(2);
});
test('handles Key Vault errors gracefully with stale cache', async () => {
const mockClient = {
getSecret: jest.fn()
.mockResolvedValueOnce({ value: 'cached-value' })
.mockRejectedValueOnce(new Error('Key Vault unavailable')),
};
const provider = new CachingSecretsProvider(mockClient, 1); // 1ms TTL
// Cache the value
await provider.getSecret('api-key');
// Force cache expiry
await new Promise(resolve => setTimeout(resolve, 10));
// Key Vault is down — consider falling back to stale cache
// This behavior depends on your resilience requirements
});
});Testing Certificate Operations
describe('Certificate operations', () => {
const { CertificateClient } = require('@azure/keyvault-certificates');
let certClient;
beforeAll(() => {
certClient = new CertificateClient(VAULT_URL, new DefaultAzureCredential());
});
test('creates and retrieves a self-signed certificate', async () => {
const certName = `test-cert-${Date.now()}`;
// Start certificate creation (async in Key Vault)
const poller = await certClient.beginCreateCertificate(certName, {
issuerName: 'Self',
subject: 'CN=test.example.com',
validityInMonths: 1,
});
// Wait for completion
const certificate = await poller.pollUntilDone();
expect(certificate.name).toBe(certName);
expect(certificate.properties.enabled).toBe(true);
expect(certificate.properties.expiresOn).toBeTruthy();
// Clean up
await certClient.beginDeleteCertificate(certName);
}, 60000);
test('retrieves certificate thumbprint', async () => {
const { properties } = await certClient.getCertificate('existing-cert');
expect(properties.x509Thumbprint).toBeTruthy();
expect(properties.expiresOn.getTime()).toBeGreaterThan(Date.now());
});
});Access Policy Testing
Verify your app only requests permissions it actually needs:
test('application uses least-privilege access', async () => {
// Try operations that should be allowed
const { value } = await client.getSecret('db-password');
expect(value).toBeTruthy();
// Try operations that should NOT be allowed
// (Your app should only have Get permission, not Set)
await expect(client.setSecret('new-secret', 'unauthorized')).rejects.toMatchObject({
statusCode: 403,
});
// Should not be able to delete
await expect(client.beginDeleteSecret('db-password')).rejects.toMatchObject({
statusCode: 403,
});
});The key principle: test Key Vault integration properly at two levels. Unit tests mock the provider completely so they're fast and offline. Integration tests hit a real Key Vault instance in CI using OIDC authentication — no long-lived credentials stored anywhere. Production uses Managed Identity, which is the same credential flow and validates that your access policies are correct.