How-To

Bot Auth vs. Human Auth: How WFW Handles Two Different Identities

Workforce Wave

April 17, 20268 min read
#architecture#auth#developers#security

One of the less obvious design decisions in WFW's architecture is that authentication isn't unified. There are two separate auth surfaces: one for humans managing their agents through the dashboard or admin API, and one for bots and AI systems calling the v2 API programmatically.

Same codebase. Same underlying data. Different trust models, different token shapes, different session behaviors.

This post explains why, and what the implementation looks like.

The Problem With Unified Auth

The naive approach — one auth system, one token type — runs into trouble fast when you think through the threat model.

A human admin authenticates once per session. They log in via the dashboard, get a session cookie, and stay authenticated for hours or days. If their session token is compromised, an attacker gets a time-bounded window that ends when the session expires or they log out.

A bot authenticates per-request (or per short token lifetime). It uses machine credentials — a client ID and secret — stored in environment variables or a secret manager. If those credentials are compromised, the blast radius is defined by what scopes the service account has, not by what the account owner can do as a human.

The authentication mechanism should reflect these different risk profiles. A session cookie that works in a browser is the wrong mechanism for a machine. An OAuth client credentials flow that works for a machine is the wrong mechanism for a browser.

More fundamentally: a compromised session token should not be usable on the bot API surface, and a compromised bot credential should not be usable to access the human admin UI. If you unify auth, you lose that separation.

The Human Surface

Human authentication uses NextAuth.js with a credentials provider backed by the ww_users table.

After successful login, NextAuth issues a JWT session token stored in an HttpOnly cookie. The session token contains:

{
  "sub": "usr_abc123",
  "email": "admin@ridgelinedental.com",
  "role": "user",
  "clientId": "cli_rldnt_001",
  "type": "session"
}

The type: session claim is the critical field. Every API route that handles requests from the human surface checks for this claim. A bot_access token attempting to reach a session-gated endpoint gets a 401 with error.code: WRONG_TOKEN_TYPE.

Role-based access control lives in this surface:

  • super_admin — full platform access, all clients, can impersonate
  • partner — access to their own client tree, white-label configuration
  • user — access to their own clientId scope only

Every session-gated route checks both type: session and the appropriate role/clientId scope. A user at clientId: cli_rldnt_001 cannot query agents belonging to clientId: cli_summit_002 — even if they somehow obtained a valid session token for that client.

The Bot Surface

Bot authentication uses OAuth 2.1 client credentials flow. Service accounts are created in the dashboard under Settings → API Keys. Each service account has a client_id and client_secret.

POST /v2/auth/token
Content-Type: application/json

{
  "client_id": "sa_rldnt_svc_001",
  "client_secret": "sk_live_...",
  "grant_type": "client_credentials"
}

The returned access token is a JWT with a distinct shape:

{
  "sub": "sa_rldnt_svc_001",
  "client_id": "cli_rldnt_001",
  "type": "bot_access",
  "scopes": ["agents:read", "calls:read", "calls:write"],
  "expires_at": 1750000000,
  "jti": "tok_unique_id_abc"
}

type: bot_access is the claim that marks this as a machine token. Bot-surface routes check for this claim before processing any request. A human session cookie on a bot route gets a 401 with error.code: WRONG_TOKEN_TYPE — the same error, same direction.

Scope Design

Bot tokens carry explicit scopes that define exactly what the service account can do. The scope list is set when the service account is created and can be narrowed but not expanded without creating a new key.

Current scopes:

ScopeWhat it allows
agents:readGET /v2/agents, GET /v2/agents/{id}
agents:writePOST /v2/agents, PATCH /v2/agents/{id}
calls:readGET /v2/calls, GET /v2/calls/{id}/transcript
calls:writePOST /v2/calls/initiate
webhooks:manageGET/POST/DELETE /v2/webhooks
kb:readGET /v2/agents/{id}/kb
kb:writePOST /v2/agents/{id}/kb/sync, /kb/propose
operations:readGET /v2/operations/{id}

We chose agent-level scopes rather than resource-level scopes (e.g., agents:agt_xyz789:read) deliberately. Resource-level scopes are more granular but create key management complexity at scale — a DSO provisioning 200 agents would need 200 × N scopes per key. Agent-level scopes are coarser but tractable. If you need resource isolation at scale, use separate service accounts per subset of agents.

