Skip to content

fix(server-v3): enforce API key authentication#2393

Open
GautamSharma99 wants to merge 1 commit into
browserbase:mainfrom
GautamSharma99:fix/server-v3-authentication
Open

fix(server-v3): enforce API key authentication#2393
GautamSharma99 wants to merge 1 commit into
browserbase:mainfrom
GautamSharma99:fix/server-v3-authentication

Conversation

@GautamSharma99

@GautamSharma99 GautamSharma99 commented Jul 23, 2026

Copy link
Copy Markdown

Summary

This PR replaces server-v3’s authentication stub—which previously authorized every request—with enforceable API-key authentication.

Requests can now authenticate using either:

  1. A valid Browserbase API key in x-bb-api-key
  2. A deployment-specific self-hosted key in x-stagehand-api-key, configured through STAGEHAND_SERVER_API_KEY

The change also associates Browserbase-authenticated sessions with the credential that created them, preventing another valid Browserbase
credential from controlling an existing session.

Problem

The previous authMiddleware ignored the incoming request and delegated to an isAuthenticated() function that always returned true:

const isAuthenticated = async (): Promise<boolean> => {
  return true;
};

Although the routes contained authorization checks, those checks could never fail. Consequently, any client capable of reaching server-v3
could invoke protected operations, including:

- creating browser sessions
- navigating pages
- executing actions
- extracting page data
- running agents
- retrieving replay data
- ending sessions

This was especially risky when the server was exposed beyond localhost because browser and model operations can consume external resources and
credentials.

## Changes

### Browserbase API-key validation

Requests containing x-bb-api-key are validated against the Browserbase API.

Validation:

- fails closed when Browserbase rejects the credential
- fails closed if credential verification throws or Browserbase is unavailable
- disables SDK retries for the authentication request
- applies a five-second verification timeout

This preserves the existing API contract, where Browserbase-hosted clients already send x-bb-api-key.

### Self-hosted server authentication

Self-hosted deployments can configure:

STAGEHAND_SERVER_API_KEY=<secret>

Clients can then authenticate with:

x-stagehand-api-key: <secret>

The provided and configured credentials are compared using timingSafeEqual. Byte lengths are checked before comparison so malformed or
differently encoded values are rejected without throwing.

This provides a local authentication option that does not require every self-hosted user to have a Browserbase account.

### Credential-validation cache

Calling Browserbase for every route operation would add unnecessary latency, particularly for sessions that perform many actions.

The middleware now maintains a bounded in-memory cache with:

- a five-minute TTL
- a maximum of 1,000 entries
- least-recently-used eviction
- SHA-256 credential fingerprints as cache keys
- no plaintext Browserbase credentials retained in the cache
- in-flight deduplication for concurrent requests using the same uncached key

Both successful and unsuccessful verification results are cached, reducing repeated outbound calls for invalid credentials as well.

### Session ownership enforcement

For routes containing a session ID, the middleware resolves the session’s stored Browserbase API key and compares it with the incoming
credential.

As a result:

- the Browserbase key that creates a session can continue operating it
- a different valid Browserbase key cannot operate that session
- missing or expired sessions fail authentication without exposing ownership details
- the configured self-hosted server key remains an administrative credential and can access server-managed sessions

The start route now retains the authenticating Browserbase key for local sessions as well as Browserbase sessions. This allows a client
authenticated through Browserbase to continue operating a local-browser session.

### OpenAPI and documentation

The OpenAPI security definition now documents two alternative authentication mechanisms:

security:
  - StagehandServerApiKey: []
  - BrowserbaseApiKey: []

A StagehandServerApiKey security scheme was added for the x-stagehand-api-key header.

The previous global security declaration also incorrectly represented the deprecated Browserbase project ID as a required authentication
credential. The generated specification now describes either the self-hosted server key or the Browserbase API key as sufficient.

The server-v3 README and .env.example now document STAGEHAND_SERVER_API_KEY.

### Test infrastructure

The local server integration-test harness now generates a deterministic self-hosted key when testing a locally managed server. The shared
request-header helper sends that key with protected requests.

Remote test runs continue to require an explicitly configured credential and do not receive the local test key automatically.

## Security behavior

 Request credentials                                          Result
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━━━
 No authentication headers                                    401 Unauthorized
───────────────────────────────────────────────────────────  ──────────────────
 Invalid self-hosted key only                                 401 Unauthorized
───────────────────────────────────────────────────────────  ──────────────────
 Valid configured self-hosted key                             Authorized
───────────────────────────────────────────────────────────  ──────────────────
 Invalid Browserbase key                                      401 Unauthorized
───────────────────────────────────────────────────────────  ──────────────────
 Valid Browserbase key for a new session                      Authorized
───────────────────────────────────────────────────────────  ──────────────────
 Browserbase session owner key                                Authorized
───────────────────────────────────────────────────────────  ──────────────────
 Different valid Browserbase key for an existing session      401 Unauthorized
───────────────────────────────────────────────────────────  ──────────────────
 Browserbase verification unavailable and no cached result    401 Unauthorized

