Authentication
Overview
The Sema Link API uses two layers of authentication:
- Cloudflare Access — validates that the request is coming from the Sema Link frontend app (service token headers)
- Application auth — validates that the user is logged in (JWT / session token in request headers)
Cloudflare Access (Layer 1)
Every request to the arc subdomain must include:
| Header | Value |
|---|---|
CF-Access-Client-Id | Service token Client ID (from VITE_CF_ACCESS_CLIENT_ID) |
CF-Access-Client-Secret | Service token Client Secret (from VITE_CF_ACCESS_CLIENT_SECRET) |
These are injected by the Axios client automatically. See Zero Trust & API Access for setup details.
Application Auth (Layer 2)
After Cloudflare Access validates the request at the edge, the API enforces its own JWT-based authentication for protected routes.
Token Types
| Token | Lifespan | Storage |
|---|---|---|
| Access token | 15 minutes | Client memory (not persisted) |
| Refresh token | 7 days | Stored as SHA-256 hash in refresh_tokens table |
The raw refresh token is returned to the client once and never stored on the server — only its hash is kept.
How to Authenticate a Request
Include the access token as a Bearer token in the Authorization header:
Authorization: Bearer <access_token>Token Lifecycle
- Login / Register — the API issues both an access token and a refresh token.
- Access token expires — call
POST /api/v1/auth/refreshwith the refresh token to get a new pair. The old refresh token is revoked immediately (rotation). - Logout — call
POST /api/v1/auth/logoutwith the refresh token. The token is revoked and can no longer be used.
Refresh tokens can only be used once. Attempting to reuse a revoked token returns 401 Unauthorized.
JWT Payload
{
"sub": "<user_id>",
"accountId": "<account_id>",
"role": "owner | admin | member"
}accountId is the currently active organization. role is the user's role within that org, sourced from the account_members junction table — not from users.role.
Role-Based Access
Some endpoints restrict access by role:
| Role | Capabilities |
|---|---|
owner | Full access — invite members, change roles, remove members |
admin | Can invite members, cannot change roles or remove members |
member | Read/write own profile only |
Multi-Organization Support
A single user can belong to multiple organizations. Each organization is a separate accounts row. Membership is tracked in the account_members junction table — one row per (user_id, account_id) pair.
How it works
- On signup, a personal organization is created automatically and the user gets an
account_membersrow withrole: 'owner'. - On login, the API picks the user's first active membership and encodes its
accountIdandrolein the JWT. - On org creation (
POST /api/v1/accounts), a new org is created, the user is added as owner, and the token pair is immediately rotated to the new org context. - On org switch (
POST /api/v1/accounts/switch), all active refresh tokens for the current org are revoked and a new pair is issued for the target org.
Token rotation on context change
Whenever the active org changes (create or switch), the backend calls revokeAllActiveTokens(userId, currentAccountId) before issuing new tokens. This prevents the old refresh token from being used to re-enter the previous org context without an explicit switch.
Listing memberships
GET /api/v1/accounts returns all orgs the user belongs to, with an isCurrent flag. See Accounts & Orgs endpoints for the full reference.