---
title: "fix: Stop baking API_AUTH_SECRET into the admin JS bundle"
type: fix
status: active
date: 2026-04-24
---

# fix: Stop baking API_AUTH_SECRET into the admin JS bundle

## Overview

The admin SPA build currently writes `VITE_API_AUTH_SECRET=${AUTH_SECRET}` into `apps/admin/.env.production`. Vite inlines that value at build time, so the secret ships in the public JS bundle — empirically confirmed on `https://admin.thinkwork.ai/assets/index-TL1AECJr.js`, which contains the literal `"tw-dev-secret"` 9 times. Any browser visitor can view-source, grab it, and call any `/api/*` handler with a forged `x-tenant-id` header to read/write another tenant's data.

This PR removes the baked-in secret from the admin build and migrates every admin REST call site to send the user's Cognito id token as the `Authorization` bearer. To preserve service continuity across the swap, every admin-reachable handler currently using `validateApiSecret` gets a minimal bridge so it also accepts Cognito JWTs (via the existing `authenticate()` helper). Full membership enforcement lands in a separate PR (B); this PR is strictly about removing the secret-in-bundle class of exposure.

---

## Problem Frame

Thinkwork is about to launch v1 as a multi-tenant product (4 enterprises × 100+ agents). The admin SPA is publicly reachable (login-gated at the app layer, not at the CDN). Today the secret-in-bundle class of exposure means:

1. **Pre-auth attack** — an anonymous browser can load the admin index.html, pull the JS bundle, grep out the secret, and hit any `/api/*` handler with `x-tenant-id: <any-victim-uuid>`. Login is not required.
2. **Handlers trust header tenantId** — nearly every handler reads `event.headers["x-tenant-id"]` as authoritative after `validateApiSecret`. The header is client-controllable.
3. **MCP fix is pending** — PR #518 is still open against main at the time of this plan. Its `requireTenantMembership` helper does not yet exist in the tree. This plan does NOT depend on #518 (no imports from `tenant-membership.ts`). If #518 merges first, this PR's bridged `mcp-admin-*` handlers are irrelevant because #518 replaces them with stricter enforcement; if this plan merges first, `mcp-admin-keys.ts` and `mcp-admin-provision.ts` are bridged here alongside the other 14 handlers and #518 rebases on top. Either ordering works — plan runs independently.

Removing the bundled secret breaks the "any browser visitor" attack class entirely. The bridge also changes the attacker model: before, a random visitor with the bundled secret could forge `x-tenant-id`; after, only authenticated Cognito users can (any account in the pool, including Google-federated ones). This is a smaller population than "anyone who can load the page" but larger than "anyone with the leaked secret." Closing the remaining cross-tenant attack class fully is PR B's job — U8 names the deploy-gate relationship to v1 launch so the residual does not ship into enterprise production unexamined.

---

## Requirements Trace