Health and readiness endpoints remain unauthenticated.

## Testing

The following validation was completed:

pnpm --filter @browserbasehq/stagehand-server-v3 lint
pnpm --filter @browserbasehq/stagehand typecheck
pnpm --filter @browserbasehq/stagehand-server-v3 build:esm-tests

Authentication unit tests cover:

- requests without credentials
- empty and repeated API-key headers
- valid and invalid Browserbase credentials
- valid and invalid self-hosted credentials
- multibyte self-hosted credential comparison
- verification failures and fail-closed behavior
- session-owner credential enforcement
- credential-result caching
- concurrent verification deduplication
- least-recently-used cache eviction

Results:

- authentication unit tests: 8 passed
- complete server-v3 unit suite: 71 passed
- replay authentication integration tests: 3 passed

The integration tests verify that:

- missing authentication returns 401
- an invalid self-hosted key returns 401
- a valid self-hosted key reaches the protected route successfully

## Deployment notes

Self-hosted deployments that do not want to authenticate through Browserbase should set STAGEHAND_SERVER_API_KEY and configure clients to send
it through x-stagehand-api-key.

Deployments that already authenticate using Browserbase credentials can continue using x-bb-api-key; no client header change is required.

Because authentication now fails closed, previously unauthenticated clients will receive 401 Unauthorized until they provide one of the
supported credentials.

## Changeset

This PR includes patch changesets for:

- @browserbasehq/stagehand
- @browserbasehq/stagehand-server-v3

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 89b81eb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@browserbasehq/stagehand Patch
@browserbasehq/stagehand-server-v3 Patch
@browserbasehq/stagehand-evals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

This PR is from an external contributor and must be approved by a stagehand team member with write access before CI can run.
Approving the latest commit mirrors it into an internal PR owned by the approver.
If new commits are pushed later, the internal PR stays open but is marked stale until someone approves the latest external commit and refreshes it.

@github-actions github-actions Bot added external-contributor Tracks PRs mirrored from external contributor forks. external-contributor:awaiting-approval Waiting for a stagehand team member to approve the latest external commit. labels Jul 23, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 12 files

Confidence score: 2/5

  • In packages/server-v3/src/lib/auth.ts, the ownership-only branch can let revoked Browserbase credentials keep controlling an existing session, creating a concrete auth bypass risk with ongoing unauthorized access—validate the matching key against the Browserbase cache/verification path before allowing session reuse.
  • In packages/server-v3/tests/unit/auth.test.ts, the current cache test in auth.test.ts can pass even if plaintext API keys are retained, so the intended security guarantee is unproven and regressions could slip in—add an assertion on fingerprinted/hashed cache contents (or an inspectable cache view) to verify key material is not stored in plaintext.
  • In packages/server-v3/scripts/test-server.ts, an empty STAGEHAND_SERVER_API_KEY is treated as set and then rejected by getHeaders(), causing local/SEA integration runs to fail before requests—treat empty values as unset so defaults apply consistently.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/server-v3/tests/unit/auth.test.ts">

<violation number="1" location="packages/server-v3/tests/unit/auth.test.ts:124">
P2: The cache security guarantee is not actually covered: this test validates reuse but would still pass if the cache retained plaintext API keys. An inspectable cache representation or a focused assertion around fingerprinted cache keys would make the no-plaintext requirement executable.</violation>
</file>

<file name="packages/server-v3/scripts/test-server.ts">

<violation number="1" location="packages/server-v3/scripts/test-server.ts:195">
P2: Local/SEA integration runs fail before requests when callers export `STAGEHAND_SERVER_API_KEY=`: the empty value bypasses this default, then `getHeaders()` rejects it as missing. Treat an empty value as unset so this runner always provisions its isolated test credential.</violation>
</file>

<file name="packages/server-v3/src/lib/auth.ts">

