|

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.

ApproachWhere secrets liveIn Git?Best for
Hardcoded in specSource filesYes (bad)Never
.env + dotenvLocal file + secrets managerNoLocal dev and small teams
CI injected env varsCI secrets storeNoPipelines and shared runners
Cloud secrets managerVault / AWS / GCPNoEnterprise, 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.env with console.log in a test; it dumps every secret to the report.
  • Git-ignore .env, .env.*, and the storageState auth directory.
  • Use storageState so 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.