- **R1.** `curl https://admin.thinkwork.ai/assets/<bundle>.js | grep tw-dev-secret` returns zero matches after deploy.
- **R2.** Every admin UI feature that previously worked with `VITE_API_AUTH_SECRET` still works after this PR. Specifically: navigating routes, opening scheduled-job/webhook form dialogs, submitting forms, and the sidebar count queries.
- **R3.** `scripts/build-admin.sh` no longer writes `VITE_API_AUTH_SECRET`.
- **R4.** `apps/admin/.env.production` generated by the build script has no `VITE_API_AUTH_SECRET` line.
- **R5.** Backend handlers reachable by the admin SPA accept a valid Cognito id token bearer (in addition to their existing apikey path, which stays intact for CI/CLI — see Key Technical Decisions for how the apikey path is preserved without relying on `authenticate()`'s current behavior).
- **R6.** The CLI's current auth shape (`Authorization: Bearer <API_AUTH_SECRET>`, no `x-api-key` header) continues to work against every bridged handler post-merge. Verified by retaining a Bearer-as-apikey fallback in the handler bridge.

> **Non-requirement (scope note, not deliverable):** This PR does not enforce caller-to-tenant membership on REST handlers. Handlers continue to read tenantId from the `x-tenant-id` header. Tightening that is PR B. Captured here so a reviewer doesn't read it as oversight.

---

## Scope Boundaries

- **Not** enforcing caller-to-tenant membership on REST handlers (PR B).
- **Not** hardening AWS Scheduler / wakeup-processor callback authentication (PR C).
- **Not** migrating the apikey path to IAM-signed internal requests (longer-term).
- **Not** touching apps/mobile — mobile already sends Cognito tokens for REST.
- **Not** sweeping GraphQL auth — GraphQL already uses `authenticate()` and accepts Cognito JWTs.
- **Not** rotating the current `API_AUTH_SECRET` value itself — that is a separate ops task the user should do after this PR ships (since the secret has been public in the bundle, it should be treated as compromised). Called out in post-deploy notes, not performed by this plan.

### Deferred to Follow-Up Work

- **PR B: handler-side `requireTenantMembership` sweep** — replace the minimal JWT-accept bridge added here with full tenant-membership enforcement across the ~16 user-reachable handlers identified in the REST auth audit (the 14 bridged here plus `mcp-admin-keys` and `mcp-admin-provision` whether #518 or this plan lands first). **Timing constraint:** PR B must merge before any new enterprise tenant is onboarded to v1. Until then, any authenticated Cognito user can set `x-tenant-id` to a victim tenant UUID and read/write that tenant's data. The compensating control during the A→B window is that dev has a single-tenant-owner population; production enterprise onboarding cannot begin while the window is open.
- **PR C: scheduler callback hardening** — replace shared-secret auth on the `scheduled-jobs/:id/fire` and `trigger-runs/wakeup/:id` endpoints with HMAC-signed payloads or IAM-signed requests.

---

## Context & Research

### Relevant Code and Patterns

- **Pattern to follow for every migrated call site:** `apps/admin/src/lib/workspace-files-api.ts` already uses the user's Cognito id token for a REST call. Mirror its `request(body)` helper shape — call `await getIdToken()`, send `Authorization: Bearer ${token}`. That file was explicitly migrated off `VITE_API_AUTH_SECRET` and is the canonical admin-side reference.
- **Token source:** `useAuth().getToken()` from `apps/admin/src/context/AuthContext.tsx` in React components/routes; the lower-level `getIdToken()` from `apps/admin/src/lib/auth.ts` for non-React modules (the helper pattern that `workspace-files-api.ts` uses).
- **Backend bridge target:** `packages/api/src/lib/cognito-auth.ts` already exports `authenticate(headers)` that tries JWT first then falls back to apikey. Handlers doing `if (!validateApiSecret(token)) return unauthorized()` bridge cleanly to `const auth = await authenticate(headers); if (!auth) return unauthorized()`.
- **Call-site inventory (17 files in `apps/admin/src`):**
  - API modules: `lib/mcp-api.ts`, `lib/skills-api.ts`, `lib/guardrails-api.ts`, `lib/knowledge-base-api.ts`, `lib/builtin-tools-api.ts`
  - Context/providers: `context/TenantContext.tsx`
  - Components: `components/Sidebar.tsx`, `components/scheduled-jobs/ScheduledJobFormDialog.tsx`, `components/webhooks/WebhookFormDialog.tsx`, `components/agents/AgentScheduledJobs.tsx`
  - Routes: `routes/_authed/_tenant/scheduled-jobs/index.tsx`, `routes/_authed/_tenant/scheduled-jobs/$scheduledJobId.tsx`, `routes/_authed/_tenant/agents/$agentId.tsx`, `routes/_authed/_tenant/agents/$agentId_.scheduled-jobs.$scheduledJobId.tsx`, `routes/_authed/_tenant/agents/invites.tsx`, `routes/_authed/_tenant/webhooks/index.tsx`, `routes/_authed/_tenant/webhooks/$webhookId.tsx`
- **Handler audit (14 admin-reachable handlers currently on `validateApiSecret` only):** `activity.ts`, `agents.ts`, `agent-actions.ts`, `budgets.ts`, `connections.ts`, `guardrails-handler.ts`, `invites.ts`, `routines.ts`, `scheduled-jobs.ts`, `skills.ts`, `team-members.ts`, `teams.ts`, `tenants.ts`, `webhooks-admin.ts` (all need the JWT-accept bridge). `mcp-admin-keys.ts` and `mcp-admin-provision.ts` already landed `requireTenantMembership` via PR #518.

### Institutional Learnings

- `feedback_oauth_tenant_resolver.md`: `ctx.auth.tenantId` is null for Google-federated users until the Cognito pre-token trigger lands; resolvers must use `resolveCallerTenantId(ctx)` fallback. This PR does not enforce tenantId at the handler, so the fallback is not on the critical path here — flagged for PR B.
- `feedback_mobile_cognito_sync_invariant.md`: getCurrentUser() + CognitoSecureStorage hydration must stay sync in mobile. Admin is different — still useful as a reminder that session-restore is subtle.
- `project_mobile_auth_google_oauth.md`: Eric signs in via Google, not a password. The admin id token is a Google-federated Cognito id token in practice; the plan's auth path must not assume a native Cognito password user.
- `project_admin_worktree_cognito_callbacks.md`: concurrent admin vite ports (5174, 5175+) must be listed in the Cognito `ThinkworkAdmin` client's CallbackURLs or Google OAuth fails with `redirect_mismatch`. Not something this PR changes, but flagged because the implementer will be running the admin dev server to test.
- **Prior migration model:** the commit that moved `workspace-files-api.ts` off `VITE_API_AUTH_SECRET` is the working example — its top-of-file comment documents the migration. Use that as the reference diff when planning each call-site edit.

### External References

No external research. The codebase already has a working admin+mobile Cognito bearer pattern and a working `authenticate()` helper on the server — this is a grep-and-replace + handler bridge, not a design exploration.

---

## Key Technical Decisions

- **Do not split into sub-PRs.** Removing `VITE_API_AUTH_SECRET` from the build without migrating every call site breaks the SPA on merge. Removing the env var and migrating call sites must land together. The handler-side bridge also has to land in the same PR — otherwise, a SPA deployment that reaches a handler before the bridge deploys would 401. Single PR, three coordinated changes.
- **Use `authenticate()` for the cognito path, and extend it (or add a thin wrapper) so `Authorization: Bearer <API_AUTH_SECRET>` also satisfies the apikey check.** The helper in `packages/api/src/lib/cognito-auth.ts` as of main accepts apikey values only via the `x-api-key` header. The thinkwork CLI (`apps/cli/src/api-client.ts`) and Strands container both send the secret as `Authorization: Bearer <secret>` with no `x-api-key`. A literal swap to `authenticate()` would 401 every CLI + container call. Fix: add a Bearer-as-apikey fallback to `authenticate()` itself — after JWT verification fails and before returning null, re-check the bearer against `acceptedApiKeys()` and return an apikey `AuthResult` if it matches. This keeps the helper the single auth entry point for REST handlers and preserves every current service-to-service caller. Touched file list: `packages/api/src/lib/cognito-auth.ts` (enhance `authenticate()`), `packages/api/src/lib/cognito-auth.test.ts` (add a "Bearer-as-apikey accepted" case). This is a 5-line helper change that absorbs the CLI back-compat concern centrally.
- **Handler bridge varies by handler shape, not always a one-line swap.** For the majority (activity, agents, agent-actions, budgets, guardrails-handler, routines, scheduled-jobs, team-members, teams, tenants, webhooks-admin, invites), the existing pattern `extractBearerToken` + `validateApiSecret` → `authenticate(event.headers)` is a drop-in replacement once the bearer-fallback above is in place. Three handlers need specific care: `skills.ts` has a multi-token + AppSync-API-key acceptance branch that must be preserved; `connections.ts` currently has a permissive `!apiKey` check that accepts any `x-api-key` value without validation and swapping to strict `authenticate()` will tighten the bar (verify app-manager sends `GRAPHQL_API_KEY` or `API_AUTH_SECRET` before swapping); `invites.ts` handles three public routes before the auth gate and the gate swap must stay positioned after the public-route dispatch. U7 calls these out explicitly.
- **Keep the `x-tenant-id` header trust intact for now.** Handlers continue to read tenantId from the header. PR B replaces that with membership-verified tenantId. Documenting this explicitly so a reviewer doesn't think the residual gap is oversight.
- **Centralize the admin fetch helper.** Every migrated call site will go through a tiny `apiFetch(path, options)` wrapper in `apps/admin/src/lib/api-fetch.ts` that reads the id token via `getIdToken()`. When no token is available the helper throws a dedicated `NotReadyError` (distinct from a generic `ApiError`), so callers in high-traffic contexts (`Sidebar.tsx`, route loaders) can catch it and retry on the next render instead of surfacing it to the router/ErrorBoundary. This resolves the throw-vs-graceful-retry tension between U1 and U4 by giving callers a typed signal to distinguish "auth hydrating" from "auth expired."
- **Normalization of header case.** API Gateway HTTP API v2 delivers headers already lowercased (per the AWS contract); `cognito-auth.ts`'s `authenticate()` reads `headers["authorization"]` and `headers["x-api-key"]` directly without case folding. No `normalizeHeaders` wrapper is required. (The plan previously referenced a `normalizeHeaders` helper from `tenant-membership.ts` — that file doesn't exist on main and is not a dependency of this PR.)
- **Keep `VITE_API_AUTH_SECRET` as a build-time fallback for local-dev convenience: NO.** If we keep it as "optional," someone will set it in local dev, push the build, and we're back to baking the secret. Full removal is safer. Local dev uses Cognito just like prod.

---

## Open Questions

### Resolved During Planning

- **Does `authenticate()` currently accept `Authorization: Bearer <API_AUTH_SECRET>` (CLI back-compat)?** No. As of main, `authenticate()` only accepts the apikey via the `x-api-key` header. The CLI sends the secret via `Authorization: Bearer …` only. A literal swap to `authenticate()` in the handlers would 401 the CLI. Resolution: U7 extends `authenticate()` with a Bearer-as-apikey fallback (5-line change in `cognito-auth.ts`); this restores CLI + Strands-container compatibility via the single helper.
- **Does a valid Cognito id token work through `authenticate()`'s Authorization-header branch?** Yes — the JWT-verify branch reads the Authorization header first and succeeds on a valid id token from the configured pool. No change needed on the cognito side.
- **Does every admin-reachable handler currently use the `x-tenant-id` header (and therefore survive the bridge swap)?** Mostly yes; 11 of the 14 handlers use the straight `extractBearerToken` + `validateApiSecret` gate and can be swapped directly. `skills.ts`, `connections.ts`, and `invites.ts` have handler-specific auth shapes (AppSync API key acceptance, permissive `!apiKey` branch, public-route-before-gate) and need targeted care — see U7's per-handler notes.
- **Do we need Cognito env vars wired into every bridged handler's Lambda?** Yes, and largely already done: `terraform/modules/app/lambda-api/handlers.tf` injects `COGNITO_USER_POOL_ID` + `COGNITO_APP_CLIENT_IDS` into `common_env`. U7 verifies each bridged handler Lambda inherits `common_env` rather than assuming it — a single grep check, not a rewrite.
- **Is `tenant-membership.ts` / `normalizeHeaders` / PR #518 a dependency of this plan?** No — removed. The plan previously referenced PR #518's helpers; it now uses `authenticate()` directly from `cognito-auth.ts` and takes the case-folding safety from API Gateway's own lowercasing guarantee.

### Deferred to Implementation

- **Exact `apiFetch` helper shape for admin.** The name, signature, and file path are specified (`apps/admin/src/lib/api-fetch.ts`), but the error-handling ergonomics (e.g., does it throw on 4xx or return a typed result?) can match whatever the existing lib modules expect. Resolve during U1.
- **Will any current admin REST call site expect a non-JSON response?** The existing handlers all return JSON, but a streaming or binary response would need different handling. If one surfaces, address inline.
- **Does `apps/admin/src/lib/auth.ts` export `getIdToken` today, or is it only reachable via `useAuth().getToken()`?** `workspace-files-api.ts` (non-React module) imports `getIdToken` directly, so it's exported. Verify at execution time and, if the export is private, expose it.

---

## Implementation Units

- [ ] U1. **Introduce admin `apiFetch` helper**

**Goal:** Centralize the "REST call with Cognito id token" pattern into a single admin-side helper so per-call-site migrations are one-line imports.

**Requirements:** R2, R5

**Dependencies:** None

**Files:**
- Create: `apps/admin/src/lib/api-fetch.ts`
- Test: `apps/admin/src/lib/api-fetch.test.ts`

**Approach:**
- Export `apiFetch<T>(path: string, options?: RequestInit, extraHeaders?: Record<string,string>): Promise<T>`.
- Export two typed error classes: `NotReadyError` (thrown when `getIdToken()` returns null — auth hydrating) and `ApiError` (thrown on non-2xx responses, carrying `status` and parsed body). Callers in high-traffic contexts (`Sidebar.tsx`, route loaders) catch `NotReadyError` and retry on the next render instead of surfacing to the router.
- Internally: `const token = await getIdToken(); if (!token) throw new NotReadyError("Auth not ready");` then fetch with `Authorization: Bearer ${token}` + `Content-Type: application/json`.
- Spread existing headers so callers that also set `x-tenant-id` still work (handlers still read the header until PR B). Add a TODO comment noting this pass-through should be removed as part of PR B so future contributors see the intended lifetime.
- On non-2xx: parse the body as JSON best-effort, throw `new ApiError(status, bodyJsonOrText)`. If JSON parse fails, attach the raw text to `ApiError.body` rather than propagating the parse failure.
- Read `VITE_API_URL` for the base URL (already the convention; used by `workspace-files-api.ts`).

**Patterns to follow:**
- `apps/admin/src/lib/workspace-files-api.ts` — already uses `await getIdToken()` + `Authorization: Bearer`. Mirror its token acquisition and error-shape.
- `apps/mobile/lib/workspace-api.ts` for the equivalent shape on mobile (useful cross-check).

**Test scenarios:**
- Happy path: `apiFetch("/api/foo")` with `getIdToken` returning a token → fetch gets called with `Authorization: Bearer <token>` and `Content-Type: application/json`; response JSON is parsed and returned.
- Happy path: `apiFetch("/api/foo", { method: "POST", body })` → body is forwarded; method override is honored; default headers are preserved.
- Edge case: caller passes `extraHeaders: { "x-tenant-id": "abc" }` → the header reaches fetch.
- Error path: `getIdToken()` resolves null → helper throws `NotReadyError` (distinct class, not generic `Error`) before any fetch happens.
- Error path: fetch response is 401 → helper throws an `ApiError` whose `status=401` and whose parsed body is attached.
- Error path: fetch response body is not valid JSON → helper throws an `ApiError` with the raw text attached on `.body` rather than propagating the parse failure.
- Error-class distinguishability: `NotReadyError` and `ApiError` are both exported and `instanceof`-checkable by callers.

**Verification:**
- `apps/admin` typechecks and the new module's unit tests pass.
- The helper is consumable from both plain TS modules (lib/*) and React components (routes/*).

---

- [ ] U2. **Migrate admin lib/*.ts modules to `apiFetch`**

**Goal:** Replace `VITE_API_AUTH_SECRET` usage in the 5 centralized admin API modules.

**Requirements:** R1, R2

**Dependencies:** U1

**Files:**
- Modify: `apps/admin/src/lib/mcp-api.ts`
- Modify: `apps/admin/src/lib/skills-api.ts`
- Modify: `apps/admin/src/lib/guardrails-api.ts`
- Modify: `apps/admin/src/lib/knowledge-base-api.ts`
- Modify: `apps/admin/src/lib/builtin-tools-api.ts`

**Approach:**
- For each module: delete the `const API_AUTH_SECRET = import.meta.env.VITE_API_AUTH_SECRET || ""` line and the `Authorization: Bearer ${API_AUTH_SECRET}` header. Replace fetch calls with `apiFetch(path, options, extraHeaders)`.
- Preserve every existing `x-tenant-id` or other extra header via the `extraHeaders` parameter — handlers still need them (until PR B).
- No behavioral change to response handling; the helper surfaces the same shape callers expect.

**Patterns to follow:**
- The workspace-files-api.ts shape, now standardized via `apiFetch`.

**Test scenarios:**
- Per-module smoke: existing unit tests (if any) pass after the swap. For modules without tests, rely on type-check + manual verification in U6.
- Static check: `grep -rn "VITE_API_AUTH_SECRET" apps/admin/src/lib/` returns no matches after this unit.

**Verification:**
- `VITE_API_AUTH_SECRET` is gone from all 5 lib modules.
- Typecheck passes.
- None of the modified modules import from the old secret env var.

---

- [ ] U3. **Migrate admin route components to `apiFetch`**

**Goal:** Replace `VITE_API_AUTH_SECRET` usage in the 7 admin route files that make REST calls.

**Requirements:** R1, R2

**Dependencies:** U1

**Files:**
- Modify: `apps/admin/src/routes/_authed/_tenant/scheduled-jobs/index.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/scheduled-jobs/$scheduledJobId.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/agents/$agentId.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/agents/$agentId_.scheduled-jobs.$scheduledJobId.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/agents/invites.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/webhooks/index.tsx`
- Modify: `apps/admin/src/routes/_authed/_tenant/webhooks/$webhookId.tsx`

**Approach:**
- For each route: remove the `const API_AUTH_SECRET = import.meta.env.VITE_API_AUTH_SECRET || ""` line and the `Authorization: Bearer ${API_AUTH_SECRET}` header. Replace inline `fetch()` calls with `apiFetch()` (or `useAuth().getToken()` + fetch if the call must stay inline for React-Query integration).
- Preserve route-specific headers like `x-tenant-id` and `Content-Type` via the `extraHeaders` parameter.
- No UI/UX change.

**Patterns to follow:**
- U1's `apiFetch` helper.
- Existing routes that already use Cognito session data (any route already reading `useAuth()` serves as a reference for React ergonomics).

**Test scenarios:**
- Manual (in U6): navigating to each of these routes in the admin dev server triggers the expected API calls with a valid Cognito bearer and returns data; form submissions succeed.
- Static check: `grep -rn "VITE_API_AUTH_SECRET" apps/admin/src/routes/` returns no matches after this unit.

**Verification:**
- `VITE_API_AUTH_SECRET` is gone from all 7 routes.
- Each route renders and exercises its REST call successfully with a real Cognito session in dev.

---

- [ ] U4. **Migrate admin component files to `apiFetch`**

**Goal:** Replace `VITE_API_AUTH_SECRET` usage in the remaining 4 admin component files: `Sidebar.tsx`, `ScheduledJobFormDialog.tsx`, `WebhookFormDialog.tsx`, `AgentScheduledJobs.tsx`.

**Requirements:** R1, R2

**Dependencies:** U1

**Files:**
- Modify: `apps/admin/src/components/Sidebar.tsx`
- Modify: `apps/admin/src/components/scheduled-jobs/ScheduledJobFormDialog.tsx`
- Modify: `apps/admin/src/components/webhooks/WebhookFormDialog.tsx`
- Modify: `apps/admin/src/components/agents/AgentScheduledJobs.tsx`

**Approach:**
- Same pattern as U3: replace the secret-header path with `apiFetch`.
- `Sidebar.tsx` is the highest-traffic call site (fires on every tenant-scoped page load) — verify the helper handles the "token not yet available" window gracefully, falling back to a retry on next render rather than throwing into the router.

**Test scenarios:**
- Manual (in U6): sidebar badges render correct counts; scheduled-job form dialog creates a job end-to-end; webhook form dialog creates a webhook end-to-end; agent scheduled-jobs view updates after a job create.
- Static check: `grep -rn "VITE_API_AUTH_SECRET" apps/admin/src/components/` returns no matches after this unit.

**Verification:**
- `VITE_API_AUTH_SECRET` is gone from all 4 components.
- The dialogs and sidebar reliably use the Cognito bearer in network calls (confirm in dev tools Network panel).

---

- [ ] U5. **Migrate `TenantContext.tsx` bootstrap fetch**

**Goal:** Replace the secret-bearer call in the tenant bootstrap — this is the first REST call the SPA makes, so extra care is needed.

**Requirements:** R1, R2

**Dependencies:** U1

**Files:**
- Modify: `apps/admin/src/context/TenantContext.tsx`

**Approach:**
- Remove the `VITE_API_AUTH_SECRET` usage at line 42 and in the fetch header around line 67–69. Swap for `apiFetch(`/api/tenants/${tenantId}`, ...)`.
- Critical sequencing: `TenantContext` runs during the boot sequence, potentially before the Cognito session is fully hydrated. `AuthContext` must be available (provider wrapping order) and `getIdToken` must return a non-null token by the time this code runs. If there's a race, gate the fetch on the auth provider's `isAuthenticated`/`isReady` signal rather than firing unconditionally.
- Do **not** change the existing OAuth-refresh path (`feedback_mobile_oauth_ephemeral_session.md` is mobile-only, but the admin has its own Google-federated session — session-restore uses the refresh-token path per `project_mobile_auth_google_oauth.md`; honor the same invariant here by not calling `restoreWithCredentials` or equivalent).

**Execution note:** This is the riskiest unit because a broken tenant bootstrap breaks the whole SPA. Verify in the dev server with a fresh-login flow, a reload flow, and a cold-cache flow before calling it done.

**Patterns to follow:**
- U1's `apiFetch`.
- The existing `AuthContext` provider initialization — mirror its "wait until token is available" posture.

**Test scenarios:**
- Happy path (manual, U6): fresh sign-in → tenant bootstrap fetches successfully → user lands on the dashboard.
- Happy path (manual, U6): reload an already-authenticated session → tenant bootstrap re-fetches with a valid token → dashboard restores.
- Edge case: sign out, then sign back in → context re-initializes cleanly (no stale `x-tenant-id` bleeding between sessions).

**Verification:**
- `VITE_API_AUTH_SECRET` is gone from `TenantContext.tsx`.
- Admin dev server boot is indistinguishable from pre-change in both fresh-login and reload flows.

---

- [ ] U6. **Remove `VITE_API_AUTH_SECRET` from build tooling**

**Goal:** Strip the env var from the build script and the generated `.env.production` file so the secret can never leak into a future bundle.

**Requirements:** R3, R4

**Dependencies:** U2, U3, U4, U5 (all call sites must be migrated first, otherwise a build produces a broken SPA)

**Files:**
- Modify: `scripts/build-admin.sh` (remove `VITE_API_AUTH_SECRET=` line + dead `AUTH_SECRET=` resolution)
- Modify: `apps/admin/.env` (remove the `VITE_API_AUTH_SECRET=tw-dev-secret` line — the checked-in `.env` is Vite's default baseline and is read in all modes; leaving it bakes the secret into every build regardless of what `.env.production` says)
- Modify: `.github/workflows/deploy.yml` (remove `API_AUTH_SECRET: ${{ secrets.API_AUTH_SECRET }}` from the admin deploy step's env block; the server-side Lambda env injection is a separate path and stays untouched)
- Verify: `apps/admin/vite.config.ts` does not whitelist the prefix (grep only — no change expected)
- Delete (if present): any local `.env.production` or `.env.local` in `apps/admin/` that writes the var

**Approach:**
- Delete the `VITE_API_AUTH_SECRET=${AUTH_SECRET}` line in `scripts/build-admin.sh:61` AND the now-dead `AUTH_SECRET="${API_AUTH_SECRET:-}"` line at `scripts/build-admin.sh:46`. Full removal, not conditional — Key Technical Decisions resolved this: no local-dev fallback.
- Edit `apps/admin/.env` to remove the `VITE_API_AUTH_SECRET=tw-dev-secret` line. This is critical — Vite layers `.env` under every mode, so stripping only `.env.production` leaves the baseline value active.
- Remove `API_AUTH_SECRET: ${{ secrets.API_AUTH_SECRET }}` from `deploy.yml`'s admin deploy step. Confirm the build step still runs cleanly without it.
- Confirm the admin build still succeeds without the var set.

**Patterns to follow:**
- The existing `VITE_GRAPHQL_API_KEY` (AppSync API key) line in the script remains — that's a different, scoped credential.

**Test scenarios:**
- Build the admin app locally: `bash scripts/build-admin.sh dev` → succeeds.
- Check `.env.production` after the build: no `VITE_API_AUTH_SECRET` line.
- Grep the built bundle: `grep -c "tw-dev-secret" apps/admin/dist/assets/*.js` → 0 matches.

**Verification:**
- Build passes with the env var absent.
- Deployed bundle has no occurrence of the secret value.

---

- [ ] U7. **Bridge admin-reachable REST handlers to accept Cognito JWTs**

**Goal:** For each of the 14 handlers the admin SPA now hits with a Cognito id token, accept the JWT in addition to the existing apikey path — so the SPA's migrated calls succeed.

**Requirements:** R5, R6

**Dependencies:** None (can land in parallel with U2-U5, but MUST land before U6 is deployed — otherwise the SPA's calls will 401)

**Files:**
- Modify: `packages/api/src/lib/cognito-auth.ts` (enhance `authenticate()` with Bearer-as-apikey fallback; ~5 lines)
- Modify: `packages/api/src/lib/cognito-auth.test.ts` (add "Bearer <API_AUTH_SECRET> accepted" + "Bearer <junk> rejected" cases)
- Modify (drop-in swap; 11 handlers): `packages/api/src/handlers/activity.ts`, `agents.ts`, `agent-actions.ts`, `budgets.ts`, `guardrails-handler.ts`, `routines.ts`, `scheduled-jobs.ts`, `team-members.ts`, `teams.ts`, `tenants.ts`, `webhooks-admin.ts`
- Modify (requires specific care): `packages/api/src/handlers/skills.ts`, `packages/api/src/handlers/connections.ts`, `packages/api/src/handlers/invites.ts` (see per-handler notes below)
- Test: `packages/api/src/__tests__/admin-rest-auth-bridge.test.ts` (shared table-driven test harness)

**Approach:**
- **Step 1 — enhance `authenticate()`.** Open `cognito-auth.ts`. After JWT verification fails and the existing `x-api-key` branch fails, add a third branch: if the Authorization header carried a Bearer token and that token value is in `acceptedApiKeys()`, return an apikey `AuthResult` (`principalId = headers["x-principal-id"] ?? null`, same as the x-api-key branch). This is the entire back-compat restoration for CLI + Strands container callers.
- **Step 2 — drop-in swap (11 handlers).** Replace the pattern:
  ```
  const token = extractBearerToken(event);
  if (!token || !validateApiSecret(token)) return unauthorized();
  ```
  with:
  ```
  const auth = await authenticate(event.headers);
  if (!auth) return unauthorized();
  ```
  Import: `import { authenticate } from "../lib/cognito-auth.js";`. No `normalizeHeaders` wrapper — API Gateway HTTP API v2 delivers lowercased headers by contract and `authenticate()` reads them directly. No other code changes in these 11 handlers: all of them continue to read `x-tenant-id` from the header downstream.
- **Step 3 — skills.ts (special care).** Current code (lines 66-71) coerces `x-api-key` into the `token` variable and checks it against both `API_AUTH_SECRET` and `APPSYNC_API_KEY` / `GRAPHQL_API_KEY`. Preserve both acceptance paths by calling `authenticate(event.headers)` (which already accepts `GRAPHQL_API_KEY` via `acceptedApiKeys()`). Keep the pre-auth OAuth branch at lines 57-63 positioned BEFORE the gate. Confirm mobile/app-manager callers supply the AppSync API key via `x-api-key` (not via Authorization) — if they use Authorization-Bearer, Step 1's fallback covers it.
- **Step 4 — connections.ts (intentional tightening).** Current gate at line 60 is `if (!(token && validateApiSecret(token)) && !apiKey) return unauthorized()` — note the bare `!apiKey` which passes ANY `x-api-key` value without validation. Swap to `authenticate(event.headers)`. Before swapping, grep the repo + deployed envs for what app-manager actually sends (`GRAPHQL_API_KEY`, `API_AUTH_SECRET`, or a distinct value): if app-manager's key is not in `acceptedApiKeys()`, either register it there or document the intentional deprecation of the permissive branch.
- **Step 5 — invites.ts (structural care).** Handler serves three public routes (`GET /api/invites/:token`, `POST /api/invites/:token/accept`, `POST /api/join-requests/:id/claim-api-key`) BEFORE the auth gate at line 103. The gate swap is mechanically a drop-in, but the gate MUST remain below the public-route dispatch — do not reorder the function body.
- **Terraform verification:** Grep `terraform/modules/app/lambda-api/` for each of the 14 handler Lambda resources and confirm each includes `merge(local.common_env, …)` in its environment block. (`common_env` is where `COGNITO_USER_POOL_ID` + `COGNITO_APP_CLIENT_IDS` live.) If any handler uses a bespoke env block without `common_env`, add the merge. Expected: all 14 inherit; this is a confirmation, not a rewrite.

**Execution note:** Land the `authenticate()` enhancement + its unit tests first (safe, additive, covered by the existing cognito-auth.test.ts pattern). Then the 11 drop-in handlers. Then the 3 special-care handlers one at a time with their own tests. The shared `admin-rest-auth-bridge.test.ts` runs at the end to assert every handler passes both JWT and Bearer-apikey paths uniformly.

**Patterns to follow:**
- The existing `authenticate()` implementation in `packages/api/src/lib/cognito-auth.ts` — follow its `AuthResult` return shape and `acceptedApiKeys()` helper when adding the Bearer-fallback branch.

**Test scenarios:**
- **Authenticate enhancement:**
  - Happy path: Authorization: Bearer <JWT-from-pool> → returns cognito `AuthResult` with `principalId = sub`.
  - Happy path: Authorization: Bearer <API_AUTH_SECRET> (no x-api-key) → returns apikey `AuthResult`.
  - Happy path: x-api-key: <API_AUTH_SECRET> (no Authorization) → returns apikey `AuthResult` (existing behavior preserved).
  - Error path: Authorization: Bearer <random-string> + no x-api-key → returns null.
  - Error path: no headers → returns null.
  - Edge case: Authorization: Bearer <expired-JWT> → JWT verify fails, fallback checks against apikey list, returns null (does not mistake an expired JWT for an apikey).
- **Handler bridge (table-driven across all 14):**
  - Happy path: each handler with Authorization: Bearer <valid-JWT> → proceeds past the auth gate (status != 401 on a primary GET/POST route).
  - Happy path: each handler with Authorization: Bearer <API_AUTH_SECRET> → continues to work (CLI back-compat).
  - Error path: invalid JWT + no apikey → 401.
  - Error path: no auth header → 401.
  - Edge case: missing `COGNITO_USER_POOL_ID` env → `authenticate()` JWT branch throws in verify(), caught by existing try/catch, falls through → apikey branch also fails → null → 401 (not 500). Test stubs the env absence.

**Verification:**
- `packages/api/src/lib/cognito-auth.test.ts` adds the Bearer-as-apikey cases and passes.
- Every existing handler test still passes.
- The shared auth-bridge test passes for all 14 handlers on both JWT and Bearer-apikey paths.
- `pnpm --filter @thinkwork/api test` is green.

---

- [ ] U8. **Smoke-test end-to-end in dev**

**Goal:** Verify the whole flow works in the deployed dev stage and pre-check the bundle does not contain the secret string.

**Requirements:** R1, R2, R6

**Dependencies:** U1, U2, U3, U4, U5, U6, U7

**Files:**
- Modify: `docs/solutions/security/admin-secret-in-bundle-remediation-2026-04-24.md` (create — attack shape closed, residual surface, pointer to PR B + U9)

**Approach:**
- Deploy the PR to dev.
- **Bundle check:** `curl https://admin.thinkwork.ai/assets/<hash>.js | grep -c tw-dev-secret` returns 0. Additionally, `grep -rn VITE_API_AUTH_SECRET apps/admin/src/` returns only the top-of-file comment in `workspace-files-api.ts` (historic documentation of the prior migration) — no live references.
- **Cross-reference check:** grep the admin tree for `fetch("/api/` or `fetch(\`${API_URL}/api/` to cross-reference which routes the SPA hits vs. the 14 bridged handlers. If any admin call site hits a handler NOT on the bridged list (e.g., `messages.ts`, `github-repos.ts`, `sandbox-*`), flag it as a missed migration candidate — either add it to U7 or document why it's not admin-reachable.
- **UI walkthrough:** navigate each migrated surface in a real browser against dev: login, dashboard load (TenantContext), sidebar counts (Sidebar), a scheduled-job CRUD, a webhook CRUD, a skills view, a guardrail view, an agent detail. Confirm all fetches succeed and network panel shows `Authorization: Bearer eyJ…` (JWT prefix).
- **CLI back-compat:** run `thinkwork mcp key create -t <tenant>` (or any `thinkwork` command that hits a bridged handler) against dev. Confirm it succeeds — proves Bearer-as-apikey fallback works end-to-end.
- Write the solutions doc.

**Test scenarios:**
- Manual end-to-end walkthrough (not automated).

**Verification:**
- No `tw-dev-secret` in deployed bundle.
- Every admin surface works; network traffic uses Cognito JWTs.
- `thinkwork` CLI continues to work.
- Solutions doc committed.

---

- [ ] U9. **Rotate the `API_AUTH_SECRET` value as a blocking deploy step**

**Goal:** The secret `tw-dev-secret` has been publicly readable in every shipped admin bundle for the life of the service. Even after U6 strips it from future bundles, every historical bundle copy (CDN caches, user archives, browser history) still contains the exfiltratable value. Until rotation, the exposure window stays open. This unit closes it.

**Requirements:** R1 (completion — bundle stripping alone is preparatory, rotation is the actual exposure closure)

**Dependencies:** U6, U7, U8 (deploy completed, handlers accept Cognito JWT, smoke test passes — rotation cannot happen until the Lambdas accept the replacement auth path, or CI/Strands/CLI will 401 on the mid-rotation window)

**Files:**
- Modify: `terraform/examples/greenfield/terraform.tfvars` (change `api_auth_secret` value; do not commit the new value — paste locally or source from a throwaway env var per existing tfvars hygiene)
- Modify: GitHub Actions `secrets.API_AUTH_SECRET` (via `gh secret set`)
- Trigger: a fresh CI deploy after both rotations so Lambda env vars pick up the new value

**Approach:**
- Generate a new secret via `openssl rand -base64 48` (or similar), keeping it out of shell history.
- Update the CI secret first: `gh secret set API_AUTH_SECRET` with the new value.
- Update `terraform.tfvars` locally with the same value (this file is never committed per repo policy).
- Trigger a fresh deploy (`gh workflow run deploy.yml`) — Terraform rotates the Lambda env var + Secrets Manager copy. Watch the deploy complete.
- Verify the old value is rejected: `curl -H "Authorization: Bearer tw-dev-secret" https://<api>/api/tenants` should return 401 after the new value lands.
- Verify the new value is accepted: CLI `thinkwork me` (which uses the new secret from `~/.thinkwork/config.json` if updated) returns a valid identity payload.
- Document rotation date + new-secret fingerprint (sha256 of first 8 chars, not the full value) in the solutions doc from U8.

**Execution note:** This is an ops step that must run in-order after the deploy completes. Do not skip or defer. The old secret is effectively a P0 credential leak; rotating it is the actual remediation.

**Patterns to follow:**
- Memory: `project_tfvars_secrets_hygiene.md` ("`terraform.tfvars` holds plaintext secrets; migrate to SSM when prod lands") — this rotation is done in-place; SSM migration is a separate follow-up.

**Test scenarios:**
- Post-rotation: old value (`tw-dev-secret`) returns 401 from a bridged handler — evidence rotation took effect.
- Post-rotation: new value returns 200 from a bridged handler via either `Authorization: Bearer` or `x-api-key`.
- Post-rotation: CLI works end-to-end against dev with the new value in `~/.thinkwork/config.json`.

**Verification:**
- Old secret rejected; new secret accepted.
- Solutions doc updated with rotation timestamp.
- CI deploy finishes green after rotation.

---

## System-Wide Impact

- **Interaction graph:**
  - Admin SPA REST fetch calls now depend on `AuthContext` being ready when a call fires. `Sidebar` (tenant page load) and `TenantContext` (boot) are the most sensitive; route components fire later and are safer.
  - Backend handlers' auth gate changes from "secret match" to "JWT verify OR secret match." The JWT verifier uses `aws-jwt-verify` which makes a JWK fetch on first call — handler cold-start latency increases slightly (one-time per container).
- **Error propagation:**
  - The `apiFetch` helper should throw `ApiError` on 4xx so call sites can surface "Not authenticated" messages; blanket `throw new Error("HTTP 401")` is not enough because route components need to distinguish auth-expired from tenant-permission errors.
- **State lifecycle risks:**
  - Stale Cognito tokens: if a user's session expires mid-page, a call fires with an expired token and the handler returns 401. The existing `AuthContext` already handles refresh; verify it invalidates and refetches rather than throwing the user back to login unconditionally.
- **API surface parity:**
  - Mobile already does this. No parity work. CLI uses `Bearer <API_AUTH_SECRET>` for service-to-service — stays on the apikey path via `authenticate()`'s fallback.
- **Integration coverage:**
  - "Valid Cognito session + migrated SPA call + bridged handler" is the integration scenario that unit tests alone won't prove. U8's manual E2E walkthrough is the coverage.
- **Unchanged invariants:**
  - The shared `API_AUTH_SECRET` apikey path remains valid for all existing service-to-service callers (CLI, Strands container, GitHub Actions). This PR does not change how they authenticate.
  - Handlers continue to read `x-tenant-id` from the header for tenant resolution. That invariant is the next PR's target, not this one's.

---

## Risks & Dependencies

| Risk | Mitigation |
|------|------------|
| SPA deploys before handler bridge is live → every REST call 401s | Single PR, single merge. All changes ship together. `ce-work` execution order pins U7 (handler bridge) + U6 (build strip) atomically. |
| TenantContext bootstrap fires before Cognito token is available → blank dashboard | U5's execution note: verify in fresh-login, reload, and cold-cache flows before marking the unit done. Gate the fetch on `AuthContext.isReady` if a race is observed. |
| A call site uses an undocumented header (e.g., webhooks with a signing header) that the helper drops | `apiFetch` accepts `extraHeaders` — migration is "wrap fetch, keep any extra headers as a pass-through." The grep in U8 cross-checks every call site. |
| Cognito env vars missing or misconfigured in a Lambda → every bridged handler 500s | U7 test scenario: stubbed missing env returns 401 not 500. Also — env vars are already injected for GraphQL via `common_env`; same inheritance applies to every handler Lambda. |
| Post-deploy: the old `API_AUTH_SECRET` value is public and must still be rotated | U8's solutions doc calls this out as a follow-up ops task. The PR itself does not rotate — rotation is a separate, deliberate action by the user. |

---

## Documentation / Operational Notes

- **`docs/solutions/security/admin-secret-in-bundle-remediation-2026-04-24.md`** (created in U8): short incident-style writeup of the attack class that was closed, the residual risk, and the rotation recommendation.
- **Post-deploy ops action:** the value of `API_AUTH_SECRET` in `terraform/examples/greenfield/terraform.tfvars` (currently `tw-dev-secret`) has been publicly readable for the life of every admin bundle it shipped in. It should be rotated once this PR is merged + deployed. Not part of this plan; flagged for the user.
- **CLAUDE.md update candidate:** add a line under "Secrets + config" noting that `API_AUTH_SECRET` must never be exposed to `VITE_*` env vars. Include in U8's solutions doc; a later PR can promote it into CLAUDE.md if the pattern recurs.

---

## Sources & References

- REST auth audit, in-session (no artifact committed to repo yet — see chat history and the audit table in the PR #518 discussion thread).
- Prior PR #518 (`feat(api): enforce tenant-membership gate on MCP admin-key endpoints`) — the closest-in-tree precedent for handler auth changes.
- `apps/admin/src/lib/workspace-files-api.ts` — existing example of the admin SPA using a Cognito bearer for REST.
- `apps/mobile/lib/workspace-api.ts` — mobile mirror of the same pattern.
- `packages/api/src/lib/cognito-auth.ts` — the `authenticate()` helper that powers the bridge.
- `packages/api/src/lib/tenant-membership.ts` — added in PR #518; this PR uses its `normalizeHeaders` helper.