<violation number="1" location="packages/server-v3/src/lib/auth.ts:117">
P1: Revoked Browserbase credentials can continue controlling an existing session indefinitely. This branch checks only stored-key ownership and bypasses cached Browserbase validation, so validate the matching key as well and fail closed on verification failure.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant MW as authMiddleware
    participant Cache as Auth Cache
    participant VKey as verifyBrowserbaseApiKey()
    participant BB as Browserbase API
    participant SessionStore as Session Store
    participant Route as Protected Route Handler

    Note over Client,Route: Authentication Flow (NEW: enforced API key auth)

    Client->>MW: Request with x-bb-api-key or x-stagehand-api-key
    MW->>MW: Check x-stagehand-api-key header
    alt Valid self-hosted key (timingSafeEqual match)
        MW-->>Client: true → proceed to route
    else Missing or invalid self-hosted key
        MW->>MW: Check x-bb-api-key header
        alt No or empty x-bb-api-key
            MW-->>Client: false → 401 Unauthorized
        else x-bb-api-key present
            MW->>MW: Check session ID in route params
            alt Session ID present
                MW->>SessionStore: getSessionConfig(sessionId)
                SessionStore-->>MW: browserbaseApiKey or undefined
                alt Matching key
                    MW-->>Client: true → proceed to route
                else Key mismatch or missing session
                    MW-->>Client: false → 401 Unauthorized
                end
            else No session ID
                MW->>MW: fingerprintApiKey(x-bb-api-key)
                MW->>Cache: Lookup fingerprint
                alt Cached result (valid TTL)
                    Cache-->>MW: { authenticated }
                    MW->>Cache: Refresh LRU order
                    alt authenticated
                        MW-->>Client: true
                    else not authenticated
                        MW-->>Client: false
                    end
                else Cache miss or expired
                    alt Concurrent request in-flight for same key?
                        Cache-->>MW: Found pending verification
                        MW->>MW: await existing promise
                        MW-->>Client: result
                    else First request for this key
                        MW->>VKey: verifyApiKey(apiKey)
                        VKey->>BB: projects.list({ maxRetries: 0, timeout: 5000 })
                        alt BB responds successfully
                            BB-->>VKey: projects list
                            VKey-->>MW: true
                        else BB rejects or unavailable
                            BB-->>VKey: Error
                            VKey-->>MW: false
                        end
                        MW->>Cache: cacheResult(fingerprint, authenticated)
                        MW-->>Client: authenticated
                    end
                end
            end
        end
    end

    alt Authenticated
        Client->>Route: Proceed with original request
        Note over Route: Route handler may call SessionStore to store<br/>browserbaseApiKey for session ownership (NEW)
        Route-->>Client: Response
    else Not authenticated
        MW-->>Client: 401 Unauthorized
    end

    Note over Cache: NEW: In-memory LRU cache (max 1000 entries, 5-min TTL)<br/>SHA-256 fingerprints, no plaintext keys stored
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const sessionId = getSessionId(request);
if (sessionId) {
const sessionApiKey = await resolveApiKeyForSession(sessionId);
return sessionApiKey ? apiKeysMatch(headerValue, sessionApiKey) : false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Revoked Browserbase credentials can continue controlling an existing session indefinitely. This branch checks only stored-key ownership and bypasses cached Browserbase validation, so validate the matching key as well and fail closed on verification failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v3/src/lib/auth.ts, line 117:

<comment>Revoked Browserbase credentials can continue controlling an existing session indefinitely. This branch checks only stored-key ownership and bypasses cached Browserbase validation, so validate the matching key as well and fail closed on verification failure.</comment>

<file context>
@@ -1,14 +1,152 @@
+    const sessionId = getSessionId(request);
+    if (sessionId) {
+      const sessionApiKey = await resolveApiKeyForSession(sessionId);
+      return sessionApiKey ? apiKeysMatch(headerValue, sessionApiKey) : false;
+    }
+
</file context>

assert.equal(browserbaseVerificationCalls, 0);
});

it("caches verification results without retaining plaintext keys", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The cache security guarantee is not actually covered: this test validates reuse but would still pass if the cache retained plaintext API keys. An inspectable cache representation or a focused assertion around fingerprinted cache keys would make the no-plaintext requirement executable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v3/tests/unit/auth.test.ts, line 124:

<comment>The cache security guarantee is not actually covered: this test validates reuse but would still pass if the cache retained plaintext API keys. An inspectable cache representation or a focused assertion around fingerprinted cache keys would make the no-plaintext requirement executable.</comment>

<file context>
@@ -0,0 +1,176 @@
+    assert.equal(browserbaseVerificationCalls, 0);
+  });
+
+  it("caches verification results without retaining plaintext keys", async () => {
+    const verifiedKeys: string[] = [];
+    const authenticate = createAuthMiddleware({
</file context>

process.env.STAGEHAND_SERVER_TARGET ?? "sea"
).toLowerCase();
if (serverTarget !== "remote") {
process.env.STAGEHAND_SERVER_API_KEY ??= "stagehand-integration-test-key";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Local/SEA integration runs fail before requests when callers export STAGEHAND_SERVER_API_KEY=: the empty value bypasses this default, then getHeaders() rejects it as missing. Treat an empty value as unset so this runner always provisions its isolated test credential.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v3/scripts/test-server.ts, line 195:

<comment>Local/SEA integration runs fail before requests when callers export `STAGEHAND_SERVER_API_KEY=`: the empty value bypasses this default, then `getHeaders()` rejects it as missing. Treat an empty value as unset so this runner always provisions its isolated test credential.</comment>

<file context>
@@ -191,6 +191,9 @@ if (!fs.existsSync(allTestsDir)) {
   process.env.STAGEHAND_SERVER_TARGET ?? "sea"
 ).toLowerCase();
+if (serverTarget !== "remote") {
+  process.env.STAGEHAND_SERVER_API_KEY ??= "stagehand-integration-test-key";
+}
 const explicitBaseUrl =
</file context>
Suggested change
process.env.STAGEHAND_SERVER_API_KEY ??= "stagehand-integration-test-key";
process.env.STAGEHAND_SERVER_API_KEY ||= "stagehand-integration-test-key";

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contributor:awaiting-approval Waiting for a stagehand team member to approve the latest external commit. external-contributor Tracks PRs mirrored from external contributor forks.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant