Site Auth Reference
Waymaker Site Auth provides OAuth-style authentication for any site or app hosted on Waymaker Host. It proxies Clerk authentication through a centralized auth service at auth.waymakerone.com, issuing Waymaker-scoped access tokens that work across the platform.
Auth Flow
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ Your Site │ │ auth.waymakerone.com│ │ Clerk │
│ (Host App) │ │ (Auth Proxy) │ │ (IdP) │
└──────┬───────┘ └────────┬───────────┘ └──────┬───────┘
│ │ │
│ 1. wm.login() │ │
│─────────────────────>│ │
│ redirect to /login │ │
│ ?redirect_url=... │ │
│ &app_id=... │ │
│ │ 2. Initiate sign-in │
│ │───────────────────────>│
│ │ │
│ │ 3. User authenticates │
│ │ (email/password, SSO) │
│ │<───────────────────────│
│ │ │
│ 4. Redirect back │ │
│ ?wm_token=<exchange>│ │
│<─────────────────────│ │
│ │ │
│ 5. POST /exchange │ │
│ { token } │ │
│─────────────────────>│ │
│ │ │
│ 6. { access_token, │ │
│ expires_at, │ │
│ user, workspace }│ │
│<─────────────────────│ │
│ │ │
│ 7. Bearer token │ │
│ to Ambassador │ │
│──────────────> │ │
Step-by-step
- Login redirect — The auth client redirects to
auth.waymakerone.com/loginwith the app ID and the URL to return to. - Clerk sign-in — The auth proxy presents the Clerk sign-in UI. The user authenticates via email/password, magic link, or SSO.
- Callback — Clerk redirects back to the auth proxy callback endpoint with the authenticated session.
- Exchange token — The auth proxy generates a single-use, 60-second exchange token and redirects the user back to the original site with
?wm_token=<token>in the URL. - Token exchange — The auth client automatically POSTs the exchange token to
/context/auth/exchangeand receives an access token. - Session established — The access token (1-hour JWT) is stored in
sessionStorage. The?wm_token=parameter is removed from the URL. - API calls — The access token is sent as a Bearer token to Ambassadors and Context API endpoints.
Endpoints
All endpoints are relative to https://auth.waymakerone.com.
POST /context/auth/login
Initiates the login flow. Validates the app and redirect domain, then redirects to Clerk.
Request:
{
"redirect_url": "https://yourdomain.com/admin",
"app_id": "app_abc123"
}
Behavior:
- Validates
app_idexists and is a registered Host app - Validates
redirect_urldomain is in the app'sallowed_auth_domains - Redirects (302) to Clerk hosted sign-in with callback URL
Errors:
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_APP_ID | App ID not found |
| 400 | INVALID_REDIRECT_URL | Domain not in allowed list for this app |
GET /context/auth/callback
Clerk redirects here after successful sign-in. Not called directly by client code.
Behavior:
- Receives Clerk session data
- Generates a cryptographically random exchange token (64 bytes, hex-encoded)
- Stores exchange token in
host_auth_exchange_tokenstable (single-use, 60-second expiry) - Redirects (302) to original
redirect_url?wm_token=<exchange-token>
POST /context/auth/exchange
Exchanges a single-use token for an access token and session.
Request:
{
"token": "a1b2c3d4e5f6..."
}
Response (200):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-03-09T15:30:00Z",
"user": {
"id": "user_abc123",
"email": "jane@acme.com",
"name": "Jane Smith",
"role": "admin"
},
"workspace": {
"id": "org_xyz789",
"name": "Acme Corp",
"tier": "professional"
}
}
Errors:
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_TOKEN | Token not found or malformed |
| 400 | TOKEN_EXPIRED | Exchange token has expired (60-second window) |
| 400 | TOKEN_USED | Exchange token has already been used |
Security notes:
- Exchange tokens are single-use — once exchanged, the token is marked as used and cannot be replayed
- Exchange tokens expire after 60 seconds
- The access token returned is a Waymaker-issued JWT, not a raw Clerk token
POST /context/auth/refresh
Refreshes a near-expiry access token without requiring the user to sign in again.
Request:
{
"access_token": "eyJhbGciOiJIUzI1NiIs..."
}
Response (200):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-03-09T16:30:00Z"
}
Errors:
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_TOKEN | Token is invalid or not found |
| 401 | SESSION_INVALIDATED | Session has been explicitly logged out |
Notes:
- The auth client calls this automatically at the 50-minute mark (for 1-hour tokens)
- The original session must still be valid (not logged out)
POST /context/auth/logout
Invalidates the session on the server. The auth client also clears local session storage.
Request:
{
"access_token": "eyJhbGciOiJIUzI1NiIs..."
}
Response (200):
{
"success": true
}
GET /context/auth/validate
Validates an access token and returns the associated user and workspace. Used by Ambassadors to verify requests.
Request headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Response (200):
{
"user": {
"id": "user_abc123",
"email": "jane@acme.com",
"name": "Jane Smith",
"role": "admin"
},
"workspace": {
"id": "org_xyz789",
"name": "Acme Corp",
"tier": "professional"
}
}
Errors:
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_TOKEN | Token is invalid, expired, or not found |
| 401 | SESSION_INVALIDATED | Session has been logged out |
Token Lifecycle
Login → Exchange Token (60s, single-use)
→ Access Token (1 hour, refreshable)
→ Refresh at 50 min → New Access Token (1 hour)
→ Logout → Session invalidated
Exchange Token
- Generated after successful Clerk sign-in
- Cryptographically random (64 bytes, hex-encoded)
- Single-use — marked as
usedafter first exchange - Expires after 60 seconds
- Stored in
host_auth_exchange_tokenstable - Passed via URL query parameter (
?wm_token=)
Access Token
- Waymaker-issued JWT (not a raw Clerk JWT)
- 1-hour expiry
- Contains: user ID, organization ID, app ID
- Stored in browser
sessionStorage(tab-scoped) - Refreshable via
/context/auth/refresh - Validated by Ambassadors via
/context/auth/validate
Why sessionStorage (Not localStorage)
sessionStorageis scoped to a single browser tab- Closing the tab clears the session — no stale tokens
- Other tabs or windows do not share the token
- Reduces the window for token theft via XSS
Security Model
Domain Validation
Every Host app registers its allowed authentication domains. The auth proxy validates redirect_url against this list before initiating the sign-in flow.
App: grahamleo (app_abc123)
Allowed domains: ['grahamleo.com', 'www.grahamleo.com']
Request: redirect_url=https://grahamleo.com/admin → allowed
Request: redirect_url=https://evil.com/steal → rejected (400)
This prevents open redirect attacks — the auth proxy only redirects to domains that belong to the app.
Exchange Token Security
- Single-use: Each token can only be exchanged once. A replayed token returns
TOKEN_USED. - Short-lived: 60-second expiry. Even if intercepted, the window for use is narrow.
- Cryptographically random: 64 bytes of randomness (128 hex characters). Not guessable.
- Stored server-side: Tokens are validated against the database, not decoded from the URL.
CORS
Auth endpoints accept requests only from domains registered in the app's allowed_auth_domains. Requests from unregistered origins are rejected.
Token Hashing
Exchange tokens stored in the database are hashed (SHA-256). The raw token appears only in the URL redirect and the exchange request. If the database is compromised, raw tokens cannot be recovered.
Ambassador Middleware Pattern
A reusable pattern for protecting Ambassador endpoints with Site Auth:
interface AuthContext {
user: {
id: string
email: string
name: string
role: string
}
workspace: {
id: string
name: string
tier: string
}
}
async function authenticate(request: Request): Promise<AuthContext | null> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '')
if (!token) return null
const response = await fetch('https://auth.waymakerone.com/context/auth/validate', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) return null
return response.json()
}
// Usage in an Ambassador
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Public routes
if (request.url.endsWith('/health')) {
return new Response('OK')
}
// Protected routes
const auth = await authenticate(request)
if (!auth) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
// Route with auth context
const url = new URL(request.url)
if (url.pathname === '/upload' && request.method === 'POST') {
return handleUpload(request, auth, env)
}
if (url.pathname === '/documents' && request.method === 'GET') {
return listDocuments(auth, env)
}
return new Response('Not Found', { status: 404 })
}
}
async function handleUpload(
request: Request,
auth: AuthContext,
env: Env
): Promise<Response> {
// auth.user.id tells you who uploaded
// auth.workspace.id tells you which organization
const body = await request.json()
// Process the upload...
return new Response(JSON.stringify({
success: true,
uploaded_by: auth.user.email
}), {
headers: { 'Content-Type': 'application/json' }
})
}
async function listDocuments(
auth: AuthContext,
env: Env
): Promise<Response> {
// Filter by workspace
// Only return documents for auth.workspace.id
return new Response(JSON.stringify({ documents: [] }), {
headers: { 'Content-Type': 'application/json' }
})
}
Caching Validation Responses
For high-traffic Ambassadors, cache the validation response to avoid calling /context/auth/validate on every request:
const AUTH_CACHE = new Map<string, { context: AuthContext; expires: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
async function authenticate(request: Request): Promise<AuthContext | null> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '')
if (!token) return null
// Check cache
const cached = AUTH_CACHE.get(token)
if (cached && cached.expires > Date.now()) {
return cached.context
}
// Validate with Context API
const response = await fetch('https://auth.waymakerone.com/context/auth/validate', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) return null
const context = await response.json()
AUTH_CACHE.set(token, { context, expires: Date.now() + CACHE_TTL })
return context
}
Trade-off: Cached validation means a logged-out user may still have access for up to the cache TTL. Use a shorter TTL for sensitive operations.
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
INVALID_APP_ID | 400 | The app_id does not match a registered Host app |
INVALID_REDIRECT_URL | 400 | The redirect domain is not in the app's allowed domains |
INVALID_TOKEN | 400 or 401 | Token is malformed, not found, or expired |
TOKEN_EXPIRED | 400 | Exchange token exceeded its 60-second window |
TOKEN_USED | 400 | Exchange token has already been consumed |
SESSION_INVALIDATED | 401 | Session was explicitly logged out |
CORS_REJECTED | 403 | Request origin does not match allowed domains |
All error responses follow this format:
{
"error": "TOKEN_EXPIRED",
"message": "Exchange token has expired. Please initiate a new login."
}
Troubleshooting
Exchange token expired
The user took more than 60 seconds between the Clerk sign-in and arriving back at your site. This can happen with slow networks or if the user paused mid-flow. The auth client handles this automatically by redirecting to login again.
CORS errors on /exchange
Your site's domain is not in the app's allowed_auth_domains. Add it in Host app settings or via CLI:
waymaker host apps update <app-id> --allowed-auth-domains "yourdomain.com,www.yourdomain.com"
Token validation returns 401 but token is recent
- Check that the session was not explicitly logged out from another tab
- Verify the token is being sent with
Bearerprefix (note the space) - Confirm the Ambassador is calling the correct endpoint (
/context/auth/validate)
Session lost after page refresh
Tokens are stored in sessionStorage, which persists across page refreshes within the same tab. If the session is lost:
- Confirm you are not opening the page in a new tab (new tab = new session storage)
- Check that no JavaScript is clearing
sessionStorageon load - Verify the auth client script is loading before your application code