---
title: "Authentication"
description: "Set up login for your tests before authoring them: shared account vs per-test auth, storage state, and the auth.setup.ts / auth.login.ts patterns your agent writes and repairs via /shiplight auth."
---

# Authentication

<div class="view-markdown-wrapper">
<ViewMarkdown />
</div>

If your app requires login, run **`/shiplight auth`** and describe your login flow. Your agent sets up (or repairs) the login and saved storage state using one of the patterns below. Do this before [authoring tests](/local/agent-workflow/create-tests) for an authenticated flow: which pattern you pick determines whether individual test files need their own `use: auth:` field or none at all. The patterns are documented here so you can review and adjust what the agent writes.

There are two approaches depending on whether all tests share the same account or individual tests need their own identity.

## Shared Account (Most Common)

All tests share one authenticated session. This is the standard [Playwright authentication pattern](https://playwright.dev/docs/auth): a setup project logs in once, saves the browser state, and all tests reuse it.

```ts
// auth.setup.ts
import { test as setup } from "@playwright/test";

setup("login", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.USERNAME!);
  await page.getByLabel("Password").fill(process.env.PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");
  await page.context().storageState({ path: ".auth/default.json" });
});
```

```ts
// playwright.config.ts
export default defineConfig({
  ...shiplightConfig(),
  projects: [
    { name: "auth", testMatch: "auth.setup.ts" },
    {
      name: "default",
      dependencies: ["auth"],
      use: {
        baseURL: "https://staging.example.com",
        storageState: ".auth/default.json",
      },
    },
  ],
});
```

Tests don't need any `account` field: they're already authenticated via `storageState`. This is pure Playwright, no Shiplight-specific logic.

**Multiple pre-defined accounts:** If you have a small number of accounts (e.g., admin, viewer) but tests don't pick which one to use, parameterize `auth.setup.ts` with an environment variable and select the account at runtime:

```bash
TEST_ACCOUNT=admin npx shiplight test
```

This is still a shared account, just selected at runtime instead of hardcoded.

## Per-Test Auth (Advanced)

When individual tests need their own identity (e.g., one test runs as admin, another as viewer in the same run), each test declares its `auth` script and optional `args` inline. This allows login customization per test.

### 1. Auth login function

Create an `auth.login.ts` that exports a `login(args)` function. It receives the args from the test config, performs the login flow, manages storageState caching and expiration, and returns the path to a storageState JSON file.

```ts
// auth.login.ts
import { chromium } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";

export async function login(args: Record<string, unknown>): Promise<string> {
  const stateFile = path.join(".auth", `${args.username}.json`);

  // Return cached state if it exists (you control expiration logic here)
  if (fs.existsSync(stateFile)) return stateFile;

  // Perform login
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto("/login");
  await page.getByLabel("Email").fill(args.username as string);
  await page.getByLabel("Password").fill(args.password as string);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");

  // Cache storageState (cookies + localStorage + IndexedDB)
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });
  await context.storageState({ path: stateFile, indexedDB: true });
  await browser.close();

  return stateFile;
}
```

### 2. Use in tests

Each test declares `auth` (path to the login script) and optional `args` passed to the login function. See the [Fixtures](/local/yaml-tests/fixture#authentication-auth) reference for the exact field syntax:

```yaml
use:
  auth: ./auth.login.ts
  args:
    username: admin@example.com
    password: "{{ADMIN_PASSWORD}}"
goal: Admin can manage users
statements:
  - URL: /admin/users
  - VERIFY: User management page is visible
```

The `args` object is passed directly to the login function. This means your auth script can receive any fields your login flow needs (TOTP secrets, API tokens, org IDs, etc.). Tests that don't need args can omit the `args` field entirely.

The fixture uses the storage state path returned by the `login()` function to create the browser context, so the test runs in an already-logged-in state.

Tests without `auth` use the default context: if a setup project is configured, they get its `storageState`; otherwise they run unauthenticated.
