Secrets Management in Playwright with dotenv
Hardcoding a password into a Playwright test feels harmless until that test file lands in a public repository, leaks an admin credential, and someone has to rotate the secret across every environment at 2 a.m. The real problem is that test suites need real usernames, API tokens, and base URLs to run, yet none of those values should ever be committed to Git. In this guide you will learn practical Playwright secrets management dotenv in TypeScript: how to load environment variables from a .env file, keep them out of version control, type them safely, switch between environments, and avoid the classic pitfalls that leak secrets into traces and logs.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why Secrets Do Not Belong in Your Test Code
End-to-end tests are honest about what an application needs. To log in, you need a username and password. To hit a staging API, you need a token. To point the suite at the right place, you need a base URL. The temptation is to paste those values directly into playwright.config.ts or a spec file because it just works. It works right up until one of these things happens:
- A teammate pushes the repo to a public mirror and the credential is now indexed by search engines.
- The same password is reused across staging and production, so a leaked test secret becomes a production incident.
- You need to run the suite against three environments, and every switch means editing tracked files.
- CI needs different values than your laptop, and you end up with messy conditional code.
The standard answer in the Node.js ecosystem is the dotenv package. It reads a plain-text .env file and copies the key-value pairs into process.env at runtime. The file stays on your machine and in your secrets manager, never in Git. Your tests read from process.env, which means the same code runs locally and in CI without modification.
Setting Up dotenv in a Playwright Project
Start by installing the package as a dev dependency. Playwright tests run under Node, so dotenv fits naturally alongside @playwright/test.
npm install --save-dev dotenv
Create a .env file in your project root. This file holds the actual values and must never be committed. Use it for the credentials and configuration each developer or CI runner supplies.
# .env (DO NOT COMMIT THIS FILE)
BASE_URL=https://staging.example.com
TEST_USERNAME=qa.bot@example.com
TEST_PASSWORD=s3cr3t-staging-pw
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc123
The most important line you will write today goes into .gitignore. Ignore every environment file so a stray git add . can never stage a secret.
# .gitignore
.env
.env.*
!.env.example
The !.env.example line is deliberate. You should commit a template called .env.example that lists every key with empty or dummy values. New contributors copy it to .env and fill in real values, so nobody has to guess which variables the suite expects.
Loading dotenv in playwright.config.ts
The cleanest place to load environment variables is at the very top of playwright.config.ts, before defineConfig runs. Because the config file is evaluated first, every spec, fixture, and project that runs afterward sees the populated process.env. This is the heart of Playwright secrets management dotenv: load once, read everywhere.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// Load .env from the project root before the config is built.
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
testDir: './tests',
use: {
// Read the base URL from the environment, with a safe fallback.
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
With baseURL sourced from process.env.BASE_URL, any call to page.goto('/login') resolves against the configured environment. Switching from staging to a feature branch is now a matter of changing one line in .env rather than editing tracked code.
Using Secrets Inside a Test
Once dotenv has populated process.env, your specs read credentials directly. The example below performs a login using the username and password from the environment. Notice that no secret literal appears anywhere in the test file.
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in with env credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USERNAME!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
The trailing ! is a TypeScript non-null assertion telling the compiler the value will be present at runtime. It works, but it is a promise you are making by hand. A better approach is to validate the variables once and stop using ! everywhere, which the next section covers.
Reading secrets in an API request
Secrets are not only for UI logins. When you test protected endpoints with Playwright’s request fixture, the bearer token comes from the same place. Here a token from .env authenticates a direct API call.
// tests/api.spec.ts
import { test, expect } from '@playwright/test';
test('fetches the current user via API token', async ({ request }) => {
const response = await request.get('/api/me', {
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.email).toBe(process.env.TEST_USERNAME);
});
Type-Safe Secrets With Validation
The biggest weakness of raw process.env access is that everything is typed as string | undefined. A typo like process.env.TEST_PASWORD compiles fine and fails at runtime with a confusing error halfway through the suite. The fix is to validate the environment once, fail loudly if anything is missing, and export typed values.
// tests/utils/env.ts
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === '') {
throw new Error(
`Missing required environment variable: ${name}. ` +
`Copy .env.example to .env and fill it in.`,
);
}
return value;
}
export const env = {
baseURL: required('BASE_URL'),
username: required('TEST_USERNAME'),
password: required('TEST_PASSWORD'),
apiToken: required('API_TOKEN'),
} as const;
Now your tests import env and get real string types with autocomplete, and a missing secret produces a clear message at startup instead of an obscure failure deep inside a spec. If you already use Zod in your project, you can replace the hand-written required helper with a schema and get parsing plus validation in one step. Either way the principle is the same: validate at the edge, trust the values everywhere else.
// tests/login.spec.ts (refactored)
import { test, expect } from '@playwright/test';
import { env } from './utils/env';
test('user can log in with validated env credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(env.username);
await page.getByLabel('Password').fill(env.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Managing Multiple Environments
Real teams run the same suite against local, staging, and sometimes a preview environment. The common pattern is one file per environment plus a selector variable. Keep .env.staging, .env.local, and any others on disk (still git-ignored), then choose which to load based on a single switch.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// TEST_ENV=staging npx playwright test
const envName = process.env.TEST_ENV ?? 'local';
dotenv.config({
path: path.resolve(__dirname, `.env.${envName}`),
override: false,
});
export default defineConfig({
use: { baseURL: process.env.BASE_URL },
});
Now TEST_ENV=staging npx playwright test loads .env.staging, while the default runs against .env.local. Setting override: false means a variable already present in the real environment (for example, one injected by CI) wins over the file. That ordering matters in pipelines, which the next section explains.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
How dotenv interacts with CI secrets
In CI you usually do not ship a .env file at all. Instead the platform (GitHub Actions, GitLab CI, Jenkins) injects secrets as real environment variables before the job runs. By default, dotenv.config() never overwrites a variable that already exists in process.env. So if BASE_URL is already set by CI, the .env file is ignored for that key and the CI value is used. This is exactly the behavior you want: the same config works locally from a file and in CI from injected secrets, with no branching code.
| Approach | Where secrets live | In Git? | Best for |
|---|---|---|---|
| Hardcoded in spec | Source files | Yes (bad) | Never |
| .env + dotenv | Local file + secrets manager | No | Local dev and small teams |
| CI injected env vars | CI secrets store | No | Pipelines and shared runners |
| Cloud secrets manager | Vault / AWS / GCP | No | Enterprise, audited access |
Avoiding Secret Leaks in Traces and Reports
Loading secrets safely is only half the job. Playwright captures traces, screenshots, and videos, and a trace records the values typed into inputs. A trace artifact uploaded to CI or shared in a bug report can therefore expose a password. There are a few real defenses.
First, mask sensitive inputs in the DOM. Password fields are already obscured visually, but for tokens pasted into text inputs, consider using API-level setup instead of typing them through the UI. Second, prefer programmatic authentication: log in once in a setup project, save storageState to a file, and reuse it so individual tests never type credentials at all. This keeps the password out of nearly every trace.
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import { env } from './utils/env';
const authFile = 'playwright/.auth/user.json';
setup('authenticate once', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(env.username);
await page.getByLabel('Password').fill(env.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
// Persist cookies and localStorage so other tests skip the login UI.
await page.context().storageState({ path: authFile });
});
Wire this setup project into your config with a dependency, and downstream projects load storageState: 'playwright/.auth/user.json'. The credential is typed exactly once, in one trace, instead of in every test. Crucially, add playwright/.auth to .gitignore too, because the saved state file contains live session tokens.
- Never print
process.envwithconsole.login a test; it dumps every secret to the report. - Git-ignore
.env,.env.*, and thestorageStateauth directory. - Use
storageStateso passwords appear in one setup trace, not in every spec. - Rotate any secret that has ever been committed, even after you remove it from history.
Conclusion
Solid Playwright secrets management dotenv comes down to a short, disciplined checklist: put real values in a git-ignored .env, load it once at the top of playwright.config.ts, read everything from process.env, validate required keys so failures are loud and early, and let CI inject its own variables without overrides. Layer on storageState-based authentication and careful trace hygiene, and your suite stays fast and portable without ever shipping a credential to Git. Start by adding three lines to .gitignore today, then move your first hardcoded password into a .env file, and you will already be ahead of most test suites in the wild.
FAQ
Should I commit my .env file to Git?
No. The .env file holds real credentials and must be listed in .gitignore so it is never staged. Commit a .env.example template instead, with every key present but the values blank or dummy, so teammates know exactly which variables the suite needs. If a secret was ever committed by accident, removing it from the latest commit is not enough because it still lives in history, so you must rotate that secret.
Where should I call dotenv.config() in a Playwright project?
Call it at the very top of playwright.config.ts, before defineConfig runs. The config file is evaluated before any test, fixture, or global setup, so loading there guarantees every part of the run sees the populated process.env. Avoid scattering dotenv.config() calls inside individual spec files; doing it once centrally is cleaner and prevents inconsistent load order.
How do environment variables from dotenv work in CI?
In CI you typically do not provide a .env file; the platform injects secrets as real environment variables. By default dotenv.config() will not overwrite a variable that already exists in process.env, so any value set by GitHub Actions, GitLab, or Jenkins takes precedence over the file. That means the same Playwright config reads from a file locally and from injected secrets in CI without any conditional code.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
