Auth API
REST endpoints for sign up, sign in, sessions, password reset, OAuth, and API key management.
#Auth API
Authentication endpoints live under /api/auth/ and are not version-prefixed. They handle user registration, sign-in flows, session management, and API key operations.
Auth routes are powered by Better Auth running natively on Cloudflare Workers.
#Sign up
POST /api/auth/sign-up/email
Create a new user account with email and password. Registration availability is controlled by RBAC — see Registration below.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | User's email address |
password |
string |
Yes | Password (hashed server-side with PBKDF2 via Web Crypto) |
name |
string |
Yes | Display name |
#Request
#HTTP
POST /api/auth/sign-up/email
Content-Type: application/json
{
"email": "alex@example.com",
"password": "securePassword123",
"name": "Alex Chen"
}
#SDK
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com'
});
const session = await sdk.auth.signUp({
email: 'alex@example.com',
password: 'securePassword123',
name: 'Alex Chen'
});
// session.user → { id, email, name, role }
// session.session → { id, expiresAt }
#Response
200 OK
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_01HXK5M9ABCDEF",
"email": "alex@example.com",
"name": "Alex Chen",
"role": "viewer"
}
}
#Errors
| Status | Code | Description |
|---|---|---|
400 |
invalid_request |
Missing or malformed email/password |
403 |
signup_forbidden |
Registration is closed — the public role lacks system/users:create |
409 |
already_exists |
An account with this email already exists |
429 |
rate_limited |
Too many sign-up attempts (5 per minute per IP) |
#Registration
Who can register is controlled by RBAC, not an environment variable. A sign-up
request is checked as the public role, so it succeeds only when public holds
the system/users:create permission:
| Policy | How to set it | Behaviour |
|---|---|---|
| Open | Grant public → system/users:create (choose "Open" at setup, or toggle it in Access → Roles → public) |
Anyone can register and joins the shared workspace as viewer |
| Closed (default) | Leave public without the grant |
Sign-up returns 403 signup_forbidden; an admin creates accounts via the users UI or POST /api/v1/users |
The very first sign-up on a fresh install is always allowed — it bootstraps the instance and its owner regardless of this grant.
#Sign in
POST /api/auth/sign-in/email
Authenticate with email and password to receive a session token.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | Account email address |
password |
string |
Yes | Account password |
#Request
#HTTP
POST /api/auth/sign-in/email
Content-Type: application/json
{
"email": "alex@example.com",
"password": "securePassword123"
}
#SDK
const session = await sdk.auth.signIn({
email: 'alex@example.com',
password: 'securePassword123'
});
// The SDK stores the session token automatically for future calls
#Response
200 OK
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_01HXK5M9ABCDEF",
"email": "alex@example.com",
"name": "Alex Chen",
"role": "admin"
}
}
#Errors
| Status | Code | Description |
|---|---|---|
401 |
unauthorized |
Invalid email or password |
403 |
mfa_required |
Account has TOTP enabled; provide the TOTP code |
403 |
account_banned |
Account has been banned by an admin |
429 |
rate_limited |
Too many sign-in attempts (5 per minute per IP) |
#Username sign-in (opt-in)
These endpoints exist only while username sign-in is enabled in Settings → Authentication; otherwise they return 404.
POST /api/auth/sign-in/username — sign in with { username, password }. Response and errors match email sign-in (same generic 401 for unknown-user and wrong-password).
POST /api/auth/is-username-available — check { username }, returns { "available": boolean }. Shares the auth rate limit; call on blur/submit, not per keystroke.
GET /api/auth/capabilities — public, always available. Returns which auth features the instance has enabled, for pre-auth login UIs:
{
"usernameEnabled": true,
"requireEmail": false,
"socialProviders": { "google": true, "github": false }
}
POST /api/auth/request-password-reset — initiate a password reset with { identifier, redirectTo? }, where identifier is an email or username ({ email } is also accepted). Always returns 200 { "status": true } regardless of whether the account exists (anti-enumeration). Accounts whose only address is a shared contact email get a reset mail naming the account it applies to.
POST /api/v1/users/:id/reset-link — admin-only (system/users:update). Returns { url, expiresAt }, a one-time password-reset link valid for 1 hour, for accounts that can't receive email. Audit-logged as user.recovery_link_issued.
#Get session
GET /api/auth/get-session
Check the current session and retrieve the authenticated user's details.
#Request
#HTTP
GET /api/auth/get-session
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
#SDK
const current = await sdk.auth.getSession();
if (!current) {
// Not logged in — redirect to login page
}
#Response
200 OK
{
"user": {
"id": "usr_01HXK5M9ABCDEF",
"email": "alex@example.com",
"name": "Alex Chen",
"role": "admin",
"projectId": "proj_abc"
},
"session": {
"id": "ses_01HXK5PQRSTUV",
"expiresAt": 1719504000
}
}
#Errors
| Status | Code | Description |
|---|---|---|
401 |
unauthorized |
Missing, expired, or invalid session token |
#Sign out
POST /api/auth/sign-out
Invalidate the current session token.
#Request
#HTTP
POST /api/auth/sign-out
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
#SDK
await sdk.auth.signOut();
#Response
200 OK
{ "success": true }
#OAuth callback
GET /api/auth/callback/:provider
OAuth redirect endpoint for social sign-in. This is not called directly — EmuView redirects the user's browser here after they authenticate with the provider.
#Supported providers
| Provider | Redirect URL | Required env vars |
|---|---|---|
/api/auth/callback/google |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
|
| GitHub | /api/auth/callback/github |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
#Starting an OAuth flow
Redirect the user's browser to the provider's authorize URL. EmuView handles the callback and creates or links the account automatically.
#HTTP
GET /api/auth/sign-in/social?provider=google&callbackURL=https://your-app.example.com/auth/callback
#SDK
// In a browser context, redirect the user to start OAuth
const url = sdk.auth.getOAuthUrl('google', {
callbackURL: 'https://your-app.example.com/auth/callback'
});
window.location.href = url;
#Response
On successful authentication, the callback redirects to your callbackURL with a session token. If the user's email doesn't have an existing account, one is created automatically (when the public role holds system/users:create — see Registration).
#API keys
API keys provide server-side access without a user session. Keys are prefixed with sk- and the raw key is only shown once at creation — EmuView stores a SHA-256 hash.
#Create API key
POST /api/auth/api-keys
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
label |
string |
Yes | Human-readable label for the key |
capabilities |
string[] |
Yes | Permission scopes (e.g., ["read:products", "write:orders"]) |
expires_at |
integer |
No | Unix timestamp when the key expires (omit for no expiry) |
#Request
#HTTP
POST /api/auth/api-keys
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"label": "Production frontend",
"capabilities": ["read:products", "read:categories"],
"expires_at": null
}
#SDK
const key = await sdk.auth.createApiKey({
label: 'Production frontend',
capabilities: ['read:products', 'read:categories']
});
// key.raw → "sk-a1b2c3d4..." — save this, it won't be shown again
#Response
201 Created
{
"id": "key_01HXK5M9ABCDEF",
"label": "Production frontend",
"capabilities": ["read:products", "read:categories"],
"raw": "sk-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"created_at": 1718900000,
"expires_at": null
}
The raw field contains the full API key and is only returned at creation time. Store it securely — you cannot retrieve it again.
#List API keys
GET /api/auth/api-keys
Returns all API keys for the current project. The raw key is never included.
#Response
200 OK
[
{
"id": "key_01HXK5M9ABCDEF",
"label": "Production frontend",
"capabilities": ["read:products", "read:categories"],
"last_used_at": 1718905000,
"created_at": 1718900000,
"expires_at": null
}
]
#Revoke API key
DELETE /api/auth/api-keys/:id
Permanently revoke an API key. Any requests using this key return 401 immediately.
#Request
DELETE /api/auth/api-keys/key_01HXK5M9ABCDEF
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
#Response
200 OK
{ "success": true }
#Capability tokens
POST /api/v1/capability-tokens
A short-lived bearer token naming one document and one capability. It exists so a separate worker — a realtime document room, say — can decide what a WebSocket connection may do before it accepts it, without holding the deployment's signing secret and without asking the gateway again per message.
Nothing about the token is a second access-control system. The gateway asks its ordinary permission evaluator, on the caller's behalf, for every action the requested capability implies, and signs the answer. A token can never carry more than the caller could already do by hand.
#Request
POST /api/v1/capability-tokens
Authorization: Bearer <session token or sk- key>
Content-Type: application/json
{
"collection": "dms_documents",
"documentId": "01JABCDEFGHIJKLMNOPQRSTUVW",
"capability": "write",
"expiresIn": 300
}
| Field | Required | Notes |
|---|---|---|
collection |
yes | Collection holding the document. |
documentId |
yes | The record's id. |
capability |
no | read (default), comment or write. An omitted field always defaults to the weakest. |
expiresIn |
no | Requested lifetime in seconds. Clamped to 30–600; the granted value comes back in the response. |
#What each capability requires
| Capability | Grants needed on collections/<name> |
|---|---|
read |
read |
comment |
read + update |
write |
read + update |
comment needs the same grants as write deliberately: RBAC has no verb meaning "may
annotate but not edit", so a commenter is a writer who asked for less. write needs
read because a document session streams the whole document before it can edit anything.
The row matters as much as the verb. The document must be reachable under the
item_filter of each grant separately, so a wider read scope cannot vouch for a
narrower update one.
#Response
201 Created
{
"data": {
"token": "sct1.eyJ2Ijoic2N0MSIs...",
"capability": "write",
"document": {
"collection": "dms_documents",
"id": "01JABCDEFGHIJKLMNOPQRSTUVW",
"type": "record"
},
"subject": "usr_01HXK5M9ABCDEF",
"issuedAt": 1756000000,
"expiresAt": 1756000300,
"expiresIn": 300,
"maxExpiresIn": 600
}
}
The response is Cache-Control: no-store. The token is returned exactly once, here.
There is no GET on this endpoint and nothing stores the token — not the audit log, not
a log line — so it cannot be read back later. Treat it the way you would treat a password.
#Refusals
| Status | Meaning |
|---|---|
401 |
No credential. An anonymous caller is never minted a token. |
403 |
The caller does not hold a grant the capability needs. The body names the missing resource and action. Nothing is downgraded — asking for write with only read is refused, not quietly answered with a read token. |
404 |
No such document, or one outside the caller's row scope. The two are deliberately indistinguishable: this endpoint takes an arbitrary id, and a distinguishable refusal would let anyone enumerate documents. |
400 |
Malformed body, unknown capability, or an unusable collection name or document id. |
#Expiry is the revocation story
There is no revocation list, by design. The lifetime is at most 10 minutes and 5 by
default. Revoking a share stops new tokens immediately, because every mint asks the
live evaluator; a connection already open converges when it next reconnects. If you need
a tighter window, ask for a shorter expiresIn — the server will never grant a longer
one than you asked for, only a shorter one than you asked for if you exceeded the
ceiling.
#Session payload
When the API resolves a session token or API key, it produces the following identity object used for all permission checks:
interface AuthUser {
id: string; // Better Auth user ID
email: string;
role: UserRole; // 'super_admin' | 'admin' | 'editor' | 'viewer' | 'api'
projectId: string; // The project this user belongs to
}
#Roles
| Role | Access level |
|---|---|
super_admin |
Instance-level; sees all projects, manages all users |
admin |
Project-level; manages collections, users, settings |
editor |
Project-level; read/write on permitted collections |
viewer |
Project-level; read-only on permitted collections |
api |
API key identity; capabilities scoped per key |