Skip to content
AI Workforce OS
ES EN
Create account

Authentication and multi-tenancy

This page describes the auth service in the Go backend: accounts, sessions, two-factor authentication, roles, invitations and how Postgres enforces the boundary between organizations. It is for whoever deploys or operates AI Workforce OS or builds their own client against the API.

9 min read

On this page
  1. Two modes: AUTH_ENABLED false and true
  2. Accounts and registration
  3. Email verification and trial accounts
  4. Login, lockout and rate limits
  5. Sessions: access token and rotating refresh token
  6. Two-factor authentication and password reset
  7. Roles and permissions (RBAC)
  8. Organizations, members and switching
  9. Invitations
  10. Tenant isolation with Postgres row-level security
  11. Endpoint reference
Back to top

Two modes: AUTH_ENABLED false and true#

With AUTH_ENABLED=false (the binary's default) there is no login: the API is open, every request uses a single fixed demo organization and the server logs a warning at startup. It is meant for local development and demos.

With AUTH_ENABLED=true the API requires a bearer access token on protected routes, resolves the role on every request and scopes all data access to the caller's organization. Startup fails if:

  • JWT_SECRET is shorter than 32 bytes.
  • DATABASE_URL is missing (accounts and organizations live in Postgres).
  • ALLOWED_ORIGINS contains *. If you leave it empty, only the same origin is accepted.
.env (backend)Bash
AUTH_ENABLED=true
JWT_SECRET=<at least 32 random bytes>
DATABASE_URL=postgres://...          # required with auth
MIGRATE_DATABASE_URL=postgres://...  # owner/DDL role for migrations (default: DATABASE_URL)
DB_APP_ROLE=app_user                 # default
ALLOWED_ORIGINS=https://app.example.com   # no * when auth is on
TRUSTED_PROXIES=10.0.0.0/8           # honour X-Forwarded-For from these addresses
AUTH_COOKIE_SECURE=true              # default; false only for plain-http development
AUTH_COOKIE_NAME=wfai_refresh        # default name

Note

The development docker-compose.yml starts with AUTH_ENABLED=true and AUTH_COOKIE_SECURE=false; docker-compose.demo.yml sets it to false.

Warning

With AUTH_ENABLED=false anyone who can reach the port can read and change everything. Never expose that mode to a network.

Accounts and registration#

POST /api/v1/auth/register creates a user, a new organization and an owner membership in one step, and answers 201 with a session. The organization starts with the default budget (BUDGET_USD). Email addresses are normalized (trimmed, lowercased) before any lookup or storage, so Ana@Example.com and ana@example.com are the same account.

Passwords are hashed with Argon2id (64 MiB, 3 iterations, 2 threads). A hash created with weaker parameters is re-hashed on the next successful login. When a rule fails, the response is 422 validation_failed with the details in error.fields.

FieldRule
emailTrimmed and lowercased; max 254 characters; a plain address with a dot in the domain.
password10 to 128 characters; not in a short common-password list; not equal to the email or its local part; not a single repeated character; at least two of lowercase, uppercase, digits and symbols.
name1 to 100 characters, no control characters.
org_name2 to 100 characters, no control characters. The slug is derived from the name plus a random suffix.

Email verification and trial accounts#

With EMAIL_VERIFICATION=true, registration creates the account but opens no session: it answers 202 with verification_required and sends a one-time link (random token stored as a hash, valid for 24 hours). It needs AUTH_ENABLED=true, UI_BASE_URL and SMTP (SMTP_*) or EMAIL_DEV_LOG=true, which writes the links to the log instead of sending them.

With TRIAL_ENABLED=true (which also needs AUTH_ENABLED=true), POST /api/v1/auth/trial creates a throwaway account with its own organization and returns, exactly once, the generated email, a password (format XXXX-XXXX-XXXX) and expires_at. Only the password hash is stored. GET /api/v1/auth/config tells the UI which features are on.

  • POST /auth/login with the right password and an unverified address answers 403 email_not_verified; with a wrong password it still answers invalid_credentials, so it does not reveal which addresses exist.
  • POST /auth/verify with {token} consumes the link, marks the address verified and opens the session.
  • POST /auth/verify/resend with {email} sends the link again (3 per hour per address) and answers 204 whether or not the account exists.
  • A trial account stops working after TRIAL_TTL_HOURS (default 24): the access token never outlives the trial, login answers 403 trial_expired and renewal is refused.
  • Trial creation is limited per IP (TRIAL_PER_IP_PER_DAY, 3) and in total (TRIAL_GLOBAL_PER_DAY, 300) per day.
201 from POST /api/v1/auth/trialJSON
{
  "email": "prueba-1a2b3c4d@prueba.invalid",
  "password": "K7QD-3MWX-H9TR",
  "expires_at": "2026-10-23T12:00:00Z"
}

Login, lockout and rate limits#

Login failures are counted per account. After five wrong passwords the account is locked for 15 minutes and further attempts answer 429 account_locked with a Retry-After header. An unknown email is checked against a dummy hash, so a missing account takes as long as a wrong password.

On top of that there are per-email and per-IP limiters. Counters live in Redis when REDIS_URL is set (they survive restarts) and in memory otherwise. IP limits honour X-Forwarded-For only from addresses in TRUSTED_PROXIES. When exceeded, the response is 429 rate_limited with Retry-After.

Locked accountHTTP
HTTP/1.1 429 Too Many Requests
Retry-After: 840
Content-Type: application/json

{"error":{"code":"account_locked","message":"too many failed attempts; try again later"}}
LimitDefaultKey
Login attempts10 per 15 minEmail address (hashed), whether or not the account exists
POST /auth/login and public two-factor endpoints20 per 15 minClient IP
POST /auth/register, /invitations/accept, /verify, /verify/resend, /password/forgot, /password/reset5 per hour eachClient IP
POST /auth/refresh and /auth/logout60 per 15 minClient IP
Authenticated /auth and /invitations endpoints120 per minUser
Failed passwords before lockout5, lock for 15 minAccount

Note

If the limiter backend (Redis) fails, the limiters let requests through instead of blocking login. The per-account lockout does not depend on Redis: it lives in Postgres.

Sessions: access token and rotating refresh token#

The access token is a short-lived (15 minutes) HS256 JWT carrying the user, the organization and the role, sent in Authorization: Bearer. The refresh token is an opaque random value, valid for 30 days, stored server-side only as a SHA-256 hash and delivered in the wfai_refresh cookie (HttpOnly, SameSite=Strict, Secure by default, Path=/api/v1/auth). It never appears in a JSON body.

/auth/refresh and /auth/logout act on that cookie, so they require the X-WFAI-CSRF header and reject requests with Sec-Fetch-Site: cross-site or with an Origin outside ALLOWED_ORIGINS (or the host itself). Each refresh rotates the token inside its family:

  • Presenting an already-used refresh token revokes the whole family, which exposes theft.
  • A rejected token (expired, revoked or reused) answers 401 invalid_token and clears the cookie.
  • POST /auth/logout revokes the family and answers 204; POST /auth/logout-all revokes every refresh token of the user in every organization.
LoginHTTP
POST /api/v1/auth/login
Content-Type: application/json

{"email":"ana@example.com","password":"<password>"}

HTTP/1.1 200 OK
Cache-Control: no-store
Set-Cookie: wfai_refresh=<opaque>; Path=/api/v1/auth; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict
Content-Type: application/json

{"token_type":"Bearer","access_token":"<jwt>","expires_in":900,
 "expires_at":"2026-10-22T12:15:00Z",
 "user":{"id":"<user id>","email":"ana@example.com","name":"Ana"},
 "org_id":"<org id>","role":"owner"}
Renew the sessionBash
curl -X POST http://localhost:8080/api/v1/auth/refresh \
  -b 'wfai_refresh=<cookie value>' -H 'X-WFAI-CSRF: 1'

Two-factor authentication and password reset#

Each user can turn on a TOTP second factor: POST /auth/mfa/setup returns secret and otpauth_uri (for the QR code), and POST /auth/mfa/enable with a valid code turns it on and returns 8 recovery codes, shown exactly once. From then on, a login with the right password opens no session: it answers 200 with mfa_required and a 5-minute mfa_token, and the session opens with POST /auth/mfa/verify ({mfa_token, code}). A TOTP code cannot be replayed, and wrong codes count toward the same lockout as passwords.

An organization can require two-factor authentication from its members (login returns mfa_enrollment_required with an enrolment token) or require SSO (a password login by anyone who is not an owner answers 403 sso_required).

With email configured (SMTP or EMAIL_DEV_LOG) and UI_BASE_URL, POST /auth/password/forgot with {email} sends a link valid for 1 hour and answers 204 whether or not the account exists. POST /auth/password/reset with {token, password} applies the password policy, ends every session of the user, clears the lockout and opens no session: the user signs in again.

Roles and permissions (RBAC)#

Each membership has one role: owner, admin, member or viewer. A role is a set of permissions named resource:action, and each is a strict superset of the one below. The role is re-read from the database on every request, so demoting or removing a member takes effect immediately rather than when the token expires. GET /auth/me returns the role and its permissions list.

An owner can assign any role; an admin can assign and manage only roles below admin and cannot change their own; nobody grants a role above their own. The last owner cannot be removed (409 last_owner).

RoleAdds on top of the role below
viewerRead-only: members, agents, tasks, requests, conversations, approvals, reports, memories, activity, metrics, artifacts, files, skills and shop.
memberCreate requests and tasks, write in conversations and memories, create, edit, comment on and export artifacts, write files and skills, buy in the shop, read connections.
adminManage the organization, invite and manage members, write agents, manage tasks, decide approvals (approvals:decide), read the audit log, manage connections and their grants, pause and kill switch, approve and delete artifacts, delete files, review skills, delete conversations.
ownerDelete the organization, billing, release the kill switch (controls:release) and edit the approval policy (policy:manage).

Note

Agents never receive approval permissions such as artifacts:approve: their authority cannot approve what they themselves propose.

Organizations, members and switching#

A user can belong to several organizations, with a different role in each. GET /auth/me returns the user and the orgs list with names and roles. POST /auth/switch-org with {org_id} opens a new session in another of the user's organizations and revokes the previous refresh-token family; asking for an organization without membership answers 403 forbidden.

All member management happens inside the actor's organization, and users from other organizations are reported as not found:

  • GET /auth/members lists the members (members:read).
  • POST /auth/members with {email, role} adds a user who already has an account (members:invite).
  • PATCH /auth/members/{id} with {role} changes the role (members:manage).
  • DELETE /auth/members/{id} removes a member (members:manage) or lets anyone leave on their own. It also revokes that user's refresh tokens for the organization.

Invitations#

Admins and owners invite an email address with a role they are allowed to assign (POST /api/v1/invitations). The server stores only the SHA-256 of the one-time token and returns the plaintext exactly once. It does not send email: the inviter shares the /invite?token=... link served by the frontend. A new invitation to the same address revokes the previous pending one, and links expire after 7 days.

POST /api/v1/auth/invitations/accept is public and takes {token, name, password}. A new person picks a name and a password; an existing account proves ownership with its current password, under the same lockout as login. Unknown, expired, revoked and used tokens all fail the same way (401 invalid_token). If the inviter has since been demoted or removed, the invitation stops working. Creation, revocation and acceptance go to the audit trail, which stores only the email domain.

201 from POST /api/v1/invitationsJSON
{
  "id": "<invitation id>",
  "org_id": "<org id>",
  "email": "luis@example.com",
  "role": "member",
  "invited_by": "<user id>",
  "created_at": "2026-10-22T12:00:00Z",
  "expires_at": "2026-10-29T12:00:00Z",
  "status": "pending",
  "accepted_at": null,
  "revoked_at": null,
  "token": "<one-time token>"
}

Warning

The token field is the only copy of the secret: whoever holds it can join the organization with that role. Share it over a private channel.

Tenant isolation with Postgres row-level security#

Role checks decide what a user may do; row-level security decides which rows exist for them. Every tenant table has the tenant_isolation policy, which compares org_id with app_current_org(), and FORCE ROW LEVEL SECURITY applies it even to the table owner. Each request runs in a transaction that starts with set_config('app.org_id', ..., true): the setting is transaction-local, so a pooled connection cannot carry one tenant into another request.

It fails closed: without app.org_id, no row is visible or writable. Every application connection runs SET ROLE app_user (DB_APP_ROLE), a role that is not a superuser, has no BYPASSRLS and owns no table. For that role the audit log is append-only (UPDATE and DELETE are revoked). Migrations run with MIGRATE_DATABASE_URL when it is set.

SQL
-- One transaction per request, scoped to the caller's organization
SELECT set_config('app.org_id', '<org id>', true);   -- == SET LOCAL

CREATE OR REPLACE FUNCTION app_current_org() RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS
$$ SELECT NULLIF(current_setting('app.org_id', true), '') $$;

CREATE POLICY tenant_isolation ON agents
  USING      (org_id = app_current_org())
  WITH CHECK (org_id = app_current_org());

Endpoint reference#

The auth router is mounted at /api/v1/auth and the invitation router at /api/v1/invitations. Permissions are checked by middleware before the handler runs, and the service re-reads the actor's current membership on every member operation. Errors use the shape {"error": {"code", "message"}}.

EndpointAccess
POST /api/v1/auth/register, /login, /refresh, /logoutPublic; refresh and logout use the cookie plus X-WFAI-CSRF.
POST /api/v1/auth/verify, /verify/resend, /trial, /password/forgot, /password/resetPublic; each needs its feature turned on.
POST /api/v1/auth/mfa/verify, /mfa/enroll/setup, /mfa/enroll/enablePublic, with the mfa_token from login.
POST /api/v1/auth/invitations/acceptPublic, token in the body.
GET /api/v1/auth/configPublic: enabled, registration, verification, trial, password_reset, mfa.
GET /auth/me, POST /auth/logout-all, /auth/switch-org, /auth/mfa/setup, /auth/mfa/enable, /auth/mfa/disableAny authenticated user.
GET, POST, PATCH, DELETE /auth/membersmembers:read, members:invite, members:manage; a member may leave on their own.
GET, POST, DELETE /api/v1/invitationsmembers:read to list; members:invite to create and revoke.

Tip

The API reference lists the remaining routes and the security model covers the threat analysis.