Securing Test Automation Secrets: A Scalable Encryption Strategy
July 18, 2026
The Problem: The .env Bottleneck in RBAC Testing
When architecting enterprise-grade test automation frameworks, particularly those that require comprehensive Role-Based Access Control (RBAC) testing, we face a significant configuration challenge.
To validate an application properly, our Playwright frameworks must log in as multiple distinct users (e.g., Admin, Editor, Reader). Traditionally, to prevent exposing plaintext passwords in the Git repository, teams store all of these credentials in a local .env file.
While secure, this creates a massive Developer Experience (DX) bottleneck:
- Synchronization Nightmare: Every time a test user's password changes, or a new role is added, every automation engineer on the team must manually update their local
.envfile. - Configuration Clutter: Managing credentials for multiple roles across multiple environments (TST, INT, ACC) turns the
.envfile into an unreadable mess, disconnecting the usernames from their respective passwords.
The Architectural Solution
To combine the security of environment variables with the collaboration benefits of Git, I implemented a hybrid cryptographic solution using Node.js's native crypto module.
The strategy is simple but highly effective:
- We encrypt the passwords locally using AES-128-GCM (Authenticated Encryption).
- We commit the resulting Base64-Encoded Ciphertext directly into our TypeScript configuration files alongside the usernames.
- We store only a single
MASTER_PASSWORDin the local.envfile (or in our GitHub Actions secrets for CI/CD pipeline execution). - The framework decrypts the ciphertext dynamically at runtime, mere milliseconds before injecting it into the UI login form or API Authorization header.
The Implementation
Here is how this architecture is constructed within a modern TypeScript/Playwright ecosystem.
1. The Cryptography Utility (crypto.ts)
We use aes-128-gcm because it provides an authentication tag, ensuring the ciphertext cannot be tampered with. The utility packages the Initialization Vector (IV), the Auth Tag, and the encrypted data into a single Base64-Encoded Ciphertext string, wrapped in a custom envelope ({AESGCM:...}).
import crypto from 'node:crypto';
import * as dotenv from 'dotenv';
dotenv.config();
const ALGORITHM = 'aes-128-gcm';
const KEY_LENGTH_BYTE = 16;
const IV_LENGTH_BYTE = 12;
const TAG_LENGTH_BYTE = 16;
const SALT = 'super-test-automation-framework';
const ENVELOP_START = '{AESGCM:';
const ENVELOP_END = '}';
export function encrypt(plainText: string, masterPassword?: string): string {
masterPassword = getMasterPassword(masterPassword);
const iv = crypto.randomBytes(IV_LENGTH_BYTE);
const cipher = crypto.createCipheriv(ALGORITHM, crypto.scryptSync(masterPassword, SALT, KEY_LENGTH_BYTE), iv);
const encryptedData = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const encryptedText = Buffer.concat([iv, tag, encryptedData]).toString('base64');
return packInEnvelope(encryptedText);
}
export function decrypt(encryptedData: string, masterPassword?: string): string {
masterPassword = getMasterPassword(masterPassword);
const decodedData = Buffer.from(unpackFromEnvelope(encryptedData), 'base64');
const iv = decodedData.subarray(0, IV_LENGTH_BYTE);
const tag = decodedData.subarray(IV_LENGTH_BYTE, IV_LENGTH_BYTE + TAG_LENGTH_BYTE);
const encryptedText = decodedData.subarray(IV_LENGTH_BYTE + TAG_LENGTH_BYTE);
const decipher = crypto.createDecipheriv(ALGORITHM, crypto.scryptSync(masterPassword, SALT, KEY_LENGTH_BYTE), iv);
decipher.setAuthTag(tag);
// @ts-expect-error mute overload issue
return decipher.update(encryptedText, 'base64', 'utf8') + decipher.final('utf8');
}
function packInEnvelope(textToPack: string): string {
return ENVELOP_START + textToPack + ENVELOP_END;
}
function unpackFromEnvelope(textToUnpack: string): string {
const trimmedText = textToUnpack.trim();
if (trimmedText.startsWith(ENVELOP_START) && trimmedText.endsWith(ENVELOP_END)) {
return trimmedText.substring(ENVELOP_START.length, trimmedText.length - ENVELOP_END.length);
} else throw Error(`Given text doesn't conform format ${ENVELOP_START}<base64encoded>${ENVELOP_END}`);
}
function getMasterPassword(masterPassword: string): string {
if (!masterPassword) {
masterPassword = process.env.MASTER_PASSWORD;
}
if (masterPassword) {
return masterPassword;
} else throw Error('Master password env variable has not been provided.');
}2. The Developer CLI Integration
To make this frictionless for other engineers, I created a simple CLI script integrated into package.json.
The Script (encrypt-script.ts):
import { encrypt } from './crypto';
const plainText = process.argv[2];
const masterPassword = process.argv[3];
if (!plainText) {
console.error('Usage: `npm run encrypt -- <plainText> <masterPassword>`');
console.error('or: `npm run encrypt -- <plainText>` to use MASTER_PASSWORD from .env file.');
process.exit(1);
}
const encryptedText = encrypt(plainText, masterPassword);
console.log(`Encrypted text: ${encryptedText}`);The package.json shortcut:
When a new test user is required, an engineer simply runs: npm run encrypt -- <plain-password>.
{
"scripts": {
"encrypt": "ts-node src/utils/security/encrypt-script.ts"
}
}3. The Centralized Configuration (env.ts)
Now, instead of hunting through .env files, our environment configuration is strongly typed, highly readable, and safely committed to version control. The Base64-Encoded Ciphertext is safely stored right next to the username.
export const environmentKeys = ['tst', 'int', 'acc'] as const;
export type EnvironmentKeyType = (typeof environmentKeys)[number];
export const userKeys = ['admin', 'editor', 'reader'] as const;
export type UserKeyType = (typeof userKeys)[number];
export interface UserConfigType {
userName: string;
password: string;
email: string;
}
export interface EnvConfigType {
guiBaseUrl: string;
twoFactorAuth: boolean;
user: Record<UserKeyType, UserConfigType>;
}
// Notice how the Base64-Encoded Ciphertext is safely stored next to the username
const tstUsers: Record<UserKeyType, UserConfigType> = {
admin: {
userName: 'admin_user',
password: '{AESGCM:d2hhdGV2ZXJpdmlzMTJieXRlc2F1dGh0YWdnb2VzaGVyZTE2Ynl0ZXNlY3I1c3c3Z2Q==}',
email: '[email protected]',
},
editor: {
userName: 'editor_user',
password: '{AESGCM:cmFuZG9taXZzdHJpbmcxMmJ5dGVzYXV0aHRhZ2dvZXNoZXJlMTZieXRlc21lZGl1bXBhc3N3b3JkZXhhbXBsZTEyMw==}',
email: '[email protected]',
},
// ... reader configuration
};
export const envConfig: Record<EnvironmentKeyType, EnvConfigType> = {
tst: {
guiBaseUrl: '[https://app-tst.example.com](https://app-tst.example.com)',
twoFactorAuth: false,
user: tstUsers,
},
// ... int and acc environments
};4. Wiring the Playwright Configuration (playwright.config.ts)
Before any tests execute, the global Playwright configuration ingests the ENV variable and exports the correct environment object so the setup files can consume it globally.
import { envConfig, environmentKeys, type EnvironmentKeyType, type EnvConfigType, type UserKeyType } from '@config/env';
import * as dotenv from 'dotenv';
dotenv.config();
export const ENV = process.env.ENV;
if (!ENV || !environmentKeys.includes(ENV as EnvironmentKeyType)) {
console.log(`Incorrect environment value. Available options: ${environmentKeys}.`);
process.exit(1);
}
// Export the selected environment config globally
export const ENV_CONFIG: EnvConfigType = envConfig[ENV];
export const AUTH_STORAGE_FILE: Record<UserKeyType, string> = {
admin: STORAGE_DIR + 'admin-auth.json',
editor: STORAGE_DIR + 'editor-auth.json',
reader: STORAGE_DIR + 'reader-auth.json',
};
export default defineConfig<CommonOptions>({
use: {
trace: process.env.CI ? 'retain-on-first-failure' : 'on',
baseURL: ENV_CONFIG.guiBaseUrl,
},
projects: [
{
name: 'setup-gui',
testDir: './src/tests/gui/',
testMatch: /.*\.setup\.ts/,
use: {
trace: process.env.CI ? 'off' : 'on', // Do not save traces on CI to not disclose secrets!
},
},
{
name: 'gui-tests',
testDir: './src/tests/gui/',
dependencies: ['setup-gui'],
},
],
});5. Runtime Decryption in Playwright (login.setup.ts)
Finally, we integrate the decryption directly into our Playwright setup routines. The password is decrypted in memory mere moments before the loginWithPassword Page Object Model (POM) method is called.
Architect's Note: Ensure Playwright traces are disabled on CI/CD pipelines to prevent decrypted passwords from leaking into trace viewer logs.
import { ENV_CONFIG, AUTH_STORAGE_FILE } from '@playwright.config';
import { decrypt } from '@utils/security/crypto';
setup.describe('Authenticate Application Users', () => {
// Reset storage state to avoid cross-contamination
test.use({ storageState: { cookies: [], origins: [] } });
const paramsList = [
{ title: 'Login as Admin', user: 'admin' },
{ title: 'Login as Editor', user: 'editor' },
];
for (const params of paramsList) {
setup(params.title, async ({ startPage, loginPage, homePage, getUserData }) => {
await setup.step('Navigate to Login', async () => {
await startPage().navigate();
await startPage().clickSignInBtn();
});
await setup.step('Inject Decrypted Credentials', async () => {
const userData = getUserData(params.user);
// The password is decrypted dynamically at runtime
await loginPage().loginWithPassword(
userData.userName,
decrypt(userData.password)
);
await homePage().verifyPage();
});
// Save authentication state for test reuse
await homePage().page.context().storageState({
path: AUTH_STORAGE_FILE[params.user]
});
});
}
});Conclusion
By treating test automation frameworks with the same architectural rigor as production software, we can eliminate friction without compromising security. This pattern ensures that onboarding a new engineer takes seconds, not hours, and keeps our CI/CD pipelines secure and predictable.