Skip to content

Authentication

OpenScouter uses five authentication layers depending on the actor and context. Each layer is designed for its specific use case: human users in web sessions, testers in the Chrome extension, API consumers, incoming webhooks, and database-level enforcement.

Layer Overview

LayerActorMechanismTTL
Supabase JWTHuman users (browser)httpOnly cookie24 hours
Test tokensTesters (extension)48-char hex token24 hours
OpenClaw BearerAPI consumersBearer token in Authorization headerConfigurable
Webhook HMACStripe, external servicesHMAC-SHA256 signaturePer-request
Row-Level SecurityAll database accessSupabase RLS policiesAlways enforced

Layer 1: Supabase JWT

Supabase handles user authentication for the web application. When a user logs in, Supabase issues a signed JWT that is stored as an httpOnly cookie. httpOnly cookies are inaccessible to JavaScript running in the page, which prevents token theft via XSS attacks.

The JWT carries the user’s sub (Supabase user ID), role, and organization membership claims. These claims are used by Row-Level Security policies to control which rows the user can read and write.

Token refresh occurs automatically. Supabase refreshes the JWT before expiry using a refresh token stored in a separate httpOnly cookie. The refresh token has a 7-day TTL. After 7 days of inactivity, the user must log in again.

Supported login methods:

  • Email and password
  • Magic link (passwordless email)
  • Google OAuth
  • GitHub OAuth

Layer 2: Test Tokens

Testers authenticate to the API from within the Chrome extension using test tokens. Test tokens are distinct from the Supabase JWT because the extension operates outside the browser’s cookie context and cannot use httpOnly session cookies.

A test token is a 48-character hex string generated when the tester accepts a study offer. The token is delivered to the tester via Telegram and stored in chrome.storage.local by the extension.

Token format: [48 hex characters]
Example: a3f82c14e9d7b056219f4a3c81e072b5d4f9c823e1a054b7

Test tokens have a 24-hour TTL from the moment they are issued. The extension sends the token as a X-Test-Token header on every request to the API during an active test. The API validates the token against the sessions table, checks the TTL, and verifies that the token matches the session_id in the request body.

A test token grants access only to the endpoints required for an active test:

  • POST /api/sessions/[id]/events - Submit browser events
  • POST /api/sessions/[id]/snapshots - Submit facial snapshots
  • POST /api/sessions/[id]/voice - Submit voice segments
  • PATCH /api/sessions/[id] - Update session status

It does not grant access to study management, report generation, or billing endpoints.

Layer 3: OpenClaw Bearer Tokens

API consumers authenticate using OpenClaw Bearer tokens. These are long-lived tokens issued through the developer dashboard and sent as a standard Authorization: Bearer <token> header.

OpenClaw tokens are scoped to an organization. A token can be created with read-only, read-write, or admin scopes.

ScopeAccess
readGET endpoints only
read_writeGET and POST/PATCH endpoints
adminAll endpoints including webhook management and billing

Tokens do not expire automatically. Rotation is the token owner’s responsibility. Compromised tokens can be revoked immediately from the dashboard. Revocation takes effect within 60 seconds due to token validation caching.

Layer 4: Webhook HMAC Verification

Incoming webhooks from Stripe and other external services are verified using HMAC-SHA256 signatures. Each service provider signs its webhook payload using a shared secret configured during integration setup.

The API verifies the signature before processing any webhook. The verification steps are:

  1. Extract the signature header from the request (e.g., Stripe-Signature for Stripe)
  2. Reconstruct the expected signature using the raw request body and the stored shared secret
  3. Compare the expected and provided signatures using a constant-time comparison function
  4. Reject requests where signatures do not match with a 401 Unauthorized response

Raw request bodies are preserved for webhook routes. Body parsers that transform the payload before verification will cause signature mismatches. The API uses bodyParser: false on all webhook routes and reads the raw buffer directly.

Layer 5: Row-Level Security

Row-Level Security (RLS) is enforced on all 29 tables in the OpenScouter schema. RLS policies run at the database level and apply regardless of how a query reaches Supabase. Even if an API route contains a logic error that would otherwise expose another organization’s data, RLS will block the query.

Policy Structure

Each table has at minimum the following policies:

  • select - Controls which rows a user can read
  • insert - Controls whether a user can create new rows
  • update - Controls which rows a user can modify
  • delete - Controls which rows a user can delete

Policies reference the authenticated user’s JWT claims via auth.uid() and auth.jwt(). Custom claims added to the JWT during login (such as organization_id and role) are accessible in policy expressions.

Example Policies

The jobs table (studies) is readable only by members of the owning organization and by testers who have an active session linked to that study.

-- Organizations can read their own jobs
CREATE POLICY "org_read_own_jobs"
ON jobs FOR SELECT
USING (
organization_id = (auth.jwt() -> 'organization_id')::uuid
);
-- Testers can read jobs they are assigned to
CREATE POLICY "tester_read_assigned_jobs"
ON jobs FOR SELECT
USING (
id IN (
SELECT job_id FROM sessions
WHERE tester_id = auth.uid()
AND status != 'cancelled'
)
);

The reports table enforces that testers can only read reports for their own tests, and businesses can only read reports for their own studies.

-- Testers read their own reports
CREATE POLICY "tester_read_own_reports"
ON reports FOR SELECT
USING (tester_id = auth.uid());
-- Organizations read reports for their studies
CREATE POLICY "org_read_study_reports"
ON reports FOR SELECT
USING (
job_id IN (
SELECT id FROM jobs
WHERE organization_id = (auth.jwt() -> 'organization_id')::uuid
)
);

All 29 tables have RLS enabled. ALTER TABLE ... ENABLE ROW LEVEL SECURITY is run as part of the database migration and is verified in the CI pipeline. Any table without RLS enabled will fail the schema validation check.