Accounts and teams
Every sign-up on the control plane (:8080) creates a workspace (a tenant) and an owner user. Production starts with sign-up closed, because open sign-up lets anyone create tenants with their own budget and microVM quota. You open it on purpose, behind a captcha, rate limits and verified email. This guide covers the sign-up modes, the verify and reset flows, and the owner-only data export and workspace deletion. The dashboard pages are thin clients over the endpoints below.
Sign-up modes
Section titled “Sign-up modes”PAL_SIGNUP_MODE controls POST /auth/signup. Unset, it is closed when PAL_ENV is prod, production or staging, and open otherwise. Any other value stops the server at startup.
| Mode | Behaviour |
|---|---|
closed |
Every sign-up gets 403 {"error":"sign-up is closed"}. Default in prod. |
invite |
Needs an inviteCode bound to the email, else 403. Requires PAL_SIGNUP_INVITE_SECRET. |
open |
Anyone, subject to the rate limit and captcha. Default in dev. |
The same mode applies to a new user who signs in with Google or GitHub. A first OAuth sign-in creates a workspace, which counts as a sign-up. Users who already exist always sign in.
closedrefuses new OAuth users. The redirect is/login?error=signup_closed.inviteneeds an invite code bound to the email the provider verified. The dashboard sends it withGET /auth/oauth/{provider}/start?invite=<code>. The server stores it in the OAuth state and never sends it to the provider.openandinviteapply the per-IP sign-up rate limit, which fails closed in prod. There is no captcha on the OAuth path: completing the provider’s sign-in is the human check, and a Turnstile token can’t survive the provider redirect.
OAuth sign-in rules
Section titled “OAuth sign-in rules”- Only a provider-verified email counts. Google must report
verified_email(or OIDCemail_verified) as true. For GitHub the address always comes from/user/emailsand must be the primary address and verified; the public profile email is ignored. Otherwise the redirect is/login?error=email_unverifiedand no account is created or touched. - Linking to an existing account. Accounts are matched by email, case-insensitively, across workspaces (oldest first). An unverified password account is skipped: nobody has proven they own that address, so it cannot squat it and lock the real owner out. The owner gets their own new workspace (subject to the sign-up mode) or the verified account the email already belongs to. A provider sign-in may enter an existing account only if that account’s email is verified (
email_verified_atset). Every provider email is verified by the rule above, so a verified password account, or an account first created with the other provider, can also sign in with Google or GitHub. Nothing is stored for a link: the provider sign-in just signs in as that user. The lookup uses theusers_email_lower_idxindex (migration 0046). - Errors are codes. A failed OAuth sign-in redirects to
/login?error=<code>, plus&provider=google|githubwhen the provider is known. The codes areemail_unverified,link_refused,signup_closed,invite_required,signup_rate_limited,signup_unavailable,state_invalid,state_expired,oauth_denied,oauth_failedandinternal. The login page maps known codes to its own copy and never renders the query text. - The sign-in is bound to the browser that started it.
GET /auth/oauth/{provider}/startsets an HttpOnly,SameSite=Laxcookiepal_oauth_state(path/auth/oauth/, 10 minutes,Securein prod) holding a hash of the state. The callback refuses a state this cookie does not match (state_invalid). That stops login CSRF, where an attacker’s own code and state are delivered to your browser. The state is redeemed atomically (RedisGETDEL), so a replay or a second concurrent callback fails. PKCE (S256) is kept. - The dashboard navigates to
/auth/oauth/{provider}/start?redirect=1(a top-level load, not afetch) so the browser accepts the cookie. That answers302to the provider, and errors302back to/login?error=<code>. Withoutredirect=1the endpoint still returns{"redirect_url"}, but the cookie only sticks if the caller is a same-site browser. The start URL andOAUTH_REDIRECT_BASE(the callback) must be on the same host, or the cookie is not sent back. - First sign-ins for one email are serialized (a Postgres advisory lock on the lowercased email), so two concurrent callbacks create one workspace, not two.
First owner on a self-host
Section titled “First owner on a self-host”Production starts with sign-up closed and an empty database, so nobody could log in. To create the first owner, set PAL_BOOTSTRAP_OWNER_EMAIL=you@example.com (Helm: controlPlane.env.PAL_BOOTSTRAP_OWNER_EMAIL) and start the control plane once. If, and only if, the instance has zero users, startup creates the workspace (with the usual free-tier defaults) and an owner whose password is unusable, in one transaction. It writes a one-time password link to the server’s stderr (kubectl logs deploy/<release>-control-plane):
PAL first-owner bootstrap for you@example.comSet the owner password (single use, expires in 1 hour): https://<dashboard>/auth/reset?token=...Open the link, set a password, then log in. The link is an ordinary password-reset token (SHA-256 at rest, single use, 1 hour, rate limited), so there is no extra endpoint to attack. The raw token is never sent to the structured logger or traces. Once any user has existed, the variable is ignored for good: a durable instance_bootstrap marker is written, so erasing every workspace later does not re-arm it. Unset PAL_BOOTSTRAP_OWNER_EMAIL after your first login anyway.
If the hour passes before you redeem the link and you have a real mailer configured, use “Forgot password”. Otherwise take the tenant_id from the control-plane’s bootstrap: first owner created log line, run DELETE FROM tenants WHERE id = '<tenant_id>'; DELETE FROM instance_bootstrap; (only when the instance has zero real users: clearing the marker re-arms bootstrap) (both are needed, the second clears the marker) and restart. With sign-up closed, zero users and no bootstrap variable, the control plane logs a warning naming this variable at every start.
Sign up, verify, log in
Section titled “Sign up, verify, log in”-
POST /auth/signupRate limit, then mode, then captcha. Creates a tenant and an owner user with
email_verified_at = NULL, and mails a verification link. -
Prod:
202 verificationRequiredNo JWT is issued. Dev: 201 with a JWT straight away (verification is not enforced), and the mail goes to the server log.
-
Open the emailed link
The dashboard page
/auth/verify?token=...posts the token toPOST /auth/verify. Single use, valid 24 hours. -
POST /auth/loginReturns the JWT. In prod, an unverified user gets 403 with code
email_unverified, after the password has matched.
Against a dev stack (make run-api), sign-up is open and returns a token immediately. The log mailer prints the verification mail to the control-plane log.
curl -s -X POST http://localhost:8080/auth/signup \ -H 'Content-Type: application/json' \ -d '{"email":"new.user@example.com","password":"correct-horse-battery","name":"New User"}'# 201 {"token":"...","user":{"id":"...","tenantId":"...","email":"new.user@example.com","name":"New User","role":"owner"}}
# grab the link from the log, then redeem the token from its ?token= valuecurl -s -X POST http://localhost:8080/auth/verify \ -H 'Content-Type: application/json' -d '{"token":"<token from the link>"}'# 200 {"verified":true}Sessions and sign-out
Section titled “Sessions and sign-out”A sign-in returns a session JWT (HS256). It is short-lived and revocable:
- Lifetime.
PAL_JWT_TTL(a Go duration,5mto24h, default12h). An out-of-range or unparseable value stops the control plane at startup. A token whose lifetime is longer than the configured TTL is refused even before it expires, so lowering the TTL takes effect at once and the old 30-day tokens no longer work. There is no refresh token: sign in again after the TTL. - Revocation. Each user row has a
token_version, and every session carries it as thetvclaim. A request whosetvno longer matches is refused with401. The version goes up onPOST /auth/logout, on a password reset, on any password or role change (a database trigger does this, so it also covers changes made in SQL), and a deleted user or workspace is refused outright. A bump kills every session that user holds, on every device. - Revocation window. Each control-plane replica caches the version for up to 60 seconds. The replica that handled the logout or erasure refuses the old tokens immediately; other replicas refuse them within 60 seconds. A newer token (signed in after the change) is never held back by a stale cache.
- Where sessions are accepted. Only by the control plane (directly or through the dashboard proxy), where the checks above run. The gateway on
:8443accepts API keys only and refuses session JWTs. - Open streams end too. Server-sent event streams (
/v1/runs/{id}/events,/v1/sessions/{id}/events) close when the token expires and re-check revocation every 60 seconds, so a logout also ends a stream that was already open. POST /auth/logout. Needs a session JWT and returns204. It revokes all of the caller’s sessions, not just the one presented. An API key gets400; revoke keys withDELETE /v1/api-keys/{id}instead.
The dashboard keeps the session out of JavaScript
Section titled “The dashboard keeps the session out of JavaScript”The dashboard stores the session only in an HttpOnly, SameSite=Lax cookie whose lifetime matches the token. In production it is named __Host-pal_token, so the browser only accepts it as Secure, host-only and Path=/ (a sibling subdomain cannot plant or overwrite it). A session still under the old pal_token name is moved over and the old cookie cleared; dev keeps pal_token. Page scripts never see it, and nothing is written to localStorage. The browser calls the API through the dashboard’s own origin at /api/pal/*; that proxy adds Authorization: Bearer from the cookie on the server side and streams responses (including SSE) through unbuffered. When sign-in, sign-up, the OAuth exchange or a kiosk session returns a token, the proxy moves it into the cookie and removes it from the JSON the page receives.
Because the cookie is sent automatically, the proxy refuses anything that does not look like the dashboard’s own fetch(): a Sec-Fetch-Site other than same-origin, a Sec-Fetch-Dest other than empty (so <img>, <script> and navigations to proxy URLs are refused too), and any state-changing request whose Origin is not the dashboard’s host. Only /v1/* and /auth/* are proxied, never /internal/*, and request bodies over 2 MiB get 413. OAuth sign-in is the one exception to “everything goes through the proxy”: it starts with a top-level navigation to the API’s public host (PAL_PUBLIC_API_URL), because that response sets a cookie on the API host.
Every dashboard page also sends a Content-Security-Policy with a per-request nonce (script-src 'self' 'nonce-…' 'strict-dynamic', no unsafe-eval in production, connect-src 'self', frame-ancestors 'none'), plus X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY and a Permissions-Policy that turns off camera, microphone, geolocation, payment and USB.
The control plane rate-limits sign-up, sign-in, password reset, verification, bridge pairing and kiosk sessions by client IP, and with the proxy it sees the dashboard as the peer. The proxy never passes on the browser’s X-Forwarded-For, X-Real-IP or Forwarded: it sends exactly one X-Forwarded-For hop it worked out itself. That hop is the TCP peer of the dashboard server, or, when that peer is your ingress and you list the ingress CIDRs in PAL_DASHBOARD_TRUSTED_PROXIES, the client the ingress reported. List the dashboard pods in the control plane’s PAL_TRUSTED_PROXIES so it honours that hop; otherwise every visitor shares the dashboard’s bucket. The Helm chart sets PAL_DASHBOARD_TRUSTED_PROXIES from controlPlane.trustedProxies, so one pod CIDR covers both.
Every route checks the caller’s role on the server. A session holds the scopes of its role, and a route that needs a scope the role lacks returns 403 {"code":"role_forbidden"}. This is always on; PAL_AUTHZ_ENFORCE only stages scope enforcement for API keys and kiosk sessions. The dashboard offers each role exactly what the server allows (one source: apps/web/lib/rbac.ts, checked against the server by a test).
| Role | Scopes | Can | Cannot |
|---|---|---|---|
owner |
admin |
Everything. | |
admin |
admin |
Everything except the owner-only actions. | Export or delete the workspace, change billing, set the sandbox quota. |
member |
read, agents:write, runs:execute |
Read everything; build, edit, run and delete agents; propose promotions; star and fork marketplace agents. | API keys, provider keys, connectors and MCP credentials (they are tenant-wide secrets every agent can use), budgets, channels, voice numbers, devices, DLP, data planes, agent identity and policy, approving promotions, administering sandboxes, and the owner’s personal data (held replies, automations, people, memory). |
viewer |
read |
Read. | Anything that changes state. |
An unknown role gets viewer. API keys keep their own scopes. Kiosk sessions keep theirs (ADR 0029). Whether a caller is a session is decided by how it authenticated, not by the role it claims: a token that claims role: service is still a session (with viewer scopes), never an API key.
Credentials are never returned
Section titled “Credentials are never returned”Connector, channel (surface) and MCP-attachment config is returned with every secret masked as ********, to every role including the owner. Only identifier fields (for example accountSid, phoneNumber, email, region, botUsername) keep their value; any field not on that allowlist is masked, so a new credential field is safe by default. When a client sends a config back with a field still set to ********, the stored value is kept. Executing a connector action (GET or POST /v1/connectors/{id}/execute) needs connectors:write, because it spends tenant-wide credentials; agents are governed by their policy instead.
Email verification
Section titled “Email verification”- Enforced in prod only. Dev keeps one-step sign-up so
make devworks unchanged, but still sends the mail so the flow is testable. - The token is 32 random bytes, emailed as a link, valid 24 hours, usable once. Only its SHA-256 is stored.
- Until verified (prod), sign-up returns no JWT and
POST /auth/loginreturns403 {"error":"email not verified","code":"email_unverified"}.POST /v1/runsfrom an unverified user returns403 {"code":"email_unverified"}as a second check. API keys are not affected. - Existing users and OAuth sign-ins count as verified. Google sign-in is verified too.
- Lost the link?
POST /auth/verify/resendmails a fresh one to an unverified password user. - A completed password reset also verifies the email.
Password reset
Section titled “Password reset”POST /auth/forgot mails a link to /auth/reset?token=... on the dashboard. The token is valid 1 hour and works once. A successful reset sets the new password, verifies the email, and burns every other outstanding reset link for that user. Passwords must be at least 12 characters. Reset applies to password (local) accounts only.
/auth/forgot and /auth/verify/resend return the same 202 body whether or not the address has an account, and the mail is sent in the background, so relay latency does not show in response time. In prod, signing up with an already-registered email returns the same 202 verificationRequired as a new one. In dev it returns 409 email already registered.
Links point at OAUTH_DASHBOARD_URL (default http://localhost:3001). Set it in any deployed environment or emailed links will point at localhost.
Invite codes
Section titled “Invite codes”Invite codes are stateless and email-bound: <expiryUnix>.<hex HMAC-SHA256(secret, lower(email) + "|" + expiryUnix)>. There is no table and no admin API. A code works only for the email it was minted for and only until its expiry. Mint one on any machine that has the secret:
EXP=$(( $(date +%s) + 7*86400 )); EMAIL=new.user@example.comecho "$EXP.$(printf '%s|%s' "$EMAIL" "$EXP" | openssl dgst -sha256 -hmac "$PAL_SIGNUP_INVITE_SECRET" -r | cut -d' ' -f1)"The invitee sends it as inviteCode in the sign-up body. A missing, expired, malformed or wrong-email code gets 403 a valid invite code is required to sign up.
Captcha
Section titled “Captcha”Cloudflare Turnstile is verified server-side against siteverify with the caller IP. It is required when PAL_TURNSTILE_SECRET is set, and always required for open sign-up in prod. Closed deployments never call Turnstile. The client sends the widget token as captchaToken. The dashboard renders the widget when NEXT_PUBLIC_TURNSTILE_SITE_KEY is set at build time.
| Condition | Response |
|---|---|
| No token | 400 captcha is required |
| Token rejected | 403 captcha verification failed |
| Turnstile unreachable | 503 captcha verification unavailable (fails closed) |
| Required but no secret configured | 503 sign-up is not available. Startup refuses this in prod. |
Rate limits
Section titled “Rate limits”Counters are fixed-window and kept in Redis, keyed by client IP (and email where noted). The IP is the TCP peer. X-Forwarded-For is honored only when the peer is inside PAL_TRUSTED_PROXIES (comma-separated CIDRs). It is then read from the right: hops inside a trusted CIDR are skipped, and the first hop outside them is the client. Entries further left are client-supplied and ignored. Behind an ingress or load balancer, set PAL_TRUSTED_PROXIES to its CIDRs. Without it, every user shares the ingress IP and therefore one bucket. The bridge pairing endpoint (POST /v1/bridges/pair) uses the same client IP. Exceeding a limit returns 429.
| Endpoint | Limit | Key |
|---|---|---|
POST /auth/signup |
10 / hour | IP |
POST /auth/login |
10 / minute | IP, and separately email |
POST /auth/forgot, POST /auth/verify/resend |
20 / hour, and 5 / hour | IP, and email (each endpoint has its own counters) |
POST /auth/verify, POST /auth/reset |
20 / hour | IP (per endpoint) |
Mailer
Section titled “Mailer”Verification and reset mail use the driver in PAL_MAILER (default log). Mail is plain text, sent in the background with a 30 s limit. A failed send is logged, not returned.
| Driver | Behaviour |
|---|---|
log |
Delivers nothing. Dev logs recipient, subject and body (the link). Prod logs recipient and subject only, so tokens never reach prod logs. |
smtp |
Standard-library SMTP to PAL_SMTP_ADDR, upgraded with STARTTLS when offered. Credentials are refused over an unencrypted connection to a non-localhost server. |
http |
POSTs JSON {from,to,subject,text} to PAL_MAIL_HTTP_URL with Authorization: Bearer PAL_MAIL_HTTP_TOKEN (the shape of Resend’s /emails API). 15 second timeout; a non-2xx response is an error. |
The From address is PAL_MAIL_FROM (default PAL <no-reply@localhost>). The remaining variables are in Environment variables.
Export your data
Section titled “Export your data”GET /v1/tenants/{id}/export returns everything the workspace owns as one JSON document, sent with Content-Disposition: attachment; filename="pal-export-<tenantId>.json". It needs an owner JWT, and the id must be your own tenant. Another tenant’s id is 404 (existence is not leaked), a non-owner is 403 owner role required, and no token is 401.
{ "tenantId": "…", "exportedAt": "2026-10-27T12:00:00Z", "tables": { "agents": [ … ], "runs": [ … ], "tenants": [ { … } ] }}tables has one array per table that has a tenant_id column, discovered from the database catalog so new tables are included automatically, plus the tenant row. Credential columns are left out by name: any column with a secret, token, key, password, hash, enc/encrypted, ciphertext or credential(s) segment (for example password_hash, agent_triggers.secret_enc, data_planes.reg_token_hash), plus provider_config and connector_installs.config. Dedup keys (idempotency_key, idem_key, replay_key) and key_prefix are exported. A new credential column is excluded by default. The document is built in memory, so very large workspaces produce a large response.
Delete a workspace
Section titled “Delete a workspace”DELETE /v1/tenants/{id} permanently erases the workspace (the GDPR right to erasure). In one transaction it deletes every tenant-scoped row (runs and their journal events first, then agents, every other tenant table, users) and finally the tenant itself. It cannot be undone.
- It needs an owner JWT; otherwise
403 owner role required. 403 may only purge your own tenantif the id is not yours.- Any failure rolls the whole transaction back and returns
500 internal error. Nothing is removed. - Success is
200 {"tenant_id":"…","deleted":{"runs":12,"agents":3,…}}, a per-table row count.
The API has no confirmation parameter. The safeguard is in the dashboard: the Your data section on the General tab of Settings (owners only) has Export data (JSON) and Delete workspace. In the confirmation dialog, Delete forever stays disabled until you type delete my workspace.
Both tenant routes also carry the admin scope annotation. Scope enforcement is on by default when PAL_ENV is prod, production or staging, and off in dev. Set PAL_AUTHZ_ENFORCE=0 to opt out (logged as a warning) or =1 to force it on in dev. Row-level security is on in production whenever PAL_APP_DB_PASSWORD is set; without it the control plane refuses to start unless you set PAL_RLS_ENFORCE=0. Either way API keys are not owners, so they get 403.
Free-tier caps
Section titled “Free-tier caps”Every new workspace, from password sign-up or a first Google login, starts with a hard-fail daily spend cap and a sandbox quota, so a new account cannot run up cost. The cap is the tenant-wide budget (the row PUT /v1/budgets/tenant edits; see Budgets). The quota is the one PUT /v1/runtime/quota edits. An owner can raise or remove either. Existing workspaces and the seeded dev workspace are never touched. Set a variable to off or 0 to skip that cap.
| Variable | Default | Purpose |
|---|---|---|
PAL_FREE_TIER_COST_USD_PER_DAY |
2.00 |
Tenant-wide LLM spend per day (hard fail, HTTP 402). |
PAL_FREE_TIER_MAX_VMS |
2 |
Concurrent sandbox VMs. Compute hours (4/day) and egress (1 GB/day) are fixed. |
PAL_FREE_TIER_RUNTIME_COST_USD_PER_DAY |
1.00 |
Sandbox spend per day. |
Startup checks
Section titled “Startup checks”The control plane refuses to start when:
PAL_SIGNUP_MODEis notopen,inviteorclosed.invitemode is set withoutPAL_SIGNUP_INVITE_SECRET.PAL_MAILERis unknown, orsmtp/httplacks its address or URL.- In prod,
openmode has noPAL_TURNSTILE_SECRET. - In prod, the mode is
openorinvitewithPAL_MAILER=log, because new users could never receive their verification link.
Endpoint reference
Section titled “Endpoint reference”All /auth/* routes take JSON and need no Bearer token, except POST /auth/logout and GET /auth/me.
| Endpoint | Request | Success | Errors |
|---|---|---|---|
POST /auth/signup |
{email, password (12+ chars), name, inviteCode, captchaToken} |
Dev 201 {token, user:{id, tenantId, email, name, role}} (name defaults to the part of the email before @). Prod 202 {"verificationRequired":true,"message":"Check your email for a link to verify your account."}, no JWT. |
400 invalid body, missing or invalid email, short password, captcha is required. 403 closed, bad invite, captcha failed. 409 dev only, email already registered. 429. 503 captcha unavailable, sign-up is not available. |
POST /auth/login |
{email, password} |
JWT and user as above | 401 invalid credentials, 429, prod 403 email_unverified |
POST /auth/verify |
{token} |
200 {"verified":true} |
400 invalid or expired token (unknown, reused, expired or wrong-purpose), 429 past 20 per hour per IP |
POST /auth/verify/resend |
{email} |
202 {"status":"ok","message":"If that address has an account, we've sent it a link."} for any well-formed email |
400 a valid email is required, 429 |
POST /auth/forgot |
{email} |
Same responses and limits as resend; sends a reset link | |
POST /auth/reset |
{token, password} |
200 {"reset":true} |
400 short password or invalid or expired token, 429. The token is checked after the length, so a short password does not burn it. A reset revokes every existing session of that user. |
POST /auth/logout |
none; Bearer session JWT | 204; every session of the caller is revoked |
400 for an API key, 401 without a valid session |
GET /v1/tenants/{id}/export and DELETE /v1/tenants/{id} take a Bearer owner JWT; see above.
A workspace is one tenant. Sign-up creates its owner, and only the owner can export or delete it. Other users’ roles are enforced as described in Roles, but the control plane has no member or invite API yet, so a non-owner can only be created by an operator. The Settings page has a Team tab, but it keeps its member list in the browser’s local storage, so it does not give anyone else access to the workspace. For shared programmatic access, API keys carry scopes; a key cannot be an owner.
Limits
Section titled “Limits”- Invite codes cannot be listed or revoked. To cancel one, rotate
PAL_SIGNUP_INVITE_SECRET, which invalidates all of them. - Rate limiting needs Redis. If Redis is absent or errors, sign-up fails closed in prod (
503 sign-up is temporarily unavailable), so a Redis outage can’t be used to create accounts without a limit. Login, verification and password reset fail open, because a Redis outage must not lock owners out. Only the captcha, sign-up mode and per-email checks protect those routes while Redis is down. In dev every route fails open. Bridge pairing keeps its own limiter in memory, so Redis has no effect on it. - Emailed links use
OAUTH_DASHBOARD_URL, which defaults to localhost. - There is no refresh token. A session ends at
PAL_JWT_TTLand the user signs in again. - Another replica can honor a revoked session for up to 60 seconds (see Sessions and sign-out).