> **Building with AI coding agents?** If you're using an AI coding agent, install the official Scalekit plugin. It gives your agent full awareness of the Scalekit API — reducing hallucinations and enabling faster, more accurate code generation.
>
> - **Claude Code**: `/plugin marketplace add scalekit-inc/claude-code-authstack` then `/plugin install <auth-type>@scalekit-auth-stack`
> - **GitHub Copilot CLI**: `copilot plugin marketplace add scalekit-inc/github-copilot-authstack` then `copilot plugin install <auth-type>@scalekit-auth-stack`
> - **Codex**: run the bash installer, restart, then open Plugin Directory and enable `<auth-type>`
> - **Skills CLI** (Windsurf, Cline, 40+ agents): `npx skills add scalekit-inc/skills --list` then `--skill <skill-name>`
>
> `<auth-type>` / `<skill-name>`: `agentkit`, `full-stack-auth`, `mcp-auth`, `modular-sso`, `modular-scim` — [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# Run end-to-end tests

Set up test users to run automated E2E tests for login and signup flows
Test users let you run Playwright, Cypress, and other E2E tests against signup and login flows without waiting for real OTP or magic-link emails. Scalekit accepts a static 6-digit code that you configure, so tests are fast, reliable, and inbox-free.

**What doesn't change:** Test users only affects OTP email delivery. Everything else — organization creation, user creation, session creation, audit logs, webhooks, and post-login redirects — behaves the same as it does for a real user.

### Prerequisites

Enable the **Magic Link & OTP** auth method, with the delivery option set to `Verification Code` or `Magic Link + Verification Code`. `Magic Link`-only delivery is not supported.
> Image: Magic link/OTP configuration

## Configure test users

The test users allowlist is set per environment and accepts up to 5 emails. Configure it once — every signup and login test in that environment uses the same emails and static code.

1. ### Open Test users settings

   Go to **Dashboard > Environment settings > Test users**.
   > Image: Test users settings page

2. ### Enable and add emails to the allowlist

   Toggle **Enable test users**. Add up to 5 test emails — emails are stored lowercase and de-duplicated. Each entry must contain `+sktest` in the local part (before `@`); any domain works.

   | Email | Valid? | Reason |
   | --- | :---: | --- |
   | `john+sktest@acmecorp.com` | ✓ | `+sktest` present in the local part |
   | `bob+sktest@anything.example` | ✓ | Any domain works |
   | `john@acmecorp.com` | ✗ | Missing `+sktest` marker |
   | `john+test@acmecorp.com` | ✗ | Marker must be exactly `+sktest` |
   | `sktest+john@acmecorp.com` | ✗ | Must appear after `+`, not before `@` |

   > Image: Test users allowlist with an email added

3. ### Set the static confirmation code

   Enter a **Static confirmation code** — exactly 6 digits. The default is `424242`. Tests will enter this code at the OTP prompt.

4. ### Save

   Click **Save** and reload the page to confirm the settings persisted.

## Test the signup flow

Use this flow to verify that a new user can complete signup and end up as a member of an organization.

> tip: Use environment variables
>
> Store the test email, static code, and authorization URL as environment variables (`SCALEKIT_TEST_USER_EMAIL`, `SCALEKIT_TEST_USER_CODE`, `SCALEKIT_TEST_AUTH_URL`) — don't hard-code them in test files.

Point the test at Scalekit's authorization endpoint with `prompt=create` so the browser lands directly on the hosted signup screen:

```sh title="Authorization URL (signup)" showLineNumbers=false frame
/oauth/authorize?
  response_type=code&
  client_id=&
  redirect_uri=&
  scope=openid+profile+email+offline_access&
  state=&
  prompt=create
```

After OTP verification, Scalekit redirects to `` with an authorization code. Assert on the callback URL in your test.

  ### Playwright

```ts title="playwright/e2e/signup.spec.ts"

const AUTH_URL        = process.env.SCALEKIT_TEST_AUTH_URL!
const TEST_USER_EMAIL = process.env.SCALEKIT_TEST_USER_EMAIL!
const TEST_USER_CODE  = process.env.SCALEKIT_TEST_USER_CODE!

test('OTP signup with test user', async ({ page }) => {
  await page.goto(AUTH_URL)

  await page.fill('input[name="email"]', TEST_USER_EMAIL)
  await page.getByRole('button', { name: 'Continue', exact: true }).click()

  await page.locator('[data-scope="pin-input"][data-part="input"]').first().pressSequentially(TEST_USER_CODE)
  await page.getByRole('button', { name: 'Continue', exact: true }).click()

  // Replace with your app's post-login URL pattern
  await expect(page).toHaveURL(/dashboard\.sampleapp\.com/)
})
```

  ### Cypress

```ts title="cypress/e2e/signup.cy.ts"
const AUTH_URL        = Cypress.env('SCALEKIT_TEST_AUTH_URL')
const TEST_USER_EMAIL = Cypress.env('SCALEKIT_TEST_USER_EMAIL')
const TEST_USER_CODE  = Cypress.env('SCALEKIT_TEST_USER_CODE')

describe('OTP signup with test user', () => {
  it('signs up using the static code', () => {
    cy.visit(AUTH_URL)

    cy.get('input[name="email"]').type(TEST_USER_EMAIL)
    cy.get('button[type="submit"]').contains('Continue').click()

    cy.get('[data-scope="pin-input"][data-part="input"]').first().type(TEST_USER_CODE)
    cy.get('button[type="submit"]').contains('Continue').click()

    // Replace with your app's post-login URL pattern
    cy.url().should('include', 'dashboard.sampleapp.com')
  })
})
```

**Verify:** After running the test, check:
- A new organization exists with the test user as a member. Verify in **Dashboard > Organizations > Newly created organization > Users**.
> Image: New organization and user created
- **Auth logs** in **Dashboard > Auth logs** show two test-user events:
  - *Verification email suppressed (test user)*
  - *OTP verification successful (test user)*
  
  > Image: Auth logs for test users in signup flow

## Test the login flow

Use this flow to verify login for a user that already exists in an organization. The test user must be created before the test runs. Add them manually in **Dashboard > Organizations > Newly created organization > Users > Create user**, or create them programmatically with the Scalekit API or SDK:

  ### Node.js

```ts title="Create test user in an organization"

const scalekit = new ScalekitClient(
  process.env.SCALEKIT_ENVIRONMENT_URL!,
  process.env.SCALEKIT_CLIENT_ID!,
  process.env.SCALEKIT_CLIENT_SECRET!
)

const { user } = await scalekit.user.createUserAndMembership(orgId, {
  email: 'john+sktest@acmecorp.com',
  sendInvitationEmail: false,
})
```

  ### Python

```python title="Create test user in an organization"

from scalekit import ScalekitClient
from scalekit.v1.users.users_pb2 import CreateUser

scalekit_client = ScalekitClient(
  env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
  client_id=os.environ["SCALEKIT_CLIENT_ID"],
  client_secret=os.environ["SCALEKIT_CLIENT_SECRET"]
)

user_response = scalekit_client.users.create_user_and_membership(
  org_id,
  CreateUser(email="john+sktest@acmecorp.com"),
  send_invitation_email=False,
)
```

  ### Go

```go title="Create test user in an organization"

scalekitClient := scalekit.NewScalekitClient(
  os.Getenv("SCALEKIT_ENVIRONMENT_URL"),
  os.Getenv("SCALEKIT_CLIENT_ID"),
  os.Getenv("SCALEKIT_CLIENT_SECRET"),
)

userResp, err := scalekitClient.User().CreateUserAndMembership(ctx, orgID,
  &usersv1.CreateUser{Email: "john+sktest@acmecorp.com"},
  false,
)
if err != nil {
  log.Fatalf("failed to create user: %v", err)
}
```

  ### Java

```java title="Create test user in an organization"

ScalekitClient scalekitClient = new ScalekitClient(
  System.getenv("SCALEKIT_ENVIRONMENT_URL"),
  System.getenv("SCALEKIT_CLIENT_ID"),
  System.getenv("SCALEKIT_CLIENT_SECRET")
);

CreateUserAndMembershipRequest request = CreateUserAndMembershipRequest.newBuilder()
  .setUser(CreateUser.newBuilder().setEmail("john+sktest@acmecorp.com").build())
  .setSendInvitationEmail(false)
  .build();

CreateUserAndMembershipResponse userResp = scalekitClient.users()
  .createUserAndMembership(orgId, request);
```

Make sure the same email is also on the [test users allowlist](#enable-and-add-emails-to-the-allowlist).

### Run the test

Point the test at Scalekit's authorization endpoint with `prompt=login` so the browser lands directly on the hosted login screen:

```sh title="Authorization URL (login)" showLineNumbers=false frame
/oauth/authorize?
  response_type=code&
  client_id=&
  redirect_uri=&
  scope=openid+profile+email+offline_access&
  state=&
  prompt=login
```

After OTP verification, Scalekit redirects to `` with an authorization code. Assert on the callback URL in your test.

  ### Playwright

```ts title="playwright/e2e/login.spec.ts"

const AUTH_URL        = process.env.SCALEKIT_TEST_AUTH_URL!
const TEST_USER_EMAIL = process.env.SCALEKIT_TEST_USER_EMAIL!
const TEST_USER_CODE  = process.env.SCALEKIT_TEST_USER_CODE!

test('OTP login with test user', async ({ page }) => {
  await page.goto(AUTH_URL)

  await page.fill('input[name="email"]', TEST_USER_EMAIL)
  await page.getByRole('button', { name: 'Continue', exact: true }).click()

  await page.locator('[data-scope="pin-input"][data-part="input"]').first().pressSequentially(TEST_USER_CODE)
  await page.getByRole('button', { name: 'Continue', exact: true }).click()

  // Replace with your app's post-login URL pattern
  await expect(page).toHaveURL(/dashboard\.sampleapp\.com/)
})
```

  ### Cypress

```ts title="cypress/e2e/login.cy.ts"
const AUTH_URL        = Cypress.env('SCALEKIT_TEST_AUTH_URL')
const TEST_USER_EMAIL = Cypress.env('SCALEKIT_TEST_USER_EMAIL')
const TEST_USER_CODE  = Cypress.env('SCALEKIT_TEST_USER_CODE')

describe('OTP login with test user', () => {
  it('logs in using the static code', () => {
    cy.visit(AUTH_URL)

    cy.get('input[name="email"]').type(TEST_USER_EMAIL)
    cy.get('button[type="submit"]').contains('Continue').click()

    cy.get('[data-scope="pin-input"][data-part="input"]').first().type(TEST_USER_CODE)
    cy.get('button[type="submit"]').contains('Continue').click()

    // Replace with your app's post-login URL pattern
    cy.url().should('include', 'dashboard.sampleapp.com')
  })
})
```

**Verify:** After running the test, check:

- **Auth logs** in **Dashboard > Auth logs** show two test-user events:
  - *Verification email suppressed (test user)*
  - *OTP verification successful (test user)*

  > Image: Auth log showing test-user events

## Production safety

> caution: Test Users bypass real email verification
>
> Keep Test Users off in production environments unless you have a deliberate reason to enable it. When it is on, a warning banner appears in the dashboard.

## Common scenarios

## Why am I still getting a real verification email?

All three conditions must be true for Test Users to apply:

- **Test Users** feature is enabled in the environment.
- The exact email is on the allowlist (case-insensitive match).
- **Magic Link & OTP** auth method is **enabled** and the configuration is set to `Verification Code` or `Magic Link + Verification Code`.

## Can I use a different code for each test run?

No, the static verification code is set per environment. For parallel isolation, add multiple `+sktest` addresses (up to 5) and assign a different one to each test run.

## How do I keep Test Users out of production CI?

Never enable Test Users in your production environment. In CI, point to a dev or staging environment. Guard your test runner with an environment check — for example, require `SCALEKIT_ENVIRONMENT_URL` to contain `.dev.` or `.staging.` before running E2E tests.


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
