Authentication
Polyant uses an industry-standard authentication stack: Auth.js (formerly NextAuth.js) v5 in the admin panel, with sessions delivered as encrypted JWTs (JWE) carried in cookies. The engine validates the same JWTs on every API call.
Login methods
Two login methods are configured out of the box:
- Google OAuth. Optional — the Google sign-in button is rendered only when both
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETenv vars are set. To restrict OAuth to specific email domains, setAUTH_ALLOWED_DOMAINS(comma-separated list); an empty value allows any Google account. - Email + Password. Any user record in the
userstable with a populatedpassword_hashcan log in this way. The first Superadmin is created at first boot (see Installation and Setup). Credentials are verified by the engine viaPOST /api/auth/credentials/verify, gated by theAUTH_INTERNAL_SECRETshared between the web package and the engine.
Both methods land on the same admin panel after login.
The four ways a request is authenticated
The admin panel session is only one of them. The engine accepts:
| Mechanism | Who it is | Used for |
|---|---|---|
Auth.js session — cookie or Authorization: Bearer | a user | the admin panel, and scripted management calls |
Gateway identity — x-amzn-oidc-data | a user authenticated upstream | deployments fronted by an AWS ALB with authenticate-oidc |
Management API key — X-Polyant-Key | a service principal | machine-to-machine management calls. See API keys |
Per-agent key — Authorization: Bearer | a caller of one agent | /v1/chat/completions and the HTTP channel |
Gateway mode is selected with AUTH_MODE=alb-oidc, and it is a deliberate trade: the header’s signature is not re-verified, so the network boundary is the trust boundary — the engine’s security group must accept traffic only from the ALB, or the header can be spoofed.
Authorization is a separate question
Being signed in says nothing about what you may do. Every management route declares a required permission, resolved against your organization role and — on enterprise deployments — your workspace bindings. See Roles and permissions.
Enforcement is on by default and fails closed: a route that declares no permission is denied rather than allowed. It can be dropped to log-only for local testing with REMOVE_AUTHORIZATION_FILTER_FOR_TESTING=true, and that flag is ignored when NODE_ENV=production — enforcement in production cannot be turned off by configuration.
Platform superadmin
A platform superadmin bypasses role resolution entirely, ahead of every permission check. It is a flag on the user, granted in one of two ways:
PLATFORM_ADMIN_EMAIL— the user with that email is promoted on every boot, idempotently. Setting it before that user exists is fine; the promotion applies once they sign in.AUTH_ALB_SUPERADMIN_GROUP(gateway mode only) — thecognito:groupsclaim value that grants it. Unset, every gateway identity is an ordinary user.
This is a platform-level capability, distinct from the Owner role inside an organization and distinct again from the local superadmin account role used by Settings → Users.
First-login flow for a new local account
When a platform superadmin creates a sign-in account (see Users), the system generates a temporary password and marks the user record with mustChangePassword = true. On their first login:
- The user enters the temporary password.
- The login succeeds, but they are immediately redirected to a dedicated full-screen route at
/password-change. - They cannot navigate away from that page until they pick a new password.
- After saving, the flag clears and they can use the rest of the panel.
On enterprise deployments there is a second, better path that never involves an operator handling someone else’s password: an invitation link, where the invitee creates their own credentials and lands directly in the organization with the role you chose.
Session lifecycle
- On successful login, Auth.js issues a JWE-encrypted JWT signed with the
AUTH_SECRETenv var. - The JWT lands in the
authjs.session-tokencookie (or__Secure-authjs.session-tokenover HTTPS). - The engine uses the same
AUTH_SECRETto decrypt the JWT on every API call. There is no per-request database lookup for the session. - The token’s payload includes the user id, role, and a
mustChangePasswordflag. - When the token expires (default ~30 days), the user is bounced to
/loginwithcallbackUrlpreserved.
Get a JWT for API calls
Most how-to recipes (skills, secrets, LangSmith, instance export/import) hit the management API with curl and assume you already have a JWT. There is no dedicated “issue token” endpoint — the same JWE that backs the admin panel session is what the engine accepts. Two ways to grab it:
Option A — copy it from the browser (quickest)
-
Sign in to the admin panel.
-
Open DevTools → Application → Cookies → the panel’s origin (
http://localhost:3000). -
Copy the value of
authjs.session-token(or__Secure-authjs.session-tokenif you are on HTTPS). -
Export it as
TOKEN:export TOKEN='eyJhbGciOi...' # paste the cookie value
The engine accepts the same JWE either as a Bearer header or as a cookie. Both of the following work:
# As an Authorization header
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/instances
# As a cookie (matches the admin panel verbatim)
curl -H "Cookie: authjs.session-token=$TOKEN" http://localhost:4000/api/instancesThe Bearer form is the one you want for scripts; the cookie form is what the admin panel sends and is occasionally handier when you are debugging by replaying a request from the browser. The __Secure-authjs.session-token cookie name (HTTPS deployments) is also accepted.
Option B — script the credentials flow
Auth.js’s credentials provider is what powers the email + password login. The handshake is two requests (fetch a CSRF token, then POST credentials), and the JWE comes back in a Set-Cookie header:
BASE=http://localhost:3000
# 1) Grab a CSRF token (Auth.js double-submit cookie pattern)
CSRF=$(curl -s -c /tmp/cookies.txt "$BASE/api/auth/csrf" | jq -r .csrfToken)
# 2) Submit credentials. The session-token cookie lands in /tmp/cookies.txt.
curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt \
-X POST "$BASE/api/auth/callback/credentials" \
-d "csrfToken=$CSRF&email=$EMAIL&password=$PASSWORD&redirect=false" \
-H "Content-Type: application/x-www-form-urlencoded" \
>/dev/null
# 3) Extract the JWE. Auth.js sets the session cookie as HttpOnly, which
# in Netscape cookie-file format is encoded as a "#HttpOnly_<domain>"
# prefix on the same row — it is *not* a comment line, so the line
# must be included (don't skip rows that start with '#').
TOKEN=$(awk -F '\t' '$6 == "authjs.session-token" { print $7 }' /tmp/cookies.txt)
echo "TOKEN=$TOKEN"Use Option B in CI or unattended jobs. Tokens issued this way share the same ~30 day lifetime as panel sessions — long-running automations should plan to refresh.
The token is bearer-equivalent — anyone with it can act as the issuing user until expiry (JWT cannot be revoked early without switching session strategy; see the trade-off note below). Treat it like a password: never check it into git, never paste it into shared chats, scope your shell history accordingly.
Self-hosters: changing the Google OAuth domain
To restrict Google OAuth to a specific email domain (or a list of domains):
- Set
AUTH_ALLOWED_DOMAINSin your environment to a comma-separated list (e.g.acme.com,example.org). - Restart the admin panel.
Without a value, Google OAuth is fully open: any Google user can become a Polyant user — including ones you did not invite. Pair this only with a strict role policy and a superadmin who reviews accounts. To disable Google sign-in entirely, leave GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET unset — the button disappears.
Note what an open sign-up does not grant: a brand-new account holds no organization binding, and no binding means no access — the permission model denies by default. It is an account, not a foothold. Still, review Members rather than relying on that alone.
What logging out does
Clicking Log out deletes the session cookie. The user lands on /login. No server-side session record needs to be invalidated, because there is no server-side session record — the JWT is the session.
Trade-off. With pure JWT sessions there is no way to instantly revoke a stolen token before it expires. If revocation matters for your deployment, switch the Auth.js strategy from
jwttodatabaseand rotate theAUTH_SECRETon every revocation.