Why Bots Don't Use Sessions

The question sometimes comes up: why not just let bots use session tokens, too? It would simplify the codebase.

Several reasons:

Session tokens aren't designed for rotation. When a session expires, the human logs in again — a human-initiated event. Bot credentials need rotation on a schedule or after a suspected compromise. Client credentials can be rotated by generating a new key pair in the dashboard; the old key can be revoked immediately. Session-style auth has no clean analog to this.

Session tokens carry more privilege than bots need. A session scoped to role: user, clientId: cli_rldnt_001 implicitly allows everything that user is allowed to do across all of their resources. Bots should only have the specific scopes they actually need. The principle of least privilege is much easier to enforce with explicit scope lists than with role-based session auth.

Sessions are stateful, bots should be stateless. NextAuth session management involves server-side state (session store, refresh logic, logout handling). Bot token validation should be stateless — validate the JWT signature, check expiry, check scopes, done. No session store lookup, no server-side state. This matters at scale when bot API calls significantly outnumber human dashboard interactions.

Service Accounts: The Bridge Between Surfaces

The connection between the two surfaces is the service account record in the database. A service account belongs to a clientId, which is the same clientId that scopes a human user's session.

This means:

  • A human admin at clientId: cli_rldnt_001 can create service accounts for that client
  • Those service accounts inherit the client scope — they can't act on behalf of other clients
  • The human admin can revoke service accounts from the dashboard — the next token request with revoked credentials gets a 401

Key rotation: the dashboard lets admins generate a new client_secret for an existing service account. The old secret is immediately invalidated. Any bot currently using it will get a 401 on its next token request and will need to be updated with the new secret. This is the intended rotation path — no infrastructure changes required, just an env var update.

Idempotency: Why Bots Need It and Humans Generally Don't

One of the behaviors that's specific to the bot surface is idempotency key enforcement. The human dashboard handles this implicitly — a human doesn't double-click "Create Agent" because the UI disables the button after the first click. Bots retry on network failure.

Every mutating endpoint on the bot surface accepts an Idempotency-Key header. If you send the same key twice within the deduplication window (24 hours), you get the same response both times — no duplicate agents, no double-initiated calls, no duplicate webhooks.

The key is an arbitrary string you generate. A good pattern:

Idempotency-Key: provision-{clientId}-{businessSlug}-{dateStamp}

The deduplication window is 24 hours. After 24 hours, the same key can be reused (for a new logical operation with the same natural key). The 24-hour window is long enough to cover any realistic retry storm; it's short enough that you won't accidentally suppress a legitimate new operation days later.

If you omit the key, the request goes through normally — idempotency is opt-in. But for any production bot flow, it should be considered mandatory. Network failures are not hypothetical.

Token Introspection

Bots that want to verify their own token state can call:

GET /v2/auth/token/introspect
Authorization: Bearer {token}
{
  "active": true,
  "sub": "sa_rldnt_svc_001",
  "type": "bot_access",
  "scopes": ["agents:read", "calls:read", "calls:write"],
  "client_id": "cli_rldnt_001",
  "expires_at": 1750000000,
  "issued_at": 1749996400
}

This is useful for debugging scope issues ("why am I getting 403 on this endpoint?") without having to decode the JWT manually. It also handles the edge case where a key has been revoked server-side — active: false in the introspection response means the token won't work even if it hasn't expired.

The Security Boundary Summary

ConcernHuman surfaceBot surface
Auth mechanismNextAuth session + HttpOnly cookieOAuth 2.1 client credentials
Token type claimsessionbot_access
Privilege modelRole-based (super_admin / partner / user)Scope-based (explicit scope list)
Session durationHours to days1 hour (configurable)
RotationLogout and re-loginGenerate new key pair, revoke old
Stateful?Yes (session store)No (stateless JWT validation)
Idempotency enforced?No (UI handles it)Yes (Idempotency-Key header)

The two surfaces are deliberately isolated. That's the point. A system that serves both human operators and autonomous AI agents has to maintain a real separation between those trust domains, or the security model of one undermines the other.


Next in this series: Designing an API for AI Consumers: What's Different — the design principles behind the WFW v2 API when your primary consumer is an AI agent.

Share this article

Ready to put AI voice agents to work in your business?

Get a Live Demo — It's Free