auth-ui: add e2e tests

Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/.gitignore b/core/auth/ui/.gitignore
index cbfcd69..984983f 100644
--- a/core/auth/ui/.gitignore
+++ b/core/auth/ui/.gitignore
@@ -1,3 +1,5 @@
 server
 server_arm64
 server_amd64
+e2e/cache
+e2e/artifacts
diff --git a/core/auth/ui/Makefile b/core/auth/ui/Makefile
index cb29df7..da8b9c7 100644
--- a/core/auth/ui/Makefile
+++ b/core/auth/ui/Makefile
@@ -1,13 +1,28 @@
 repo_name ?= giolekva
 podman ?= docker
 docker_flags=--provenance=false --sbom=false
+
+.PHONY: test test-e2e test-e2e-offline clean-e2e-artifacts
 ifeq ($(podman), podman)
-manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+manifest_dest=docker://docker.io/$(repo_name)/auth-ui:latest
 endif
 
 clean:
 	rm -f server server_*
 
+test:
+	go test ./...
+	go vet ./...
+
+test-e2e:
+	go test -tags=e2e -count=1 -timeout=10m -v ./e2e
+
+test-e2e-offline:
+	AUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e
+
+clean-e2e-artifacts:
+	rm -rf -- e2e/artifacts
+
 build: clean
 	go build -o server *.go
 
