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
| Layer | Actor | Mechanism | TTL |
|---|---|---|---|
| Supabase JWT | Human users (browser) | httpOnly cookie | 24 hours |
| Test tokens | Testers (extension) | 48-char hex token | 24 hours |
| OpenClaw Bearer | API consumers | Bearer token in Authorization header | Configurable |
| Webhook HMAC | Stripe, external services | HMAC-SHA256 signature | Per-request |
| Row-Level Security | All database access | Supabase RLS policies | Always 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: a3f82c14e9d7b056219f4a3c81e072b5d4f9c823e1a054b7Test 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 eventsPOST /api/sessions/[id]/snapshots- Submit facial snapshotsPOST /api/sessions/[id]/voice- Submit voice segmentsPATCH /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.
| Scope | Access |
|---|---|
read | GET endpoints only |
read_write | GET and POST/PATCH endpoints |
admin | All 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:
- Extract the signature header from the request (e.g.,
Stripe-Signaturefor Stripe) - Reconstruct the expected signature using the raw request body and the stored shared secret
- Compare the expected and provided signatures using a constant-time comparison function
- Reject requests where signatures do not match with a
401 Unauthorizedresponse
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 readinsert- Controls whether a user can create new rowsupdate- Controls which rows a user can modifydelete- 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 jobsCREATE POLICY "org_read_own_jobs"ON jobs FOR SELECTUSING ( organization_id = (auth.jwt() -> 'organization_id')::uuid);
-- Testers can read jobs they are assigned toCREATE POLICY "tester_read_assigned_jobs"ON jobs FOR SELECTUSING ( 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 reportsCREATE POLICY "tester_read_own_reports"ON reports FOR SELECTUSING (tester_id = auth.uid());
-- Organizations read reports for their studiesCREATE POLICY "org_read_study_reports"ON reports FOR SELECTUSING ( 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.