Skip to main content

Authentication and Tenancy

harnessd protects its HTTP API with Bearer token authentication backed by a key store. Each API key carries a tenant identity and a set of permission scopes. The server validates every incoming request against those scopes before dispatching it to a handler. Multi-tenant deployments get an additional layer: a tenant isolation check that ensures one tenant cannot read or modify another's runs.

This page explains how to send authenticated requests (including the SSE fallback), what the scope hierarchy means, when auth is implicitly off, and what the isolation model guarantees.


How authentication works

Every protected route goes through authMiddleware in internal/server/auth.go. The middleware extracts the raw token, validates it against the key store, and — on success — injects the authenticated tenant ID, the first eight characters of the key (used in audit trails), and the key's scopes into the request context. Downstream scope-enforcement middleware reads those values to gate individual routes.

Token extraction order

The middleware looks for a token in two places, in this order:

  1. Authorization: Bearer <token> header — the standard method for REST calls.
  2. ?token=<token> query parameter — the fallback for EventSource clients. The browser EventSource API does not allow setting custom headers, so passing the token on the query string lets you stream GET /v1/runs/{id}/events from JavaScript without a proxy.

Only the query fallback is for EventSource

The ?token= parameter exists solely to unblock browser-based SSE clients. For all non-streaming requests prefer the Authorization header to avoid accidentally logging credentials in server access logs or proxies that capture query strings.

Sending an authenticated request

Standard REST call with the Authorization header:

TOKEN="harness_sk_yourkey"

# Start a run
curl -s -X POST http://localhost:8080/v1/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"prompt": "list files in the workspace"}'

# Fetch the run status
curl -s http://localhost:8080/v1/runs/<run_id> \
-H "Authorization: Bearer $TOKEN"

Error responses

A missing or invalid token returns HTTP 401 Unauthorized:

{"error": {"code": "unauthorized", "message": "authorization required"}}

An expired key returns the same status with "api key expired" as the message. A failed scope check returns HTTP 403 Forbidden with a structured body:

{"error": "insufficient_scope", "required": "runs:write"}

Scopes

Every API key is issued with one or more scopes. The server enforces the minimum required scope per route. Three scopes exist:

Scope hierarchy

ScopeConstantWhat it unlocks
runs:readstore.ScopeRunsReadAll GET routes: list runs, get run, get events, list conversations, list models, list skills, and more.
runs:writestore.ScopeRunsWriteAll mutation routes: POST /v1/runs, cancel, steer, continue, approve, deny, and all POST/PUT/DELETE routes. Also satisfies runs:read — a single write-scoped key can do everything.
adminstore.ScopeAdminSuperscope. Satisfies any scope check, including privileged routes like PUT /v1/providers/{name}/key and POST /v1/mcp/servers.

The scope hierarchy is implemented in hasScope (internal/server/auth.go):

  • admin satisfies every check unconditionally.
  • runs:write satisfies a runs:read check (write implies read).
  • When no scopes exist in the request context — which happens whenever auth is disabled — every scope check passes automatically, preserving the development workflow.

Generating a key

harnesscli auth login generates and stores an API key locally without contacting the server. The default flags give the key all three scopes:

harnesscli auth login \
--server http://localhost:8080 \
--tenant default \
--name my-cli-key

The command writes the server URL and raw key to ~/.harness/config.json (mode 0600) and prints an example Authorization: Bearer ... header you can copy into curl or your HTTP client configuration.

Key generation is local

harnesscli auth login does not call the server. It generates a key locally and saves it to disk. The key only becomes usable once it is registered in the server's key store — this step is handled separately when a persistent store (HARNESS_RUN_DB) is configured.


When auth is disabled

Authentication is entirely skipped under three conditions:

  1. HARNESS_AUTH_DISABLED=true environment variable — explicitly disables auth at startup. All requests are allowed through without a token.
  2. ServerOptions.AuthDisabled = true — the same flag set programmatically when embedding the server in tests or custom binaries.
  3. No Store is configured — when HARNESS_RUN_DB is not set, harnessd has no key store to validate against. The middleware detects a nil store and skips auth entirely, even without the explicit HARNESS_AUTH_DISABLED flag.

Auth is silently off without a persistent store

If you start harnessd without setting HARNESS_RUN_DB, the server accepts every request with no token required. This is intentional for local development and key-free testing, but it is a significant footgun if you expose the port to a network. Before opening the firewall or binding to a non-loopback address, make sure HARNESS_RUN_DB is set and at least one API key is registered.

The key-free smoke test exploits condition 1 deliberately:

HARNESS_PROVIDER=fake \
HARNESS_AUTH_DISABLED=true \
go run ./cmd/harnessd

This is safe for local testing because no key store is involved, and the fake provider makes no real LLM calls.

Webhook routes (/v1/webhooks/github, /v1/webhooks/slack, /v1/webhooks/linear, /v1/external/trigger) bypass Bearer auth entirely regardless of the above settings. They authenticate via HMAC signature headers instead.


Tenancy

Every API key carries a tenant_id. The harness uses this to scope all run and conversation operations to a single tenant within a shared server instance.

How isolation is enforced

When a request arrives with a valid token, the authenticated tenant ID is injected into the request context by authMiddleware. Every route that creates or retrieves a run compares the context tenant against the resource's stored tenant using normalizeTenant:

  • If the run's tenant matches the caller's tenant, the request proceeds normally.
  • If the run belongs to a different tenant, the server returns HTTP 404, not 403.

The 404-not-403 pattern is deliberate: it prevents resource-existence disclosure. A caller that guesses another tenant's run ID learns nothing — the response is indistinguishable from the run not existing at all.

The default tenant

An empty tenant_id ("") is normalized to "default" for all ownership comparisons. This means:

  • A run started without a tenant_id field is owned by the default tenant.
  • A key with tenant_id = "" compares equal to a key with tenant_id = "default".
  • In single-tenant or dev deployments, omitting tenant_id everywhere works consistently.

Tenant mismatch in run requests

When auth is enabled and you include a tenant_id in a POST /v1/runs body, that value must match the authenticated tenant of your API key. A mismatch is rejected with HTTP 400 before the run is created. If you omit tenant_id from the request body, the server silently fills it from the authenticated key — the most common and recommended usage.


Quick reference

MechanismWhereNotes
Bearer tokenAuthorization: Bearer <token> headerPreferred for all REST calls
SSE token fallback?token=<token> query paramUse only for EventSource / streaming GETs
Scope: runs:readGET routesRead-only access
Scope: runs:writePOST/PUT/DELETE routesImplies runs:read
Scope: adminPrivileged routes (provider keys, MCP)Satisfies any scope check
Disable auth (explicit)HARNESS_AUTH_DISABLED=trueDevelopment/testing
Disable auth (implicit)No HARNESS_RUN_DB setFootgun in production
Cross-tenant responseHTTP 404Prevents existence disclosure
Empty tenantNormalized to "default"Consistent in single-tenant setups

Next steps

  • See Running the Server to learn how harnessd is configured and started.
  • The HTTP API reference lists every route with its required scope.
  • To understand what events flow over the SSE stream once a run is authenticated and started, see Events.