Waymaker API
WaymakerAPI
Developer Documentation
Documentation

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

  1. Login redirect — The auth client redirects to auth.waymakerone.com/login with the app ID and the URL to return to.
  2. Clerk sign-in — The auth proxy presents the Clerk sign-in UI. The user authenticates via email/password, magic link, or SSO.
  3. Callback — Clerk redirects back to the auth proxy callback endpoint with the authenticated session.
  4. 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.
  5. Token exchange — The auth client automatically POSTs the exchange token to /context/auth/exchange and receives an access token.
  6. Session established — The access token (1-hour JWT) is stored in sessionStorage. The ?wm_token= parameter is removed from the URL.
  7. 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_id exists and is a registered Host app
  • Validates redirect_url domain is in the app's allowed_auth_domains
  • Redirects (302) to Clerk hosted sign-in with callback URL

Errors:

StatusCodeDescription
400INVALID_APP_IDApp ID not found
400INVALID_REDIRECT_URLDomain 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_tokens table (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:

StatusCodeDescription
400INVALID_TOKENToken not found or malformed
400TOKEN_EXPIREDExchange token has expired (60-second window)
400TOKEN_USEDExchange 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:

StatusCodeDescription
401INVALID_TOKENToken is invalid or not found
401SESSION_INVALIDATEDSession 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:

StatusCodeDescription
401INVALID_TOKENToken is invalid, expired, or not found
401SESSION_INVALIDATEDSession 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 used after first exchange
  • Expires after 60 seconds
  • Stored in host_auth_exchange_tokens table
  • 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)

  • sessionStorage is 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

CodeHTTP StatusDescription
INVALID_APP_ID400The app_id does not match a registered Host app
INVALID_REDIRECT_URL400The redirect domain is not in the app's allowed domains
INVALID_TOKEN400 or 401Token is malformed, not found, or expired
TOKEN_EXPIRED400Exchange token exceeded its 60-second window
TOKEN_USED400Exchange token has already been consumed
SESSION_INVALIDATED401Session was explicitly logged out
CORS_REJECTED403Request 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 Bearer prefix (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 sessionStorage on load
  • Verify the auth client script is loading before your application code