diff --git a/core/auth/ui/e2e/README.md b/core/auth/ui/e2e/README.md
new file mode 100644
index 0000000..5e2a8ac
--- /dev/null
+++ b/core/auth/ui/e2e/README.md
@@ -0,0 +1,174 @@
+# auth-ui native end-to-end tests
+
+This directory contains the opt-in, serial browser suite for `auth-ui`. It builds the current application, starts native Ory processes with in-memory SQLite databases, and drives the existing UI with Playwright-managed headless Chromium. Docker and PostgreSQL are not prerequisites.
+
+## Pinned runtime
+
+The harness accepts no `latest` or system-browser fallback:
+
+- Go **1.22 or newer** (the module directive is exactly `go 1.22`)
+- Kratos **v1.1.0**, SQLite release archive
+- Hydra **v2.2.0**, SQLite release archive
+- `github.com/mxschmitt/playwright-go` **v0.6100.0**
+- embedded Playwright CLI **1.61.1**
+- managed Chromium revision **1228**, Chromium **149.0.7827.55**
+- managed FFmpeg revision **1011**
+
+The Playwright binding, CLI, browser, and installer command are one matched version set. The suite never selects Chrome from `PATH`.
+
+### Ory archives and checksums
+
+Hashes are pinned in `artifacts.go` from the official Kratos and Hydra release `checksums.txt` files.
+
+| Platform | Kratos archive / SHA-256 | Hydra archive / SHA-256 |
+|---|---|---|
+| linux/amd64 | `kratos_1.1.0-linux_sqlite_64bit.tar.gz` / `6fb3077252dde7578c3100d2cd4eb52364ca6b3c1b0b76987e6d586e29008cbd` | `hydra_2.2.0-linux_sqlite_64bit.tar.gz` / `0fe0539fa452496ac5d98b558f93eb2dbb4cf43733da0b09f8f2bdb4445fc31e` |
+| linux/arm64 | `kratos_1.1.0-linux_sqlite_arm64.tar.gz` / `fde8a1a1aebd153baff88b1232e0c2a34fdaaafe90b5364f4ea580151e74898e` | `hydra_2.2.0-linux_sqlite_arm64.tar.gz` / `c499ffdaae0f2ab85eff0567214734515b741a393bef89115c16018f4dc0560d` |
+| darwin/amd64 | `kratos_1.1.0-macOS_sqlite_64bit.tar.gz` / `ebdc94f27cb6e6a3087ed756accfb7837465ac8e30af9433b4414101814f7769` | `hydra_2.2.0-macOS_sqlite_64bit.tar.gz` / `3d40ca8e99e2a6d840130928d5e0245212dba0eea9c26a0d7186ebb4382e673d` |
+| darwin/arm64 | `kratos_1.1.0-macOS_sqlite_arm64.tar.gz` / `6681d7b15dd04686d10764750ce3ad69672b3962553223399a3a315ba5370517` | `hydra_2.2.0-macOS_sqlite_arm64.tar.gz` / `89732ad1494c57ea39348f62dc5ef5c48de129cd205b17cb12bb67ad27094bb7` |
+
+These four Linux/macOS architecture combinations are the supported selection matrix. Windows and every other `GOOS/GOARCH` fail before downloads, builds, or service startup. Selection and compile checks do not constitute a native real-browser result. The complete native online/offline suite and automated artifact-format checks have been exercised on Linux amd64; Linux arm64 and both macOS rows remain pending and must not be described as empirically green.
+
+## Prerequisites and installation
+
+Required locally:
+
+1. Go 1.22 or newer, with access to the normal Go module cache on first use.
+2. GitHub network access for an uncached Ory archive.
+3. Playwright-managed Chromium and FFmpeg.
+4. On Linux, the host libraries required by Chromium.
+
+Install the exactly matched browser runtime:
+
+From the `auth-ui` checkout directory:
+
+```sh
+cd /path/to/repository/auth-ui
+make install-e2e-browser
+```
+
+On a Linux host that lacks Chromium system libraries, install the browser and Playwright-recommended OS packages (the package-manager step may require privileges):
+
+```sh
+make install-e2e-browser-deps
+```
+
+No installer runs during ordinary `go test ./...`, `go vet ./...`, `make test`, builds, or image publishing.
+
+## Commands
+
+Fast, untagged tests and vet (no Ory or browser process startup):
+
+```sh
+make test
+```
+
+Install/check the pinned browser, then run the complete tagged suite:
+
+```sh
+make test-e2e
+```
+
+Run from already populated caches without invoking the online browser installer target:
+
+```sh
+make test-e2e-offline
+```
+
+The suite is deliberately serial: it starts one Kratos process, one Hydra process, one fresh `auth-ui` binary, one Playwright driver, and one Chromium process, then creates an isolated browser context for each test. It does not call `t.Parallel`. Make keeps Go's hard `-timeout=10m` contract. The harness starts its **9-minute internal deadline before stack setup**, so the deadline covers archive preparation, application build, service/browser/UI setup, and test execution before timeout cleanup begins, even though Go starts its own alarm inside `m.Run`. The harness therefore begins timeout cleanup at least one minute before Go's alarm. The shorter `AUTH_UI_E2E_WATCHDOG_TIMEOUT` override is reserved for the harness's timeout subprocess regressions; ordinary runs should retain the documented 9-minute deadline. A warm-cache full run is now approximately 60–65 seconds on the implementation Linux amd64 host because the two nested real watchdog regressions deliberately consume most of that duration; first runs and other machines can be slower.
+
+For focused development:
+
+```sh
+go test -count=1 ./e2e
+go test -tags=e2e -count=1 -run '^TestHydra' -timeout=10m -v ./e2e
+```
+
+The first command runs helper unit tests only. The second requires the installed browser and Ory archives.
+
+## Downloads, caches, and offline behavior
+
+On a cache miss, the harness downloads the selected official Kratos and Hydra release archives from GitHub, enforces a size bound, verifies the pinned SHA-256, and publishes atomically. Every cache reuse recomputes the hash.
+
+The default Ory archive cache is repository-local:
+
+```text
+e2e/cache/ory/<service>/<version>/<archive>
+```
+
+Override its root when needed:
+
+```sh
+AUTH_UI_E2E_CACHE_DIR=/absolute/cache make test-e2e
+AUTH_UI_E2E_CACHE_DIR=/absolute/cache make test-e2e-offline
+```
+
+`AUTH_UI_E2E_OFFLINE=1` disables Ory downloads and reports the missing/invalid path and expected hash. `make test-e2e-offline` intentionally does not depend on `install-e2e-browser`; therefore the Go modules, Playwright driver, Chromium, FFmpeg, Ory archives, and Linux host libraries must already be available. A first-ever run needs network access for every missing Go-module, Ory, Playwright-driver, or browser cache entry.
+
+Playwright's default cache locations are:
+
+| Host | Playwright driver | Managed browsers/FFmpeg |
+|---|---|---|
+| Linux | `~/.cache/ms-playwright-go/1.61.1` | `~/.cache/ms-playwright` |
+| macOS | `~/Library/Caches/ms-playwright-go/1.61.1` | `~/Library/Caches/ms-playwright` |
+
+The Linux paths follow the normal user cache root (for example, `$XDG_CACHE_HOME` when configured). `PLAYWRIGHT_DRIVER_PATH` and `PLAYWRIGHT_BROWSERS_PATH` may point to pre-populated matching caches. Do not point them at a different Playwright release or a system browser. Go honors its standard `GOMODCACHE`/`GOCACHE` settings.
+
+## Workspaces and overrides
+
+Each invocation creates a temporary workspace using the operating system's temporary directory, named like:
+
+```text
+<os-temp>/auth-ui-e2e-*/
+  bin/{auth-ui,kratos,hydra}
+  config/{identity.schema.json,kratos.yml,hydra.yml}
+  logs/{auth-ui.log,kratos.log,hydra.log}
+```
+
+The workspace is removed after success. It is retained after setup/test failure, and `AUTH_UI_E2E_KEEP_TMP=1` retains it after success. The harness prints retained paths when relevant. All generated service and callback URLs use dynamically allocated `127.0.0.1` ports; no fixed client, identity, port, or external origin is required.
+
+## Retained artifacts and sensitivity
+
+Every tagged test retains one artifact set on success and failure under:
+
+```text
+e2e/artifacts/<run-id>/
+  run.json
+  services/{auth-ui,kratos,hydra}.log
+  <test-name>/
+    screenshots/*.png
+    video.webm
+    trace.zip
+    session.json
+```
+
+Use `AUTH_UI_E2E_ARTIFACT_DIR=/absolute/path` to change the run-artifact root. Screenshots, video, traces, DOM/network snapshots, and upstream logs can contain generated credentials, cookies, OAuth challenges, codes, or tokens. Treat the entire artifact tree as **sensitive local test data**; do not publish or attach it without review. Both the default cache and artifact directories are ignored by Git.
+
+After inspecting representative PNG, WebM, trace, and metadata files, remove only default retained runs with:
+
+```sh
+make clean-e2e-artifacts
+```
+
+That target deletes exactly `e2e/artifacts/`. It does not clear `e2e/cache`, Playwright caches, an `AUTH_UI_E2E_ARTIFACT_DIR` override, fixtures, or ordinary build output. No test or ordinary clean target deletes artifacts automatically.
+
+## Cleanup and troubleshooting
+
+The harness registers aggregate lifecycle ownership before setup and records partial stack and Playwright ownership as soon as each becomes available. Service creation and cleanup use one ownership lock across the cleanup-state check, OS process start, and slot publication, so cleanup either prevents a new spawn or observes it. After `m.Run`, normal cleanup must atomically stop and claim the watchdog before cleanup begins; if the timer already fired, timeout cleanup owns the terminal status and exit. Browser sessions use the same claim model: a normal finalization claimed before timeout finishes exactly once and is awaited, while a timeout-claimed session is finalized as failed with attempted failure/final screenshots, trace closure, context/video finalization, and failed metadata. Timeout cleanup then closes owned callback listeners, stops services and browser/driver, copies bounded redacted service logs, records `watchdog_timeout` in `run.json`, retains available workspace/artifact paths, and exits nonzero. Both timeout-triggered cleanup and normal post-`m.Run` cleanup give protocol cleanup 45 seconds. If that bound expires, the emergency path directly kills every recorded service group and every discoverable driver/browser descendant without relying on Playwright protocols. Emergency termination has a separate grace of at most 5 seconds, with an unconditional exit 125 guard armed before descendant enumeration so even a blocked external `ps` command cannot delay final exit indefinitely.
+
+This pre-timeout cleanup covers stalls while the Go test process and its cleanup goroutine can still run. It cannot guarantee cleanup after `SIGKILL`, host shutdown, kernel failure, or another abrupt OS-level termination that prevents the harness from executing. After such an interrupted run, inspect the printed workspace and service logs and verify no process whose command references that workspace remains before deleting it.
+
+Common failures:
+
+- **Unsupported E2E platform:** use Linux/macOS on amd64/arm64. Windows is intentionally unsupported.
+- **Offline cache missing or invalid:** run `make test-e2e` online once with the same `AUTH_UI_E2E_CACHE_DIR`, or place the exact official archive at the reported path; the hash must match.
+- **Playwright driver/browser missing or mismatched:** run `make install-e2e-browser` with the repository's Go environment. Do not substitute system Chrome.
+- **Chromium cannot launch on Linux:** run `make install-e2e-browser-deps` with appropriate package-manager privileges.
+- **GitHub or Go proxy unavailable:** retry when online or populate the Ory, Playwright, and Go caches in advance.
+- **Readiness timeout or early process exit:** inspect `services/*.log` and the retained workspace logs. Port bind conflicts are retried only for owned startup attempts.
+- **Trace inspection:** use the trace viewer from the same Playwright 1.61.1 toolchain; traces from this suite are sensitive.
+- **Artifact growth:** inspect what is needed, then run the explicit cleanup target. Caches remain reusable offline.
+
+## Intentional non-goals
+
+This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, settings UI, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
diff --git a/core/auth/ui/e2e/api_client.go b/core/auth/ui/e2e/api_client.go
new file mode 100644
index 0000000..4f6c987
--- /dev/null
+++ b/core/auth/ui/e2e/api_client.go
@@ -0,0 +1,187 @@
+package e2e
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+const maxDirectAPIResponse = 32 << 10
+
+type directAPIClient struct {
+	client       *http.Client
+	kratosPublic string
+	kratosAdmin  string
+}
+
+type kratosIdentity struct {
+	ID       string `json:"id"`
+	SchemaID string `json:"schema_id"`
+	State    string `json:"state"`
+	Traits   struct {
+		Username string `json:"username"`
+	} `json:"traits"`
+}
+
+type kratosSession struct {
+	Active   bool           `json:"active"`
+	Identity kratosIdentity `json:"identity"`
+}
+
+func newDirectAPIClient(kratosPublic, kratosAdmin string) *directAPIClient {
+	return &directAPIClient{
+		client:       &http.Client{Timeout: 10 * time.Second},
+		kratosPublic: strings.TrimRight(kratosPublic, "/"),
+		kratosAdmin:  strings.TrimRight(kratosAdmin, "/"),
+	}
+}
+
+func (c *directAPIClient) close() {
+	if c != nil && c.client != nil {
+		c.client.CloseIdleConnections()
+	}
+}
+
+func (c *directAPIClient) kratosWhoAmI(ctx context.Context, cookies []*http.Cookie) (kratosSession, bool, error) {
+	var session kratosSession
+	status, body, err := c.get(ctx, c.kratosPublic+"/sessions/whoami", cookies)
+	if err != nil {
+		return session, false, err
+	}
+	if status == http.StatusUnauthorized {
+		return session, false, nil
+	}
+	if status != http.StatusOK {
+		return session, false, unexpectedDirectAPIStatus("GET", "/sessions/whoami", status, body)
+	}
+	if err := json.Unmarshal(body, &session); err != nil {
+		return session, false, fmt.Errorf("decode Kratos whoami response: invalid JSON")
+	}
+	if !session.Active {
+		return session, false, fmt.Errorf("Kratos whoami response reported an inactive session")
+	}
+	if session.Identity.ID == "" || session.Identity.SchemaID == "" || session.Identity.State != "active" || session.Identity.Traits.Username == "" {
+		return session, false, fmt.Errorf("Kratos whoami response contained an inactive or incomplete identity")
+	}
+	return session, true, nil
+}
+
+func (c *directAPIClient) kratosIdentitiesByUsername(ctx context.Context, username string) ([]kratosIdentity, error) {
+	endpoint := c.kratosAdmin + "/admin/identities?page_size=500"
+	status, body, err := c.get(ctx, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+	if status != http.StatusOK {
+		return nil, unexpectedDirectAPIStatus("GET", "/admin/identities", status, body)
+	}
+	var identities []kratosIdentity
+	if err := json.Unmarshal(body, &identities); err != nil {
+		return nil, fmt.Errorf("decode Kratos identities response: invalid JSON")
+	}
+	matching := make([]kratosIdentity, 0, 1)
+	for _, identity := range identities {
+		if identity.Traits.Username == username {
+			matching = append(matching, identity)
+		}
+	}
+	return matching, nil
+}
+
+func (c *directAPIClient) get(ctx context.Context, endpoint string, cookies []*http.Cookie) (int, []byte, error) {
+	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return 0, nil, fmt.Errorf("construct bounded direct API GET %s", sanitizedEndpointPath(endpoint))
+	}
+	for _, cookie := range cookies {
+		if cookie != nil {
+			req.AddCookie(cookie)
+		}
+	}
+	return c.do(ctx, req)
+}
+
+func (c *directAPIClient) postJSON(ctx context.Context, endpoint string, body []byte) (int, []byte, error) {
+	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return 0, nil, fmt.Errorf("construct bounded direct API POST %s", sanitizedEndpointPath(endpoint))
+	}
+	req.Header.Set("Content-Type", "application/json")
+	return c.do(ctx, req)
+}
+
+func (c *directAPIClient) do(ctx context.Context, req *http.Request) (int, []byte, error) {
+	resp, err := c.client.Do(req)
+	if err != nil {
+		if ctx.Err() != nil {
+			return 0, nil, fmt.Errorf("direct API %s %s: %w", req.Method, sanitizedEndpointPath(req.URL.String()), ctx.Err())
+		}
+		return 0, nil, fmt.Errorf("direct API %s %s failed", req.Method, sanitizedEndpointPath(req.URL.String()))
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectAPIResponse+1))
+	if err != nil {
+		return 0, nil, fmt.Errorf("read direct API response %s: %w", sanitizedEndpointPath(req.URL.String()), err)
+	}
+	if len(body) > maxDirectAPIResponse {
+		return 0, nil, fmt.Errorf("direct API response %s exceeds %d bytes", sanitizedEndpointPath(req.URL.String()), maxDirectAPIResponse)
+	}
+	return resp.StatusCode, body, nil
+}
+
+func unexpectedDirectAPIStatus(method, path string, status int, body []byte) error {
+	return fmt.Errorf("direct API %s %s returned status %d: %s", method, path, status, sanitizedResponseDiagnostic(body))
+}
+
+func sanitizedEndpointPath(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || u.Path == "" {
+		return "endpoint"
+	}
+	return u.EscapedPath()
+}
+
+func sanitizedResponseDiagnostic(body []byte) string {
+	if len(body) == 0 {
+		return "empty response"
+	}
+	var value any
+	if json.Unmarshal(body, &value) != nil {
+		return "non-JSON response omitted"
+	}
+	value = redactJSONValue(value)
+	encoded, err := json.Marshal(value)
+	if err != nil {
+		return "JSON response omitted"
+	}
+	return string(encoded)
+}
+
+func redactJSONValue(value any) any {
+	switch typed := value.(type) {
+	case map[string]any:
+		for key, child := range typed {
+			typed[key] = redactJSONValue(child)
+		}
+		return typed
+	case []any:
+		for index, child := range typed {
+			typed[index] = redactJSONValue(child)
+		}
+		return typed
+	case string:
+		return "[REDACTED]"
+	default:
+		return value
+	}
+}
diff --git a/core/auth/ui/e2e/api_client_test.go b/core/auth/ui/e2e/api_client_test.go
new file mode 100644
index 0000000..f9201d9
--- /dev/null
+++ b/core/auth/ui/e2e/api_client_test.go
@@ -0,0 +1,179 @@
+package e2e
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestDirectAPIKratosWhoAmIAndIdentityLookup(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/sessions/whoami":
+			cookie, err := r.Cookie("ory_kratos_session")
+			if err != nil || cookie.Value != "accepted-session" {
+				http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+				return
+			}
+			w.Header().Set("Content-Type", "application/json")
+			fmt.Fprint(w, `{"active":true,"identity":{"id":"identity-1","schema_id":"user","state":"active","traits":{"username":"wanted-user"}}}`)
+		case "/admin/identities":
+			if r.URL.Query().Get("page_size") != "500" {
+				t.Errorf("page_size=%q", r.URL.Query().Get("page_size"))
+			}
+			w.Header().Set("Content-Type", "application/json")
+			fmt.Fprint(w, `[{"id":"identity-1","schema_id":"user","state":"active","traits":{"username":"wanted-user"}},{"id":"identity-2","traits":{"username":"other-user"}}]`)
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer server.Close()
+
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+	session, accepted, err := client.kratosWhoAmI(context.Background(), []*http.Cookie{{Name: "ory_kratos_session", Value: "accepted-session"}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !accepted || !session.Active || session.Identity.ID != "identity-1" || session.Identity.Traits.Username != "wanted-user" {
+		t.Fatalf("unexpected accepted whoami result: accepted=%v session=%+v", accepted, session)
+	}
+	identities, err := client.kratosIdentitiesByUsername(context.Background(), "wanted-user")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(identities) != 1 || identities[0].ID != session.Identity.ID || identities[0].SchemaID != "user" || identities[0].State != "active" {
+		t.Fatalf("matching identities=%+v", identities)
+	}
+}
+
+func TestDirectAPIKratosWhoAmIRejectsUnauthorizedSession(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+	}))
+	defer server.Close()
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+
+	_, accepted, err := client.kratosWhoAmI(context.Background(), nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if accepted {
+		t.Fatal("unauthorized Kratos response was accepted")
+	}
+}
+
+func TestDirectAPIKratosWhoAmIRejectsNonUnauthorizedResponses(t *testing.T) {
+	tests := []struct {
+		name   string
+		status int
+		body   string
+	}{
+		{name: "forbidden", status: http.StatusForbidden, body: `{"error":"secret-forbidden-detail"}`},
+		{name: "inactive", status: http.StatusOK, body: `{"active":false,"identity":{"id":"identity-1","schema_id":"user","state":"active","traits":{"username":"user"}}}`},
+		{name: "incomplete", status: http.StatusOK, body: `{"active":true,"identity":{"schema_id":"user","state":"active","traits":{"username":"user"}}}`},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				w.WriteHeader(test.status)
+				fmt.Fprint(w, test.body)
+			}))
+			defer server.Close()
+			client := newDirectAPIClient(server.URL, server.URL)
+			defer client.close()
+
+			_, accepted, err := client.kratosWhoAmI(context.Background(), nil)
+			if err == nil {
+				t.Fatal("non-401 Kratos response did not produce an error")
+			}
+			if accepted {
+				t.Fatal("invalid Kratos response was accepted")
+			}
+			if strings.Contains(err.Error(), "secret-forbidden-detail") {
+				t.Fatalf("whoami error leaked response detail: %v", err)
+			}
+		})
+	}
+}
+
+func TestDirectAPIPostJSONSetsContentType(t *testing.T) {
+	const requestBody = `{"username":"test-user","password":"local-secret"}`
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			t.Errorf("method=%s", r.Method)
+		}
+		if got := r.Header.Get("Content-Type"); got != "application/json" {
+			t.Errorf("Content-Type=%q", got)
+		}
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("read request: %v", err)
+		}
+		if string(body) != requestBody {
+			t.Error("POST body did not round trip")
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprint(w, `{"id":"identity-1"}`)
+	}))
+	defer server.Close()
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+
+	status, body, err := client.postJSON(context.Background(), server.URL+"/identities", []byte(requestBody))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusCreated || string(body) != `{"id":"identity-1"}` {
+		t.Fatalf("unexpected POST response: status=%d body=%s", status, sanitizedResponseDiagnostic(body))
+	}
+}
+
+func TestDirectAPIMalformedRequestDoesNotLeakQuery(t *testing.T) {
+	client := newDirectAPIClient("http://127.0.0.1", "http://127.0.0.1")
+	defer client.close()
+	const secret = "secret-query-value"
+	_, _, err := client.get(context.Background(), "http://127.0.0.1/%zz?token="+secret, nil)
+	if err == nil {
+		t.Fatal("malformed request URL was accepted")
+	}
+	if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "token=") || strings.Contains(err.Error(), "%zz") {
+		t.Fatalf("request-construction diagnostic leaked URL/query material: %v", err)
+	}
+	if err.Error() != "construct bounded direct API GET endpoint" {
+		t.Fatalf("unexpected safe request-construction diagnostic: %v", err)
+	}
+}
+
+func TestDirectAPIResponseBoundAndDiagnosticRedaction(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusBadGateway)
+		fmt.Fprint(w, strings.Repeat("x", maxDirectAPIResponse+1))
+	}))
+	defer server.Close()
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+
+	if _, _, err := client.get(context.Background(), server.URL+"/oversized?token=not-for-diagnostics", nil); err == nil || !strings.Contains(err.Error(), "exceeds") || strings.Contains(err.Error(), "not-for-diagnostics") {
+		t.Fatalf("bounded response error=%v", err)
+	}
+
+	diagnostic := sanitizedResponseDiagnostic([]byte(`{"error":"safe context","password":"secret-password","nested":{"csrfToken":"secret-csrf","code":"secret-code","flow":"secret-flow"}}`))
+	for _, secret := range []string{"secret-password", "secret-csrf", "secret-code", "secret-flow"} {
+		if strings.Contains(diagnostic, secret) {
+			t.Fatalf("diagnostic leaked secret: %s", diagnostic)
+		}
+	}
+	if strings.Contains(diagnostic, "safe context") || strings.Count(diagnostic, "[REDACTED]") != 5 {
+		t.Fatalf("unexpected sanitized diagnostic: %s", diagnostic)
+	}
+	if got := sanitizedResponseDiagnostic([]byte("password=secret")); got != "non-JSON response omitted" {
+		t.Fatalf("non-JSON diagnostic=%q", got)
+	}
+}
diff --git a/core/auth/ui/e2e/api_password_test.go b/core/auth/ui/e2e/api_password_test.go
new file mode 100644
index 0000000..e62c041
--- /dev/null
+++ b/core/auth/ui/e2e/api_password_test.go
@@ -0,0 +1,258 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+const (
+	usernameLengthMessage      = "Username must be at least 3 characters long."
+	passwordLengthMessage      = "Password must be at least 20 characters long."
+	passwordCompositionMessage = "Password must contain at least one digit, lower&upper case and special characters"
+	usernameUnavailableMessage = "Username is not available."
+	passwordChangedMessage     = "Password changed successfully."
+)
+
+type identityAPIResponse struct {
+	ID string `json:"id"`
+}
+
+type identityAPIErrors struct {
+	Errors []struct {
+		Field   string `json:"field"`
+		Message string `json:"message"`
+	} `json:"errors"`
+}
+
+func TestIdentityAPI(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	status, _, err := client.postJSON(context.Background(), testStack.APIURL+"/identities", []byte(`{"username":`))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusBadRequest {
+		t.Fatalf("malformed identity JSON returned status %d, want 400", status)
+	}
+
+	status, body := postIdentityJSON(t, client, "x", "short")
+	if status != http.StatusBadRequest {
+		t.Fatalf("invalid identity fields returned status %d, want 400: %s", status, sanitizedResponseDiagnostic(body))
+	}
+	assertIdentityAPIErrors(t, body, map[string][]string{
+		"username": {usernameLengthMessage},
+		"password": {passwordLengthMessage, passwordCompositionMessage},
+	})
+
+	status, body = postIdentityJSON(t, client, username+"-invalid", strings.Repeat("a", 24))
+	if status != http.StatusBadRequest {
+		t.Fatalf("composition-invalid password returned status %d, want 400: %s", status, sanitizedResponseDiagnostic(body))
+	}
+	assertIdentityAPIErrors(t, body, map[string][]string{
+		"password": {passwordCompositionMessage},
+	})
+
+	status, body = postIdentityJSON(t, client, username, password)
+	if status != http.StatusOK {
+		t.Fatalf("valid identity creation returned status %d, want 200: %s", status, sanitizedResponseDiagnostic(body))
+	}
+	var created identityAPIResponse
+	if err := json.Unmarshal(body, &created); err != nil || created.ID == "" {
+		t.Fatal("valid identity creation did not return a non-empty JSON id")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	identities, err := client.kratosIdentitiesByUsername(ctx, username)
+	cancel()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(identities) != 1 || identities[0].ID != created.ID || identities[0].SchemaID != "user" || identities[0].State != "active" || identities[0].Traits.Username != username {
+		t.Fatal("Kratos Admin did not persist the API-created identity with its id, user schema, active state, and username")
+	}
+
+	status, body = postIdentityJSON(t, client, username, password)
+	if status != http.StatusBadRequest {
+		t.Fatalf("duplicate identity returned status %d, want 400: %s", status, sanitizedResponseDiagnostic(body))
+	}
+	assertIdentityAPIErrors(t, body, map[string][]string{
+		"username": {usernameUnavailableMessage},
+	})
+
+	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+	checkpoint(t, session, "api-created-identity-login-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "login")
+	assertGreeting(t, session.Page, username)
+	checkpoint(t, session, "api-created-identity-greeting")
+	whoami := assertAcceptedKratosSession(t, client, session)
+	if whoami.Identity.ID != created.ID || whoami.Identity.Traits.Username != username {
+		t.Fatal("browser login did not resolve to the API-created identity id and username")
+	}
+}
+
+func TestPasswordChange(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, oldPassword := uniqueKratosCredentials(t)
+	_, newPassword := uniqueKratosCredentials(t)
+
+	if _, err := session.Page.Goto(testStack.UIURL + "/settings"); err != nil {
+		t.Fatal(err)
+	}
+	assertKratosForm(t, session.Page, "/login")
+	if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
+		t.Fatalf("unauthenticated change-password guard exposed its form: count=%d err=%v", count, err)
+	}
+	checkpoint(t, session, "unauthenticated-change-password-guard")
+
+	registerThroughBrowser(t, session, username, oldPassword, "password-change-registration")
+	original := assertAcceptedKratosSession(t, client, session)
+	openChangePasswordForm(t, session.Page, username)
+
+	if err := session.Page.Locator(`input[name="password"]`).Fill("short"); err != nil {
+		t.Fatal("fill invalid replacement password")
+	}
+	clickButton(t, session.Page, "change password")
+	assertChangePasswordForm(t, session.Page, username)
+	assertVisibleExactText(t, session.Page, passwordLengthMessage)
+	assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+	checkpoint(t, session, "invalid-password-change-validation")
+
+	logoutThroughBrowser(t, session)
+	checkpoint(t, session, "old-password-login-after-invalid-update")
+	fillCredentials(t, session.Page, username, oldPassword)
+	clickButton(t, session.Page, "login")
+	assertGreeting(t, session.Page, username)
+	oldLogin := assertAcceptedKratosSession(t, client, session)
+	if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
+		t.Fatal("old password after invalid update did not resolve to the original identity")
+	}
+	checkpoint(t, session, "old-password-accepted-after-invalid-update")
+
+	openChangePasswordForm(t, session.Page, username)
+	if err := session.Page.Locator(`input[name="password"]`).Fill(newPassword); err != nil {
+		t.Fatal("fill valid replacement password")
+	}
+	clickButton(t, session.Page, "change password")
+	assertVisibleExactText(t, session.Page, passwordChangedMessage)
+	checkpoint(t, session, "password-change-success")
+
+	logoutThroughBrowser(t, session)
+	checkpoint(t, session, "old-password-login-after-replacement")
+	fillCredentials(t, session.Page, username, oldPassword)
+	clickButton(t, session.Page, "login")
+	assertKratosForm(t, session.Page, "/login")
+	assertNoAcceptedKratosSession(t, client, session)
+	checkpoint(t, session, "old-password-rejected")
+
+	fillCredentials(t, session.Page, username, newPassword)
+	clickButton(t, session.Page, "login")
+	assertGreeting(t, session.Page, username)
+	accepted := assertAcceptedKratosSession(t, client, session)
+	if accepted.Identity.ID != original.Identity.ID || accepted.Identity.Traits.Username != username {
+		t.Fatal("replacement password did not preserve the original identity id and username")
+	}
+	checkpoint(t, session, "new-password-accepted")
+}
+
+func postIdentityJSON(t *testing.T, client *directAPIClient, username, password string) (int, []byte) {
+	t.Helper()
+	body, err := json.Marshal(map[string]string{"username": username, "password": password})
+	if err != nil {
+		t.Fatal("encode identity API request")
+	}
+	status, response, err := client.postJSON(context.Background(), testStack.APIURL+"/identities", body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return status, response
+}
+
+func assertIdentityAPIErrors(t *testing.T, body []byte, expected map[string][]string) {
+	t.Helper()
+	var response identityAPIErrors
+	if err := json.Unmarshal(body, &response); err != nil {
+		t.Fatal("identity API error response was not valid JSON")
+	}
+	remaining := make(map[string]map[string]bool, len(expected))
+	total := 0
+	for field, messages := range expected {
+		remaining[field] = make(map[string]bool, len(messages))
+		for _, message := range messages {
+			remaining[field][message] = true
+			total++
+		}
+	}
+	if len(response.Errors) != total {
+		t.Fatalf("identity API returned %d validation errors, want %d", len(response.Errors), total)
+	}
+	for _, apiError := range response.Errors {
+		messages, ok := remaining[apiError.Field]
+		if !ok || !messages[apiError.Message] {
+			t.Fatalf("identity API returned an unexpected validation error for field %q", apiError.Field)
+		}
+		delete(messages, apiError.Message)
+	}
+	for field, messages := range remaining {
+		if len(messages) != 0 {
+			t.Fatalf("identity API omitted an expected validation error for field %q", field)
+		}
+	}
+}
+
+func openChangePasswordForm(t *testing.T, page playwright.Page, username string) {
+	t.Helper()
+	link := page.Locator(`a[href="/settings"]`)
+	if err := link.Click(); err != nil {
+		t.Fatal("open change-password flow from the logged-in user page")
+	}
+	assertChangePasswordForm(t, page, username)
+}
+
+func assertChangePasswordForm(t *testing.T, page playwright.Page, username string) {
+	t.Helper()
+	u, err := url.Parse(page.URL())
+	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/settings" || u.Query().Get("flow") == "" {
+		t.Fatal("authenticated password form was not rendered through a Kratos settings flow")
+	}
+	password := page.Locator(`input[name="password"]`)
+	if err := password.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+		t.Fatalf("change-password field did not become visible: %v", err)
+	}
+	usernameInput := page.Locator(`input[name="username"]`)
+	if count, err := usernameInput.Count(); err != nil || count != 1 {
+		t.Fatalf("change-password form username field count=%d err=%v", count, err)
+	}
+	if value, err := usernameInput.InputValue(); err != nil || value != username {
+		t.Fatal("change-password form did not retain the authenticated username")
+	}
+}
+
+func assertVisibleExactText(t *testing.T, page playwright.Page, text string) {
+	t.Helper()
+	if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+		t.Fatalf("expected product message was not visible: %v", err)
+	}
+}
+
+func logoutThroughBrowser(t *testing.T, session *browserSession) {
+	t.Helper()
+	if _, err := session.Page.Goto(testStack.UIURL + "/logout"); err != nil {
+		t.Fatal(err)
+	}
+	assertKratosForm(t, session.Page, "/login")
+}
diff --git a/core/auth/ui/e2e/archive.go b/core/auth/ui/e2e/archive.go
new file mode 100644
index 0000000..f9b85dd
--- /dev/null
+++ b/core/auth/ui/e2e/archive.go
@@ -0,0 +1,104 @@
+package e2e
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+func extractBinary(archivePath, destinationDir, expectedName string) (string, error) {
+	f, err := os.Open(archivePath)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	gz, err := gzip.NewReader(f)
+	if err != nil {
+		return "", fmt.Errorf("open gzip archive: %w", err)
+	}
+	defer gz.Close()
+
+	if err := os.MkdirAll(destinationDir, 0o755); err != nil {
+		return "", err
+	}
+	var found []byte
+	tr := tar.NewReader(gz)
+	for {
+		h, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			return "", fmt.Errorf("read tar archive: %w", err)
+		}
+		clean := filepath.Clean(h.Name)
+		if filepath.IsAbs(h.Name) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+			return "", fmt.Errorf("unsafe archive path %q", h.Name)
+		}
+		if filepath.Base(clean) != expectedName {
+			continue
+		}
+		if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeRegA {
+			return "", fmt.Errorf("expected executable %q is not a regular file", h.Name)
+		}
+		if found != nil {
+			return "", fmt.Errorf("archive contains multiple %q executables", expectedName)
+		}
+		if h.Size < 1 || h.Size > 512<<20 {
+			return "", fmt.Errorf("invalid executable size %d", h.Size)
+		}
+		found, err = io.ReadAll(io.LimitReader(tr, h.Size+1))
+		if err != nil {
+			return "", err
+		}
+		if int64(len(found)) != h.Size {
+			return "", fmt.Errorf("truncated executable %q", h.Name)
+		}
+	}
+	if found == nil {
+		return "", fmt.Errorf("archive does not contain expected executable %q", expectedName)
+	}
+	destination := filepath.Join(destinationDir, expectedName)
+	if err := os.WriteFile(destination, found, 0o755); err != nil {
+		return "", err
+	}
+	if err := os.Chmod(destination, 0o755); err != nil {
+		return "", err
+	}
+	return destination, nil
+}
+
+func verifyBinaryVersion(path, expected string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	out, err := exec.CommandContext(ctx, path, "version").CombinedOutput()
+	if ctx.Err() != nil {
+		return fmt.Errorf("verify %s version: %w", filepath.Base(path), ctx.Err())
+	}
+	if err != nil {
+		return fmt.Errorf("verify %s version: %w (%s)", filepath.Base(path), err, boundedText(out, 2048))
+	}
+	if !versionOutputMatches(out, expected) {
+		return fmt.Errorf("unexpected %s version: expected %s, got %s", filepath.Base(path), expected, boundedText(out, 2048))
+	}
+	return nil
+}
+
+func versionOutputMatches(output []byte, expected string) bool {
+	withoutV := strings.TrimPrefix(expected, "v")
+	for _, field := range strings.Fields(string(output)) {
+		field = strings.Trim(field, " \t\r\n,;:()[]{}")
+		if field == expected || field == withoutV {
+			return true
+		}
+	}
+	return false
+}
diff --git a/core/auth/ui/e2e/archive_test.go b/core/auth/ui/e2e/archive_test.go
new file mode 100644
index 0000000..c9c9c91
--- /dev/null
+++ b/core/auth/ui/e2e/archive_test.go
@@ -0,0 +1,125 @@
+package e2e
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+type tarEntry struct {
+	name string
+	body string
+	kind byte
+}
+
+func writeTarGz(t *testing.T, entries []tarEntry) string {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "fixture.tar.gz")
+	f, err := os.Create(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	gz := gzip.NewWriter(f)
+	tw := tar.NewWriter(gz)
+	for _, entry := range entries {
+		kind := entry.kind
+		if kind == 0 {
+			kind = tar.TypeReg
+		}
+		h := &tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.body)), Typeflag: kind}
+		if err := tw.WriteHeader(h); err != nil {
+			t.Fatal(err)
+		}
+		if kind == tar.TypeReg {
+			if _, err := tw.Write([]byte(entry.body)); err != nil {
+				t.Fatal(err)
+			}
+		}
+	}
+	if err := tw.Close(); err != nil {
+		t.Fatal(err)
+	}
+	if err := gz.Close(); err != nil {
+		t.Fatal(err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return path
+}
+
+func TestExtractBinary(t *testing.T) {
+	workspace := t.TempDir()
+	archive := writeTarGz(t, []tarEntry{{name: "nested/kratos", body: "binary"}, {name: "README", body: "text"}})
+	path, err := extractBinary(archive, filepath.Join(workspace, "bin"), "kratos")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if filepath.Dir(path) != filepath.Join(workspace, "bin") {
+		t.Fatalf("extracted outside destination: %s", path)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if info.Mode().Perm() != 0o755 {
+		t.Fatalf("mode=%o", info.Mode().Perm())
+	}
+}
+
+func TestExtractBinaryRejectsUnsafeArchives(t *testing.T) {
+	for _, name := range []string{"../kratos", "nested/../../kratos", "/absolute/kratos"} {
+		t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) {
+			archive := writeTarGz(t, []tarEntry{{name: name, body: "bad"}})
+			if _, err := extractBinary(archive, t.TempDir(), "kratos"); err == nil || !strings.Contains(err.Error(), "unsafe archive path") {
+				t.Fatalf("error=%v", err)
+			}
+		})
+	}
+}
+
+func TestVersionOutputMatchesExactSemanticVersion(t *testing.T) {
+	for _, output := range []string{"Version:\tv1.1.0\n", "Version: 1.1.0\n"} {
+		if !versionOutputMatches([]byte(output), "v1.1.0") {
+			t.Errorf("expected match for %q", output)
+		}
+	}
+	for _, output := range []string{"Version: v11.1.0", "Version: v1.1.01", "commit-v1.1.0-extra"} {
+		if versionOutputMatches([]byte(output), "v1.1.0") {
+			t.Errorf("unexpected match for %q", output)
+		}
+	}
+}
+
+func TestExtractBinaryFailures(t *testing.T) {
+	t.Run("missing", func(t *testing.T) {
+		archive := writeTarGz(t, []tarEntry{{name: "other", body: "x"}})
+		if _, err := extractBinary(archive, t.TempDir(), "hydra"); err == nil || !strings.Contains(err.Error(), "does not contain") {
+			t.Fatalf("error=%v", err)
+		}
+	})
+	t.Run("multiple", func(t *testing.T) {
+		archive := writeTarGz(t, []tarEntry{{name: "a/hydra", body: "x"}, {name: "b/hydra", body: "y"}})
+		if _, err := extractBinary(archive, t.TempDir(), "hydra"); err == nil || !strings.Contains(err.Error(), "multiple") {
+			t.Fatalf("error=%v", err)
+		}
+	})
+	t.Run("not-regular", func(t *testing.T) {
+		archive := writeTarGz(t, []tarEntry{{name: "hydra", kind: tar.TypeSymlink}})
+		if _, err := extractBinary(archive, t.TempDir(), "hydra"); err == nil || !strings.Contains(err.Error(), "regular") {
+			t.Fatalf("error=%v", err)
+		}
+	})
+	t.Run("corrupt", func(t *testing.T) {
+		path := filepath.Join(t.TempDir(), "bad.tar.gz")
+		if err := os.WriteFile(path, []byte("not gzip"), 0o600); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := extractBinary(path, t.TempDir(), "hydra"); err == nil {
+			t.Fatal("expected corrupt archive error")
+		}
+	})
+}
diff --git a/core/auth/ui/e2e/artifacts.go b/core/auth/ui/e2e/artifacts.go
new file mode 100644
index 0000000..fda096e
--- /dev/null
+++ b/core/auth/ui/e2e/artifacts.go
@@ -0,0 +1,212 @@
+package e2e
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+)
+
+const (
+	maxOryArchiveDownloadSize int64 = 512 << 20
+
+	kratosVersion        = "v1.1.0"
+	hydraVersion         = "v2.2.0"
+	playwrightVersion    = "v0.6100.0"
+	playwrightCLIVersion = "1.61.1"
+	chromiumRevision     = "1228"
+	chromiumVersion      = "149.0.7827.55"
+	ffmpegRevision       = "1011"
+)
+
+type releaseArtifact struct {
+	Service string
+	Version string
+	Archive string
+	SHA256  string
+	URL     string
+}
+
+type platformArtifacts struct {
+	Kratos releaseArtifact
+	Hydra  releaseArtifact
+}
+
+// Hashes are from the official release checksum manifests:
+// https://github.com/ory/kratos/releases/download/v1.1.0/checksums.txt
+// https://github.com/ory/hydra/releases/download/v2.2.0/checksums.txt
+var artifactPlatforms = map[string]platformArtifacts{
+	"linux/amd64":  platform("kratos_1.1.0-linux_sqlite_64bit.tar.gz", "6fb3077252dde7578c3100d2cd4eb52364ca6b3c1b0b76987e6d586e29008cbd", "hydra_2.2.0-linux_sqlite_64bit.tar.gz", "0fe0539fa452496ac5d98b558f93eb2dbb4cf43733da0b09f8f2bdb4445fc31e"),
+	"linux/arm64":  platform("kratos_1.1.0-linux_sqlite_arm64.tar.gz", "fde8a1a1aebd153baff88b1232e0c2a34fdaaafe90b5364f4ea580151e74898e", "hydra_2.2.0-linux_sqlite_arm64.tar.gz", "c499ffdaae0f2ab85eff0567214734515b741a393bef89115c16018f4dc0560d"),
+	"darwin/amd64": platform("kratos_1.1.0-macOS_sqlite_64bit.tar.gz", "ebdc94f27cb6e6a3087ed756accfb7837465ac8e30af9433b4414101814f7769", "hydra_2.2.0-macOS_sqlite_64bit.tar.gz", "3d40ca8e99e2a6d840130928d5e0245212dba0eea9c26a0d7186ebb4382e673d"),
+	"darwin/arm64": platform("kratos_1.1.0-macOS_sqlite_arm64.tar.gz", "6681d7b15dd04686d10764750ce3ad69672b3962553223399a3a315ba5370517", "hydra_2.2.0-macOS_sqlite_arm64.tar.gz", "89732ad1494c57ea39348f62dc5ef5c48de129cd205b17cb12bb67ad27094bb7"),
+}
+
+func platform(kratosArchive, kratosHash, hydraArchive, hydraHash string) platformArtifacts {
+	return platformArtifacts{
+		Kratos: releaseArtifact{"kratos", kratosVersion, kratosArchive, kratosHash, "https://github.com/ory/kratos/releases/download/" + kratosVersion + "/" + kratosArchive},
+		Hydra:  releaseArtifact{"hydra", hydraVersion, hydraArchive, hydraHash, "https://github.com/ory/hydra/releases/download/" + hydraVersion + "/" + hydraArchive},
+	}
+}
+
+func artifactsFor(goos, goarch string) (platformArtifacts, error) {
+	p, ok := artifactPlatforms[goos+"/"+goarch]
+	if !ok {
+		return platformArtifacts{}, fmt.Errorf("unsupported E2E platform %s/%s (supported: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)", goos, goarch)
+	}
+	return p, nil
+}
+
+func repositoryDir() (string, error) {
+	_, source, _, ok := runtime.Caller(0)
+	if !ok {
+		return "", errors.New("locate e2e source file")
+	}
+	dir := filepath.Dir(filepath.Dir(source))
+	if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
+		return "", fmt.Errorf("locate auth-ui repository: %w", err)
+	}
+	return dir, nil
+}
+
+func cacheRoot(repo string) string {
+	if root := os.Getenv("AUTH_UI_E2E_CACHE_DIR"); root != "" {
+		return root
+	}
+	return filepath.Join(repo, "e2e", "cache")
+}
+
+func archiveCachePath(root string, artifact releaseArtifact) string {
+	return filepath.Join(root, "ory", artifact.Service, artifact.Version, artifact.Archive)
+}
+
+func ensureArchive(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool) (string, error) {
+	return ensureArchiveWithLimit(ctx, client, root, artifact, offline, maxOryArchiveDownloadSize)
+}
+
+func ensureArchiveWithLimit(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool, maxSize int64) (string, error) {
+	path := archiveCachePath(root, artifact)
+	valid, actual, err := validSHA256(path, artifact.SHA256)
+	if err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", err
+	}
+	if valid {
+		return path, nil
+	}
+	if offline {
+		return "", fmt.Errorf("offline E2E cache is missing or invalid: %s (expected sha256 %s, got %s); populate it with an online E2E run", path, artifact.SHA256, printableHash(actual))
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return "", err
+	}
+	unlock, err := acquireCacheLock(ctx, path+".lock")
+	if err != nil {
+		return "", fmt.Errorf("lock archive cache %s: %w", path, err)
+	}
+	defer unlock()
+
+	// Another harness process may have published a valid archive while this
+	// caller waited for the lock. Revalidate before removing the invalid path.
+	valid, actual, err = validSHA256(path, artifact.SHA256)
+	if err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", err
+	}
+	if valid {
+		return path, nil
+	}
+	if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", fmt.Errorf("remove invalid cache file: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, artifact.URL, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("download %s: %w", artifact.Archive, err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		_, _ = io.CopyN(io.Discard, resp.Body, 4096)
+		return "", fmt.Errorf("download %s: unexpected HTTP status %s", artifact.Archive, resp.Status)
+	}
+	if resp.ContentLength > maxSize {
+		return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes (content length %d)", artifact.Archive, maxSize, resp.ContentLength)
+	}
+
+	tmp, err := os.CreateTemp(filepath.Dir(path), "."+artifact.Archive+"-*")
+	if err != nil {
+		return "", err
+	}
+	tmpName := tmp.Name()
+	published := false
+	defer func() {
+		_ = tmp.Close()
+		if !published {
+			_ = os.Remove(tmpName)
+		}
+	}()
+	h := sha256.New()
+	written, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, maxSize+1))
+	if err != nil {
+		return "", fmt.Errorf("download %s body: %w", artifact.Archive, err)
+	}
+	if written > maxSize {
+		return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes", artifact.Archive, maxSize)
+	}
+	actual = hex.EncodeToString(h.Sum(nil))
+	if !strings.EqualFold(actual, artifact.SHA256) {
+		return "", fmt.Errorf("download %s checksum mismatch: expected %s, got %s", artifact.Archive, artifact.SHA256, actual)
+	}
+	if err := tmp.Sync(); err != nil {
+		return "", err
+	}
+	if err := tmp.Close(); err != nil {
+		return "", err
+	}
+	if err := os.Rename(tmpName, path); err != nil {
+		// A concurrent writer may have published the same valid archive.
+		if valid, _, checkErr := validSHA256(path, artifact.SHA256); checkErr == nil && valid {
+			return path, nil
+		}
+		return "", fmt.Errorf("publish %s: %w", path, err)
+	}
+	published = true
+	if valid, actual, err := validSHA256(path, artifact.SHA256); err != nil || !valid {
+		_ = os.Remove(path)
+		return "", fmt.Errorf("validate published archive %s: expected %s, got %s: %v", path, artifact.SHA256, actual, err)
+	}
+	return path, nil
+}
+
+func validSHA256(path, expected string) (bool, string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return false, "", err
+	}
+	defer f.Close()
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return false, "", err
+	}
+	actual := hex.EncodeToString(h.Sum(nil))
+	return strings.EqualFold(actual, expected), actual, nil
+}
+
+func printableHash(hash string) string {
+	if hash == "" {
+		return "missing"
+	}
+	return hash
+}
+
+func downloadClient() *http.Client { return &http.Client{Timeout: 2 * time.Minute} }
diff --git a/core/auth/ui/e2e/artifacts_test.go b/core/auth/ui/e2e/artifacts_test.go
new file mode 100644
index 0000000..89c8d77
--- /dev/null
+++ b/core/auth/ui/e2e/artifacts_test.go
@@ -0,0 +1,282 @@
+package e2e
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestArtifactPlatforms(t *testing.T) {
+	if len(artifactPlatforms) != 4 {
+		t.Fatalf("platform count=%d, want 4", len(artifactPlatforms))
+	}
+	for _, target := range []string{"linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64"} {
+		parts := strings.Split(target, "/")
+		artifacts, err := artifactsFor(parts[0], parts[1])
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, artifact := range []releaseArtifact{artifacts.Kratos, artifacts.Hydra} {
+			if len(artifact.SHA256) != 64 {
+				t.Errorf("%s has invalid hash %q", artifact.Archive, artifact.SHA256)
+			}
+			if !strings.Contains(artifact.URL, artifact.Version) || !strings.HasSuffix(artifact.URL, artifact.Archive) {
+				t.Errorf("invalid release URL %q", artifact.URL)
+			}
+		}
+	}
+	for _, target := range [][2]string{{"windows", "amd64"}, {"linux", "386"}, {"plan9", "amd64"}, {"darwin", "386"}} {
+		if _, err := artifactsFor(target[0], target[1]); err == nil || !strings.Contains(err.Error(), "unsupported E2E platform") {
+			t.Errorf("artifactsFor(%q,%q) error=%v", target[0], target[1], err)
+		}
+	}
+}
+
+func TestOfficialArtifactHashes(t *testing.T) {
+	want := map[string]string{
+		"kratos_1.1.0-linux_sqlite_64bit.tar.gz": "6fb3077252dde7578c3100d2cd4eb52364ca6b3c1b0b76987e6d586e29008cbd",
+		"kratos_1.1.0-linux_sqlite_arm64.tar.gz": "fde8a1a1aebd153baff88b1232e0c2a34fdaaafe90b5364f4ea580151e74898e",
+		"kratos_1.1.0-macOS_sqlite_64bit.tar.gz": "ebdc94f27cb6e6a3087ed756accfb7837465ac8e30af9433b4414101814f7769",
+		"kratos_1.1.0-macOS_sqlite_arm64.tar.gz": "6681d7b15dd04686d10764750ce3ad69672b3962553223399a3a315ba5370517",
+		"hydra_2.2.0-linux_sqlite_64bit.tar.gz":  "0fe0539fa452496ac5d98b558f93eb2dbb4cf43733da0b09f8f2bdb4445fc31e",
+		"hydra_2.2.0-linux_sqlite_arm64.tar.gz":  "c499ffdaae0f2ab85eff0567214734515b741a393bef89115c16018f4dc0560d",
+		"hydra_2.2.0-macOS_sqlite_64bit.tar.gz":  "3d40ca8e99e2a6d840130928d5e0245212dba0eea9c26a0d7186ebb4382e673d",
+		"hydra_2.2.0-macOS_sqlite_arm64.tar.gz":  "89732ad1494c57ea39348f62dc5ef5c48de129cd205b17cb12bb67ad27094bb7",
+	}
+	for _, platform := range artifactPlatforms {
+		for _, artifact := range []releaseArtifact{platform.Kratos, platform.Hydra} {
+			if want[artifact.Archive] != artifact.SHA256 {
+				t.Errorf("hash for %s=%s", artifact.Archive, artifact.SHA256)
+			}
+		}
+	}
+}
+
+func TestEnsureArchiveDownloadAndCache(t *testing.T) {
+	body := []byte("official archive bytes")
+	hash := sha256.Sum256(body)
+	var requests atomic.Int32
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		requests.Add(1)
+		_, _ = w.Write(body)
+	}))
+	defer server.Close()
+	artifact := releaseArtifact{"kratos", "v1", "archive.tar.gz", hex.EncodeToString(hash[:]), server.URL}
+	root := t.TempDir()
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	path, err := ensureArchive(ctx, server.Client(), root, artifact, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got, _ := os.ReadFile(path); string(got) != string(body) {
+		t.Fatalf("cached body=%q", got)
+	}
+	if _, err := ensureArchive(ctx, server.Client(), root, artifact, false); err != nil {
+		t.Fatal(err)
+	}
+	if requests.Load() != 1 {
+		t.Fatalf("requests=%d, want 1 valid cache hit", requests.Load())
+	}
+}
+
+func TestEnsureArchiveReplacesInvalidCache(t *testing.T) {
+	body := []byte("valid")
+	hash := sha256.Sum256(body)
+	var requests atomic.Int32
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1); _, _ = w.Write(body) }))
+	defer server.Close()
+	artifact := releaseArtifact{"hydra", "v2", "archive.tar.gz", hex.EncodeToString(hash[:]), server.URL}
+	root := t.TempDir()
+	path := archiveCachePath(root, artifact)
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, []byte("invalid"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := ensureArchive(context.Background(), server.Client(), root, artifact, false); err != nil {
+		t.Fatal(err)
+	}
+	if requests.Load() != 1 {
+		t.Fatalf("requests=%d", requests.Load())
+	}
+}
+
+func TestEnsureArchiveFailures(t *testing.T) {
+	body := []byte("complete")
+	hash := sha256.Sum256(body)
+	tests := []struct {
+		name    string
+		handler http.HandlerFunc
+		want    string
+	}{
+		{"non-200", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) }, "unexpected HTTP status"},
+		{"checksum", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("wrong")) }, "checksum mismatch"},
+		{"truncated", func(w http.ResponseWriter, _ *http.Request) {
+			w.Header().Set("Content-Length", "100")
+			_, _ = w.Write(body[:2])
+		}, "body"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewServer(tt.handler)
+			defer server.Close()
+			artifact := releaseArtifact{"kratos", "v1", "a.tar.gz", hex.EncodeToString(hash[:]), server.URL}
+			root := t.TempDir()
+			path := archiveCachePath(root, artifact)
+			_, err := ensureArchive(context.Background(), server.Client(), root, artifact, false)
+			if err == nil || !strings.Contains(err.Error(), tt.want) {
+				t.Fatalf("error=%v, want %q", err, tt.want)
+			}
+			if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
+				t.Fatalf("canonical corrupt cache exists: %v", statErr)
+			}
+		})
+	}
+}
+
+func TestEnsureArchiveRejectsOversizedResponse(t *testing.T) {
+	body := strings.Repeat("x", 33)
+	hash := sha256.Sum256([]byte(body))
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.(http.Flusher).Flush() // Force a chunked response so the stream limit is exercised.
+		_, _ = io.WriteString(w, body)
+	}))
+	defer server.Close()
+	artifact := releaseArtifact{"kratos", "v1", "oversized.tar.gz", hex.EncodeToString(hash[:]), server.URL}
+	root := t.TempDir()
+	_, err := ensureArchiveWithLimit(context.Background(), server.Client(), root, artifact, false, 32)
+	if err == nil || !strings.Contains(err.Error(), "exceeds maximum archive size 32 bytes") {
+		t.Fatalf("oversized response error=%v", err)
+	}
+	if _, err := os.Stat(archiveCachePath(root, artifact)); !os.IsNotExist(err) {
+		t.Fatalf("oversized response published canonical cache: %v", err)
+	}
+	temps, err := filepath.Glob(filepath.Join(filepath.Dir(archiveCachePath(root, artifact)), ".oversized.tar.gz-*"))
+	if err != nil || len(temps) != 0 {
+		t.Fatalf("oversized response left temporary files %v: %v", temps, err)
+	}
+}
+
+func TestEnsureArchiveConcurrentInvalidReplacementUsesPublishedWinner(t *testing.T) {
+	body := []byte("valid concurrent archive")
+	hash := sha256.Sum256(body)
+	var requests atomic.Int32
+	started := make(chan struct{})
+	release := make(chan struct{})
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		if requests.Add(1) == 1 {
+			close(started)
+			<-release
+		}
+		_, _ = w.Write(body)
+	}))
+	defer server.Close()
+	artifact := releaseArtifact{"hydra", "v2", "concurrent.tar.gz", hex.EncodeToString(hash[:]), server.URL}
+	root := t.TempDir()
+	path := archiveCachePath(root, artifact)
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, []byte("invalid"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+	results := make(chan error, 2)
+	go func() { _, err := ensureArchive(ctx, server.Client(), root, artifact, false); results <- err }()
+	select {
+	case <-started:
+	case <-ctx.Done():
+		t.Fatal("first download did not start")
+	}
+	go func() { _, err := ensureArchive(ctx, server.Client(), root, artifact, false); results <- err }()
+	time.Sleep(100 * time.Millisecond)
+	if got := requests.Load(); got != 1 {
+		close(release)
+		t.Fatalf("concurrent waiter bypassed cache lock: requests=%d", got)
+	}
+	close(release)
+	for range 2 {
+		if err := <-results; err != nil {
+			t.Fatal(err)
+		}
+	}
+	if got := requests.Load(); got != 1 {
+		t.Fatalf("published archive was downloaded again: requests=%d", got)
+	}
+	if valid, _, err := validSHA256(path, artifact.SHA256); err != nil || !valid {
+		t.Fatalf("concurrent replacement invalid: valid=%v error=%v", valid, err)
+	}
+}
+
+func TestEnsureArchiveOfflineInvalid(t *testing.T) {
+	artifact := releaseArtifact{"kratos", "v1", "a.tar.gz", strings.Repeat("a", 64), "http://invalid.example"}
+	root := t.TempDir()
+	path := archiveCachePath(root, artifact)
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, []byte("bad"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	_, err := ensureArchive(context.Background(), &http.Client{}, root, artifact, true)
+	if err == nil || !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), artifact.SHA256) {
+		t.Fatalf("error=%v", err)
+	}
+}
+
+func TestCacheRootDefaultAndOverride(t *testing.T) {
+	t.Setenv("AUTH_UI_E2E_CACHE_DIR", "")
+	if got := cacheRoot("/repo"); got != filepath.Join("/repo", "e2e", "cache") {
+		t.Fatalf("default cache root=%q", got)
+	}
+	t.Setenv("AUTH_UI_E2E_CACHE_DIR", "/override")
+	if got := cacheRoot("/repo"); got != "/override" {
+		t.Fatalf("override cache root=%q", got)
+	}
+}
+
+func TestRepositoryAndInstallerVersions(t *testing.T) {
+	repo, err := repositoryDir()
+	if err != nil {
+		t.Fatal(err)
+	}
+	old, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chdir(t.TempDir()); err != nil {
+		t.Fatal(err)
+	}
+	defer os.Chdir(old)
+	if again, err := repositoryDir(); err != nil || again != repo {
+		t.Fatalf("repositoryDir=%q err=%v", again, err)
+	}
+	module, err := os.ReadFile(filepath.Join(repo, "go.mod"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(module), "go 1.22") {
+		t.Error("go.mod does not set go 1.22")
+	}
+	if !strings.Contains(string(module), "github.com/mxschmitt/playwright-go "+playwrightVersion) {
+		t.Errorf("go.mod does not pin Playwright %s", playwrightVersion)
+	}
+	if strings.Contains(string(module), "github.com/playwright-community/playwright-go") {
+		t.Error("go.mod retains retired Playwright binding")
+	}
+	if playwrightCLIVersion != "1.61.1" || chromiumRevision != "1228" || chromiumVersion != "149.0.7827.55" || ffmpegRevision != "1011" {
+		t.Fatalf("unexpected embedded runtime contract: CLI=%s Chromium=%s/%s FFmpeg=%s", playwrightCLIVersion, chromiumRevision, chromiumVersion, ffmpegRevision)
+	}
+}
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
new file mode 100644
index 0000000..19949f5
--- /dev/null
+++ b/core/auth/ui/e2e/browser.go
@@ -0,0 +1,444 @@
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+type browserRuntime struct {
+	Playwright *playwright.Playwright
+	Browser    playwright.Browser
+	mu         sync.Mutex
+	closed     bool
+}
+
+func startBrowserRuntime() (*browserRuntime, error) {
+	return startBrowserRuntimeOwned(nil)
+}
+
+func startBrowserRuntimeOwned(owner *suiteLifecycle) (*browserRuntime, error) {
+	runtime := &browserRuntime{}
+	if owner != nil && !owner.setBrowser(runtime) {
+		return nil, fmt.Errorf("E2E watchdog fired before browser ownership registration")
+	}
+	pw, err := playwright.Run()
+	if err != nil {
+		return runtime, fmt.Errorf("start Playwright %s: %w; run `make install-e2e-browser` (go run github.com/mxschmitt/playwright-go/cmd/playwright@%s install chromium)", playwrightVersion, err, playwrightVersion)
+	}
+	runtime.mu.Lock()
+	if runtime.closed {
+		runtime.mu.Unlock()
+		_ = pw.Stop()
+		return runtime, fmt.Errorf("E2E cleanup started while launching Playwright")
+	}
+	runtime.Playwright = pw
+	runtime.mu.Unlock()
+	browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{Headless: playwright.Bool(true)})
+	if err != nil {
+		_ = runtime.Close()
+		return runtime, fmt.Errorf("launch Playwright-managed Chromium: %w; run `make install-e2e-browser`", err)
+	}
+	runtime.mu.Lock()
+	if runtime.closed {
+		runtime.mu.Unlock()
+		_ = browser.Close()
+		return runtime, fmt.Errorf("E2E cleanup started while launching Chromium")
+	}
+	runtime.Browser = browser
+	runtime.mu.Unlock()
+	if got := browser.Version(); got != chromiumVersion {
+		_ = runtime.Close()
+		return runtime, fmt.Errorf("unexpected managed Chromium version %q, want %s (revision %s, Playwright CLI %s)", got, chromiumVersion, chromiumRevision, playwrightCLIVersion)
+	}
+	return runtime, nil
+}
+
+func (r *browserRuntime) Close() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	if r.closed {
+		r.mu.Unlock()
+		return nil
+	}
+	r.closed = true
+	browser, pw := r.Browser, r.Playwright
+	r.mu.Unlock()
+	var errs []string
+	if browser != nil {
+		if err := browser.Close(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if pw != nil {
+		if err := pw.Stop(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("close browser runtime: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+type testReporter interface {
+	Name() string
+	Failed() bool
+	Errorf(string, ...any)
+	Cleanup(func())
+}
+
+type browserSession struct {
+	Page           playwright.Page
+	Context        playwright.BrowserContext
+	t              testReporter
+	dir            string
+	video          playwright.Video
+	started        time.Time
+	screenshots    []string
+	checkpoint     int
+	finalize       sync.Once
+	lifecycleMu    sync.Mutex
+	forcedFailure  bool
+	registry       *sessionRegistry
+	browserVer     string
+	blockedMu      sync.Mutex
+	blocked        []string
+	requestsMu     sync.Mutex
+	requests       []requestMetadata
+	tracingStarted bool
+	screenshotOp   func(string) error
+	stopTraceOp    func(string) error
+	closeContextOp func() error
+	saveVideoOp    func(string) error
+	finalURLOp     func() string
+}
+
+func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+	session, err := newBrowserSessionOwner(t, root, browser.Version())
+	if err != nil {
+		return nil, err
+	}
+	// Register while holding the construction/finalization lock. A watchdog
+	// either snapshots this ownership and waits here, or rejects construction
+	// before any external Playwright context exists.
+	session.lifecycleMu.Lock()
+	if activeSuiteLifecycle != nil {
+		session.registry = activeSuiteLifecycle.sessions
+		if !session.registry.add(session) {
+			session.lifecycleMu.Unlock()
+			_ = session.finalizeAfterTimeout()
+			return nil, fmt.Errorf("create browser session after E2E watchdog cleanup started")
+		}
+	}
+	defer session.lifecycleMu.Unlock()
+	videoDir := filepath.Join(session.dir, ".video")
+	context, err := browser.NewContext(playwright.BrowserNewContextOptions{
+		Viewport:       &playwright.Size{Width: 1280, Height: 720},
+		RecordVideo:    &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+		ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
+	})
+	if err != nil {
+		return nil, err
+	}
+	session.Context = context
+	if err := context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true), Sources: playwright.Bool(true)}); err != nil {
+		return nil, err
+	}
+	session.tracingStarted = true
+	page, err := context.NewPage()
+	if err != nil {
+		return nil, err
+	}
+	page.SetDefaultTimeout(15_000)
+	page.SetDefaultNavigationTimeout(30_000)
+	page.OnRequest(func(request playwright.Request) {
+		session.recordRequest(request.Method(), 0, request.URL())
+	})
+	page.OnResponse(func(response playwright.Response) {
+		session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
+	})
+	s := session
+	s.Page = page
+	s.video = page.Video()
+	policy := newRoutePolicy(allowedOrigins)
+	if err := context.Route("**/*", func(route playwright.Route) {
+		if policy(route.Request().URL()) {
+			_ = route.Continue()
+		} else {
+			session.recordBlocked(route.Request().URL())
+			_ = route.Abort("blockedbyclient")
+		}
+	}); err != nil {
+		return nil, err
+	}
+	if err := s.screenshot("00-initial.png"); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
+	dir := filepath.Join(root, sanitizeName(t.Name()))
+	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+		return nil, err
+	}
+	if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
+		return nil, err
+	}
+	session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+	session.installDefaultArtifactOps()
+	// Own metadata and all available partial artifacts before Playwright context
+	// construction. If Playwright cannot create a context/page/trace, cleanup
+	// records the failure without pretending the unavailable artifacts succeeded.
+	t.Cleanup(func() {
+		if err := session.Finalize(); err != nil {
+			t.Errorf("finalize browser artifacts: %v", err)
+		}
+	})
+	return session, nil
+}
+
+func newRoutePolicy(allowedOrigins []string) func(string) bool {
+	allowed := make(map[string]struct{}, len(allowedOrigins))
+	for _, origin := range allowedOrigins {
+		u, err := url.Parse(origin)
+		if err == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.Path == "" {
+			allowed[u.Scheme+"://"+u.Host] = struct{}{}
+		}
+	}
+	return func(raw string) bool {
+		u, err := url.Parse(raw)
+		if err != nil || u.Scheme != "http" || u.Hostname() != "127.0.0.1" || u.Port() == "" || u.User != nil {
+			return false
+		}
+		_, ok := allowed[u.Scheme+"://"+u.Host]
+		return ok
+	}
+}
+
+type requestMetadata struct {
+	Method string `json:"method"`
+	Status int    `json:"status,omitempty"`
+	Origin string `json:"origin"`
+	Path   string `json:"path"`
+}
+
+func sanitizedRequest(method string, status int, raw string) (requestMetadata, bool) {
+	u, err := url.Parse(raw)
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return requestMetadata{}, false
+	}
+	return requestMetadata{Method: method, Status: status, Origin: u.Scheme + "://" + u.Host, Path: u.EscapedPath()}, true
+}
+
+func (s *browserSession) recordRequest(method string, status int, raw string) {
+	metadata, ok := sanitizedRequest(method, status, raw)
+	if !ok {
+		return
+	}
+	s.requestsMu.Lock()
+	s.requests = append(s.requests, metadata)
+	s.requestsMu.Unlock()
+}
+
+func (s *browserSession) RequestMetadata() []requestMetadata {
+	s.requestsMu.Lock()
+	defer s.requestsMu.Unlock()
+	return append([]requestMetadata(nil), s.requests...)
+}
+
+func (s *browserSession) recordBlocked(raw string) {
+	u, err := url.Parse(raw)
+	if err != nil {
+		return
+	}
+	s.blockedMu.Lock()
+	s.blocked = append(s.blocked, u.Scheme+"://"+u.Host+u.Path)
+	s.blockedMu.Unlock()
+}
+
+func (s *browserSession) BlockedRequests() []string {
+	s.blockedMu.Lock()
+	defer s.blockedMu.Unlock()
+	return sortedStrings(s.blocked)
+}
+
+func (s *browserSession) Checkpoint(name string) error {
+	s.lifecycleMu.Lock()
+	defer s.lifecycleMu.Unlock()
+	s.checkpoint++
+	return s.screenshot(fmt.Sprintf("%02d-%s.png", s.checkpoint, sanitizeName(name)))
+}
+
+func (s *browserSession) installDefaultArtifactOps() {
+	s.screenshotOp = func(path string) error {
+		if s.Page == nil {
+			return fmt.Errorf("page unavailable")
+		}
+		_, err := s.Page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path), FullPage: playwright.Bool(true)})
+		return err
+	}
+	s.stopTraceOp = func(path string) error {
+		if s.Context == nil || !s.tracingStarted {
+			return fmt.Errorf("tracing unavailable")
+		}
+		return s.Context.Tracing().Stop(path)
+	}
+	s.closeContextOp = func() error {
+		if s.Context == nil {
+			return nil
+		}
+		return s.Context.Close()
+	}
+	s.saveVideoOp = func(path string) error {
+		if s.video == nil {
+			return fmt.Errorf("video unavailable")
+		}
+		return s.video.SaveAs(path)
+	}
+	s.finalURLOp = func() string {
+		if s.Page == nil {
+			return ""
+		}
+		return s.Page.URL()
+	}
+}
+
+func (s *browserSession) screenshot(name string) error {
+	path := filepath.Join(s.dir, "screenshots", name)
+	if err := s.screenshotOp(path); err != nil {
+		return err
+	}
+	s.screenshots = append(s.screenshots, name)
+	return nil
+}
+
+func (s *browserSession) Finalize() error {
+	if s.registry != nil {
+		return s.registry.finalizeNormal(s)
+	}
+	return s.finalizeClaimed(false)
+}
+
+func (s *browserSession) finalizeAfterTimeout() error {
+	return s.finalizeClaimed(true)
+}
+
+func (s *browserSession) finalizeClaimed(forcedFailure bool) error {
+	s.lifecycleMu.Lock()
+	defer s.lifecycleMu.Unlock()
+	if forcedFailure {
+		s.forcedFailure = true
+	}
+	var finalErr error
+	s.finalize.Do(func() {
+		var errs []string
+		if err := s.screenshot("99-final.png"); err != nil {
+			errs = append(errs, "final screenshot: "+err.Error())
+		}
+		if s.t.Failed() || s.forcedFailure {
+			if err := s.screenshot("failure.png"); err != nil {
+				errs = append(errs, "failure screenshot: "+err.Error())
+			}
+		}
+		if err := s.stopTraceOp(filepath.Join(s.dir, "trace.zip")); err != nil {
+			errs = append(errs, "trace: "+err.Error())
+		}
+		finalURL := sanitizeFinalURL(s.finalURLOp())
+		if err := s.closeContextOp(); err != nil {
+			errs = append(errs, "context: "+err.Error())
+		}
+		if err := s.saveVideoOp(filepath.Join(s.dir, "video.webm")); err != nil {
+			errs = append(errs, "video: "+err.Error())
+		} else {
+			_ = os.RemoveAll(filepath.Join(s.dir, ".video"))
+		}
+		outcome := "passed"
+		if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
+			outcome = "failed"
+		}
+		metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+		data, err := json.MarshalIndent(metadata, "", "  ")
+		if err == nil {
+			err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
+		}
+		if err != nil {
+			errs = append(errs, "metadata: "+err.Error())
+		}
+		if len(errs) > 0 {
+			finalErr = fmt.Errorf("%s", strings.Join(errs, "; "))
+		}
+	})
+	return finalErr
+}
+
+func artifactOutcomeExpectation(failedBeforeFinalize, failedAfterFinalize bool) (outcome string, requireFailureScreenshot bool) {
+	if failedBeforeFinalize {
+		return "failed", true
+	}
+	if failedAfterFinalize {
+		return "failed", false
+	}
+	return "passed", false
+}
+
+const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
+
+func isExpectedBlockedBrowserRequest(request string) bool {
+	return request == expectedExternalFontRequest
+}
+
+type sessionMetadata struct {
+	TestName         string    `json:"test_name"`
+	StartedAt        time.Time `json:"started_at"`
+	FinishedAt       time.Time `json:"finished_at"`
+	Outcome          string    `json:"outcome"`
+	BindingVersion   string    `json:"binding_version"`
+	CLIVersion       string    `json:"playwright_cli_version"`
+	ChromiumRevision string    `json:"chromium_revision"`
+	BrowserVersion   string    `json:"browser_version"`
+	Screenshots      []string  `json:"screenshots"`
+	FinalURL         string    `json:"final_url"`
+}
+
+var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
+
+func sanitizeName(name string) string {
+	name = strings.Trim(unsafeName.ReplaceAllString(name, "-"), "-.")
+	if name == "" {
+		return "unnamed"
+	}
+	if len(name) > 100 {
+		name = name[:100]
+	}
+	return name
+}
+
+func sanitizeFinalURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return ""
+	}
+	u.RawQuery = ""
+	u.Fragment = ""
+	u.User = nil
+	return u.String()
+}
+
+func sortedStrings(values []string) []string {
+	out := append([]string(nil), values...)
+	sort.Strings(out)
+	return out
+}
diff --git a/core/auth/ui/e2e/browser_artifacts_test.go b/core/auth/ui/e2e/browser_artifacts_test.go
new file mode 100644
index 0000000..5825dcf
--- /dev/null
+++ b/core/auth/ui/e2e/browser_artifacts_test.go
@@ -0,0 +1,144 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestPinnedStackReady(t *testing.T) {
+	if testStack == nil || testBrowser == nil {
+		t.Fatal("suite was not initialized")
+	}
+	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
+	failedBeforeFinalize := false
+	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
+	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, testStack.allowedOrigins())
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { failedBeforeFinalize = t.Failed() })
+
+	for name, process := range map[string]*supervisedProcess{"kratos": testStack.Kratos, "hydra": testStack.Hydra, "auth-ui": testStack.AuthUI} {
+		if !process.alive() {
+			t.Fatalf("%s process is not alive", name)
+		}
+	}
+	for _, endpoint := range []string{testStack.KratosURL + "/health/ready", testStack.KratosAdmin + "/health/ready", testStack.HydraURL + "/health/ready", testStack.HydraAdmin + "/health/ready"} {
+		if err := testStack.healthy(endpoint); err != nil {
+			t.Fatalf("health check %s: %v", sanitizeFinalURL(endpoint), err)
+		}
+	}
+	if kratosVersion != "v1.1.0" || hydraVersion != "v2.2.0" || playwrightVersion != "v0.6100.0" {
+		t.Fatalf("unexpected pinned versions: %s %s %s", kratosVersion, hydraVersion, playwrightVersion)
+	}
+	if got := testBrowser.Browser.Version(); got != chromiumVersion {
+		t.Fatalf("Chromium version=%q, want %s revision %s", got, chromiumVersion, chromiumRevision)
+	}
+	listenerLogs := map[string][]int{
+		"kratos": {testStack.Ports.KratosPublic, testStack.Ports.KratosAdmin},
+		"hydra":  {testStack.Ports.HydraPublic, testStack.Ports.HydraAdmin},
+	}
+	for service, ports := range listenerLogs {
+		log, err := os.ReadFile(testStack.logPath(service))
+		if err != nil {
+			t.Fatal(err)
+		}
+		if strings.Contains(string(log), "0.0.0.0:") {
+			t.Fatalf("%s reported a wildcard listener", service)
+		}
+		for _, port := range ports {
+			if !strings.Contains(string(log), fmt.Sprintf("http server on 127.0.0.1:%d", port)) && !strings.Contains(string(log), fmt.Sprintf("httpd on: 127.0.0.1:%d", port)) {
+				t.Fatalf("%s did not report loopback listener port %d", service, port)
+			}
+		}
+	}
+
+	if _, err := session.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
+		visible, err := session.Page.Locator(selector).IsVisible()
+		if err != nil || !visible {
+			t.Fatalf("login field %s visible=%v err=%v", selector, visible, err)
+		}
+	}
+	if err := session.screenshot("00-login.png"); err != nil {
+		t.Fatal(err)
+	}
+	blocked := session.BlockedRequests()
+	foundCDN := false
+	for _, request := range blocked {
+		if strings.HasPrefix(request, "https://cdnjs.cloudflare.com/") {
+			foundCDN = true
+		}
+	}
+	if !foundCDN {
+		t.Fatalf("expected external CDN font request to be aborted, blocked=%v", blocked)
+	}
+	policy := newRoutePolicy(testStack.allowedOrigins())
+	for _, request := range session.RequestMetadata() {
+		if request.Status > 0 && !policy(request.Origin+request.Path) {
+			t.Fatalf("unowned request received a response: %+v", request)
+		}
+	}
+}
+
+func TestBrowserArtifactFinalization(t *testing.T) {
+	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
+	failedBeforeFinalize := false
+	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
+	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, testStack.allowedOrigins())
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { failedBeforeFinalize = t.Failed() })
+	if _, err := session.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	if err := session.Checkpoint("login-rendered"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func assertArtifactSet(t *testing.T, dir string, failedBeforeFinalize bool) {
+	t.Helper()
+	wantOutcome, requireFailureScreenshot := artifactOutcomeExpectation(failedBeforeFinalize, t.Failed())
+	required := []string{"screenshots/00-initial.png", "screenshots/99-final.png", "video.webm", "trace.zip", "session.json"}
+	if requireFailureScreenshot {
+		required = append(required, "screenshots/failure.png")
+	}
+	for _, path := range required {
+		info, err := os.Stat(filepath.Join(dir, path))
+		if err != nil || info.Size() == 0 {
+			t.Fatalf("artifact %s missing or empty: info=%v err=%v", path, info, err)
+		}
+	}
+	png, err := os.ReadFile(filepath.Join(dir, "screenshots", "99-final.png"))
+	if err != nil || !bytes.HasPrefix(png, []byte("\x89PNG\r\n\x1a\n")) {
+		t.Fatalf("final screenshot is not PNG: %v", err)
+	}
+	zr, err := zip.OpenReader(filepath.Join(dir, "trace.zip"))
+	if err != nil {
+		t.Fatalf("trace is not a valid ZIP: %v", err)
+	}
+	if len(zr.File) == 0 {
+		t.Error("trace ZIP is empty")
+	}
+	_ = zr.Close()
+	data, err := os.ReadFile(filepath.Join(dir, "session.json"))
+	var metadata sessionMetadata
+	if err != nil || json.Unmarshal(data, &metadata) != nil || metadata.Outcome != wantOutcome {
+		t.Fatalf("invalid session metadata: %v: %s", err, data)
+	}
+	if metadata.BindingVersion != playwrightVersion || metadata.CLIVersion != playwrightCLIVersion || metadata.ChromiumRevision != chromiumRevision || metadata.BrowserVersion != chromiumVersion {
+		t.Fatalf("unexpected Playwright metadata: %+v", metadata)
+	}
+}
diff --git a/core/auth/ui/e2e/browser_test.go b/core/auth/ui/e2e/browser_test.go
new file mode 100644
index 0000000..3a42eb3
--- /dev/null
+++ b/core/auth/ui/e2e/browser_test.go
@@ -0,0 +1,251 @@
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestLoopbackRoutePolicy(t *testing.T) {
+	policy := newRoutePolicy([]string{"http://127.0.0.1:10001", "http://127.0.0.1:10002"})
+	for _, raw := range []string{"http://127.0.0.1:10001/login?flow=secret", "http://127.0.0.1:10002/static/main.css"} {
+		if !policy(raw) {
+			t.Errorf("policy rejected owned URL %q", raw)
+		}
+	}
+	for _, raw := range []string{
+		"https://cdnjs.cloudflare.com/font.css", "http://example.test:10001/", "http://localhost:10001/",
+		"http://127.0.0.1:9999/", "https://127.0.0.1:10001/", "file:///etc/passwd", "data:text/plain,x",
+		"http://user@127.0.0.1:10001/", "not a URL",
+	} {
+		if policy(raw) {
+			t.Errorf("policy allowed unowned URL %q", raw)
+		}
+	}
+}
+
+func TestRoutePolicyRejectsMalformedAllowedOrigins(t *testing.T) {
+	policy := newRoutePolicy([]string{"https://127.0.0.1:1", "http://localhost:2", "http://127.0.0.1/no-port", "garbage"})
+	if policy("http://127.0.0.1:1/") {
+		t.Fatal("malformed allowed origin was accepted")
+	}
+}
+
+func TestBrowserArtifactOutcomeExpectation(t *testing.T) {
+	tests := []struct {
+		name                  string
+		failedBeforeFinalize  bool
+		failedAfterFinalize   bool
+		outcome               string
+		requireFailureCapture bool
+	}{
+		{name: "passed", outcome: "passed"},
+		{name: "behavior failed", failedBeforeFinalize: true, failedAfterFinalize: true, outcome: "failed", requireFailureCapture: true},
+		{name: "finalization failed", failedAfterFinalize: true, outcome: "failed"},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			outcome, failure := artifactOutcomeExpectation(test.failedBeforeFinalize, test.failedAfterFinalize)
+			if outcome != test.outcome || failure != test.requireFailureCapture {
+				t.Fatalf("expectation outcome=%q failure=%v", outcome, failure)
+			}
+		})
+	}
+}
+
+func TestExpectedBlockedBrowserRequest(t *testing.T) {
+	if !isExpectedBlockedBrowserRequest(expectedExternalFontRequest) {
+		t.Fatal("known external font request was not recognized")
+	}
+	for _, request := range []string{
+		"https://cdnjs.cloudflare.com/other.css",
+		"https://example.test/unexpected.js",
+		"http://127.0.0.1:1234/unowned",
+		expectedExternalFontRequest + "?token=secret",
+	} {
+		if isExpectedBlockedBrowserRequest(request) {
+			t.Fatalf("unexpected blocked request was accepted: %s", sanitizeFinalURL(request))
+		}
+	}
+}
+
+func TestSanitizedRequestMetadata(t *testing.T) {
+	metadata, ok := sanitizedRequest("GET", 200, "http://127.0.0.1:1234/login?flow=sensitive#fragment")
+	if !ok {
+		t.Fatal("request was not parsed")
+	}
+	if metadata.Method != "GET" || metadata.Status != 200 || metadata.Origin != "http://127.0.0.1:1234" || metadata.Path != "/login" {
+		t.Fatalf("metadata=%+v", metadata)
+	}
+	data, _ := json.Marshal(metadata)
+	if strings.Contains(string(data), "sensitive") || strings.Contains(string(data), "flow") {
+		t.Fatalf("request metadata leaked query: %s", data)
+	}
+	if _, ok := sanitizedRequest("GET", 0, "not a URL"); ok {
+		t.Fatal("invalid URL accepted")
+	}
+}
+
+func TestArtifactNamingAndFailedMetadata(t *testing.T) {
+	if got := sanitizeName("Test Thing/../../secret value"); got != "Test-Thing-..-..-secret-value" {
+		t.Fatalf("sanitized name=%q", got)
+	}
+	if got := sanitizeFinalURL("http://127.0.0.1:1234/login?flow=sensitive#fragment"); got != "http://127.0.0.1:1234/login" {
+		t.Fatalf("sanitized URL=%q", got)
+	}
+	metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
+	data, err := json.Marshal(metadata)
+	if err != nil {
+		t.Fatal(err)
+	}
+	text := string(data)
+	for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`} {
+		if !strings.Contains(text, required) {
+			t.Errorf("metadata missing %s: %s", required, text)
+		}
+	}
+	for _, forbidden := range []string{"flow=", "password", "token"} {
+		if strings.Contains(text, forbidden) {
+			t.Errorf("metadata contains forbidden value %q", forbidden)
+		}
+	}
+}
+
+type fakeReporter struct {
+	name     string
+	failed   bool
+	cleanups []func()
+	errors   []string
+}
+
+func (r *fakeReporter) Name() string                 { return r.name }
+func (r *fakeReporter) Failed() bool                 { return r.failed }
+func (r *fakeReporter) Cleanup(fn func())            { r.cleanups = append(r.cleanups, fn) }
+func (r *fakeReporter) Errorf(f string, args ...any) { r.failed = true; r.errors = append(r.errors, f) }
+
+func TestFailedReporterFinalizationWritesFailureArtifacts(t *testing.T) {
+	dir := t.TempDir()
+	reporter := &fakeReporter{name: "TestBehavioralFailure", failed: true}
+	s := &browserSession{t: reporter, dir: dir, started: time.Unix(1, 0).UTC(), browserVer: chromiumVersion}
+	s.screenshotOp = func(path string) error { return os.WriteFile(path, []byte("png"), 0o600) }
+	s.stopTraceOp = func(path string) error { return os.WriteFile(path, []byte("trace"), 0o600) }
+	s.closeContextOp = func() error { return nil }
+	s.saveVideoOp = func(path string) error { return os.WriteFile(path, []byte("video"), 0o600) }
+	s.finalURLOp = func() string { return "http://127.0.0.1:1234/login?flow=secret" }
+	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Finalize(); err != nil {
+		t.Fatal(err)
+	}
+	for _, path := range []string{"screenshots/99-final.png", "screenshots/failure.png", "trace.zip", "video.webm", "session.json"} {
+		if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
+			t.Fatalf("required failed artifact %s: %v", path, err)
+		}
+	}
+	data, err := os.ReadFile(filepath.Join(dir, "session.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var metadata sessionMetadata
+	if err := json.Unmarshal(data, &metadata); err != nil {
+		t.Fatal(err)
+	}
+	if metadata.Outcome != "failed" || metadata.FinalURL != "http://127.0.0.1:1234/login" {
+		t.Fatalf("metadata=%+v", metadata)
+	}
+	if !containsString(metadata.Screenshots, "failure.png") || !containsString(metadata.Screenshots, "99-final.png") {
+		t.Fatalf("screenshots=%v", metadata.Screenshots)
+	}
+}
+
+func containsString(values []string, want string) bool {
+	for _, value := range values {
+		if value == want {
+			return true
+		}
+	}
+	return false
+}
+
+func TestFailedVideoSaveRetainsRawRecording(t *testing.T) {
+	dir := t.TempDir()
+	reporter := &fakeReporter{name: "TestVideoSaveFailure", failed: true}
+	s := &browserSession{t: reporter, dir: dir, started: time.Unix(1, 0).UTC(), browserVer: chromiumVersion}
+	s.screenshotOp = func(path string) error { return os.WriteFile(path, []byte("png"), 0o600) }
+	s.stopTraceOp = func(path string) error { return os.WriteFile(path, []byte("trace"), 0o600) }
+	s.closeContextOp = func() error { return nil }
+	s.saveVideoOp = func(string) error { return fmt.Errorf("injected SaveAs failure") }
+	s.finalURLOp = func() string { return "http://127.0.0.1:1234/login" }
+	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	raw := filepath.Join(dir, ".video", "raw-recording.webm")
+	if err := os.MkdirAll(filepath.Dir(raw), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(raw, []byte("only raw recording"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Finalize(); err == nil || !strings.Contains(err.Error(), "injected SaveAs failure") {
+		t.Fatalf("finalization error=%v", err)
+	}
+	if data, err := os.ReadFile(raw); err != nil || string(data) != "only raw recording" {
+		t.Fatalf("raw recording was not retained: data=%q error=%v", data, err)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "video.webm")); !os.IsNotExist(err) {
+		t.Fatalf("final video should not be fabricated: %v", err)
+	}
+}
+
+func TestPartialBrowserOwnerRecordsMetadataAndCleanupError(t *testing.T) {
+	reporter := &fakeReporter{name: "TestContextConstructionFailure", failed: true}
+	root := t.TempDir()
+	s, err := newBrowserSessionOwner(reporter, root, chromiumVersion)
+	if err != nil {
+		t.Fatal(err)
+	}
+	raw := filepath.Join(s.dir, ".video", "partial.data")
+	if err := os.WriteFile(raw, []byte("partial Playwright data"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if len(reporter.cleanups) != 1 {
+		t.Fatalf("cleanup count=%d, want 1", len(reporter.cleanups))
+	}
+	reporter.cleanups[0]()
+	if len(reporter.errors) != 1 || !strings.Contains(reporter.errors[0], "finalize browser artifacts") {
+		t.Fatalf("cleanup errors=%v", reporter.errors)
+	}
+	data, err := os.ReadFile(filepath.Join(s.dir, "session.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var metadata sessionMetadata
+	if err := json.Unmarshal(data, &metadata); err != nil {
+		t.Fatal(err)
+	}
+	if metadata.Outcome != "failed" {
+		t.Fatalf("metadata outcome=%q", metadata.Outcome)
+	}
+	for _, unavailable := range []string{"screenshots/99-final.png", "screenshots/failure.png", "trace.zip", "video.webm"} {
+		if _, err := os.Stat(filepath.Join(s.dir, unavailable)); !os.IsNotExist(err) {
+			t.Fatalf("unavailable artifact %s was fabricated: %v", unavailable, err)
+		}
+	}
+	if _, err := os.Stat(raw); err != nil {
+		t.Fatalf("partial raw data not retained: %v", err)
+	}
+}
+
+func TestSanitizeNameBoundsAndEmpty(t *testing.T) {
+	if sanitizeName("////") != "unnamed" {
+		t.Fatal("empty name was not replaced")
+	}
+	if got := sanitizeName(strings.Repeat("a", 200)); len(got) != 100 {
+		t.Fatalf("name length=%d", len(got))
+	}
+}
diff --git a/core/auth/ui/e2e/cache_lock_unix.go b/core/auth/ui/e2e/cache_lock_unix.go
new file mode 100644
index 0000000..16f7e89
--- /dev/null
+++ b/core/auth/ui/e2e/cache_lock_unix.go
@@ -0,0 +1,37 @@
+//go:build linux || darwin
+
+package e2e
+
+import (
+	"context"
+	"errors"
+	"os"
+	"syscall"
+	"time"
+)
+
+func acquireCacheLock(ctx context.Context, path string) (func(), error) {
+	file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+	if err != nil {
+		return nil, err
+	}
+	for {
+		err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+		if err == nil {
+			return func() {
+				_ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+				_ = file.Close()
+			}, nil
+		}
+		if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
+			_ = file.Close()
+			return nil, err
+		}
+		select {
+		case <-ctx.Done():
+			_ = file.Close()
+			return nil, ctx.Err()
+		case <-time.After(25 * time.Millisecond):
+		}
+	}
+}
diff --git a/core/auth/ui/e2e/cache_lock_unsupported.go b/core/auth/ui/e2e/cache_lock_unsupported.go
new file mode 100644
index 0000000..99c4d3a
--- /dev/null
+++ b/core/auth/ui/e2e/cache_lock_unsupported.go
@@ -0,0 +1,12 @@
+//go:build !linux && !darwin
+
+package e2e
+
+import (
+	"context"
+	"fmt"
+)
+
+func acquireCacheLock(_ context.Context, _ string) (func(), error) {
+	return nil, fmt.Errorf("unsupported E2E cache-lock platform")
+}
diff --git a/core/auth/ui/e2e/config.go b/core/auth/ui/e2e/config.go
new file mode 100644
index 0000000..010b3f4
--- /dev/null
+++ b/core/auth/ui/e2e/config.go
@@ -0,0 +1,78 @@
+package e2e
+
+import (
+	"bytes"
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+)
+
+type fixtureValues struct {
+	KratosPublicURL  string
+	KratosAdminURL   string
+	KratosPublicPort int
+	KratosAdminPort  int
+	HydraPublicURL   string
+	HydraAdminURL    string
+	HydraPublicPort  int
+	HydraAdminPort   int
+	UIURL            string
+	SchemaURL        string
+	SMTPPort         int
+	CookieSecret     string
+	CipherSecret     string
+	HydraSecret      string
+}
+
+func renderFixtures(repo, configDir string, values fixtureValues) error {
+	if err := os.MkdirAll(configDir, 0o755); err != nil {
+		return err
+	}
+	schemaSource := filepath.Join(repo, "e2e", "testdata", "identity.schema.json")
+	schemaDestination := filepath.Join(configDir, "identity.schema.json")
+	data, err := os.ReadFile(schemaSource)
+	if err != nil {
+		return err
+	}
+	if err := os.WriteFile(schemaDestination, data, 0o600); err != nil {
+		return err
+	}
+	values.SchemaURL = "file://" + filepath.ToSlash(schemaDestination)
+	for _, name := range []string{"kratos.yml", "hydra.yml"} {
+		if err := renderFixture(filepath.Join(repo, "e2e", "testdata", name+".tmpl"), filepath.Join(configDir, name), values); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func renderFixture(source, destination string, values fixtureValues) error {
+	data, err := os.ReadFile(source)
+	if err != nil {
+		return err
+	}
+	tmpl, err := template.New(filepath.Base(source)).Option("missingkey=error").Parse(string(data))
+	if err != nil {
+		return err
+	}
+	var rendered bytes.Buffer
+	if err := tmpl.Execute(&rendered, values); err != nil {
+		return err
+	}
+	if strings.Contains(rendered.String(), "{{") || strings.Contains(rendered.String(), "}}") {
+		return fmt.Errorf("unresolved template marker in %s", source)
+	}
+	return os.WriteFile(destination, rendered.Bytes(), 0o600)
+}
+
+func randomSecret(bytesCount int) (string, error) {
+	b := make([]byte, bytesCount)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(b), nil
+}
diff --git a/core/auth/ui/e2e/config_test.go b/core/auth/ui/e2e/config_test.go
new file mode 100644
index 0000000..a1cf000
--- /dev/null
+++ b/core/auth/ui/e2e/config_test.go
@@ -0,0 +1,94 @@
+package e2e
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestRenderFixturesContracts(t *testing.T) {
+	repo, err := repositoryDir()
+	if err != nil {
+		t.Fatal(err)
+	}
+	values := fixtureValues{
+		KratosPublicURL: "http://127.0.0.1:11001", KratosAdminURL: "http://127.0.0.1:11002",
+		KratosPublicPort: 11001, KratosAdminPort: 11002,
+		HydraPublicURL: "http://127.0.0.1:11003", HydraAdminURL: "http://127.0.0.1:11004",
+		HydraPublicPort: 11003, HydraAdminPort: 11004, UIURL: "http://127.0.0.1:11005", SMTPPort: 11007,
+		CookieSecret: strings.Repeat("a", 32), CipherSecret: strings.Repeat("b", 32), HydraSecret: strings.Repeat("c", 64),
+	}
+	first, second := t.TempDir(), t.TempDir()
+	if err := renderFixtures(repo, first, values); err != nil {
+		t.Fatal(err)
+	}
+	if err := renderFixtures(repo, second, values); err != nil {
+		t.Fatal(err)
+	}
+	for _, name := range []string{"kratos.yml", "hydra.yml", "identity.schema.json"} {
+		a, err := os.ReadFile(filepath.Join(first, name))
+		if err != nil {
+			t.Fatal(err)
+		}
+		b, err := os.ReadFile(filepath.Join(second, name))
+		if err != nil {
+			t.Fatal(err)
+		}
+		normalizedA := strings.ReplaceAll(string(a), filepath.ToSlash(first), "<CONFIG>")
+		normalizedB := strings.ReplaceAll(string(b), filepath.ToSlash(second), "<CONFIG>")
+		if normalizedA != normalizedB {
+			t.Errorf("%s rendering is not deterministic", name)
+		}
+		if strings.Contains(string(a), "{{") || strings.Contains(string(a), "}}") {
+			t.Errorf("%s has unresolved placeholder", name)
+		}
+	}
+	kratos, _ := os.ReadFile(filepath.Join(first, "kratos.yml"))
+	for _, required := range []string{"dsn: memory", "default_schema_id: user", "id: user", "password:\n      enabled: true", "hook: session", "leak_sensitive_values: false", "smtp://e2e:e2e@127.0.0.1:11007", "http://127.0.0.1:11005/login", "file://"} {
+		if !strings.Contains(string(kratos), required) {
+			t.Errorf("kratos config missing %q", required)
+		}
+	}
+	if strings.Count(string(kratos), "host: 127.0.0.1") != 2 {
+		t.Errorf("Kratos listeners are not both loopback-bound: %s", kratos)
+	}
+	hydra, _ := os.ReadFile(filepath.Join(first, "hydra.yml"))
+	for _, required := range []string{"dsn: memory", "port: 11003", "port: 11004", "issuer: http://127.0.0.1:11003/", "login: http://127.0.0.1:11005/login", "consent: http://127.0.0.1:11005/consent", "leak_sensitive_values: false"} {
+		if !strings.Contains(string(hydra), required) {
+			t.Errorf("hydra config missing %q", required)
+		}
+	}
+	if strings.Count(string(hydra), "host: 127.0.0.1") != 2 {
+		t.Errorf("Hydra listeners are not both loopback-bound: %s", hydra)
+	}
+	schema, _ := os.ReadFile(filepath.Join(first, "identity.schema.json"))
+	for _, required := range []string{
+		`"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json"`,
+		`"title": "User"`,
+		`"username"`,
+		`"format": "username"`,
+		`"title": "Username"`,
+		`"minLength": 3`,
+		`"password"`,
+		`"identifier": true`,
+		`"additionalProperties": false`,
+	} {
+		if !strings.Contains(string(schema), required) {
+			t.Errorf("schema missing production contract %q", required)
+		}
+	}
+	if strings.Contains(string(schema), `"required"`) {
+		t.Error("schema unexpectedly diverges from production with required traits")
+	}
+}
+
+func TestRenderFixtureRejectsMissingValue(t *testing.T) {
+	source := filepath.Join(t.TempDir(), "fixture.tmpl")
+	if err := os.WriteFile(source, []byte("{{.DoesNotExist}}"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := renderFixture(source, filepath.Join(t.TempDir(), "out"), fixtureValues{}); err == nil {
+		t.Fatal("expected invalid template error")
+	}
+}
diff --git a/core/auth/ui/e2e/doc.go b/core/auth/ui/e2e/doc.go
new file mode 100644
index 0000000..66136f3
--- /dev/null
+++ b/core/auth/ui/e2e/doc.go
@@ -0,0 +1,4 @@
+// Package e2e provides the native Ory and Playwright end-to-end test harness.
+// Full-stack tests are opt-in through the e2e build tag; untagged tests are pure
+// unit tests and never download or start Ory or Playwright.
+package e2e
diff --git a/core/auth/ui/e2e/hydra_test.go b/core/auth/ui/e2e/hydra_test.go
new file mode 100644
index 0000000..84e4e38
--- /dev/null
+++ b/core/auth/ui/e2e/hydra_test.go
@@ -0,0 +1,309 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+type workflowStep struct {
+	origin string
+	path   string
+}
+
+func TestHydraAuthorizationCode(t *testing.T) {
+	session, callbacks := newHydraTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	status, body := postIdentityJSON(t, client, username, password)
+	if status != http.StatusOK {
+		t.Fatalf("create unique OAuth user returned status %d: %s", status, sanitizedResponseDiagnostic(body))
+	}
+	var identity identityAPIResponse
+	if err := json.Unmarshal(body, &identity); err != nil || identity.ID == "" {
+		t.Fatal("create unique OAuth user did not return a non-empty identity id")
+	}
+
+	clientToken, err := randomSecret(12)
+	if err != nil {
+		t.Fatal("generate unique OAuth client id")
+	}
+	clientSecret, err := randomSecret(24)
+	if err != nil {
+		t.Fatal("generate unique OAuth client secret")
+	}
+	clientID := "e2e-client-" + clientToken
+	requestedClient := hydraOAuthClient{
+		ClientID:                clientID,
+		ClientSecret:            clientSecret,
+		RedirectURIs:            []string{callbacks.URI()},
+		GrantTypes:              []string{"authorization_code"},
+		ResponseTypes:           []string{"code"},
+		Scope:                   "openid",
+		TokenEndpointAuthMethod: "client_secret_basic",
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	created, err := client.createHydraOAuthClient(ctx, testStack.HydraAdmin, requestedClient)
+	cancel()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if created.ClientID != clientID {
+		t.Fatal("Hydra Admin did not create the unique confidential client")
+	}
+
+	state := uniqueOAuthValue(t, "state")
+	nonce := uniqueOAuthValue(t, "nonce")
+	firstAttempt, err := callbacks.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := session.Page.Goto(hydraAuthorizationURL(testStack.HydraURL, clientID, callbacks.URI(), state, nonce)); err != nil {
+		t.Fatal("navigate to bounded Hydra authorization request")
+	}
+	checkpoint(t, session, "authorization-start")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "authorization-login-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "login")
+	checkpoint(t, session, "post-login-automatic-consent")
+
+	firstCallback, err := firstAttempt.Wait(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	firstCode, err := validateOAuthCallback(firstCallback, state)
+	if err != nil {
+		t.Fatal(err)
+	}
+	assertCallbackPage(t, session, callbacks)
+	checkpoint(t, session, "callback-completion")
+	if err := firstAttempt.Finish(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+
+	firstSequenceEnd := len(session.RequestMetadata())
+	assertHydraLoggedOutSequence(t, session.RequestMetadata()[:firstSequenceEnd], callbacks.Origin())
+	firstTokens := exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), firstCode, username, nonce)
+	if firstTokens.AccessToken == "" || firstTokens.IDToken == "" {
+		t.Fatal("Hydra token response did not contain non-empty access and ID tokens")
+	}
+
+	status, _, replayError, err := client.exchangeHydraAuthorizationCode(context.Background(), testStack.HydraURL, clientID, clientSecret, callbacks.URI(), firstCode)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusBadRequest || replayError.Error != "invalid_grant" {
+		t.Fatalf("authorization-code replay returned status %d without invalid_grant", status)
+	}
+
+	secondState := uniqueOAuthValue(t, "state")
+	secondNonce := uniqueOAuthValue(t, "nonce")
+	secondAttempt, err := callbacks.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	beforeSecond := len(session.RequestMetadata())
+	if _, err := session.Page.Goto(hydraAuthorizationURL(testStack.HydraURL, clientID, callbacks.URI(), secondState, secondNonce)); err != nil {
+		t.Fatal("navigate to second bounded Hydra authorization request")
+	}
+	secondCallback, err := secondAttempt.Wait(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	secondCode, err := validateOAuthCallback(secondCallback, secondState)
+	if err != nil {
+		t.Fatal(err)
+	}
+	assertCallbackPage(t, session, callbacks)
+	if count, err := session.Page.Locator(`input[name="password"]`).Count(); err != nil || count != 0 {
+		t.Fatalf("second authorization rendered another password form: count=%d", count)
+	}
+	checkpoint(t, session, "second-authenticated-callback")
+	if err := secondAttempt.Finish(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+	assertSecondAuthorizationUsesExistingSession(t, session.RequestMetadata()[beforeSecond:])
+	_ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), secondCode, username, secondNonce)
+}
+
+func newHydraTestSession(t *testing.T) (*browserSession, *callbackCapture) {
+	t.Helper()
+	callbacks, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	transferred := false
+	defer func() {
+		if !transferred {
+			if err := callbacks.Close(); err != nil {
+				t.Errorf("close callback listener after browser setup failure: %v", err)
+			}
+		}
+	}()
+	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
+	failedBeforeFinalize := false
+	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
+	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL, testStack.HydraURL, callbacks.Origin()})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { failedBeforeFinalize = t.Failed() })
+	t.Cleanup(func() { assertHydraBrowserOrigins(t, session, callbacks.Origin()) })
+	// Registered last so callback shutdown runs first. A shutdown failure marks
+	// the test failed before failure.png, failed session metadata, and artifact
+	// validation are finalized by the earlier LIFO cleanups.
+	t.Cleanup(func() {
+		if err := callbacks.Close(); err != nil {
+			t.Errorf("close callback listener: %v", err)
+		}
+	})
+	transferred = true
+	return session, callbacks
+}
+
+func uniqueOAuthValue(t *testing.T, prefix string) string {
+	t.Helper()
+	value, err := randomSecret(16)
+	if err != nil {
+		t.Fatalf("generate unique OAuth %s", prefix)
+	}
+	return prefix + "-" + value
+}
+
+func hydraAuthorizationURL(hydraPublic, clientID, redirectURI, state, nonce string) string {
+	values := url.Values{
+		"client_id":     {clientID},
+		"redirect_uri":  {redirectURI},
+		"response_type": {"code"},
+		"scope":         {"openid"},
+		"state":         {state},
+		"nonce":         {nonce},
+	}
+	return strings.TrimRight(hydraPublic, "/") + "/oauth2/auth?" + values.Encode()
+}
+
+func assertCallbackPage(t *testing.T, session *browserSession, callbacks *callbackCapture) {
+	t.Helper()
+	current, err := url.Parse(session.Page.URL())
+	if err != nil || current.Scheme+"://"+current.Host != callbacks.Origin() || current.Path != "/callback" {
+		t.Fatal("authorization did not finish at the owned loopback callback")
+	}
+	body, err := session.Page.Locator("body").InnerText()
+	if err != nil || !strings.Contains(body, "Authorization callback received.") {
+		t.Fatal("owned loopback callback completion was not visible")
+	}
+}
+
+func exchangeAndValidateHydraCode(t *testing.T, client *directAPIClient, clientID, clientSecret, redirectURI, code, username, nonce string) hydraTokenResponse {
+	t.Helper()
+	status, tokens, oauthErr, err := client.exchangeHydraAuthorizationCode(context.Background(), testStack.HydraURL, clientID, clientSecret, redirectURI, code)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusOK || oauthErr.Error != "" {
+		t.Fatalf("Hydra authorization-code exchange returned status %d", status)
+	}
+	if !strings.EqualFold(tokens.TokenType, "bearer") || tokens.AccessToken == "" || tokens.IDToken == "" {
+		t.Fatal("Hydra token response omitted the bearer token type, access token, or ID token")
+	}
+	claims, err := decodeJWTPayloadUnverified(tokens.IDToken)
+	if err != nil {
+		t.Fatal(err)
+	}
+	expected := map[string]string{
+		"sub": username, "username": username, "email": username + "@example.test",
+		"iss": testStack.HydraURL + "/", "nonce": nonce,
+	}
+	for name, want := range expected {
+		if got, ok := jwtStringClaim(claims, name); !ok || got != want {
+			t.Fatalf("ID-token payload claim %s did not match", name)
+		}
+	}
+	if !jwtAudienceContains(claims, clientID) {
+		t.Fatal("ID-token payload audience did not include the generated client id")
+	}
+	return tokens
+}
+
+func assertHydraLoggedOutSequence(t *testing.T, metadata []requestMetadata, callbackOrigin string) {
+	t.Helper()
+	expected := []workflowStep{
+		{testStack.HydraURL, "/oauth2/auth"},
+		{testStack.UIURL, "/login"},
+		{testStack.KratosURL, "/self-service/login/browser"},
+		{testStack.UIURL, "/login"},
+		{testStack.HydraURL, "/oauth2/auth"},
+		{testStack.UIURL, "/consent"},
+		{testStack.HydraURL, "/oauth2/auth"},
+		{callbackOrigin, "/callback"},
+	}
+	index := 0
+	for _, request := range metadata {
+		if request.Status < http.StatusOK || request.Status >= http.StatusBadRequest || index == len(expected) {
+			continue
+		}
+		if request.Origin == expected[index].origin && request.Path == expected[index].path {
+			index++
+		}
+	}
+	if index != len(expected) {
+		t.Fatalf("sanitized Hydra workflow sequence stopped before required step %d of %d", index+1, len(expected))
+	}
+}
+
+func assertSecondAuthorizationUsesExistingSession(t *testing.T, metadata []requestMetadata) {
+	t.Helper()
+	observedUILoginGET := false
+	for _, request := range metadata {
+		if request.Status < http.StatusOK || request.Status >= http.StatusBadRequest {
+			continue
+		}
+		if request.Method == http.MethodGet && request.Origin == testStack.UIURL && request.Path == "/login" {
+			observedUILoginGET = true
+		}
+		if request.Origin == testStack.KratosURL && request.Path == "/self-service/login/browser" {
+			t.Fatal("second authorization initiated another Kratos browser login flow")
+		}
+		if request.Method == http.MethodPost && request.Origin == testStack.UIURL && request.Path == "/login" {
+			t.Fatal("second authorization submitted another login form")
+		}
+	}
+	if !observedUILoginGET {
+		t.Fatal("second authorization did not exercise auth-ui's existing-session GET /login branch")
+	}
+}
+
+func assertHydraBrowserOrigins(t *testing.T, session *browserSession, callbackOrigin string) {
+	t.Helper()
+	expected := map[string]bool{testStack.UIURL: false, testStack.KratosURL: false, testStack.HydraURL: false, callbackOrigin: false}
+	for _, request := range session.RequestMetadata() {
+		if request.Status < http.StatusOK || request.Status >= http.StatusBadRequest {
+			continue
+		}
+		if _, ok := expected[request.Origin]; !ok {
+			t.Errorf("browser received a successful response from unexpected origin %s%s", request.Origin, request.Path)
+			continue
+		}
+		expected[request.Origin] = true
+	}
+	for origin, observed := range expected {
+		if !observed {
+			t.Errorf("browser did not receive an expected successful OAuth workflow response from %s", origin)
+		}
+	}
+	for _, request := range session.BlockedRequests() {
+		if !isExpectedBlockedBrowserRequest(request) {
+			t.Errorf("browser blocked an unexpected query-free target %s", request)
+		}
+	}
+}
diff --git a/core/auth/ui/e2e/kratos_test.go b/core/auth/ui/e2e/kratos_test.go
new file mode 100644
index 0000000..d909b1d
--- /dev/null
+++ b/core/auth/ui/e2e/kratos_test.go
@@ -0,0 +1,339 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+func TestKratosUnauthenticatedLanding(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+
+	if _, err := session.Page.Goto(testStack.UIURL + "/"); err != nil {
+		t.Fatal(err)
+	}
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "unauthenticated-login-form")
+	assertNoAcceptedKratosSession(t, client, session)
+}
+
+func TestKratosRegistrationAndWhoAmI(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	registerThroughBrowser(t, session, username, password, "registration")
+	whoami := assertAcceptedKratosSession(t, client, session)
+	if whoami.Identity.Traits.Username != username || whoami.Identity.ID == "" {
+		t.Fatal("Kratos whoami did not return the registered identity ID and username")
+	}
+}
+
+func TestKratosDuplicateRegistration(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	registerThroughBrowser(t, session, username, password, "initial-registration")
+	first := assertAcceptedKratosSession(t, client, session)
+	if err := session.Context.ClearCookies(); err != nil {
+		t.Fatal(err)
+	}
+	assertNoAcceptedKratosSession(t, client, session)
+
+	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+	checkpoint(t, session, "duplicate-registration-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "Create Account")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "duplicate-registration-return-login")
+	assertNoAcceptedKratosSession(t, client, session)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	identities, err := client.kratosIdentitiesByUsername(ctx, username)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(identities) != 1 || identities[0].ID != first.Identity.ID {
+		t.Fatalf("duplicate registration identity count=%d, want exactly one unchanged identity", len(identities))
+	}
+}
+
+func TestKratosLogoutInvalidatesSession(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	registerThroughBrowser(t, session, username, password, "registration-before-logout")
+	acceptedCookies := browserKratosCookies(t, session)
+	if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil || !accepted {
+		t.Fatalf("registered Kratos session was not accepted before logout: accepted=%v err=%v", accepted, err)
+	}
+	clickLink(t, session.Page, "logout")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "logout-return-login")
+
+	if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil {
+		t.Fatal(err)
+	} else if accepted {
+		t.Fatal("Kratos continued accepting the logged-out session")
+	}
+	assertNoAcceptedKratosSession(t, client, session)
+}
+
+func TestKratosValidLaterLogin(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	registerThroughBrowser(t, session, username, password, "registration-before-login")
+	registered := assertAcceptedKratosSession(t, client, session)
+	clickLink(t, session.Page, "logout")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "later-login-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "login")
+	assertGreeting(t, session.Page, username)
+	checkpoint(t, session, "later-login-greeting")
+	loggedIn := assertAcceptedKratosSession(t, client, session)
+	if loggedIn.Identity.ID != registered.Identity.ID || loggedIn.Identity.Traits.Username != username {
+		t.Fatal("later login did not restore the original Kratos identity")
+	}
+}
+
+func TestKratosWrongPasswordRejected(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+	_, wrongPassword := uniqueKratosCredentials(t)
+
+	registerThroughBrowser(t, session, username, password, "registration-before-wrong-password")
+	clickLink(t, session.Page, "logout")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "wrong-password-login-form")
+	fillCredentials(t, session.Page, username, wrongPassword)
+	clickButton(t, session.Page, "login")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "wrong-password-return-login")
+	assertNoAcceptedKratosSession(t, client, session)
+}
+
+func TestKratosUnknownUsernameRejected(t *testing.T) {
+	session := newKratosTestSession(t)
+	client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+	defer client.close()
+	username, password := uniqueKratosCredentials(t)
+
+	openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+	checkpoint(t, session, "unknown-username-login-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "login")
+	assertKratosForm(t, session.Page, "/login")
+	checkpoint(t, session, "unknown-username-return-login")
+	assertNoAcceptedKratosSession(t, client, session)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	identities, err := client.kratosIdentitiesByUsername(ctx, username)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(identities) != 0 {
+		t.Fatalf("unknown-username login unexpectedly matched %d identities", len(identities))
+	}
+}
+
+func newKratosTestSession(t *testing.T) *browserSession {
+	t.Helper()
+	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
+	failedBeforeFinalize := false
+	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
+	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { failedBeforeFinalize = t.Failed() })
+	t.Cleanup(func() { assertKratosBrowserOrigins(t, session) })
+	return session
+}
+
+func uniqueKratosCredentials(t *testing.T) (string, string) {
+	t.Helper()
+	usernameToken, err := randomSecret(10)
+	if err != nil {
+		t.Fatal("generate unique test-local username")
+	}
+	passwordToken, err := randomSecret(14)
+	if err != nil {
+		t.Fatal("generate unique test-local password")
+	}
+	return "e2e-" + usernameToken, "Correct-Horse-" + passwordToken + "-9!"
+}
+
+func registerThroughBrowser(t *testing.T, session *browserSession, username, password, checkpointPrefix string) {
+	t.Helper()
+	openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+	checkpoint(t, session, checkpointPrefix+"-form")
+	fillCredentials(t, session.Page, username, password)
+	clickButton(t, session.Page, "Create Account")
+	assertGreeting(t, session.Page, username)
+	checkpoint(t, session, checkpointPrefix+"-greeting")
+}
+
+func openKratosForm(t *testing.T, page playwright.Page, address, route string) {
+	t.Helper()
+	if _, err := page.Goto(address); err != nil {
+		t.Fatal(err)
+	}
+	assertKratosForm(t, page, route)
+}
+
+func assertKratosForm(t *testing.T, page playwright.Page, route string) {
+	t.Helper()
+	u, err := url.Parse(page.URL())
+	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != route || u.Query().Get("flow") == "" {
+		t.Fatalf("expected rendered %s flow at the UI origin", route)
+	}
+	for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
+		if err := page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+			t.Fatalf("required credential field %s did not become visible: %v", selector, err)
+		}
+	}
+	csrf := page.Locator(`input[name="csrf_token"]`)
+	if err := csrf.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateAttached, Timeout: playwright.Float(10_000)}); err != nil {
+		t.Fatalf("rendered flow CSRF field was not attached: %v", err)
+	}
+	count, err := csrf.Count()
+	if err != nil || count != 1 {
+		t.Fatalf("rendered flow does not contain exactly one CSRF field: count=%d err=%v", count, err)
+	}
+}
+
+func fillCredentials(t *testing.T, page playwright.Page, username, password string) {
+	t.Helper()
+	if err := page.Locator(`input[name="username"]`).Fill(username); err != nil {
+		t.Fatal("fill username field")
+	}
+	if err := page.Locator(`input[name="password"]`).Fill(password); err != nil {
+		t.Fatal("fill password field")
+	}
+}
+
+func clickButton(t *testing.T, page playwright.Page, name string) {
+	t.Helper()
+	if err := page.GetByRole("button", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Click(); err != nil {
+		t.Fatalf("click %s button: %v", name, err)
+	}
+}
+
+func clickLink(t *testing.T, page playwright.Page, name string) {
+	t.Helper()
+	if err := page.GetByText(name, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).Click(); err != nil {
+		t.Fatalf("click %s link: %v", name, err)
+	}
+}
+
+func assertGreeting(t *testing.T, page playwright.Page, username string) {
+	t.Helper()
+	u, err := url.Parse(page.URL())
+	if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/" {
+		t.Fatal("successful authentication did not return to the UI landing route")
+	}
+	body, err := page.Locator("body").InnerText()
+	if err != nil || !strings.Contains(body, fmt.Sprintf("Hello %s!", username)) {
+		t.Fatalf("authenticated greeting is not visible: %v", err)
+	}
+}
+
+func checkpoint(t *testing.T, session *browserSession, name string) {
+	t.Helper()
+	if err := session.Checkpoint(name); err != nil {
+		t.Fatalf("capture checkpoint %s: %v", name, err)
+	}
+}
+
+func browserKratosCookies(t *testing.T, session *browserSession) []*http.Cookie {
+	t.Helper()
+	cookies, err := session.Context.Cookies(testStack.KratosURL)
+	if err != nil {
+		t.Fatal("export browser cookies for bounded Kratos assertion")
+	}
+	result := make([]*http.Cookie, 0, len(cookies))
+	for _, cookie := range cookies {
+		result = append(result, &http.Cookie{Name: cookie.Name, Value: cookie.Value, Path: cookie.Path, Domain: cookie.Domain, Secure: cookie.Secure, HttpOnly: cookie.HttpOnly})
+	}
+	return result
+}
+
+func assertAcceptedKratosSession(t *testing.T, client *directAPIClient, browser *browserSession) kratosSession {
+	t.Helper()
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	session, accepted, err := client.kratosWhoAmI(ctx, browserKratosCookies(t, browser))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !accepted {
+		t.Fatal("browser cookies were not accepted as an active Kratos session")
+	}
+	return session
+}
+
+func assertNoAcceptedKratosSession(t *testing.T, client *directAPIClient, browser *browserSession) {
+	t.Helper()
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	_, accepted, err := client.kratosWhoAmI(ctx, browserKratosCookies(t, browser))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if accepted {
+		t.Fatal("browser received cookies accepted as an active Kratos session")
+	}
+}
+
+func assertKratosBrowserOrigins(t *testing.T, session *browserSession) {
+	t.Helper()
+	expected := map[string]bool{testStack.UIURL: false, testStack.KratosURL: false}
+	for _, request := range session.RequestMetadata() {
+		if request.Status < http.StatusOK || request.Status >= http.StatusBadRequest {
+			continue
+		}
+		if _, ok := expected[request.Origin]; !ok {
+			t.Errorf("browser received a successful response from unexpected origin %s%s", request.Origin, request.Path)
+			continue
+		}
+		expected[request.Origin] = true
+	}
+	for origin, observed := range expected {
+		if !observed {
+			t.Errorf("browser did not receive an expected successful workflow response from %s", origin)
+		}
+	}
+	blocked := session.BlockedRequests()
+	if len(blocked) == 0 {
+		t.Error("browser did not block the expected external CDN font request")
+	}
+	for _, request := range blocked {
+		if !isExpectedBlockedBrowserRequest(request) {
+			t.Errorf("browser blocked an unexpected query-free target %s", request)
+		}
+	}
+}
diff --git a/core/auth/ui/e2e/oauth_helpers.go b/core/auth/ui/e2e/oauth_helpers.go
new file mode 100644
index 0000000..d7efa73
--- /dev/null
+++ b/core/auth/ui/e2e/oauth_helpers.go
@@ -0,0 +1,347 @@
+package e2e
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	callbackWaitTimeout  = 10 * time.Second
+	callbackCloseTimeout = 2 * time.Second
+	maxJWTPayload        = 32 << 10
+)
+
+type hydraOAuthClient struct {
+	ClientID                string   `json:"client_id"`
+	ClientSecret            string   `json:"client_secret"`
+	RedirectURIs            []string `json:"redirect_uris"`
+	GrantTypes              []string `json:"grant_types"`
+	ResponseTypes           []string `json:"response_types"`
+	Scope                   string   `json:"scope"`
+	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method"`
+}
+
+type hydraTokenResponse struct {
+	AccessToken string `json:"access_token"`
+	IDToken     string `json:"id_token"`
+	TokenType   string `json:"token_type"`
+}
+
+type oauthTokenError struct {
+	Error string `json:"error"`
+}
+
+func (c *directAPIClient) createHydraOAuthClient(ctx context.Context, hydraAdmin string, requested hydraOAuthClient) (hydraOAuthClient, error) {
+	var created hydraOAuthClient
+	body, err := json.Marshal(requested)
+	if err != nil {
+		return created, fmt.Errorf("encode Hydra OAuth client request")
+	}
+	status, response, err := c.postJSON(ctx, strings.TrimRight(hydraAdmin, "/")+"/admin/clients", body)
+	if err != nil {
+		return created, err
+	}
+	if status != http.StatusCreated {
+		return created, unexpectedDirectAPIStatus(http.MethodPost, "/admin/clients", status, response)
+	}
+	if err := json.Unmarshal(response, &created); err != nil {
+		return created, fmt.Errorf("decode Hydra OAuth client response: invalid JSON")
+	}
+	if created.ClientID == "" || created.ClientID != requested.ClientID {
+		return created, fmt.Errorf("Hydra OAuth client response did not contain the requested client id")
+	}
+	return created, nil
+}
+
+func (c *directAPIClient) exchangeHydraAuthorizationCode(ctx context.Context, hydraPublic, clientID, clientSecret, redirectURI, code string) (int, hydraTokenResponse, oauthTokenError, error) {
+	var tokens hydraTokenResponse
+	var oauthErr oauthTokenError
+	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+	form := url.Values{
+		"grant_type":   {"authorization_code"},
+		"code":         {code},
+		"redirect_uri": {redirectURI},
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(hydraPublic, "/")+"/oauth2/token", strings.NewReader(form.Encode()))
+	if err != nil {
+		return 0, tokens, oauthErr, fmt.Errorf("construct bounded Hydra token request")
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.SetBasicAuth(clientID, clientSecret)
+	status, body, err := c.do(ctx, req)
+	if err != nil {
+		return 0, tokens, oauthErr, err
+	}
+	if status == http.StatusOK {
+		if err := json.Unmarshal(body, &tokens); err != nil {
+			return status, tokens, oauthErr, fmt.Errorf("decode Hydra token response: invalid JSON")
+		}
+		return status, tokens, oauthErr, nil
+	}
+	if err := json.Unmarshal(body, &oauthErr); err != nil {
+		return status, tokens, oauthErr, unexpectedDirectAPIStatus(http.MethodPost, "/oauth2/token", status, body)
+	}
+	oauthErr.Error = sanitizedOAuthErrorCode(oauthErr.Error)
+	return status, tokens, oauthErr, nil
+}
+
+var oauthErrorCode = regexp.MustCompile(`^[A-Za-z_]{1,64}$`)
+
+func sanitizedOAuthErrorCode(value string) string {
+	if oauthErrorCode.MatchString(value) {
+		return value
+	}
+	return "invalid_response"
+}
+
+// decodeJWTPayloadUnverified decodes only the middle base64url segment of a JWT.
+// It deliberately performs no signature, algorithm, issuer, or key validation.
+func decodeJWTPayloadUnverified(token string) (map[string]any, error) {
+	parts := strings.Split(token, ".")
+	if len(parts) != 3 || parts[1] == "" {
+		return nil, fmt.Errorf("decode JWT payload: expected three compact segments")
+	}
+	if len(parts[1]) > base64.RawURLEncoding.EncodedLen(maxJWTPayload) {
+		return nil, fmt.Errorf("decode JWT payload: payload exceeds %d bytes", maxJWTPayload)
+	}
+	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil {
+		return nil, fmt.Errorf("decode JWT payload: invalid base64url")
+	}
+	if len(payload) > maxJWTPayload {
+		return nil, fmt.Errorf("decode JWT payload: payload exceeds %d bytes", maxJWTPayload)
+	}
+	var claims map[string]any
+	if err := json.Unmarshal(payload, &claims); err != nil || claims == nil {
+		return nil, fmt.Errorf("decode JWT payload: invalid JSON object")
+	}
+	return claims, nil
+}
+
+func jwtStringClaim(claims map[string]any, name string) (string, bool) {
+	value, ok := claims[name].(string)
+	return value, ok && value != ""
+}
+
+func jwtAudienceContains(claims map[string]any, expected string) bool {
+	switch audience := claims["aud"].(type) {
+	case string:
+		return audience == expected
+	case []any:
+		for _, value := range audience {
+			if text, ok := value.(string); ok && text == expected {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+type oauthCallback struct {
+	Code  []string
+	State []string
+	Error []string
+}
+
+type callbackAttempt struct {
+	capture *callbackCapture
+	result  chan oauthCallback
+	count   int
+	done    bool
+}
+
+type callbackCapture struct {
+	listener     net.Listener
+	server       *http.Server
+	serveDone    chan error
+	closing      chan struct{}
+	closeTimeout time.Duration
+	closeOnce    sync.Once
+	closeErr     error
+	mu           sync.Mutex
+	active       *callbackAttempt
+	closed       bool
+}
+
+func startCallbackCapture() (*callbackCapture, error) {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return nil, fmt.Errorf("bind OAuth callback listener: %w", err)
+	}
+	capture := &callbackCapture{
+		listener:     listener,
+		serveDone:    make(chan error, 1),
+		closing:      make(chan struct{}),
+		closeTimeout: callbackCloseTimeout,
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/callback", capture.handle)
+	capture.server = &http.Server{
+		Handler:           mux,
+		ReadHeaderTimeout: 2 * time.Second,
+		ReadTimeout:       5 * time.Second,
+		WriteTimeout:      5 * time.Second,
+		IdleTimeout:       5 * time.Second,
+	}
+	go func() { capture.serveDone <- capture.server.Serve(listener) }()
+	if activeSuiteLifecycle != nil && !activeSuiteLifecycle.closers.add(capture) {
+		_ = capture.Close()
+		return nil, fmt.Errorf("create OAuth callback listener after E2E watchdog cleanup started")
+	}
+	return capture, nil
+}
+
+func (c *callbackCapture) URI() string {
+	return "http://" + c.listener.Addr().String() + "/callback"
+}
+
+func (c *callbackCapture) Origin() string {
+	return "http://" + c.listener.Addr().String()
+}
+
+func (c *callbackCapture) Begin() (*callbackAttempt, error) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if c.closed {
+		return nil, fmt.Errorf("OAuth callback capture is closed")
+	}
+	if c.active != nil && !c.active.done {
+		return nil, fmt.Errorf("OAuth callback capture already has an active authorization")
+	}
+	attempt := &callbackAttempt{capture: c, result: make(chan oauthCallback, 1)}
+	c.active = attempt
+	return attempt, nil
+}
+
+func (c *callbackCapture) handle(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+	c.mu.Lock()
+	attempt := c.active
+	if attempt == nil || attempt.done {
+		c.mu.Unlock()
+		http.Error(w, "callback not expected", http.StatusConflict)
+		return
+	}
+	attempt.count++
+	first := attempt.count == 1
+	if first {
+		query := r.URL.Query()
+		attempt.result <- oauthCallback{
+			Code:  append([]string(nil), query["code"]...),
+			State: append([]string(nil), query["state"]...),
+			Error: append([]string(nil), query["error"]...),
+		}
+	}
+	c.mu.Unlock()
+	if !first {
+		http.Error(w, "duplicate callback", http.StatusConflict)
+		return
+	}
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.WriteHeader(http.StatusOK)
+	_, _ = io.WriteString(w, "Authorization callback received.\n")
+}
+
+func (a *callbackAttempt) Wait(ctx context.Context) (oauthCallback, error) {
+	ctx, cancel := context.WithTimeout(ctx, callbackWaitTimeout)
+	defer cancel()
+	select {
+	case result := <-a.result:
+		return result, nil
+	case <-a.capture.closing:
+		return oauthCallback{}, fmt.Errorf("wait for OAuth callback: capture closed")
+	case <-ctx.Done():
+		return oauthCallback{}, fmt.Errorf("wait for OAuth callback: %w", ctx.Err())
+	}
+}
+
+func (a *callbackAttempt) Finish(ctx context.Context) error {
+	quiet := time.NewTimer(100 * time.Millisecond)
+	defer quiet.Stop()
+	select {
+	case <-quiet.C:
+	case <-ctx.Done():
+		return fmt.Errorf("confirm OAuth callback cardinality: %w", ctx.Err())
+	}
+	a.capture.mu.Lock()
+	defer a.capture.mu.Unlock()
+	if a.capture.active != a {
+		return fmt.Errorf("OAuth callback attempt is no longer active")
+	}
+	a.done = true
+	if a.count != 1 {
+		return fmt.Errorf("OAuth callback cardinality was %d, want exactly one", a.count)
+	}
+	return nil
+}
+
+func validateOAuthCallback(callback oauthCallback, expectedState string) (string, error) {
+	if len(callback.Error) != 0 {
+		return "", fmt.Errorf("OAuth callback contained an error")
+	}
+	if len(callback.State) != 1 || callback.State[0] != expectedState {
+		return "", fmt.Errorf("OAuth callback state did not match exactly")
+	}
+	if len(callback.Code) != 1 || callback.Code[0] == "" {
+		return "", fmt.Errorf("OAuth callback did not contain exactly one non-empty code")
+	}
+	return callback.Code[0], nil
+}
+
+func (c *callbackCapture) Close() error {
+	if c == nil {
+		return nil
+	}
+	if activeSuiteLifecycle != nil {
+		defer activeSuiteLifecycle.closers.remove(c)
+	}
+	c.closeOnce.Do(func() {
+		c.mu.Lock()
+		c.closed = true
+		close(c.closing)
+		c.mu.Unlock()
+
+		timeout := c.closeTimeout
+		if timeout <= 0 {
+			timeout = callbackCloseTimeout
+		}
+		ctx, cancel := context.WithTimeout(context.Background(), timeout)
+		defer cancel()
+		shutdownErr := c.server.Shutdown(ctx)
+
+		var serveErr error
+		select {
+		case serveErr = <-c.serveDone:
+		case <-ctx.Done():
+			if shutdownErr != nil {
+				c.closeErr = fmt.Errorf("close OAuth callback: %w", shutdownErr)
+			} else {
+				c.closeErr = fmt.Errorf("close OAuth callback: %w", ctx.Err())
+			}
+			return
+		}
+		if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+			c.closeErr = fmt.Errorf("serve OAuth callback: %w", serveErr)
+			return
+		}
+		if shutdownErr != nil {
+			c.closeErr = fmt.Errorf("close OAuth callback: %w", shutdownErr)
+		}
+	})
+	return c.closeErr
+}
diff --git a/core/auth/ui/e2e/oauth_helpers_test.go b/core/auth/ui/e2e/oauth_helpers_test.go
new file mode 100644
index 0000000..5f62b6f
--- /dev/null
+++ b/core/auth/ui/e2e/oauth_helpers_test.go
@@ -0,0 +1,388 @@
+package e2e
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestHydraClientCreationAndBasicAuthTokenExchange(t *testing.T) {
+	const (
+		clientID     = "generated-client"
+		clientSecret = "generated-client-secret"
+		code         = "generated-authorization-code"
+		redirectURI  = "http://127.0.0.1:43210/callback"
+	)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/admin/clients":
+			if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
+				t.Errorf("unexpected client request method or content type")
+			}
+			var request hydraOAuthClient
+			if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+				t.Errorf("decode client request: %v", err)
+			}
+			if request.ClientID != clientID || request.ClientSecret != clientSecret || len(request.RedirectURIs) != 1 || request.RedirectURIs[0] != redirectURI || request.TokenEndpointAuthMethod != "client_secret_basic" {
+				t.Error("Hydra client request did not preserve the confidential-client contract")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusCreated)
+			_ = json.NewEncoder(w).Encode(request)
+		case "/oauth2/token":
+			gotID, gotSecret, ok := r.BasicAuth()
+			if !ok || gotID != clientID || gotSecret != clientSecret {
+				t.Error("token exchange did not use HTTP Basic client authentication")
+			}
+			if err := r.ParseForm(); err != nil {
+				t.Errorf("parse token form: %v", err)
+			}
+			if r.Form.Get("grant_type") != "authorization_code" || r.Form.Get("code") != code || r.Form.Get("redirect_uri") != redirectURI {
+				t.Error("token exchange form did not preserve the authorization-code contract")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"access","id_token":"header.payload.signature","token_type":"bearer"}`)
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer server.Close()
+
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+	requested := hydraOAuthClient{
+		ClientID: clientID, ClientSecret: clientSecret, RedirectURIs: []string{redirectURI},
+		GrantTypes: []string{"authorization_code"}, ResponseTypes: []string{"code"}, Scope: "openid", TokenEndpointAuthMethod: "client_secret_basic",
+	}
+	created, err := client.createHydraOAuthClient(context.Background(), server.URL, requested)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if created.ClientID != clientID {
+		t.Fatal("created Hydra client id did not match")
+	}
+	status, tokens, oauthErr, err := client.exchangeHydraAuthorizationCode(context.Background(), server.URL, clientID, clientSecret, redirectURI, code)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusOK || tokens.AccessToken == "" || tokens.IDToken == "" || tokens.TokenType != "bearer" || oauthErr.Error != "" {
+		t.Fatal("unexpected successful token response")
+	}
+}
+
+func TestHydraTokenErrorIsBoundedAndSanitized(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusBadRequest)
+		fmt.Fprint(w, `{"error":"invalid_grant","error_description":"generated-authorization-code"}`)
+	}))
+	defer server.Close()
+	client := newDirectAPIClient(server.URL, server.URL)
+	defer client.close()
+
+	status, _, oauthErr, err := client.exchangeHydraAuthorizationCode(context.Background(), server.URL, "client", "secret", "http://127.0.0.1/callback", "generated-authorization-code")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status != http.StatusBadRequest || oauthErr.Error != "invalid_grant" {
+		t.Fatalf("unexpected OAuth error status=%d code=%q", status, oauthErr.Error)
+	}
+	if got := sanitizedOAuthErrorCode("generated-secret-value"); got != "invalid_response" {
+		t.Fatalf("unsafe OAuth error code was retained: %q", got)
+	}
+}
+
+func TestDecodeJWTPayloadUnverifiedAndAudience(t *testing.T) {
+	payload, err := json.Marshal(map[string]any{
+		"sub": "generated-user", "username": "generated-user", "email": "generated-user@example.test",
+		"iss": "http://127.0.0.1:4444/", "aud": []string{"generated-client", "other-client"}, "nonce": "generated-nonce",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	token := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".unsigned"
+	claims, err := decodeJWTPayloadUnverified(token)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if subject, ok := jwtStringClaim(claims, "sub"); !ok || subject != "generated-user" {
+		t.Fatal("JWT string claim was not decoded")
+	}
+	if !jwtAudienceContains(claims, "generated-client") || jwtAudienceContains(claims, "missing-client") {
+		t.Fatal("JWT audience membership was incorrect")
+	}
+	claims["aud"] = "single-client"
+	if !jwtAudienceContains(claims, "single-client") {
+		t.Fatal("JWT string audience was not accepted")
+	}
+}
+
+func TestDecodeJWTPayloadUnverifiedRejectsMalformedAndOversized(t *testing.T) {
+	invalid := []string{
+		"not-a-jwt",
+		"header.%%%.signature",
+		"header." + base64.RawURLEncoding.EncodeToString([]byte("[]")) + ".signature",
+		"header." + base64.RawURLEncoding.EncodeToString([]byte("not-json")) + ".signature",
+		"header." + strings.Repeat("a", base64.RawURLEncoding.EncodedLen(maxJWTPayload)+1) + ".signature",
+	}
+	for _, token := range invalid {
+		if _, err := decodeJWTPayloadUnverified(token); err == nil {
+			t.Fatal("malformed or oversized JWT payload was accepted")
+		}
+	}
+}
+
+func TestCallbackCaptureSuccessDuplicateAndRearm(t *testing.T) {
+	capture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer capture.Close()
+	client := &http.Client{Timeout: time.Second}
+	defer client.CloseIdleConnections()
+	attempt, err := capture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	values := url.Values{"code": {"generated-code"}, "state": {"generated-state"}}
+	response, err := client.Get(capture.URI() + "?" + values.Encode())
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = response.Body.Close()
+	callback, err := attempt.Wait(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := validateOAuthCallback(callback, "generated-state"); err != nil {
+		t.Fatal(err)
+	}
+	duplicate, err := client.Get(capture.URI() + "?" + values.Encode())
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = duplicate.Body.Close()
+	if duplicate.StatusCode != http.StatusConflict {
+		t.Fatalf("duplicate callback status=%d, want 409", duplicate.StatusCode)
+	}
+	if err := attempt.Finish(context.Background()); err == nil || !strings.Contains(err.Error(), "cardinality") {
+		t.Fatalf("duplicate callback cardinality error=%v", err)
+	}
+
+	second, err := capture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	response, err = client.Get(capture.URI() + "?code=second-code&state=second-state")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = response.Body.Close()
+	if _, err := second.Wait(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+	if err := second.Finish(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestCallbackCaptureTimeoutCancellationAndValidation(t *testing.T) {
+	capture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer capture.Close()
+	attempt, err := capture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	defer cancel()
+	if _, err := attempt.Wait(ctx); err == nil || !strings.Contains(err.Error(), "deadline exceeded") {
+		t.Fatalf("callback timeout error=%v", err)
+	}
+
+	cancelCapture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer cancelCapture.Close()
+	cancelAttempt, err := cancelCapture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	canceled, cancelNow := context.WithCancel(context.Background())
+	cancelNow()
+	if _, err := cancelAttempt.Wait(canceled); err == nil || !strings.Contains(err.Error(), "context canceled") {
+		t.Fatalf("callback cancellation error=%v", err)
+	}
+
+	for _, callback := range []oauthCallback{
+		{Error: []string{"access_denied"}, State: []string{"state"}},
+		{Code: []string{"code"}, State: []string{"wrong"}},
+		{Code: []string{"one", "two"}, State: []string{"state"}},
+		{Code: []string{"code"}, State: []string{"state", "state"}},
+	} {
+		if _, err := validateOAuthCallback(callback, "state"); err == nil {
+			t.Fatal("invalid OAuth callback was accepted")
+		}
+	}
+}
+
+func TestCallbackCaptureCloseIdempotentAndBeginAfterClose(t *testing.T) {
+	capture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := capture.Close(); err != nil {
+		t.Fatal(err)
+	}
+	if err := capture.Close(); err != nil {
+		t.Fatalf("idempotent second close: %v", err)
+	}
+	if _, err := capture.Begin(); err == nil || !strings.Contains(err.Error(), "closed") {
+		t.Fatalf("Begin after close error=%v", err)
+	}
+}
+
+func TestCallbackCaptureCloseReleasesActiveWait(t *testing.T) {
+	capture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	attempt, err := capture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	waitDone := make(chan error, 1)
+	go func() {
+		_, err := attempt.Wait(context.Background())
+		waitDone <- err
+	}()
+	if err := capture.Close(); err != nil {
+		t.Fatal(err)
+	}
+	select {
+	case err := <-waitDone:
+		if err == nil || !strings.Contains(err.Error(), "capture closed") {
+			t.Fatalf("active callback wait close error=%v", err)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("active callback wait was not released by close")
+	}
+}
+
+func TestCallbackCaptureCloseBoundsMissingServeCompletion(t *testing.T) {
+	capture := &callbackCapture{
+		server:       &http.Server{},
+		serveDone:    make(chan error),
+		closing:      make(chan struct{}),
+		closeTimeout: 20 * time.Millisecond,
+	}
+	started := time.Now()
+	err := capture.Close()
+	if err == nil || !strings.Contains(err.Error(), "deadline exceeded") {
+		t.Fatalf("bounded close error=%v", err)
+	}
+	if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
+		t.Fatalf("bounded close took %s", elapsed)
+	}
+	if second := capture.Close(); second == nil || second.Error() != err.Error() {
+		t.Fatalf("idempotent failed close=%v, want %v", second, err)
+	}
+}
+
+func TestCallbackCaptureConcurrentDuplicates(t *testing.T) {
+	capture, err := startCallbackCapture()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer capture.Close()
+	attempt, err := capture.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	const requests = 8
+	client := &http.Client{Timeout: time.Second}
+	defer client.CloseIdleConnections()
+	start := make(chan struct{})
+	statuses := make(chan int, requests)
+	errors := make(chan error, requests)
+	var workers sync.WaitGroup
+	for range requests {
+		workers.Add(1)
+		go func() {
+			defer workers.Done()
+			<-start
+			response, err := client.Get(capture.URI() + "?code=generated-code&state=generated-state")
+			if err != nil {
+				errors <- err
+				return
+			}
+			_ = response.Body.Close()
+			statuses <- response.StatusCode
+		}()
+	}
+	close(start)
+	workers.Wait()
+	close(statuses)
+	close(errors)
+	for err := range errors {
+		t.Errorf("concurrent callback request: %v", err)
+	}
+	ok, conflicts := 0, 0
+	for status := range statuses {
+		switch status {
+		case http.StatusOK:
+			ok++
+		case http.StatusConflict:
+			conflicts++
+		default:
+			t.Errorf("concurrent callback status=%d", status)
+		}
+	}
+	if ok != 1 || conflicts != requests-1 {
+		t.Fatalf("concurrent callback statuses: ok=%d conflicts=%d", ok, conflicts)
+	}
+	if _, err := attempt.Wait(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+	if err := attempt.Finish(context.Background()); err == nil || !strings.Contains(err.Error(), "cardinality") {
+		t.Fatalf("concurrent duplicate cardinality error=%v", err)
+	}
+}
+
+func TestFinalMakeTargetsRemainOptIn(t *testing.T) {
+	repo, err := repositoryDir()
+	if err != nil {
+		t.Fatal(err)
+	}
+	data, err := os.ReadFile(filepath.Join(repo, "Makefile"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	makefile := string(data)
+	for _, required := range []string{
+		"test:\n\tgo test ./...\n\tgo vet ./...",
+		"test-e2e:\n\tgo test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+		"test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+		"clean-e2e-artifacts:\n\trm -rf -- e2e/artifacts",
+	} {
+		if !strings.Contains(makefile, required) {
+			t.Fatalf("Makefile omitted required opt-in target contract %q", required)
+		}
+	}
+	if strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
+		t.Fatal("Makefile made an ordinary or offline target depend on an online/destructive E2E target")
+	}
+}
diff --git a/core/auth/ui/e2e/process.go b/core/auth/ui/e2e/process.go
new file mode 100644
index 0000000..2de96de
--- /dev/null
+++ b/core/auth/ui/e2e/process.go
@@ -0,0 +1,222 @@
+package e2e
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+	"time"
+)
+
+type processSignal int
+
+const (
+	processTerminate processSignal = iota
+	processKill
+)
+
+type supervisedProcess struct {
+	name       string
+	cmd        *exec.Cmd
+	logPath    string
+	logFile    *os.File
+	pgid       int
+	completed  chan struct{}
+	resultMu   sync.RWMutex
+	result     error
+	stop       sync.Once
+	stopErr    error
+	redactions []string
+}
+
+func startProcess(name, logPath, dir, executable string, args ...string) (*supervisedProcess, error) {
+	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+	if err != nil {
+		return nil, err
+	}
+	cmd := exec.Command(executable, args...)
+	cmd.Dir = dir
+	cmd.Stdout = logFile
+	cmd.Stderr = logFile
+	if err := configureOwnedProcess(cmd); err != nil {
+		_ = logFile.Close()
+		return nil, fmt.Errorf("configure %s process ownership: %w", name, err)
+	}
+	if err := cmd.Start(); err != nil {
+		_ = logFile.Close()
+		return nil, fmt.Errorf("start %s: %w", name, err)
+	}
+	p := &supervisedProcess{name: name, cmd: cmd, logPath: logPath, logFile: logFile, pgid: cmd.Process.Pid, completed: make(chan struct{})}
+	go func() {
+		err := cmd.Wait()
+		_ = logFile.Close()
+		p.resultMu.Lock()
+		p.result = err
+		p.resultMu.Unlock()
+		close(p.completed)
+	}()
+	return p, nil
+}
+
+func (p *supervisedProcess) alive() bool {
+	if p == nil {
+		return false
+	}
+	select {
+	case <-p.completed:
+		return false
+	default:
+		return true
+	}
+}
+
+func (p *supervisedProcess) completion() error {
+	p.resultMu.RLock()
+	defer p.resultMu.RUnlock()
+	return p.result
+}
+
+func (p *supervisedProcess) stopAndWait(timeout time.Duration) error {
+	if p == nil {
+		return nil
+	}
+	p.stop.Do(func() {
+		// Signal the group even when the leader has already exited: descendants may
+		// still own the process group and are part of this harness's ownership.
+		if err := signalOwnedProcessGroup(p.pgid, processTerminate); err != nil && !errors.Is(err, os.ErrProcessDone) {
+			p.stopErr = fmt.Errorf("terminate %s process group: %w", p.name, err)
+		}
+		deadline := time.Now().Add(timeout)
+		for ownedProcessGroupAlive(p.pgid) && time.Now().Before(deadline) {
+			time.Sleep(10 * time.Millisecond)
+		}
+		if ownedProcessGroupAlive(p.pgid) {
+			if err := signalOwnedProcessGroup(p.pgid, processKill); err != nil && !errors.Is(err, os.ErrProcessDone) && p.stopErr == nil {
+				p.stopErr = fmt.Errorf("kill %s process group: %w", p.name, err)
+			}
+		}
+
+		postKill := time.NewTimer(2 * time.Second)
+		defer postKill.Stop()
+		select {
+		case <-p.completed:
+		case <-postKill.C:
+			if p.stopErr == nil {
+				p.stopErr = fmt.Errorf("wait for %s leader after process-group kill: timeout", p.name)
+			}
+		}
+		groupDeadline := time.Now().Add(2 * time.Second)
+		for ownedProcessGroupAlive(p.pgid) && time.Now().Before(groupDeadline) {
+			time.Sleep(10 * time.Millisecond)
+		}
+		if ownedProcessGroupAlive(p.pgid) && p.stopErr == nil {
+			p.stopErr = fmt.Errorf("%s process group %d survived cleanup", p.name, p.pgid)
+		}
+		// A non-zero leader exit belongs to startup/readiness diagnostics, not
+		// cleanup. Once the owned group is gone, explicit cleanup succeeded.
+	})
+	return p.stopErr
+}
+
+func (p *supervisedProcess) exited() (bool, error) {
+	select {
+	case <-p.completed:
+		return true, p.completion()
+	default:
+		return false, nil
+	}
+}
+
+func (p *supervisedProcess) logTail(limit int64) string {
+	data, err := os.ReadFile(p.logPath)
+	if err != nil {
+		return fmt.Sprintf("<read log: %v>", err)
+	}
+	if int64(len(data)) > limit {
+		data = data[len(data)-int(limit):]
+	}
+	text := boundedText(data, int(limit))
+	for _, secret := range p.redactions {
+		if secret != "" {
+			text = strings.ReplaceAll(text, secret, "[REDACTED]")
+		}
+	}
+	return text
+}
+
+func waitHTTPReady(ctx context.Context, p *supervisedProcess, endpoints ...string) error {
+	client := &http.Client{Timeout: 750 * time.Millisecond}
+	defer client.CloseIdleConnections()
+	for {
+		allReady := true
+		for _, endpoint := range endpoints {
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+			if err != nil {
+				return err
+			}
+			resp, err := client.Do(req)
+			if err != nil {
+				allReady = false
+				break
+			}
+			_, _ = io.CopyN(io.Discard, resp.Body, 4096)
+			_ = resp.Body.Close()
+			if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+				allReady = false
+				break
+			}
+		}
+		if allReady {
+			return nil
+		}
+		if exited, err := p.exited(); exited {
+			return fmt.Errorf("%s exited during readiness: %v\nlog tail:\n%s", p.name, err, p.logTail(8192))
+		}
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("%s readiness: %w\nlog tail:\n%s", p.name, ctx.Err(), p.logTail(8192))
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+}
+
+func waitTCPReady(ctx context.Context, p *supervisedProcess, addresses ...string) error {
+	pending := append([]string(nil), addresses...)
+	for len(pending) > 0 {
+		next := pending[:0]
+		for _, address := range pending {
+			conn, err := (&net.Dialer{Timeout: 500 * time.Millisecond}).DialContext(ctx, "tcp", address)
+			if err == nil {
+				_ = conn.Close()
+				continue
+			}
+			next = append(next, address)
+		}
+		pending = next
+		if len(pending) == 0 {
+			return nil
+		}
+		if exited, exitErr := p.exited(); exited {
+			return fmt.Errorf("%s exited during readiness: %v\nlog tail:\n%s", p.name, exitErr, p.logTail(8192))
+		}
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("%s readiness for %s: %w\nlog tail:\n%s", p.name, strings.Join(pending, ", "), ctx.Err(), p.logTail(8192))
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+	return nil
+}
+
+func boundedText(data []byte, limit int) string {
+	if len(data) > limit {
+		data = data[len(data)-limit:]
+	}
+	return strings.ToValidUTF8(string(data), "�")
+}
diff --git a/core/auth/ui/e2e/process_test.go b/core/auth/ui/e2e/process_test.go
new file mode 100644
index 0000000..49f7cb9
--- /dev/null
+++ b/core/auth/ui/e2e/process_test.go
@@ -0,0 +1,432 @@
+//go:build linux || darwin
+
+package e2e
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"os/exec"
+	"os/signal"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"syscall"
+	"testing"
+	"time"
+)
+
+func TestProcessHelper(t *testing.T) {
+	if os.Getenv("AUTH_UI_E2E_PROCESS_HELPER") != "1" {
+		return
+	}
+	os.Exit(runProcessHelper())
+}
+
+func runTaggedProcessHelper() int { return runProcessHelper() }
+
+// runProcessHelper is shared by the ordinary helper test and the e2e-tagged
+// TestMain fast path. Keeping dispatch here prevents tagged helper subprocesses
+// from entering stack or browser setup before the test runner reaches this test.
+func runProcessHelper() int {
+	switch os.Getenv("AUTH_UI_E2E_PROCESS_MODE") {
+	case "exit":
+		fmt.Fprintln(os.Stderr, "helper deterministic failure marker")
+		return 23
+	case "sleep":
+		fmt.Fprintln(os.Stdout, "helper ready and sleeping")
+		for {
+			time.Sleep(time.Second)
+		}
+	case "ignore-term":
+		signalIgnoreTerminate()
+		fmt.Fprintln(os.Stdout, "helper ignoring termination")
+		for {
+			time.Sleep(time.Second)
+		}
+	case "descendant-leader-exit":
+		cmd := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
+		cmd.Env = append(os.Environ(), "AUTH_UI_E2E_PROCESS_MODE=ignore-term")
+		cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+		if err := cmd.Start(); err != nil {
+			fmt.Fprintf(os.Stderr, "start descendant: %v\n", err)
+			return 25
+		}
+		fmt.Fprintf(os.Stdout, "DESCENDANT_PID=%d\n", cmd.Process.Pid)
+		return 0
+	default:
+		return 24
+	}
+}
+
+func signalIgnoreTerminate() { signal.Ignore(syscall.SIGTERM) }
+
+func helperProcessAt(t *testing.T, logPath, mode string) *supervisedProcess {
+	t.Helper()
+	oldHelper, hadHelper := os.LookupEnv("AUTH_UI_E2E_PROCESS_HELPER")
+	oldMode, hadMode := os.LookupEnv("AUTH_UI_E2E_PROCESS_MODE")
+	_ = os.Setenv("AUTH_UI_E2E_PROCESS_HELPER", "1")
+	_ = os.Setenv("AUTH_UI_E2E_PROCESS_MODE", mode)
+	p, err := startProcess("helper", logPath, "", os.Args[0], "-test.run=^TestProcessHelper$")
+	if hadHelper {
+		_ = os.Setenv("AUTH_UI_E2E_PROCESS_HELPER", oldHelper)
+	} else {
+		_ = os.Unsetenv("AUTH_UI_E2E_PROCESS_HELPER")
+	}
+	if hadMode {
+		_ = os.Setenv("AUTH_UI_E2E_PROCESS_MODE", oldMode)
+	} else {
+		_ = os.Unsetenv("AUTH_UI_E2E_PROCESS_MODE")
+	}
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() {
+		if ownedProcessGroupAlive(p.pgid) {
+			if err := p.stopAndWait(time.Second); err != nil {
+				t.Errorf("cleanup helper process: %v", err)
+			}
+		}
+	})
+	return p
+}
+
+func helperProcess(t *testing.T, mode string) *supervisedProcess {
+	t.Helper()
+	return helperProcessAt(t, filepath.Join(t.TempDir(), "helper.log"), mode)
+}
+
+func TestProcessHelperDispatchBypassesTaggedSuiteSetup(t *testing.T) {
+	artifactRoot := filepath.Join(t.TempDir(), "artifacts")
+	cacheRoot := filepath.Join(t.TempDir(), "cache")
+	t.Setenv("AUTH_UI_E2E_ARTIFACT_DIR", artifactRoot)
+	t.Setenv("AUTH_UI_E2E_CACHE_DIR", cacheRoot)
+	p := helperProcess(t, "exit")
+
+	select {
+	case <-p.completed:
+	case <-time.After(3 * time.Second):
+		_ = p.stopAndWait(100 * time.Millisecond)
+		t.Fatal("helper invocation did not exit deterministically")
+	}
+	exit, ok := p.completion().(*exec.ExitError)
+	if !ok || exit.ExitCode() != 23 {
+		t.Fatalf("helper exit=%v, want status 23", p.completion())
+	}
+	if tail := p.logTail(8192); !strings.Contains(tail, "helper deterministic failure marker") || strings.Contains(tail, "E2E setup failed") {
+		t.Fatalf("unexpected helper output: %q", tail)
+	}
+	for name, path := range map[string]string{"artifact": artifactRoot, "cache": cacheRoot} {
+		if _, err := os.Stat(path); !os.IsNotExist(err) {
+			t.Fatalf("helper created %s path %s: %v", name, path, err)
+		}
+	}
+}
+
+func TestSupervisedProcessEarlyExitAndTail(t *testing.T) {
+	p := helperProcess(t, "exit")
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+	err := waitTCPReady(ctx, p, "127.0.0.1:1")
+	if err == nil || !strings.Contains(err.Error(), "exited during readiness") || !strings.Contains(err.Error(), "deterministic failure marker") {
+		t.Fatalf("error=%v", err)
+	}
+	for i := 0; i < 10; i++ {
+		if exited, got := p.exited(); !exited || got == nil {
+			t.Fatalf("completion read %d: exited=%v error=%v", i, exited, got)
+		}
+	}
+}
+
+func TestReadinessTimeoutIncludesBoundedTail(t *testing.T) {
+	p := helperProcess(t, "sleep")
+	defer p.stopAndWait(time.Second)
+	ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
+	defer cancel()
+	err := waitTCPReady(ctx, p, "127.0.0.1:1")
+	if err == nil || !strings.Contains(err.Error(), "deadline exceeded") || !strings.Contains(err.Error(), "helper ready") {
+		t.Fatalf("error=%v", err)
+	}
+}
+
+func TestHTTPReadiness(t *testing.T) {
+	p := helperProcess(t, "sleep")
+	defer p.stopAndWait(time.Second)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
+	defer server.Close()
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	if err := waitHTTPReady(ctx, p, server.URL); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestEmergencyTerminationKillsKnownGroupAndDescendants(t *testing.T) {
+	p := helperProcessAt(t, filepath.Join(t.TempDir(), "emergency.log"), "ignore-term")
+	if err := emergencyTerminateOwnedProcesses(p.cmd.Process.Pid, []int{p.pgid}); err != nil {
+		t.Fatal(err)
+	}
+	select {
+	case <-p.completed:
+	case <-time.After(2 * time.Second):
+		t.Fatal("emergency termination did not reap helper leader")
+	}
+	if ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("emergency termination left known process group alive")
+	}
+}
+
+func TestSupervisedProcessStopIsIdempotent(t *testing.T) {
+	p := helperProcess(t, "sleep")
+	if err := p.stopAndWait(time.Second); err != nil {
+		t.Fatal(err)
+	}
+	if err := p.stopAndWait(time.Second); err != nil {
+		t.Fatal(err)
+	}
+	if p.alive() || ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("stopped process or group is alive")
+	}
+}
+
+func TestSupervisedProcessForceKillsIgnoringLeader(t *testing.T) {
+	p := helperProcess(t, "ignore-term")
+	waitForLog(t, p.logPath, "helper ignoring termination")
+	started := time.Now()
+	if err := p.stopAndWait(100 * time.Millisecond); err != nil {
+		t.Fatal(err)
+	}
+	if elapsed := time.Since(started); elapsed > 3*time.Second {
+		t.Fatalf("force cleanup exceeded bound: %s", elapsed)
+	}
+	if ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("force-killed process group is alive")
+	}
+}
+
+func TestSupervisedProcessCleansDescendantAfterLeaderExit(t *testing.T) {
+	p := helperProcess(t, "descendant-leader-exit")
+	pidText := waitForLogValue(t, p.logPath, "DESCENDANT_PID=")
+	pid, err := strconv.Atoi(pidText)
+	if err != nil {
+		t.Fatal(err)
+	}
+	select {
+	case <-p.completed:
+	case <-time.After(3 * time.Second):
+		t.Fatal("leader did not exit")
+	}
+	if !ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("descendant process group unexpectedly absent before cleanup")
+	}
+	if err := p.stopAndWait(100 * time.Millisecond); err != nil {
+		t.Fatal(err)
+	}
+	waitForPIDGone(t, pid)
+}
+
+func TestEmergencyGroupSnapshotDoesNotBlockOnAtomicStart(t *testing.T) {
+	s := &Stack{}
+	s.ownershipMu.Lock()
+	done := make(chan []int, 1)
+	go func() { done <- s.emergencyProcessGroups() }()
+	select {
+	case groups := <-done:
+		if len(groups) != 0 {
+			t.Fatalf("busy emergency group snapshot=%v", groups)
+		}
+	case <-time.After(100 * time.Millisecond):
+		s.ownershipMu.Unlock()
+		t.Fatal("emergency group snapshot blocked on ownership lock")
+	}
+	s.ownershipMu.Unlock()
+}
+
+func TestStackCleanupWinsBeforeAtomicProcessStart(t *testing.T) {
+	var starts atomic.Int32
+	s := &Stack{startProcessOp: func(string, string, string, string, ...string) (*supervisedProcess, error) {
+		starts.Add(1)
+		return nil, fmt.Errorf("unexpected start")
+	}}
+	s.ownershipMu.Lock()
+	result := make(chan error, 1)
+	go func() {
+		_, err := s.startOwnedProcess(&s.Kratos, "kratos", "log", "dir", "binary")
+		result <- err
+	}()
+	// The proposed start is blocked immediately before its cleanupStarted check.
+	s.cleanupStarted = true
+	s.ownershipMu.Unlock()
+	if err := <-result; err == nil || !strings.Contains(err.Error(), "cleanup started before launching kratos") {
+		t.Fatalf("atomic start error=%v", err)
+	}
+	if starts.Load() != 0 || s.Kratos != nil {
+		t.Fatalf("cleanup-winning start calls=%d process=%v", starts.Load(), s.Kratos)
+	}
+}
+
+func TestAtomicProcessStartPublishesBeforeCleanupSnapshot(t *testing.T) {
+	entered, release := make(chan struct{}), make(chan struct{})
+	process := &supervisedProcess{name: "kratos", pgid: 99999999, completed: make(chan struct{})}
+	close(process.completed)
+	var stopped atomic.Int32
+	s := &Stack{
+		startProcessOp: func(string, string, string, string, ...string) (*supervisedProcess, error) {
+			close(entered)
+			<-release
+			return process, nil
+		},
+		stopProcessOp: func(got *supervisedProcess, _ time.Duration) error {
+			if got != process {
+				t.Errorf("cleanup observed process %p, want %p", got, process)
+			}
+			stopped.Add(1)
+			return nil
+		},
+	}
+	startDone := make(chan error, 1)
+	go func() {
+		_, err := s.startOwnedProcess(&s.Kratos, "kratos", "log", "dir", "binary")
+		startDone <- err
+	}()
+	<-entered
+	cleanupDone := make(chan error, 1)
+	go func() { cleanupDone <- s.StopServices() }()
+	select {
+	case <-cleanupDone:
+		t.Fatal("cleanup bypassed an in-flight atomic start")
+	case <-time.After(20 * time.Millisecond):
+	}
+	close(release)
+	if err := <-startDone; err != nil {
+		t.Fatal(err)
+	}
+	if err := <-cleanupDone; err != nil {
+		t.Fatal(err)
+	}
+	if stopped.Load() != 1 {
+		t.Fatalf("published process cleanup calls=%d", stopped.Load())
+	}
+}
+
+func TestStartupRetryAbortsAndCombinesCleanupFailure(t *testing.T) {
+	startupErr := fmt.Errorf("listen: address already in use")
+	cleanupErr := fmt.Errorf("owned group survived")
+	retry, err := startupRetryDecision(1, startupErr, cleanupErr)
+	if retry || err == nil || !strings.Contains(err.Error(), startupErr.Error()) || !strings.Contains(err.Error(), cleanupErr.Error()) {
+		t.Fatalf("retry=%v error=%v", retry, err)
+	}
+	if retry, err := startupRetryDecision(1, startupErr, nil); !retry || err != nil {
+		t.Fatalf("clean bind-conflict retry=%v error=%v", retry, err)
+	}
+}
+
+func TestStackCleanupAcceptsExitedLeaderOnceGroupIsGone(t *testing.T) {
+	p := helperProcess(t, "exit")
+	select {
+	case <-p.completed:
+	case <-time.After(3 * time.Second):
+		t.Fatal("helper did not exit")
+	}
+	s := &Stack{Kratos: p}
+	if err := s.stopProcessSet(); err != nil {
+		t.Fatalf("exited process cleanup=%v", err)
+	}
+	if s.Kratos != nil {
+		t.Fatal("confirmed-dead exited process retained ownership")
+	}
+}
+
+func TestStackRetainsProcessOwnershipUntilGroupGone(t *testing.T) {
+	p := helperProcess(t, "sleep")
+	s := &Stack{Kratos: p, stopProcessOp: func(*supervisedProcess, time.Duration) error { return nil }}
+	if err := s.stopProcessSet(); err == nil || !strings.Contains(err.Error(), "survived cleanup") {
+		t.Fatalf("cleanup error=%v", err)
+	}
+	if s.Kratos != p || !ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("failed cleanup discarded ownership of a live process group")
+	}
+
+	s.stopProcessOp = nil
+	if err := s.stopProcessSet(); err != nil {
+		t.Fatal(err)
+	}
+	if s.Kratos != nil || ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("confirmed-dead process group retained ownership")
+	}
+}
+
+func TestStackCloseReapsPartialStart(t *testing.T) {
+	workspace := t.TempDir()
+	artifactDir := filepath.Join(t.TempDir(), "artifacts")
+	if err := os.MkdirAll(filepath.Join(workspace, "logs"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(artifactDir, 0o700); err != nil {
+		t.Fatal(err)
+	}
+	p := helperProcessAt(t, filepath.Join(workspace, "logs", "kratos.log"), "sleep")
+	s := &Stack{Workspace: workspace, ArtifactDir: artifactDir, Kratos: p, startedAt: time.Now().UTC()}
+	if err := s.Close(false); err != nil {
+		t.Fatal(err)
+	}
+	if p.alive() || ownedProcessGroupAlive(p.pgid) {
+		t.Fatal("partial-start process survived Stack.Close")
+	}
+	if _, err := os.Stat(filepath.Join(artifactDir, "services", "kratos.log")); err != nil {
+		t.Fatalf("retained partial-start log: %v", err)
+	}
+}
+
+func TestProcessLogTailRedactsKnownSecrets(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "service.log")
+	if err := os.WriteFile(path, []byte("prefix generated-secret suffix"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	p := &supervisedProcess{logPath: path, redactions: []string{"generated-secret"}}
+	got := p.logTail(1024)
+	if strings.Contains(got, "generated-secret") || !strings.Contains(got, "[REDACTED]") {
+		t.Fatalf("redacted tail=%q", got)
+	}
+}
+
+func TestBoundedText(t *testing.T) {
+	if got := boundedText([]byte("0123456789"), 4); got != "6789" {
+		t.Fatalf("got %q", got)
+	}
+}
+
+func waitForLog(t *testing.T, path, marker string) { _ = waitForLogValue(t, path, marker) }
+
+func waitForLogValue(t *testing.T, path, marker string) string {
+	t.Helper()
+	deadline := time.Now().Add(3 * time.Second)
+	for time.Now().Before(deadline) {
+		data, _ := os.ReadFile(path)
+		if index := strings.Index(string(data), marker); index >= 0 {
+			value := strings.TrimSpace(string(data[index+len(marker):]))
+			if newline := strings.IndexByte(value, '\n'); newline >= 0 {
+				value = value[:newline]
+			}
+			return value
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Fatalf("marker %q not found in %s", marker, path)
+	return ""
+}
+
+func waitForPIDGone(t *testing.T, pid int) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		if err := syscall.Kill(pid, 0); err == syscall.ESRCH {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Fatalf("PID %d survived cleanup", pid)
+}
diff --git a/core/auth/ui/e2e/process_unix.go b/core/auth/ui/e2e/process_unix.go
new file mode 100644
index 0000000..23b627e
--- /dev/null
+++ b/core/auth/ui/e2e/process_unix.go
@@ -0,0 +1,104 @@
+//go:build linux || darwin
+
+package e2e
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"syscall"
+	"time"
+)
+
+func configureOwnedProcess(cmd *exec.Cmd) error {
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+	return nil
+}
+
+func signalOwnedProcessGroup(pgid int, signal processSignal) error {
+	sig := syscall.SIGTERM
+	if signal == processKill {
+		sig = syscall.SIGKILL
+	}
+	err := syscall.Kill(-pgid, sig)
+	if errors.Is(err, syscall.ESRCH) {
+		return os.ErrProcessDone
+	}
+	return err
+}
+
+func ownedProcessGroupAlive(pgid int) bool {
+	err := syscall.Kill(-pgid, 0)
+	return err == nil || errors.Is(err, syscall.EPERM)
+}
+
+var enumerateDescendantProcessIDs = descendantProcessIDs
+
+func descendantProcessIDs(rootPID int) ([]int, error) {
+	output, err := exec.Command("ps", "-eo", "pid=,ppid=").Output()
+	if err != nil {
+		return nil, fmt.Errorf("list descendants: %w", err)
+	}
+	type relation struct{ pid, ppid int }
+	var relations []relation
+	for _, line := range strings.Split(string(output), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) != 2 {
+			continue
+		}
+		pid, pidErr := strconv.Atoi(fields[0])
+		ppid, ppidErr := strconv.Atoi(fields[1])
+		if pidErr == nil && ppidErr == nil {
+			relations = append(relations, relation{pid, ppid})
+		}
+	}
+	owned := map[int]bool{rootPID: true}
+	for changed := true; changed; {
+		changed = false
+		for _, relation := range relations {
+			if owned[relation.ppid] && !owned[relation.pid] {
+				owned[relation.pid] = true
+				changed = true
+			}
+		}
+	}
+	var result []int
+	for pid := range owned {
+		if pid != rootPID {
+			result = append(result, pid)
+		}
+	}
+	return result, nil
+}
+
+// emergencyTerminateOwnedProcesses avoids Playwright/service protocols. It
+// kills every recorded service group and repeatedly snapshots direct and
+// indirect descendants of the harness before returning.
+func emergencyTerminateOwnedProcesses(rootPID int, processGroups []int) error {
+	var errs []string
+	for _, pgid := range processGroups {
+		if err := signalOwnedProcessGroup(pgid, processKill); err != nil && !errors.Is(err, os.ErrProcessDone) {
+			errs = append(errs, err.Error())
+		}
+	}
+	for attempt := 0; attempt < 3; attempt++ {
+		pids, err := enumerateDescendantProcessIDs(rootPID)
+		if err != nil {
+			errs = append(errs, err.Error())
+			break
+		}
+		for i := len(pids) - 1; i >= 0; i-- {
+			if err := syscall.Kill(pids[i], syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
+				errs = append(errs, fmt.Sprintf("kill descendant %d: %v", pids[i], err))
+			}
+		}
+		time.Sleep(25 * time.Millisecond)
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("emergency process cleanup: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
diff --git a/core/auth/ui/e2e/process_unsupported.go b/core/auth/ui/e2e/process_unsupported.go
new file mode 100644
index 0000000..cbdbada
--- /dev/null
+++ b/core/auth/ui/e2e/process_unsupported.go
@@ -0,0 +1,24 @@
+//go:build !linux && !darwin
+
+package e2e
+
+import (
+	"fmt"
+	"os/exec"
+)
+
+func configureOwnedProcess(_ *exec.Cmd) error {
+	return fmt.Errorf("unsupported E2E process platform")
+}
+
+func signalOwnedProcessGroup(_ int, _ processSignal) error { return nil }
+func ownedProcessGroupAlive(_ int) bool                    { return false }
+
+var enumerateDescendantProcessIDs = descendantProcessIDs
+
+func descendantProcessIDs(_ int) ([]int, error) {
+	return nil, fmt.Errorf("unsupported E2E process platform")
+}
+func emergencyTerminateOwnedProcesses(_ int, _ []int) error {
+	return fmt.Errorf("unsupported E2E process platform")
+}
diff --git a/core/auth/ui/e2e/process_unsupported_test.go b/core/auth/ui/e2e/process_unsupported_test.go
new file mode 100644
index 0000000..14bc5cc
--- /dev/null
+++ b/core/auth/ui/e2e/process_unsupported_test.go
@@ -0,0 +1,23 @@
+//go:build !linux && !darwin
+
+package e2e
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+)
+
+func runTaggedProcessHelper() int { return 24 }
+
+func TestUnsupportedPlatformRejectsBeforeProcessStart(t *testing.T) {
+	if _, err := artifactsFor(runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "unsupported E2E platform") {
+		t.Fatalf("platform rejection error=%v", err)
+	}
+	_, err := startProcess("unsupported", filepath.Join(t.TempDir(), "process.log"), "", os.Args[0])
+	if err == nil || !strings.Contains(err.Error(), "unsupported E2E process platform") {
+		t.Fatalf("process rejection error=%v", err)
+	}
+}
diff --git a/core/auth/ui/e2e/stack.go b/core/auth/ui/e2e/stack.go
new file mode 100644
index 0000000..9bf17fa
--- /dev/null
+++ b/core/auth/ui/e2e/stack.go
@@ -0,0 +1,517 @@
+package e2e
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+type stackPorts struct {
+	KratosPublic int `json:"kratos_public"`
+	KratosAdmin  int `json:"kratos_admin"`
+	HydraPublic  int `json:"hydra_public"`
+	HydraAdmin   int `json:"hydra_admin"`
+	UI           int `json:"ui"`
+	API          int `json:"api"`
+	SMTP         int `json:"smtp"`
+}
+
+type Stack struct {
+	Repo           string
+	Workspace      string
+	ArtifactDir    string
+	Ports          stackPorts
+	KratosURL      string
+	KratosAdmin    string
+	HydraURL       string
+	HydraAdmin     string
+	UIURL          string
+	APIURL         string
+	Kratos         *supervisedProcess
+	Hydra          *supervisedProcess
+	AuthUI         *supervisedProcess
+	startedAt      time.Time
+	ready          bool
+	lastStatus     string
+	metadataMu     sync.Mutex
+	redactions     []string
+	ownershipMu    sync.Mutex
+	cleanupStarted bool
+	stopOnce       sync.Once
+	stopErr        error
+	startProcessOp func(string, string, string, string, ...string) (*supervisedProcess, error)
+	stopProcessOp  func(*supervisedProcess, time.Duration) error
+}
+
+func startStack() (*Stack, error) {
+	return startStackOwned(nil)
+}
+
+func startStackOwned(owner *suiteLifecycle) (*Stack, error) {
+	if _, err := artifactsFor(runtime.GOOS, runtime.GOARCH); err != nil {
+		return nil, err
+	}
+	repo, err := repositoryDir()
+	if err != nil {
+		return nil, err
+	}
+	workspace, err := os.MkdirTemp("", "auth-ui-e2e-*")
+	if err != nil {
+		return nil, err
+	}
+	runDir, err := createRunArtifactDir(repo)
+	if err != nil {
+		_ = os.RemoveAll(workspace)
+		return nil, err
+	}
+	s := &Stack{Repo: repo, Workspace: workspace, ArtifactDir: runDir, startedAt: time.Now().UTC()}
+	if owner != nil && !owner.setStack(s) {
+		_ = s.Close(false)
+		return s, fmt.Errorf("E2E watchdog fired before stack ownership registration")
+	}
+	if err := s.prepare(); err != nil {
+		s.recordRun("setup_failed", err)
+		return s, fmt.Errorf("prepare E2E stack: %w", err)
+	}
+	for attempt := 1; attempt <= 3; attempt++ {
+		err = s.startAttempt()
+		if err == nil {
+			s.metadataMu.Lock()
+			s.ready = true
+			s.metadataMu.Unlock()
+			s.recordRun("ready", nil)
+			return s, nil
+		}
+		cleanupErr := s.stopProcessSet()
+		retry, attemptErr := startupRetryDecision(attempt, err, cleanupErr)
+		if !retry {
+			s.recordRun("setup_failed", attemptErr)
+			return s, attemptErr
+		}
+	}
+	return s, err
+}
+
+func (s *Stack) prepare() error {
+	for _, dir := range []string{"bin", "config", "logs"} {
+		if err := os.MkdirAll(filepath.Join(s.Workspace, dir), 0o700); err != nil {
+			return err
+		}
+	}
+	platform, _ := artifactsFor(runtime.GOOS, runtime.GOARCH)
+	offline := os.Getenv("AUTH_UI_E2E_OFFLINE") == "1"
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+	defer cancel()
+	for _, artifact := range []releaseArtifact{platform.Kratos, platform.Hydra} {
+		archivePath, err := ensureArchive(ctx, downloadClient(), cacheRoot(s.Repo), artifact, offline)
+		if err != nil {
+			return err
+		}
+		binary, err := extractBinary(archivePath, filepath.Join(s.Workspace, "bin"), artifact.Service)
+		if err != nil {
+			return err
+		}
+		if err := verifyBinaryVersion(binary, artifact.Version); err != nil {
+			return err
+		}
+	}
+	ctx, cancel = context.WithTimeout(context.Background(), 2*time.Minute)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "go", "build", "-o", filepath.Join(s.Workspace, "bin", "auth-ui"), ".")
+	cmd.Dir = s.Repo
+	output, err := cmd.CombinedOutput()
+	if ctx.Err() != nil {
+		return fmt.Errorf("build auth-ui: %w", ctx.Err())
+	}
+	if err != nil {
+		return fmt.Errorf("build auth-ui: %w: %s", err, boundedText(output, 8192))
+	}
+	return nil
+}
+
+func (s *Stack) startAttempt() error {
+	reservations, ports, err := reservePorts(7)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		for _, reservation := range reservations {
+			_ = reservation.Close()
+		}
+	}()
+	s.metadataMu.Lock()
+	s.Ports = stackPorts{ports[0], ports[1], ports[2], ports[3], ports[4], ports[5], ports[6]}
+	s.KratosURL = loopbackURL(s.Ports.KratosPublic)
+	s.KratosAdmin = loopbackURL(s.Ports.KratosAdmin)
+	s.HydraURL = loopbackURL(s.Ports.HydraPublic)
+	s.HydraAdmin = loopbackURL(s.Ports.HydraAdmin)
+	s.UIURL = loopbackURL(s.Ports.UI)
+	s.APIURL = loopbackURL(s.Ports.API)
+	s.metadataMu.Unlock()
+	cookie, err := randomSecret(16)
+	if err != nil {
+		return err
+	}
+	cipher, err := randomSecret(16)
+	if err != nil {
+		return err
+	}
+	hydra, err := randomSecret(32)
+	if err != nil {
+		return err
+	}
+	s.metadataMu.Lock()
+	s.redactions = []string{cookie, cipher, hydra}
+	s.metadataMu.Unlock()
+	values := fixtureValues{
+		KratosPublicURL: s.KratosURL, KratosAdminURL: s.KratosAdmin,
+		KratosPublicPort: s.Ports.KratosPublic, KratosAdminPort: s.Ports.KratosAdmin,
+		HydraPublicURL: s.HydraURL, HydraAdminURL: s.HydraAdmin,
+		HydraPublicPort: s.Ports.HydraPublic, HydraAdminPort: s.Ports.HydraAdmin,
+		UIURL: s.UIURL, SMTPPort: s.Ports.SMTP,
+		CookieSecret: cookie, CipherSecret: cipher, HydraSecret: hydra,
+	}
+	if err := renderFixtures(s.Repo, filepath.Join(s.Workspace, "config"), values); err != nil {
+		return err
+	}
+	_ = reservations[6].Close() // No courier is started; only a valid unused SMTP address is needed.
+
+	_ = reservations[0].Close()
+	_ = reservations[1].Close()
+	process, err := s.startOwnedProcess(&s.Kratos, "kratos", s.logPath("kratos"), s.Workspace, s.binary("kratos"), "serve", "-c", s.config("kratos.yml"), "--dev", "--sqa-opt-out")
+	if err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	err = waitHTTPReady(ctx, process, s.KratosURL+"/health/ready", s.KratosAdmin+"/health/ready")
+	cancel()
+	if err != nil {
+		return err
+	}
+
+	_ = reservations[2].Close()
+	_ = reservations[3].Close()
+	process, err = s.startOwnedProcess(&s.Hydra, "hydra", s.logPath("hydra"), s.Workspace, s.binary("hydra"), "serve", "-c", s.config("hydra.yml"), "all", "--dev", "--sqa-opt-out")
+	if err != nil {
+		return err
+	}
+	ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
+	err = waitHTTPReady(ctx, process, s.HydraURL+"/health/ready", s.HydraAdmin+"/health/ready")
+	cancel()
+	if err != nil {
+		return err
+	}
+
+	args := []string{"-port", strconv.Itoa(s.Ports.UI), "-api-port", strconv.Itoa(s.Ports.API), "-kratos", s.KratosURL, "-kratos-api", s.KratosAdmin, "-hydra", "127.0.0.1:" + strconv.Itoa(s.Ports.HydraAdmin), "-enable-registration=true", "-email-domain=example.test"}
+	_ = reservations[4].Close()
+	_ = reservations[5].Close()
+	process, err = s.startOwnedProcess(&s.AuthUI, "auth-ui", s.logPath("auth-ui"), s.Repo, s.binary("auth-ui"), args...)
+	if err != nil {
+		return err
+	}
+	ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
+	err = waitTCPReady(ctx, process,
+		"127.0.0.1:"+strconv.Itoa(s.Ports.UI),
+		"127.0.0.1:"+strconv.Itoa(s.Ports.API),
+	)
+	cancel()
+	return err
+}
+
+func reservePorts(count int) ([]net.Listener, []int, error) {
+	listeners := make([]net.Listener, 0, count)
+	ports := make([]int, 0, count)
+	for len(ports) < count {
+		listener, err := net.Listen("tcp", "127.0.0.1:0")
+		if err != nil {
+			for _, reserved := range listeners {
+				_ = reserved.Close()
+			}
+			return nil, nil, err
+		}
+		listeners = append(listeners, listener)
+		ports = append(ports, listener.Addr().(*net.TCPAddr).Port)
+	}
+	return listeners, ports, nil
+}
+
+func loopbackURL(port int) string           { return "http://127.0.0.1:" + strconv.Itoa(port) }
+func (s *Stack) binary(name string) string  { return filepath.Join(s.Workspace, "bin", name) }
+func (s *Stack) config(name string) string  { return filepath.Join(s.Workspace, "config", name) }
+func (s *Stack) logPath(name string) string { return filepath.Join(s.Workspace, "logs", name+".log") }
+
+func isBindConflict(err error) bool {
+	text := strings.ToLower(err.Error())
+	return strings.Contains(text, "address already in use") || strings.Contains(text, "address in use")
+}
+
+func startupRetryDecision(attempt int, startupErr, cleanupErr error) (bool, error) {
+	if cleanupErr != nil {
+		return false, fmt.Errorf("startup attempt %d failed: %v; retry cleanup failed: %w", attempt, startupErr, cleanupErr)
+	}
+	if !isBindConflict(startupErr) || attempt >= 3 {
+		return false, startupErr
+	}
+	return true, nil
+}
+
+func (s *Stack) startOwnedProcess(slot **supervisedProcess, name, logPath, dir, executable string, args ...string) (*supervisedProcess, error) {
+	// Cleanup and process creation share this lock from the terminal-state check
+	// through cmd.Start and slot publication. StopServices can therefore either
+	// prevent the spawn or observe the complete ownership; it cannot snapshot a
+	// nil slot while a process is being created.
+	s.ownershipMu.Lock()
+	defer s.ownershipMu.Unlock()
+	if s.cleanupStarted {
+		return nil, fmt.Errorf("E2E cleanup started before launching %s", name)
+	}
+	start := s.startProcessOp
+	if start == nil {
+		start = startProcess
+	}
+	process, err := start(name, logPath, dir, executable, args...)
+	if err != nil {
+		return process, err
+	}
+	if process == nil {
+		return nil, fmt.Errorf("started %s process ownership is unavailable", name)
+	}
+	process.redactions = append([]string(nil), s.redactions...)
+	*slot = process
+	return process, nil
+}
+
+func (s *Stack) ownedProcessGroups() []int {
+	if s == nil {
+		return nil
+	}
+	s.ownershipMu.Lock()
+	defer s.ownershipMu.Unlock()
+	return s.ownedProcessGroupsLocked()
+}
+
+func (s *Stack) emergencyProcessGroups() []int {
+	if s == nil || !s.ownershipMu.TryLock() {
+		// Descendant discovery remains available when an OS start is holding the
+		// ownership lock, so emergency exit must never block on this mutex.
+		return nil
+	}
+	defer s.ownershipMu.Unlock()
+	return s.ownedProcessGroupsLocked()
+}
+
+func (s *Stack) ownedProcessGroupsLocked() []int {
+	var groups []int
+	for _, process := range []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos} {
+		if process != nil && process.pgid > 0 {
+			groups = append(groups, process.pgid)
+		}
+	}
+	return groups
+}
+
+func (s *Stack) stopOwnedProcess(process *supervisedProcess, timeout time.Duration) error {
+	if s.stopProcessOp != nil {
+		return s.stopProcessOp(process, timeout)
+	}
+	return process.stopAndWait(timeout)
+}
+
+func (s *Stack) stopProcessSet() error {
+	s.ownershipMu.Lock()
+	processes := []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos}
+	s.ownershipMu.Unlock()
+	var errs []string
+	for _, process := range processes {
+		if process == nil {
+			continue
+		}
+		stopErr := s.stopOwnedProcess(process, 5*time.Second)
+		if stopErr != nil {
+			errs = append(errs, stopErr.Error())
+		}
+		// Do not discard ownership merely because cleanup returned. Retain the
+		// process until its complete owned group is confirmed absent.
+		if ownedProcessGroupAlive(process.pgid) {
+			if stopErr == nil {
+				errs = append(errs, fmt.Sprintf("%s process group %d survived cleanup", process.name, process.pgid))
+			}
+		} else {
+			s.ownershipMu.Lock()
+			if s.AuthUI == process {
+				s.AuthUI = nil
+			}
+			if s.Hydra == process {
+				s.Hydra = nil
+			}
+			if s.Kratos == process {
+				s.Kratos = nil
+			}
+			s.ownershipMu.Unlock()
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("stop service processes: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+func (s *Stack) StopServices() error {
+	if s == nil {
+		return nil
+	}
+	s.ownershipMu.Lock()
+	s.cleanupStarted = true
+	s.ownershipMu.Unlock()
+	s.stopOnce.Do(func() { s.stopErr = s.stopProcessSet() })
+	return s.stopErr
+}
+
+func (s *Stack) Close(success bool) error {
+	if s == nil {
+		return nil
+	}
+	stopErr := s.StopServices()
+	finalizeErr := s.Finalize(success)
+	if stopErr != nil && finalizeErr != nil {
+		return fmt.Errorf("%v; %w", stopErr, finalizeErr)
+	}
+	if stopErr != nil {
+		return stopErr
+	}
+	return finalizeErr
+}
+
+func (s *Stack) Finalize(success bool) error {
+	s.metadataMu.Lock()
+	ready, lastStatus := s.ready, s.lastStatus
+	redactions := append([]string(nil), s.redactions...)
+	s.metadataMu.Unlock()
+	if ready {
+		if success {
+			s.recordRun("passed", nil)
+		} else if lastStatus == "ready" {
+			s.recordRun("failed", nil)
+		}
+	}
+	var errs []string
+	services := filepath.Join(s.ArtifactDir, "services")
+	if err := os.MkdirAll(services, 0o700); err != nil {
+		errs = append(errs, err.Error())
+	}
+	for _, name := range []string{"auth-ui", "hydra", "kratos"} {
+		if err := copyFileBounded(s.logPath(name), filepath.Join(services, name+".log"), 1<<20, redactions); err != nil && !errors.Is(err, os.ErrNotExist) {
+			errs = append(errs, err.Error())
+		}
+	}
+	keep := !success || os.Getenv("AUTH_UI_E2E_KEEP_TMP") == "1"
+	if keep {
+		fmt.Fprintf(os.Stderr, "E2E workspace retained at %s\nE2E artifacts retained at %s\n", s.Workspace, s.ArtifactDir)
+	} else if err := os.RemoveAll(s.Workspace); err != nil {
+		errs = append(errs, err.Error())
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("stack cleanup: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+func createRunArtifactDir(repo string) (string, error) {
+	root := os.Getenv("AUTH_UI_E2E_ARTIFACT_DIR")
+	if root == "" {
+		root = filepath.Join(repo, "e2e", "artifacts")
+	}
+	if err := os.MkdirAll(root, 0o700); err != nil {
+		return "", err
+	}
+	random, err := randomSecret(4)
+	if err != nil {
+		return "", err
+	}
+	name := fmt.Sprintf("%s-%d-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid(), random)
+	path := filepath.Join(root, name)
+	return path, os.Mkdir(path, 0o700)
+}
+
+func (s *Stack) recordRun(status string, setupErr error) {
+	s.metadataMu.Lock()
+	defer s.metadataMu.Unlock()
+	if s.lastStatus == "watchdog_timeout" && status != "watchdog_timeout" {
+		return
+	}
+	s.lastStatus = status
+	metadata := map[string]any{"started_at": s.startedAt, "updated_at": time.Now().UTC(), "status": status, "workspace": s.Workspace, "platform": runtime.GOOS + "/" + runtime.GOARCH, "kratos_version": kratosVersion, "hydra_version": hydraVersion, "playwright_binding_version": playwrightVersion, "playwright_cli_version": playwrightCLIVersion, "chromium_revision": chromiumRevision, "chromium_version": chromiumVersion, "ffmpeg_revision": ffmpegRevision, "ports": s.Ports}
+	if setupErr != nil {
+		metadata["error"] = redactDiagnostic(setupErr.Error())
+	}
+	data, err := json.MarshalIndent(metadata, "", "  ")
+	if err == nil {
+		_ = os.WriteFile(filepath.Join(s.ArtifactDir, "run.json"), append(data, '\n'), 0o600)
+	}
+}
+
+func redactDiagnostic(value string) string {
+	// Text logs intentionally omit generated secrets. Strip query strings from URLs
+	// as a final guard against opaque flow/challenge/token values.
+	words := strings.Fields(value)
+	for i, word := range words {
+		if strings.Contains(word, "http://") && strings.Contains(word, "?") {
+			words[i] = strings.SplitN(word, "?", 2)[0]
+		}
+	}
+	return strings.Join(words, " ")
+}
+
+func copyFileBounded(source, destination string, limit int64, redactions []string) error {
+	f, err := os.Open(source)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	info, err := f.Stat()
+	if err != nil {
+		return err
+	}
+	if info.Size() > limit {
+		if _, err := f.Seek(info.Size()-limit, io.SeekStart); err != nil {
+			return err
+		}
+	}
+	data, err := io.ReadAll(io.LimitReader(f, limit))
+	if err != nil {
+		return err
+	}
+	text := string(data)
+	for _, secret := range redactions {
+		if secret != "" {
+			text = strings.ReplaceAll(text, secret, "[REDACTED]")
+		}
+	}
+	return os.WriteFile(destination, []byte(text), 0o600)
+}
+
+func (s *Stack) healthy(endpoint string) error {
+	client := &http.Client{Timeout: 2 * time.Second}
+	defer client.CloseIdleConnections()
+	resp, err := client.Get(endpoint)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("health status %s", resp.Status)
+	}
+	return nil
+}
diff --git a/core/auth/ui/e2e/suite_test.go b/core/auth/ui/e2e/suite_test.go
new file mode 100644
index 0000000..4a49252
--- /dev/null
+++ b/core/auth/ui/e2e/suite_test.go
@@ -0,0 +1,186 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"fmt"
+	"os"
+	"testing"
+	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+const emergencyExitProbeEnv = "AUTH_UI_E2E_EMERGENCY_EXIT_PROBE"
+
+var (
+	testStack   *Stack
+	testBrowser *browserRuntime
+)
+
+func TestMain(m *testing.M) {
+	if os.Getenv(emergencyExitProbeEnv) == "1" {
+		enumerateDescendantProcessIDs = func(int) ([]int, error) { select {} }
+		runBoundedEmergencyExit(nil, "injected emergency-exit probe")
+	}
+	if os.Getenv("AUTH_UI_E2E_PROCESS_HELPER") == "1" {
+		os.Exit(runTaggedProcessHelper())
+	}
+	watchdogTimeout, err := suiteWatchdogTimeout()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "E2E watchdog setup failed: %v\n", err)
+		os.Exit(1)
+	}
+	lifecycle := newSuiteLifecycle(nil, nil)
+	activeSuiteLifecycle = lifecycle
+	watchdog := startSuiteWatchdog(watchdogTimeout, func() {
+		_ = lifecycle.claimTimeout()
+		finishWatchdogTimeoutOrWait(lifecycle, watchdogTimeout)
+	})
+
+	stack, err := startStackOwned(lifecycle)
+	if err != nil {
+		workspace, artifacts := lifecycle.diagnosticPaths()
+		fmt.Fprintf(os.Stderr, "E2E setup failed: %v\nworkspace: %s\nartifacts: %s\n", err, workspace, artifacts)
+		finishSetupFailure(lifecycle, watchdog, watchdogTimeout)
+	}
+	testStack = stack
+	if os.Getenv(watchdogSetupStallEnv) == "1" {
+		if err := publishWatchdogProbeReady(); err != nil {
+			fmt.Fprintf(os.Stderr, "E2E setup-timeout probe failed: %v\n", err)
+			finishSetupFailure(lifecycle, watchdog, watchdogTimeout)
+		}
+		select {}
+	}
+
+	runtime, err := startBrowserRuntimeOwned(lifecycle)
+	if err != nil {
+		stack.recordRun("browser_setup_failed", err)
+		fmt.Fprintf(os.Stderr, "E2E browser setup failed: %v\nworkspace: %s\nartifacts: %s\n", err, stack.Workspace, stack.ArtifactDir)
+		finishSetupFailure(lifecycle, watchdog, watchdogTimeout)
+	}
+	testBrowser = runtime
+	if err := probeLoginPage(runtime.Browser, stack); err != nil {
+		stack.recordRun("ui_readiness_failed", err)
+		fmt.Fprintf(os.Stderr, "E2E UI readiness failed: %v\nworkspace: %s\nartifacts: %s\n", err, stack.Workspace, stack.ArtifactDir)
+		finishSetupFailure(lifecycle, watchdog, watchdogTimeout)
+	}
+
+	code := m.Run()
+	reason := lifecycle.claimAfterRun(watchdog)
+	if reason == terminalTimeout {
+		finishWatchdogTimeoutOrWait(lifecycle, watchdogTimeout)
+	}
+	if err := finishNormalWithEmergencyBound(lifecycle, code == 0); err != nil {
+		fmt.Fprintf(os.Stderr, "E2E suite cleanup failed: %s\n", redactDiagnostic(err.Error()))
+		code = 1
+	}
+	os.Exit(code)
+}
+
+func finishSetupFailure(lifecycle *suiteLifecycle, watchdog *suiteWatchdog, watchdogTimeout time.Duration) {
+	reason := lifecycle.claimAfterRun(watchdog)
+	if reason == terminalTimeout {
+		finishWatchdogTimeoutOrWait(lifecycle, watchdogTimeout)
+	}
+	if err := finishNormalWithEmergencyBound(lifecycle, false); err != nil {
+		fmt.Fprintf(os.Stderr, "E2E setup cleanup failed: %s\n", redactDiagnostic(err.Error()))
+	}
+	os.Exit(1)
+}
+
+func finishNormalWithEmergencyBound(lifecycle *suiteLifecycle, success bool) error {
+	cleanupExit := time.AfterFunc(watchdogCleanupExitTimeout, func() {
+		runBoundedEmergencyExit(lifecycle, "normal cleanup exceeded its 45s protocol bound")
+	})
+	err := lifecycle.finish(success)
+	stopCleanupTimerOrWait(cleanupExit.Stop, waitForEmergencyExit)
+	return err
+}
+
+func runBoundedEmergencyExit(lifecycle *suiteLifecycle, reason string) {
+	// Arm the unconditional guard before any process enumeration. Even a hung
+	// external ps invocation cannot postpone exit beyond this short grace.
+	unconditionalExit := time.AfterFunc(emergencyCleanupExitGrace, func() {
+		fmt.Fprintf(os.Stderr, "E2E emergency cleanup exceeded its %s grace; exiting unconditionally\n", emergencyCleanupExitGrace)
+		os.Exit(125)
+	})
+	var emergencyErr error
+	if lifecycle != nil {
+		emergencyErr = lifecycle.emergencyTerminate()
+	} else {
+		emergencyErr = emergencyTerminateOwnedProcesses(os.Getpid(), nil)
+	}
+	if emergencyErr != nil {
+		fmt.Fprintf(os.Stderr, "E2E emergency process cleanup failed: %s\n", redactDiagnostic(emergencyErr.Error()))
+	}
+	fmt.Fprintf(os.Stderr, "E2E %s\n", reason)
+	unconditionalExit.Stop()
+	os.Exit(125)
+}
+
+func finishWatchdogTimeoutOrWait(lifecycle *suiteLifecycle, timeout time.Duration) {
+	if lifecycle.claimTimeoutExit() {
+		runWatchdogTimeoutExit(lifecycle, timeout)
+	}
+	select {}
+}
+
+func runWatchdogTimeoutExit(lifecycle *suiteLifecycle, timeout time.Duration) {
+	workspace, artifacts := lifecycle.diagnosticPaths()
+	fmt.Fprintf(os.Stderr, "E2E internal watchdog reached %s; finalizing owned sessions and processes before the Go test timeout\nworkspace: %s\nartifacts: %s\n", timeout, workspace, artifacts)
+	cleanupExit := time.AfterFunc(watchdogCleanupExitTimeout, func() {
+		runBoundedEmergencyExit(lifecycle, "watchdog cleanup exceeded its 45s protocol bound")
+	})
+	if err := lifecycle.finish(false); err != nil {
+		fmt.Fprintf(os.Stderr, "E2E watchdog cleanup failed: %s\n", redactDiagnostic(err.Error()))
+	}
+	// Reap any owned descendant that appeared after the protocol cleanup
+	// snapshots (for example FFmpeg or a late driver child) before normal
+	// watchdog exit as well as on the emergency path.
+	if err := lifecycle.emergencyTerminate(); err != nil {
+		fmt.Fprintf(os.Stderr, "E2E final descendant cleanup failed: %s\n", redactDiagnostic(err.Error()))
+	}
+	stopCleanupTimerOrWait(cleanupExit.Stop, waitForEmergencyExit)
+	os.Exit(124)
+}
+
+func probeLoginPage(browser playwright.Browser, stack *Stack) error {
+	context, err := browser.NewContext(playwright.BrowserNewContextOptions{Viewport: &playwright.Size{Width: 1280, Height: 720}, ServiceWorkers: playwright.ServiceWorkerPolicyBlock})
+	if err != nil {
+		return err
+	}
+	defer context.Close()
+	policy := newRoutePolicy(stack.allowedOrigins())
+	if err := context.Route("**/*", func(route playwright.Route) {
+		if policy(route.Request().URL()) {
+			_ = route.Continue()
+		} else {
+			_ = route.Abort("blockedbyclient")
+		}
+	}); err != nil {
+		return err
+	}
+	page, err := context.NewPage()
+	if err != nil {
+		return err
+	}
+	page.SetDefaultTimeout(15_000)
+	if _, err := page.Goto(stack.UIURL + "/login"); err != nil {
+		return err
+	}
+	for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
+		visible, err := page.Locator(selector).IsVisible()
+		if err != nil {
+			return err
+		}
+		if !visible {
+			return fmt.Errorf("login selector %s is not visible at %s", selector, sanitizeFinalURL(page.URL()))
+		}
+	}
+	return nil
+}
+
+func (s *Stack) allowedOrigins() []string {
+	return []string{s.UIURL, s.APIURL, s.KratosURL, s.KratosAdmin, s.HydraURL, s.HydraAdmin}
+}
diff --git a/core/auth/ui/e2e/testdata/hydra.yml.tmpl b/core/auth/ui/e2e/testdata/hydra.yml.tmpl
new file mode 100644
index 0000000..047c1c2
--- /dev/null
+++ b/core/auth/ui/e2e/testdata/hydra.yml.tmpl
@@ -0,0 +1,36 @@
+dsn: memory
+
+serve:
+  cookies:
+    same_site_mode: Lax
+  public:
+    host: 127.0.0.1
+    port: {{.HydraPublicPort}}
+  admin:
+    host: 127.0.0.1
+    port: {{.HydraAdminPort}}
+
+urls:
+  self:
+    issuer: {{.HydraPublicURL}}/
+  login: {{.UIURL}}/login
+  consent: {{.UIURL}}/consent
+  logout: {{.UIURL}}/logout
+  error: {{.UIURL}}/error
+
+secrets:
+  system:
+    - {{.HydraSecret}}
+
+log:
+  level: info
+  format: text
+  leak_sensitive_values: false
+
+oidc:
+  subject_identifiers:
+    supported_types:
+      - public
+      - pairwise
+    pairwise:
+      salt: {{.HydraSecret}}
diff --git a/core/auth/ui/e2e/testdata/identity.schema.json b/core/auth/ui/e2e/testdata/identity.schema.json
new file mode 100644
index 0000000..f37a3df
--- /dev/null
+++ b/core/auth/ui/e2e/testdata/identity.schema.json
@@ -0,0 +1,27 @@
+{
+  "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "User",
+  "type": "object",
+  "properties": {
+    "traits": {
+      "type": "object",
+      "properties": {
+        "username": {
+          "type": "string",
+          "format": "username",
+          "title": "Username",
+          "minLength": 3,
+          "ory.sh/kratos": {
+            "credentials": {
+              "password": {
+                "identifier": true
+              }
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    }
+  }
+}
diff --git a/core/auth/ui/e2e/testdata/kratos.yml.tmpl b/core/auth/ui/e2e/testdata/kratos.yml.tmpl
new file mode 100644
index 0000000..820786a
--- /dev/null
+++ b/core/auth/ui/e2e/testdata/kratos.yml.tmpl
@@ -0,0 +1,74 @@
+version: v1.1.0
+dsn: memory
+
+serve:
+  public:
+    base_url: {{.KratosPublicURL}}/
+    host: 127.0.0.1
+    port: {{.KratosPublicPort}}
+    cors:
+      enabled: false
+  admin:
+    base_url: {{.KratosAdminURL}}/
+    host: 127.0.0.1
+    port: {{.KratosAdminPort}}
+
+selfservice:
+  default_browser_return_url: {{.UIURL}}/
+  allowed_return_urls:
+    - {{.UIURL}}/
+  methods:
+    password:
+      enabled: true
+  flows:
+    error:
+      ui_url: {{.UIURL}}/error
+    settings:
+      ui_url: {{.UIURL}}/settings
+    recovery:
+      enabled: false
+      ui_url: {{.UIURL}}/recovery
+    verification:
+      enabled: false
+      ui_url: {{.UIURL}}/verification
+    logout:
+      after:
+        default_browser_return_url: {{.UIURL}}/login
+    login:
+      ui_url: {{.UIURL}}/login
+    registration:
+      enabled: true
+      ui_url: {{.UIURL}}/register
+      after:
+        password:
+          hooks:
+            - hook: session
+
+log:
+  level: info
+  format: text
+  leak_sensitive_values: false
+
+secrets:
+  cookie:
+    - {{.CookieSecret}}
+  cipher:
+    - {{.CipherSecret}}
+
+ciphers:
+  algorithm: xchacha20-poly1305
+
+hashers:
+  algorithm: bcrypt
+  bcrypt:
+    cost: 4
+
+identity:
+  default_schema_id: user
+  schemas:
+    - id: user
+      url: {{.SchemaURL}}
+
+courier:
+  smtp:
+    connection_uri: smtp://e2e:e2e@127.0.0.1:{{.SMTPPort}}/?skip_ssl_verify=true
diff --git a/core/auth/ui/e2e/watchdog.go b/core/auth/ui/e2e/watchdog.go
new file mode 100644
index 0000000..5705c63
--- /dev/null
+++ b/core/auth/ui/e2e/watchdog.go
@@ -0,0 +1,379 @@
+package e2e
+
+import (
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	// The harness deadline includes setup and leaves one minute before Make's
+	// exact 10-minute Go timeout for cleanup and the emergency exit bound.
+	defaultSuiteWatchdogTimeout = 9 * time.Minute
+	watchdogCleanupExitTimeout  = 45 * time.Second
+	emergencyCleanupExitGrace   = 5 * time.Second
+	suiteWatchdogTimeoutEnv     = "AUTH_UI_E2E_WATCHDOG_TIMEOUT"
+)
+
+type sessionClaim uint8
+
+const (
+	sessionUnclaimed sessionClaim = iota
+	sessionNormal
+	sessionTimeout
+)
+
+type sessionEntry struct {
+	claim sessionClaim
+	done  chan struct{}
+	err   error
+}
+
+type sessionRegistry struct {
+	mu       sync.Mutex
+	sessions map[*browserSession]*sessionEntry
+	timedOut bool
+}
+
+func newSessionRegistry() *sessionRegistry {
+	return &sessionRegistry{sessions: make(map[*browserSession]*sessionEntry)}
+}
+
+func (r *sessionRegistry) add(session *browserSession) bool {
+	if r == nil || session == nil {
+		return false
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.timedOut {
+		return false
+	}
+	r.sessions[session] = &sessionEntry{done: make(chan struct{})}
+	return true
+}
+
+func (r *sessionRegistry) finalizeNormal(session *browserSession) error {
+	if r == nil {
+		return session.finalizeClaimed(false)
+	}
+	r.mu.Lock()
+	entry := r.sessions[session]
+	if entry == nil {
+		r.mu.Unlock()
+		return session.finalizeClaimed(false)
+	}
+	owner := entry.claim == sessionUnclaimed
+	if owner {
+		entry.claim = sessionNormal
+	}
+	r.mu.Unlock()
+	if owner {
+		err := session.finalizeClaimed(false)
+		r.complete(session, entry, err)
+		return err
+	}
+	<-entry.done
+	return entry.err
+}
+
+func (r *sessionRegistry) complete(session *browserSession, entry *sessionEntry, err error) {
+	r.mu.Lock()
+	entry.err = err
+	delete(r.sessions, session)
+	close(entry.done)
+	r.mu.Unlock()
+}
+
+func (r *sessionRegistry) finalizeAfterTimeout() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	r.timedOut = true
+	type claimedSession struct {
+		session *browserSession
+		entry   *sessionEntry
+		owner   bool
+	}
+	claimed := make([]claimedSession, 0, len(r.sessions))
+	for session, entry := range r.sessions {
+		owner := entry.claim == sessionUnclaimed
+		if owner {
+			entry.claim = sessionTimeout
+		}
+		claimed = append(claimed, claimedSession{session: session, entry: entry, owner: owner})
+	}
+	r.mu.Unlock()
+	sort.Slice(claimed, func(i, j int) bool { return claimed[i].session.dir < claimed[j].session.dir })
+	var errs []string
+	for _, claim := range claimed {
+		if claim.owner {
+			err := claim.session.finalizeClaimed(true)
+			r.complete(claim.session, claim.entry, err)
+		}
+		<-claim.entry.done
+		if claim.entry.err != nil {
+			errs = append(errs, fmt.Sprintf("%s: %v", filepathBase(claim.session.dir), claim.entry.err))
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("finalize timed-out browser sessions: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+// filepathBase avoids putting full retained paths into aggregate errors.
+func filepathBase(path string) string {
+	for len(path) > 1 && os.IsPathSeparator(path[len(path)-1]) {
+		path = path[:len(path)-1]
+	}
+	if i := strings.LastIndexAny(path, `/\\`); i >= 0 {
+		return path[i+1:]
+	}
+	return path
+}
+
+type ownedCloser interface{ Close() error }
+
+type closerRegistry struct {
+	mu      sync.Mutex
+	closers map[ownedCloser]struct{}
+	closing bool
+}
+
+func newCloserRegistry() *closerRegistry {
+	return &closerRegistry{closers: make(map[ownedCloser]struct{})}
+}
+
+func (r *closerRegistry) add(closer ownedCloser) bool {
+	if r == nil || closer == nil {
+		return false
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.closing {
+		return false
+	}
+	r.closers[closer] = struct{}{}
+	return true
+}
+
+func (r *closerRegistry) remove(closer ownedCloser) {
+	if r == nil || closer == nil {
+		return
+	}
+	r.mu.Lock()
+	delete(r.closers, closer)
+	r.mu.Unlock()
+}
+
+func (r *closerRegistry) closeAll() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	r.closing = true
+	closers := make([]ownedCloser, 0, len(r.closers))
+	for closer := range r.closers {
+		closers = append(closers, closer)
+		delete(r.closers, closer)
+	}
+	r.mu.Unlock()
+	var errs []string
+	for _, closer := range closers {
+		if err := closer.Close(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("close owned listeners: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+type terminalReason uint8
+
+const (
+	terminalRunning terminalReason = iota
+	terminalNormal
+	terminalTimeout
+)
+
+type suiteLifecycle struct {
+	mu                 sync.Mutex
+	reason             terminalReason
+	timeoutExitClaimed bool
+	stack              *Stack
+	browser            *browserRuntime
+	sessions           *sessionRegistry
+	closers            *closerRegistry
+
+	finishOnce sync.Once
+	finishErr  error
+}
+
+// Installed before tagged-suite setup and retained until process exit.
+var activeSuiteLifecycle *suiteLifecycle
+
+func newSuiteLifecycle(stack *Stack, browser *browserRuntime) *suiteLifecycle {
+	return &suiteLifecycle{stack: stack, browser: browser, sessions: newSessionRegistry(), closers: newCloserRegistry()}
+}
+
+func (l *suiteLifecycle) setStack(stack *Stack) bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.stack = stack
+	return true
+}
+
+func (l *suiteLifecycle) setBrowser(browser *browserRuntime) bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.browser = browser
+	return true
+}
+
+func (l *suiteLifecycle) claimTimeout() bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.reason = terminalTimeout
+	return true
+}
+
+// claimAfterRun stops and claims normal cleanup atomically. If the timer is
+// already firing, this caller claims timeout completion so timeout status and
+// artifacts cannot be lost while the callback is waiting to run.
+func (l *suiteLifecycle) claimAfterRun(watchdog *suiteWatchdog) terminalReason {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return l.reason
+	}
+	if watchdog != nil && !watchdog.timer.Stop() {
+		l.reason = terminalTimeout
+	} else {
+		l.reason = terminalNormal
+	}
+	return l.reason
+}
+
+func (l *suiteLifecycle) claimTimeoutExit() bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalTimeout || l.timeoutExitClaimed {
+		return false
+	}
+	l.timeoutExitClaimed = true
+	return true
+}
+
+func (l *suiteLifecycle) currentReason() terminalReason {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	return l.reason
+}
+
+func (l *suiteLifecycle) diagnosticPaths() (workspace, artifacts string) {
+	l.mu.Lock()
+	stack := l.stack
+	l.mu.Unlock()
+	if stack != nil {
+		return stack.Workspace, stack.ArtifactDir
+	}
+	return "unavailable (setup incomplete)", "unavailable (setup incomplete)"
+}
+
+func (l *suiteLifecycle) finish(success bool) error {
+	if l == nil {
+		return nil
+	}
+	l.finishOnce.Do(func() {
+		l.mu.Lock()
+		reason, stack, browser := l.reason, l.stack, l.browser
+		l.mu.Unlock()
+		timedOut := reason == terminalTimeout
+		var errs []string
+		if timedOut && stack != nil {
+			stack.recordRun("watchdog_timeout", fmt.Errorf("internal E2E suite deadline exceeded"))
+		}
+		if timedOut {
+			if err := l.sessions.finalizeAfterTimeout(); err != nil {
+				errs = append(errs, err.Error())
+			}
+		}
+		if err := l.closers.closeAll(); err != nil {
+			errs = append(errs, err.Error())
+		}
+		if stack != nil {
+			if err := stack.StopServices(); err != nil {
+				errs = append(errs, "services: "+err.Error())
+			}
+		}
+		if browser != nil {
+			if err := browser.Close(); err != nil {
+				errs = append(errs, "browser: "+err.Error())
+			}
+		}
+		finalSuccess := success && !timedOut && len(errs) == 0
+		if stack != nil {
+			if err := stack.Finalize(finalSuccess); err != nil {
+				errs = append(errs, "artifacts: "+err.Error())
+			}
+		}
+		if len(errs) > 0 {
+			l.finishErr = fmt.Errorf("E2E suite cleanup: %s", strings.Join(errs, "; "))
+		}
+	})
+	return l.finishErr
+}
+
+func (l *suiteLifecycle) emergencyTerminate() error {
+	l.mu.Lock()
+	stack := l.stack
+	l.mu.Unlock()
+	var groups []int
+	if stack != nil {
+		groups = stack.emergencyProcessGroups()
+	}
+	return emergencyTerminateOwnedProcesses(os.Getpid(), groups)
+}
+
+func suiteWatchdogTimeout() (time.Duration, error) {
+	value := os.Getenv(suiteWatchdogTimeoutEnv)
+	if value == "" {
+		return defaultSuiteWatchdogTimeout, nil
+	}
+	duration, err := time.ParseDuration(value)
+	if err != nil || duration <= 0 {
+		return 0, fmt.Errorf("%s must be a positive Go duration", suiteWatchdogTimeoutEnv)
+	}
+	return duration, nil
+}
+
+type suiteWatchdog struct{ timer *time.Timer }
+
+func startSuiteWatchdog(timeout time.Duration, fire func()) *suiteWatchdog {
+	return &suiteWatchdog{timer: time.AfterFunc(timeout, fire)}
+}
+
+func (w *suiteWatchdog) stop() bool { return w == nil || w.timer.Stop() }
+
+func stopCleanupTimerOrWait(stop func() bool, wait func()) {
+	if !stop() {
+		wait()
+	}
+}
+
+func waitForEmergencyExit() { select {} }
diff --git a/core/auth/ui/e2e/watchdog_subprocess_test.go b/core/auth/ui/e2e/watchdog_subprocess_test.go
new file mode 100644
index 0000000..b7dc51e
--- /dev/null
+++ b/core/auth/ui/e2e/watchdog_subprocess_test.go
@@ -0,0 +1,600 @@
+//go:build e2e
+
+package e2e
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+const (
+	watchdogProbeChildEnv = "AUTH_UI_E2E_WATCHDOG_PROBE_CHILD"
+	watchdogProbeReadyEnv = "AUTH_UI_E2E_WATCHDOG_PROBE_READY_FILE"
+	watchdogSetupStallEnv = "AUTH_UI_E2E_WATCHDOG_SETUP_STALL"
+)
+
+type processIdentity struct {
+	PID     int    `json:"pid"`
+	Command string `json:"command"`
+}
+
+type watchdogProbeState struct {
+	Workspace   string            `json:"workspace"`
+	ArtifactDir string            `json:"artifact_dir"`
+	Processes   []processIdentity `json:"processes"`
+}
+
+func TestWatchdogTimeoutCleanup(t *testing.T) {
+	if os.Getenv(watchdogProbeChildEnv) == "1" {
+		runWatchdogProbeChild(t)
+		return
+	}
+
+	// The tagged parent regression is a normal real-browser test and therefore
+	// retains the same complete artifact set as every other tagged test.
+	parentSession := newWatchdogProbeSession(t)
+	if _, err := parentSession.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	if err := parentSession.Checkpoint("watchdog-regression-parent"); err != nil {
+		t.Fatal(err)
+	}
+
+	childRoot := t.TempDir()
+	readyFile := filepath.Join(childRoot, "ready.json")
+	executable, err := os.Executable()
+	if err != nil {
+		t.Fatal(err)
+	}
+	cmd := exec.Command(executable, "-test.run=^TestWatchdogTimeoutCleanup$", "-test.count=1", "-test.timeout=2m", "-test.v")
+	cmd.Env = environmentWithOverrides(map[string]string{
+		watchdogProbeChildEnv:      "1",
+		watchdogProbeReadyEnv:      readyFile,
+		suiteWatchdogTimeoutEnv:    "20s",
+		"AUTH_UI_E2E_OFFLINE":      "1",
+		"AUTH_UI_E2E_ARTIFACT_DIR": filepath.Join(childRoot, "artifacts"),
+	})
+	if err := configureOwnedProcess(cmd); err != nil {
+		t.Fatal(err)
+	}
+	var output bytes.Buffer
+	cmd.Stdout = &output
+	cmd.Stderr = &output
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+
+	var state watchdogProbeState
+	var observedMu sync.Mutex
+	observed := make(map[int]processIdentity)
+	monitorStop := make(chan struct{})
+	monitorDone := make(chan struct{})
+	var monitorStopOnce sync.Once
+	stopMonitor := func() {
+		monitorStopOnce.Do(func() {
+			close(monitorStop)
+			<-monitorDone
+		})
+	}
+	go func() {
+		defer close(monitorDone)
+		ticker := time.NewTicker(50 * time.Millisecond)
+		defer ticker.Stop()
+		for {
+			if identities, snapshotErr := descendantProcessIdentities(cmd.Process.Pid); snapshotErr == nil {
+				observedMu.Lock()
+				for _, identity := range identities {
+					observed[identity.PID] = identity
+				}
+				observedMu.Unlock()
+			}
+			select {
+			case <-monitorStop:
+				return
+			case <-ticker.C:
+			}
+		}
+	}()
+	cleanupChild := func() {
+		stopMonitor()
+		if cmd.Process != nil && cmd.ProcessState == nil {
+			if identities, snapshotErr := descendantProcessIdentities(cmd.Process.Pid); snapshotErr == nil {
+				observedMu.Lock()
+				for _, identity := range identities {
+					observed[identity.PID] = identity
+				}
+				observedMu.Unlock()
+			}
+			_ = signalOwnedProcessGroup(cmd.Process.Pid, processKill)
+		}
+		observedMu.Lock()
+		allObserved := make([]processIdentity, 0, len(observed)+len(state.Processes))
+		for _, process := range observed {
+			allObserved = append(allObserved, process)
+		}
+		observedMu.Unlock()
+		allObserved = append(allObserved, state.Processes...)
+		for _, process := range allObserved {
+			if ownedProcessStillMatches(process) {
+				if p, findErr := os.FindProcess(process.PID); findErr == nil {
+					_ = p.Kill()
+				}
+			}
+		}
+		if state.Workspace != "" {
+			_ = os.RemoveAll(state.Workspace)
+		}
+	}
+	t.Cleanup(cleanupChild)
+
+	waitDone := make(chan error, 1)
+	go func() { waitDone <- cmd.Wait() }()
+	var waitErr error
+	select {
+	case waitErr = <-waitDone:
+	case <-time.After(90 * time.Second):
+		if readyData, readErr := os.ReadFile(readyFile); readErr == nil {
+			_ = json.Unmarshal(readyData, &state)
+		}
+		cleanupChild()
+		select {
+		case waitErr = <-waitDone:
+		case <-time.After(5 * time.Second):
+			t.Fatal("watchdog subprocess did not exit after forced cleanup")
+		}
+		t.Fatalf("watchdog subprocess exceeded its 90s outer regression deadline: %v", waitErr)
+	}
+
+	stopMonitor()
+	readyData, err := os.ReadFile(readyFile)
+	if err != nil || json.Unmarshal(readyData, &state) != nil {
+		t.Fatalf("watchdog child did not publish ownership state: %v\noutput:\n%s", err, boundedText(output.Bytes(), 16<<10))
+	}
+	observedMu.Lock()
+	for _, process := range observed {
+		state.Processes = appendUniqueProcessIdentity(state.Processes, process)
+	}
+	observedMu.Unlock()
+	if exitErr, ok := waitErr.(*exec.ExitError); !ok || exitErr.ExitCode() != 124 {
+		t.Fatalf("watchdog child exit=%v, want status 124\noutput:\n%s", waitErr, boundedText(output.Bytes(), 16<<10))
+	}
+	if !strings.Contains(output.String(), "E2E internal watchdog reached 20s") {
+		t.Fatalf("watchdog child omitted timeout diagnostic:\n%s", boundedText(output.Bytes(), 16<<10))
+	}
+
+	assertWatchdogChildArtifacts(t, state)
+	assertWatchdogOwnedProcessesGone(t, state.Processes)
+	if ownedProcessGroupAlive(cmd.Process.Pid) {
+		t.Fatalf("watchdog child process group %d survived", cmd.Process.Pid)
+	}
+	childSession := filepath.Join(state.ArtifactDir, sanitizeName("TestWatchdogTimeoutCleanup"))
+	video, _ := os.Stat(filepath.Join(childSession, "video.webm"))
+	trace, _ := os.Stat(filepath.Join(childSession, "trace.zip"))
+	finalPNG, _ := os.Stat(filepath.Join(childSession, "screenshots", "99-final.png"))
+	failurePNG, _ := os.Stat(filepath.Join(childSession, "screenshots", "failure.png"))
+	t.Logf("watchdog child verified: exit=124 status=watchdog_timeout owned_processes=%d video=%d trace=%d final_png=%d failure_png=%d", len(state.Processes), video.Size(), trace.Size(), finalPNG.Size(), failurePNG.Size())
+}
+
+func TestEmergencyExitBoundsBlockedDescendantEnumerator(t *testing.T) {
+	parentSession := newWatchdogProbeSession(t)
+	if _, err := parentSession.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	if err := parentSession.Checkpoint("emergency-exit-regression-parent"); err != nil {
+		t.Fatal(err)
+	}
+
+	executable, err := os.Executable()
+	if err != nil {
+		t.Fatal(err)
+	}
+	cmd := exec.Command(executable, "-test.run=^TestEmergencyExitBoundsBlockedDescendantEnumerator$", "-test.count=1", "-test.timeout=30s", "-test.v")
+	cmd.Env = environmentWithOverrides(map[string]string{emergencyExitProbeEnv: "1"})
+	if err := configureOwnedProcess(cmd); err != nil {
+		t.Fatal(err)
+	}
+	var output bytes.Buffer
+	cmd.Stdout, cmd.Stderr = &output, &output
+	started := time.Now()
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() {
+		if cmd.ProcessState == nil {
+			_ = signalOwnedProcessGroup(cmd.Process.Pid, processKill)
+		}
+	})
+	waitDone := make(chan error, 1)
+	go func() { waitDone <- cmd.Wait() }()
+	var waitErr error
+	select {
+	case waitErr = <-waitDone:
+	case <-time.After(10 * time.Second):
+		t.Fatal("emergency-exit probe exceeded its outer 10s bound")
+	}
+	elapsed := time.Since(started)
+	if exitErr, ok := waitErr.(*exec.ExitError); !ok || exitErr.ExitCode() != 125 {
+		t.Fatalf("emergency-exit probe=%v, want status 125\n%s", waitErr, boundedText(output.Bytes(), 8192))
+	}
+	if elapsed > 8*time.Second || !strings.Contains(output.String(), "emergency cleanup exceeded its 5s grace") {
+		t.Fatalf("emergency-exit elapsed=%s output=%s", elapsed, boundedText(output.Bytes(), 8192))
+	}
+	if strings.Contains(output.String(), "E2E setup failed") || strings.Contains(output.String(), "workspace:") {
+		t.Fatalf("emergency helper unexpectedly entered suite setup: %s", boundedText(output.Bytes(), 8192))
+	}
+	t.Logf("blocked-enumerator emergency exit verified: status=125 elapsed=%s", elapsed.Round(time.Millisecond))
+}
+
+func TestWatchdogCoversSetupTimeout(t *testing.T) {
+	parentSession := newWatchdogProbeSession(t)
+	if _, err := parentSession.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	if err := parentSession.Checkpoint("setup-watchdog-regression-parent"); err != nil {
+		t.Fatal(err)
+	}
+
+	childRoot := t.TempDir()
+	readyFile := filepath.Join(childRoot, "setup-ready.json")
+	executable, err := os.Executable()
+	if err != nil {
+		t.Fatal(err)
+	}
+	cmd := exec.Command(executable, "-test.run=^TestWatchdogCoversSetupTimeout$", "-test.count=1", "-test.timeout=2m", "-test.v")
+	cmd.Env = environmentWithOverrides(map[string]string{
+		watchdogSetupStallEnv:      "1",
+		watchdogProbeReadyEnv:      readyFile,
+		suiteWatchdogTimeoutEnv:    "20s",
+		"AUTH_UI_E2E_OFFLINE":      "1",
+		"AUTH_UI_E2E_ARTIFACT_DIR": filepath.Join(childRoot, "artifacts"),
+	})
+	if err := configureOwnedProcess(cmd); err != nil {
+		t.Fatal(err)
+	}
+	var output bytes.Buffer
+	cmd.Stdout, cmd.Stderr = &output, &output
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+	var state watchdogProbeState
+	t.Cleanup(func() {
+		if cmd.ProcessState == nil {
+			if fallback, snapshotErr := descendantProcessIdentities(cmd.Process.Pid); snapshotErr == nil {
+				state.Processes = append(state.Processes, fallback...)
+			}
+			_ = signalOwnedProcessGroup(cmd.Process.Pid, processKill)
+		}
+		for _, process := range state.Processes {
+			if ownedProcessStillMatches(process) {
+				if owned, findErr := os.FindProcess(process.PID); findErr == nil {
+					_ = owned.Kill()
+				}
+			}
+		}
+		if state.Workspace != "" {
+			_ = os.RemoveAll(state.Workspace)
+		}
+	})
+	waitDone := make(chan error, 1)
+	go func() { waitDone <- cmd.Wait() }()
+	var waitErr error
+	select {
+	case waitErr = <-waitDone:
+	case <-time.After(90 * time.Second):
+		t.Fatal("setup watchdog subprocess exceeded 90s")
+	}
+	readyData, err := os.ReadFile(readyFile)
+	if err != nil || json.Unmarshal(readyData, &state) != nil {
+		t.Fatalf("setup watchdog child omitted ownership state: %v\n%s", err, boundedText(output.Bytes(), 16<<10))
+	}
+	if exitErr, ok := waitErr.(*exec.ExitError); !ok || exitErr.ExitCode() != 124 {
+		t.Fatalf("setup watchdog child exit=%v, want 124\n%s", waitErr, boundedText(output.Bytes(), 16<<10))
+	}
+	if !strings.Contains(output.String(), "E2E internal watchdog reached 20s") {
+		t.Fatalf("setup watchdog diagnostic missing:\n%s", boundedText(output.Bytes(), 16<<10))
+	}
+	runData, err := os.ReadFile(filepath.Join(state.ArtifactDir, "run.json"))
+	var run map[string]any
+	if err != nil || json.Unmarshal(runData, &run) != nil || run["status"] != "watchdog_timeout" || run["workspace"] != state.Workspace {
+		t.Fatalf("setup watchdog run metadata invalid: %v %s", err, runData)
+	}
+	for _, service := range []string{"auth-ui", "hydra", "kratos"} {
+		if _, statErr := os.Stat(filepath.Join(state.ArtifactDir, "services", service+".log")); statErr != nil {
+			t.Errorf("setup watchdog omitted retained %s log: %v", service, statErr)
+		}
+	}
+	assertWatchdogSetupProcessesGone(t, state.Processes)
+	if ownedProcessGroupAlive(cmd.Process.Pid) {
+		t.Fatalf("setup watchdog child group %d survived", cmd.Process.Pid)
+	}
+	t.Logf("setup watchdog child verified: exit=124 status=watchdog_timeout owned_processes=%d", len(state.Processes))
+}
+
+func newWatchdogProbeSession(t *testing.T) *browserSession {
+	t.Helper()
+	dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
+	failedBeforeFinalize := false
+	t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
+	session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, testStack.allowedOrigins())
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { failedBeforeFinalize = t.Failed() })
+	return session
+}
+
+func runWatchdogProbeChild(t *testing.T) {
+	session := newWatchdogProbeSession(t)
+	if _, err := session.Page.Goto(testStack.UIURL + "/login"); err != nil {
+		t.Fatal(err)
+	}
+	if err := session.Checkpoint("stalled-real-session"); err != nil {
+		t.Fatal(err)
+	}
+	if err := publishWatchdogProbeReady(); err != nil {
+		t.Fatal(err)
+	}
+	// Spawn an independently grouped descendant after ready.json so the parent
+	// proves its continuous fallback accounting and the harness's final
+	// non-protocol descendant sweep, not merely the original snapshot.
+	late := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
+	late.Env = environmentWithOverrides(map[string]string{
+		"AUTH_UI_E2E_PROCESS_HELPER": "1",
+		"AUTH_UI_E2E_PROCESS_MODE":   "ignore-term",
+	})
+	if err := configureOwnedProcess(late); err != nil {
+		t.Fatal(err)
+	}
+	if err := late.Start(); err != nil {
+		t.Fatal(err)
+	}
+	go func() { _ = late.Wait() }()
+	// The initial publication above still predates the independently grouped
+	// spawn. Republish only after bounded polling observes the exact late PID,
+	// making the final ownership proof deterministic while retaining the
+	// parent's continuous monitor and kill-time fallback.
+	if err := publishWatchdogProbeReadyAfterProcess(late.Process.Pid); err != nil {
+		t.Fatal(err)
+	}
+	select {}
+}
+
+func publishWatchdogProbeReady() error {
+	return publishWatchdogProbeReadyAfterProcess(0)
+}
+
+func publishWatchdogProbeReadyAfterProcess(requiredPID int) error {
+	deadline := time.NewTimer(2 * time.Second)
+	defer deadline.Stop()
+	poll := time.NewTicker(10 * time.Millisecond)
+	defer poll.Stop()
+	for {
+		processes, err := descendantProcessIdentities(os.Getpid())
+		if err != nil {
+			return err
+		}
+		observed := requiredPID == 0
+		for _, process := range processes {
+			if process.PID == requiredPID && strings.Contains(strings.ToLower(process.Command), "testprocesshelper") {
+				observed = true
+				break
+			}
+		}
+		if observed {
+			return writeWatchdogProbeReady(processes)
+		}
+		select {
+		case <-deadline.C:
+			return fmt.Errorf("late watchdog helper process %d was not observable before readiness deadline", requiredPID)
+		case <-poll.C:
+		}
+	}
+}
+
+func writeWatchdogProbeReady(processes []processIdentity) error {
+	if testStack == nil {
+		return fmt.Errorf("watchdog probe stack ownership is unavailable")
+	}
+	state := watchdogProbeState{Workspace: testStack.Workspace, ArtifactDir: testStack.ArtifactDir, Processes: processes}
+	data, err := json.MarshalIndent(state, "", "  ")
+	if err != nil {
+		return err
+	}
+	readyFile := os.Getenv(watchdogProbeReadyEnv)
+	if readyFile == "" {
+		return fmt.Errorf("watchdog probe ready path is missing")
+	}
+	temporary, err := os.CreateTemp(filepath.Dir(readyFile), ".watchdog-ready-*")
+	if err != nil {
+		return err
+	}
+	temporaryPath := temporary.Name()
+	defer os.Remove(temporaryPath)
+	if err := temporary.Chmod(0o600); err != nil {
+		_ = temporary.Close()
+		return err
+	}
+	if _, err := temporary.Write(append(data, '\n')); err != nil {
+		_ = temporary.Close()
+		return err
+	}
+	if err := temporary.Close(); err != nil {
+		return err
+	}
+	return os.Rename(temporaryPath, readyFile)
+}
+
+func appendUniqueProcessIdentity(processes []processIdentity, candidate processIdentity) []processIdentity {
+	for _, process := range processes {
+		if process.PID == candidate.PID {
+			return processes
+		}
+	}
+	return append(processes, candidate)
+}
+
+func environmentWithOverrides(overrides map[string]string) []string {
+	environment := make([]string, 0, len(os.Environ())+len(overrides))
+	for _, entry := range os.Environ() {
+		name := strings.SplitN(entry, "=", 2)[0]
+		if _, overridden := overrides[name]; !overridden {
+			environment = append(environment, entry)
+		}
+	}
+	for name, value := range overrides {
+		environment = append(environment, name+"="+value)
+	}
+	return environment
+}
+
+func descendantProcessIdentities(rootPID int) ([]processIdentity, error) {
+	output, err := exec.Command("ps", "-eo", "pid=,ppid=,command=").Output()
+	if err != nil {
+		return nil, fmt.Errorf("list watchdog-owned processes: %w", err)
+	}
+	type entry struct {
+		pid, ppid int
+		command   string
+	}
+	var entries []entry
+	for _, line := range strings.Split(string(output), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) < 3 {
+			continue
+		}
+		pid, pidErr := strconv.Atoi(fields[0])
+		ppid, ppidErr := strconv.Atoi(fields[1])
+		if pidErr == nil && ppidErr == nil {
+			entries = append(entries, entry{pid: pid, ppid: ppid, command: strings.Join(fields[2:], " ")})
+		}
+	}
+	owned := map[int]bool{rootPID: true}
+	for changed := true; changed; {
+		changed = false
+		for _, entry := range entries {
+			if owned[entry.ppid] && !owned[entry.pid] {
+				owned[entry.pid] = true
+				changed = true
+			}
+		}
+	}
+	var result []processIdentity
+	for _, entry := range entries {
+		if entry.pid != rootPID && owned[entry.pid] {
+			result = append(result, processIdentity{PID: entry.pid, Command: entry.command})
+		}
+	}
+	return result, nil
+}
+
+func ownedProcessStillMatches(want processIdentity) bool {
+	output, err := exec.Command("ps", "-p", strconv.Itoa(want.PID), "-o", "command=").Output()
+	return err == nil && strings.TrimSpace(string(output)) == want.Command
+}
+
+func assertWatchdogSetupProcessesGone(t *testing.T, processes []processIdentity) {
+	t.Helper()
+	services := map[string]bool{"kratos": false, "hydra": false, "auth-ui": false}
+	for _, process := range processes {
+		command := strings.ToLower(process.Command)
+		for service := range services {
+			if strings.Contains(command, "/bin/"+service) {
+				services[service] = true
+			}
+		}
+		if ownedProcessStillMatches(process) {
+			t.Errorf("setup-watchdog process survived: pid=%d command=%s", process.PID, boundedText([]byte(process.Command), 512))
+		}
+	}
+	for service, found := range services {
+		if !found {
+			t.Errorf("setup watchdog ownership snapshot did not include %s", service)
+		}
+	}
+}
+
+func assertWatchdogOwnedProcessesGone(t *testing.T, processes []processIdentity) {
+	t.Helper()
+	categories := map[string]bool{"kratos": false, "hydra": false, "auth-ui": false, "driver": false, "chromium": false, "late-helper": false}
+	for _, process := range processes {
+		command := strings.ToLower(process.Command)
+		for _, service := range []string{"kratos", "hydra", "auth-ui"} {
+			if strings.Contains(command, "/bin/"+service) {
+				categories[service] = true
+			}
+		}
+		if strings.Contains(command, "playwright.sh run-driver") || strings.Contains(command, "cli.js run-driver") {
+			categories["driver"] = true
+		}
+		if strings.Contains(command, "chrome") || strings.Contains(command, "chromium") {
+			categories["chromium"] = true
+		}
+		if strings.Contains(command, "testprocesshelper") {
+			categories["late-helper"] = true
+		}
+		if ownedProcessStillMatches(process) {
+			t.Errorf("watchdog-owned process survived: pid=%d command=%s", process.PID, boundedText([]byte(process.Command), 512))
+		}
+	}
+	for category, found := range categories {
+		if !found {
+			t.Errorf("watchdog ownership snapshot did not include %s", category)
+		}
+	}
+}
+
+func assertWatchdogChildArtifacts(t *testing.T, state watchdogProbeState) {
+	t.Helper()
+	if state.Workspace == "" || state.ArtifactDir == "" {
+		t.Fatalf("incomplete watchdog state: %+v", state)
+	}
+	runData, err := os.ReadFile(filepath.Join(state.ArtifactDir, "run.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var run map[string]any
+	if err := json.Unmarshal(runData, &run); err != nil || run["status"] != "watchdog_timeout" || run["workspace"] != state.Workspace {
+		t.Fatalf("invalid watchdog run metadata: error=%v data=%s", err, runData)
+	}
+	for _, service := range []string{"auth-ui", "hydra", "kratos"} {
+		info, statErr := os.Stat(filepath.Join(state.ArtifactDir, "services", service+".log"))
+		if statErr != nil {
+			t.Errorf("retained %s service log missing: info=%v error=%v", service, info, statErr)
+		} else if service != "auth-ui" && info.Size() == 0 {
+			t.Errorf("retained %s service log is empty", service)
+		}
+	}
+	sessionDir := filepath.Join(state.ArtifactDir, sanitizeName("TestWatchdogTimeoutCleanup"))
+	for _, path := range []string{"screenshots/99-final.png", "screenshots/failure.png", "video.webm", "trace.zip", "session.json"} {
+		info, statErr := os.Stat(filepath.Join(sessionDir, path))
+		if statErr != nil || info.Size() == 0 {
+			t.Fatalf("watchdog artifact %s missing or empty: info=%v error=%v", path, info, statErr)
+		}
+	}
+	for _, name := range []string{"99-final.png", "failure.png"} {
+		png, readErr := os.ReadFile(filepath.Join(sessionDir, "screenshots", name))
+		if readErr != nil || !bytes.HasPrefix(png, []byte("\x89PNG\r\n\x1a\n")) {
+			t.Fatalf("watchdog %s is not a PNG: %v", name, readErr)
+		}
+	}
+	trace, err := zip.OpenReader(filepath.Join(sessionDir, "trace.zip"))
+	if err != nil || len(trace.File) == 0 {
+		t.Fatalf("watchdog trace is not a valid non-empty ZIP: %v", err)
+	}
+	_ = trace.Close()
+	sessionData, err := os.ReadFile(filepath.Join(sessionDir, "session.json"))
+	var metadata sessionMetadata
+	if err != nil || json.Unmarshal(sessionData, &metadata) != nil || metadata.Outcome != "failed" || !containsString(metadata.Screenshots, "failure.png") {
+		t.Fatalf("invalid watchdog session metadata: error=%v data=%s", err, sessionData)
+	}
+}
diff --git a/core/auth/ui/e2e/watchdog_test.go b/core/auth/ui/e2e/watchdog_test.go
new file mode 100644
index 0000000..5a2500c
--- /dev/null
+++ b/core/auth/ui/e2e/watchdog_test.go
@@ -0,0 +1,206 @@
+package e2e
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestSuiteWatchdogTimeoutConfiguration(t *testing.T) {
+	t.Setenv(suiteWatchdogTimeoutEnv, "")
+	if got, err := suiteWatchdogTimeout(); err != nil || got != 9*time.Minute {
+		t.Fatalf("default timeout=%s error=%v", got, err)
+	}
+	t.Setenv(suiteWatchdogTimeoutEnv, "250ms")
+	if got, err := suiteWatchdogTimeout(); err != nil || got != 250*time.Millisecond {
+		t.Fatalf("override timeout=%s error=%v", got, err)
+	}
+	for _, value := range []string{"0", "-1s", "not-a-duration"} {
+		t.Setenv(suiteWatchdogTimeoutEnv, value)
+		if _, err := suiteWatchdogTimeout(); err == nil {
+			t.Fatalf("invalid timeout %q was accepted", value)
+		}
+	}
+}
+
+func TestStopCleanupTimerOrWaitHonorsTimerWinner(t *testing.T) {
+	waits := 0
+	stopCleanupTimerOrWait(func() bool { return true }, func() { waits++ })
+	if waits != 0 {
+		t.Fatalf("wait called after timer was stopped: %d", waits)
+	}
+	stopCleanupTimerOrWait(func() bool { return false }, func() { waits++ })
+	if waits != 1 {
+		t.Fatalf("already-fired timer did not own exit wait: %d", waits)
+	}
+}
+
+func TestNormalTerminalClaimStopsWatchdogBeforeCleanup(t *testing.T) {
+	lifecycle := newSuiteLifecycle(nil, nil)
+	fired := make(chan struct{}, 1)
+	watchdog := startSuiteWatchdog(time.Hour, func() {
+		if lifecycle.claimTimeout() {
+			fired <- struct{}{}
+		}
+	})
+	if got := lifecycle.claimAfterRun(watchdog); got != terminalNormal {
+		t.Fatalf("terminal reason=%v, want normal", got)
+	}
+	if err := lifecycle.finish(true); err != nil {
+		t.Fatal(err)
+	}
+	select {
+	case <-fired:
+		t.Fatal("stopped watchdog claimed cleanup")
+	case <-time.After(20 * time.Millisecond):
+	}
+}
+
+func TestTimeoutTerminalClaimCannotBeLostToNormalCleanup(t *testing.T) {
+	lifecycle := newSuiteLifecycle(nil, nil)
+	if !lifecycle.claimTimeout() {
+		t.Fatal("timeout did not claim terminal state")
+	}
+	watchdog := startSuiteWatchdog(time.Hour, func() {})
+	defer watchdog.stop()
+	if got := lifecycle.claimAfterRun(watchdog); got != terminalTimeout {
+		t.Fatalf("terminal reason=%v, want timeout", got)
+	}
+	if err := lifecycle.finish(false); err != nil {
+		t.Fatal(err)
+	}
+	if lifecycle.sessions.add(&browserSession{}) {
+		t.Fatal("timeout cleanup did not close session registry")
+	}
+}
+
+func newRegistryTestSession(t *testing.T, name string) (*browserSession, *sessionRegistry, *atomic.Int32) {
+	t.Helper()
+	dir := filepath.Join(t.TempDir(), name)
+	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	registry := newSessionRegistry()
+	reporter := &fakeReporter{name: name}
+	session := &browserSession{t: reporter, dir: dir, started: time.Unix(1, 0).UTC(), browserVer: chromiumVersion, registry: registry}
+	var screenshots atomic.Int32
+	session.screenshotOp = func(path string) error {
+		screenshots.Add(1)
+		return os.WriteFile(path, []byte("png"), 0o600)
+	}
+	session.stopTraceOp = func(path string) error { return os.WriteFile(path, []byte("trace"), 0o600) }
+	session.closeContextOp = func() error { return nil }
+	session.saveVideoOp = func(path string) error { return os.WriteFile(path, []byte("video"), 0o600) }
+	session.finalURLOp = func() string { return "http://127.0.0.1:1234/login?flow=secret" }
+	if !registry.add(session) {
+		t.Fatal("registry rejected session")
+	}
+	return session, registry, &screenshots
+}
+
+func TestSessionNormalClaimWinsAndTimeoutAwaitsIt(t *testing.T) {
+	session, registry, screenshots := newRegistryTestSession(t, "TestNormalWinner")
+	started, release := make(chan struct{}), make(chan struct{})
+	originalScreenshot := session.screenshotOp
+	session.screenshotOp = func(path string) error {
+		if screenshots.Load() == 0 {
+			close(started)
+			<-release
+		}
+		return originalScreenshot(path)
+	}
+	normalDone := make(chan error, 1)
+	go func() { normalDone <- session.Finalize() }()
+	<-started
+	timeoutDone := make(chan error, 1)
+	go func() { timeoutDone <- registry.finalizeAfterTimeout() }()
+	select {
+	case <-timeoutDone:
+		t.Fatal("timeout did not await normally claimed finalization")
+	case <-time.After(20 * time.Millisecond):
+	}
+	close(release)
+	if err := <-normalDone; err != nil {
+		t.Fatal(err)
+	}
+	if err := <-timeoutDone; err != nil {
+		t.Fatal(err)
+	}
+	assertRegistryMetadata(t, session.dir, "passed", false)
+	if screenshots.Load() != 1 {
+		t.Fatalf("normal winner screenshots=%d, want one final capture", screenshots.Load())
+	}
+}
+
+func TestSessionTimeoutClaimWinsAndNormalAwaitsFailedFinalization(t *testing.T) {
+	session, registry, screenshots := newRegistryTestSession(t, "TestTimeoutWinner")
+	started, release := make(chan struct{}), make(chan struct{})
+	originalScreenshot := session.screenshotOp
+	session.screenshotOp = func(path string) error {
+		if screenshots.Load() == 0 {
+			close(started)
+			<-release
+		}
+		return originalScreenshot(path)
+	}
+	timeoutDone := make(chan error, 1)
+	go func() { timeoutDone <- registry.finalizeAfterTimeout() }()
+	<-started
+	normalDone := make(chan error, 1)
+	go func() { normalDone <- session.Finalize() }()
+	select {
+	case <-normalDone:
+		t.Fatal("normal finalization did not await timeout claim")
+	case <-time.After(20 * time.Millisecond):
+	}
+	close(release)
+	if err := <-timeoutDone; err != nil {
+		t.Fatal(err)
+	}
+	if err := <-normalDone; err != nil {
+		t.Fatal(err)
+	}
+	assertRegistryMetadata(t, session.dir, "failed", true)
+	if screenshots.Load() != 2 {
+		t.Fatalf("timeout winner screenshots=%d, want final and failure", screenshots.Load())
+	}
+}
+
+func assertRegistryMetadata(t *testing.T, dir, outcome string, failurePNG bool) {
+	t.Helper()
+	data, err := os.ReadFile(filepath.Join(dir, "session.json"))
+	var metadata sessionMetadata
+	if err != nil || json.Unmarshal(data, &metadata) != nil {
+		t.Fatalf("session metadata: %v %s", err, data)
+	}
+	if metadata.Outcome != outcome || containsString(metadata.Screenshots, "failure.png") != failurePNG {
+		t.Fatalf("metadata=%+v", metadata)
+	}
+}
+
+type countedCloser struct{ calls atomic.Int32 }
+
+func (c *countedCloser) Close() error { c.calls.Add(1); return nil }
+
+func TestCloserRegistryClosesExactlyOnceAndRejectsLateOwnership(t *testing.T) {
+	closer := &countedCloser{}
+	registry := newCloserRegistry()
+	if !registry.add(closer) {
+		t.Fatal("closer registry rejected ownership")
+	}
+	if err := registry.closeAll(); err != nil {
+		t.Fatal(err)
+	}
+	if err := registry.closeAll(); err != nil {
+		t.Fatal(err)
+	}
+	if closer.calls.Load() != 1 {
+		t.Fatalf("closer calls=%d", closer.calls.Load())
+	}
+	if registry.add(&countedCloser{}) {
+		t.Fatal("closer registry accepted ownership after cleanup")
+	}
+}
diff --git a/core/auth/ui/go.mod b/core/auth/ui/go.mod
index 0930937..cf0dd37 100644
--- a/core/auth/ui/go.mod
+++ b/core/auth/ui/go.mod
@@ -1,16 +1,21 @@
 module github.com/giolekva/pcloud/core/auth/ui
 
-go 1.20
+go 1.22
 
 require (
 	github.com/gorilla/mux v1.8.0
 	github.com/itaysk/regogo v0.0.0-20200423164851-e9433c1fe5a7
+	github.com/mxschmitt/playwright-go v0.6100.0
 )
 
 require (
 	github.com/OneOfOne/xxhash v1.2.7 // indirect
+	github.com/deckarep/golang-set/v2 v2.8.0 // indirect
 	github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4 // indirect
+	github.com/go-jose/go-jose/v3 v3.0.5 // indirect
+	github.com/go-stack/stack v1.8.1 // indirect
 	github.com/gobwas/glob v0.2.3 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/open-policy-agent/opa v0.18.0 // indirect
 	github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0 // indirect
 	github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a // indirect
diff --git a/core/auth/ui/go.sum b/core/auth/ui/go.sum
index 69c993e..b548d65 100644
--- a/core/auth/ui/go.sum
+++ b/core/auth/ui/go.sum
@@ -5,14 +5,24 @@
 github.com/OneOfOne/xxhash v1.2.7/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
+github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4 h1:bRzFpEzvausOAt4va+I/22BZ1vXDtERngp0BNYDKej0=
 github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
+github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
+github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/golang/protobuf v0.0.0-20181025225059-d3de96c4c28e/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/gorilla/mux v0.0.0-20181024020800-521ea7b17d02/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
@@ -23,15 +33,22 @@
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/mattn/go-runewidth v0.0.0-20181025052659-b20a3daf6a39/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mna/pigeon v0.0.0-20180808201053-bb0192cfc2ae/go.mod h1:Iym28+kJVnC1hfQvv5MUtI6AiFFzvQjHcvI4RFTG/04=
+github.com/mxschmitt/playwright-go v0.6100.0 h1:HYNnbGZsTHz8veJyDGe4fU1iPxfvXqzmwKchzuvGCsY=
+github.com/mxschmitt/playwright-go v0.6100.0/go.mod h1:A7VtrS3j/c8ToGnSVUaOfNtQQVxi6JotUS0jeuus6r4=
 github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
 github.com/open-policy-agent/opa v0.18.0 h1:EC81mO3/517Kq5brJHydqKE5MLzJ+4cdJvUQKxLzHy8=
 github.com/open-policy-agent/opa v0.18.0/go.mod h1:6pC1cMYDI92i9EY/GoA2m+HcZlcCrh3jbfny5F7JVTA=
 github.com/peterh/liner v0.0.0-20170211195444-bf27d3ba8e1d/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc=
 github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0 h1:R+lX9nKwNd1n7UE5SQAyoorREvRn3aLF6ZndXBoIWqY=
 github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v0.0.0-20181025174421-f30f42803563/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@@ -39,35 +56,80 @@
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ=
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
+github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/cobra v0.0.0-20181021141114-fe5e611709b0/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/pflag v0.0.0-20181024212040-082b515c9490/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
-github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
+github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
+github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
-github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/yashtewari/glob-intersection v0.0.0-20180916065949-5c77d914dd0b h1:vVRagRXf67ESqAb72hG2C/ZwI8NtJF2u2V76EsuOHGY=
 github.com/yashtewari/glob-intersection v0.0.0-20180916065949-5c77d914dd0b/go.mod h1:HptNXiXVDcJjXe9SqMd0v2FsL9f8dz4GnXgltU6q/co=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/lint v0.0.0-20181023182221-1baf3a9d7d67/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/core/auth/ui/main.go b/core/auth/ui/main.go
index 73561e7..978ddc7 100644
--- a/core/auth/ui/main.go
+++ b/core/auth/ui/main.go
@@ -134,8 +134,8 @@
 	s.r.Path("/consent").Methods(http.MethodGet).HandlerFunc(s.consent)
 	s.r.Path("/consent").Methods(http.MethodPost).HandlerFunc(s.processConsent)
 	s.r.Path("/logout").Methods(http.MethodGet).HandlerFunc(s.logout)
-	s.r.Path("/change-password").Methods("POST").HandlerFunc(s.changePassword)
-	s.r.Path("/change-password").Methods("GET").HandlerFunc(s.changePasswordForm)
+	s.r.Path("/settings").Methods("POST").HandlerFunc(s.changePassword)
+	s.r.Path("/settings").Methods("GET").HandlerFunc(s.changePasswordForm)
 	s.r.Path("/").HandlerFunc(s.whoami)
 	return s.serv.ListenAndServe()
 }
@@ -593,20 +593,28 @@
 type changePasswordData struct {
 	Username       string
 	Password       string
+	CSRFToken      string
+	FormAction     string
 	PasswordErrors []ValidationError
 }
 
 func (s *Server) changePasswordForm(w http.ResponseWriter, r *http.Request) {
-	_, username, err := getWhoAmIFromKratos(r.Cookies())
-	if err != nil {
-		if errors.Is(err, ErrNotLoggedIn) {
-			http.Redirect(w, r, "/", http.StatusSeeOther)
-		} else {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-		}
+	flow := r.FormValue("flow")
+	if flow == "" {
+		http.Redirect(w, r, s.kratos+"/self-service/settings/browser", http.StatusSeeOther)
 		return
 	}
-	if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username}); err != nil {
+	_, username, err := getWhoAmIFromKratos(r.Cookies())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	csrfToken, err := getCSRFToken("settings", flow, r.Cookies())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, CSRFToken: csrfToken, FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow)}); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -617,28 +625,46 @@
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
+	flow := r.FormValue("flow")
+	if flow == "" {
+		http.Redirect(w, r, s.kratos+"/self-service/settings/browser", http.StatusSeeOther)
+		return
+	}
 	password := r.FormValue("password")
-	id, username, err := getWhoAmIFromKratos(r.Cookies())
+	_, username, err := getWhoAmIFromKratos(r.Cookies())
 	if err != nil {
-		if errors.Is(err, ErrNotLoggedIn) {
-			http.Redirect(w, r, "/", http.StatusSeeOther)
-		} else {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if verr := validatePassword(password); len(verr) > 0 {
+		if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: verr}); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
-	if verr, err := s.api.apiPasswordChange(id, username, password); err != nil {
+	resp, err := postFormToKratos("settings", flow, r.Cookies(), url.Values{
+		"csrf_token": {r.FormValue("csrf_token")},
+		"method":     {"password"},
+		"password":   {password},
+	})
+	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-	} else if len(verr) > 0 {
-		if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{username, password, verr}); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= http.StatusBadRequest {
+		if err := extractError(resp.Body); err != nil {
+			if renderErr := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: []ValidationError{{Field: "password", Message: err.Error()}}}); renderErr != nil {
+				http.Error(w, renderErr.Error(), http.StatusInternalServerError)
+			}
 			return
 		}
-	} else {
-		if err := s.tmpls.ChangePasswordSuccess.Execute(w, nil); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
+		http.Error(w, "password change failed", resp.StatusCode)
+		return
+	}
+	if err := s.tmpls.ChangePasswordSuccess.Execute(w, nil); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
 	}
 }
 
diff --git a/core/auth/ui/templates/change-password.html b/core/auth/ui/templates/change-password.html
index 4d0b487..ca5ea0b 100644
--- a/core/auth/ui/templates/change-password.html
+++ b/core/auth/ui/templates/change-password.html
@@ -4,7 +4,7 @@
 	<div class="logo">
 		<span>do</span><span>do:</span>
 	</div>
-	<form action="" method="POST">
+	<form action="{{ .FormAction }}" method="POST">
 		<label>
 			new password
 			<input type="password" name="password" aria-label="Password" value="{{ .Password }}" aria-invalid="{{ if .PasswordErrors }}true{{ else }}undefined{{ end }}" required/>
@@ -17,6 +17,8 @@
 		{{ end }}
 		{{ end }}
 		<button type="submit">change password</button>
+		<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
+		<input type="hidden" name="method" value="password" />
 		<input type="hidden" name="username" value="{{ .Username }}" />
 	</form>
 </div>
diff --git a/core/auth/ui/templates/whoami.html b/core/auth/ui/templates/whoami.html
index 944ed28..01be060 100644
--- a/core/auth/ui/templates/whoami.html
+++ b/core/auth/ui/templates/whoami.html
@@ -1,5 +1,6 @@
 {{ define "title" }}dodo: who am i{{ end }}
 {{ define "main" }}
 Hello {{.}}!
+<a href="/settings" role="button">change password</a>
 <a href="/logout" role="button">logout</a>
 {{ end }}