Authentication
On this page
- Login methods
- The five ways a request is authenticated
- Gateway mode is currently refused
- Authorization is a separate question
- Platform admin
- First-login flow for a new local account
- Two-factor authentication
- Mandating it
- What a mandate does at sign-in
- What a mandate does not cover
- Forgotten passwords
- Session lifecycle
- Get a JWT for API calls
- Option A - copy it from the browser (quickest)
- Option B - script the credentials flow
- Self-hosters: where the Google credentials come from
- Who may sign in with Google
- 1. The organization’s SSO domain list
- 2. Entry is by invitation
- The one exemption
- Setting it up on a new installation
- What logging out does
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
Polyant has two login methods, and the open-source build ships only the second:
- Google OAuth. Available with managed services, and optional - the Google sign-in button is rendered only when both
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETenv vars are set. Which accounts it admits is decided by two rules that both have to agree, neither of them an environment variable: the organization’s SSO domain list, and the rule that entry is by invitation. - Email + Password. Any user record in the
userstable with a populatedpassword_hashcan log in this way. The first platform admin 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 signed-out pages (sign-in, forgotten password, password reset and invitation acceptance) carry their own language switch, so someone who is not signed in yet is not stuck on the default language. The choice carries over into the panel after sign-in.
The five 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 |
Management API key - X-Polyant-Key | a service principal | machine-to-machine management calls. See API keys |
Personal API token - X-Polyant-Key, pat_ prefix | a user, without a browser | scripts run on your own authority. See Personal API tokens |
Per-agent key - Authorization: Bearer | a caller of one agent | /v1/chat/completions, the HTTP channel, and the A2A endpoint |
Gateway mode is currently refused
AUTH_MODE=alb-oidc - trusting the x-amzn-oidc-data header injected by an AWS ALB - is rejected at boot. Setting it stops the engine starting rather than producing a deployment that half-works.
That is deliberate, and the reason is worth knowing if you were relying on it. The gateway path only ever resolved a tenancy on a single-organization deployment, where the guard stamps the default organization onto the identity. With several organizations it stamps nothing, every permission-checked route denies, and the result is a panel that loads and then 403s on every call - a failure the operator hears about from users rather than from the deployment. Refusing the value at boot turns a silent runtime failure into a loud startup one.
The code path is dormant, not deleted: it comes back when a gateway identity is mapped onto a local user in every case, not just the single-organization one. Until then, gateway-fronted deployments authenticate through the session path.
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 managed-services 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 admin
A platform admin sits above every organization and bypasses role resolution entirely, ahead of every permission check. It also outranks every organization role in the anti-escalation checks - without that it ranked below a Viewer inside a tenant it holds no binding in, and an organization that had lost its last Owner could not be repaired by anyone. It is granted in one of two ways:
- The first admin, seeded at boot.
INITIAL_ADMIN_PASSWORD(with the optionalINITIAL_ADMIN_EMAIL, defaultadministrator@local) creates the account already holding the flag, and the same boot gives it the default organization’s membership and Owner binding - so a fresh installation is administrable with nothing else configured. Without that password no admin is seeded at all: the engine skips it rather than writing a generated password into its own log. - Promotion from Platform members, by someone who already holds the flag. That is how anyone else gets it, including an account that signs in with Google.
PLATFORM_ADMIN_EMAIL is gone. It named a second bootstrap identity, promoted at every boot and - for an identity that had not signed in yet - through an internal endpoint the panel called during sign-in. A platform admin always exists because the seeder makes one, so the variable bought nothing and could promote whatever address configuration happened to name.
The flag is read from the database, deliberately not carried in the session token, so revoking it does not wait for a token to expire. The read is cached per process for five minutes, and that cache is the thing to plan a revocation around: the replica that handles the revocation flushes its copy at once, while any other replica keeps answering from its own copy until it expires. On a single-replica deployment revocation is immediate; on a multi-replica one, budget five minutes.
Removing the flag also does not revoke what the account issued while it held it - see Where a platform admin reaches.
It is stored as a boolean column, users.is_platform_admin, and read from the database on each privileged check rather than carried on the session token - so revoking it takes effect within the check’s cache window. See Platform members.
This is an installation-level capability, distinct from the Owner role inside an organization.
First-login flow for a new local account
When a platform admin creates a sign-in account (see Platform members), 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 managed-services 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.
Two-factor authentication
Managed services. Two-factor authentication ships in the managed-services build. Note that it is not licence-gated once present: an organization on any tier of a deployment that has it can require a second factor.
A person enrols a TOTP authenticator app from their Account page: scan the code, then prove one code to confirm. The secret exists outside the database only during that one exchange.
Enrolment also issues a batch of ten recovery codes, each usable once, for the day the authenticator device is gone. They can be regenerated, which replaces the whole batch.
On top of the per-caller rate limit there is a per-account lockout: five consecutive failures freeze the account for five minutes. A success resets the counter, so the limit is on consecutive failures rather than lifetime ones - someone who mistypes twice is never affected, while a distributed guesser is capped.
Mandating it
Two tiers can require a second factor, and they compose in one order:
- The installation. Admin Console → Two-factor mandate. Applies to everyone, and no tenant can opt out.
- An organization. A section of Settings → Organization → General, behind the same
org:writegate. An account is mandated if any organization it belongs to mandates it.
“Any” rather than “all”, deliberately: a factor is proven at sign-in, before any organization is addressed, and the resulting session can reach every organization the person holds. If belonging to a relaxed organization excused someone from a strict one’s mandate, the relaxed organization would be setting the strict one’s security floor. The cost is stated plainly - joining one mandated organization obliges the account to enrol, which is exactly what the strictest member is asking for.
What a mandate does at sign-in
A mandated account with no factor yet is admitted and sent to enrolment, not refused. A refusal would be unrecoverable: enrolment happens inside the panel, so turning the mandate on would lock out every member - including the administrator who turned it on. It works like the forced password change: you reach one full-screen page and nothing else until you are done.
A mandated account that is enrolled is asked for a code, and “no code supplied” is reported distinctly from “wrong code” so the login form can ask for one instead of claiming the password was wrong.
What a mandate does not cover
- Google or SSO sign-in satisfies it. The mandate governs password sign-ins, which is where this deployment holds the credential. A federated identity has already been through its provider’s own second factor, and that flow never reaches the engine’s credential check at all - there is no point at which it could be gated. Re-challenging would add a weaker factor on top of a stronger one and leave us a second credential to protect.
- Gateway mode does not arise. It would be the gateway’s business rather than ours - but
AUTH_MODE=alb-oidcis refused at boot on this build, so there is no such deployment to reason about.
Forgotten passwords
The login page offers a self-service reset. Requesting one emails a link valid for 30 minutes; following it sets a new password and drops every existing session for that account.
Three properties are worth knowing:
- A password is never emailed - only a link. A password in a mailbox is a permanent credential sitting in plaintext in an inbox, on a relay, and in whatever backs both up.
- It needs a mailer. With none configured the request logs an error rather than pretending to have sent anything. Configure it at Installation policies. Outbound email itself ships in the managed-services build, so this path exists only where that does.
- An account with no password - one that has only ever signed in through Google - gets a mail telling it which provider to use, instead of a reset link.
It is deliberately a third path alongside the two that already existed: the admin-driven reset, which hands a generated password to an operator over HTTP, and the authenticated own-password change. This one authenticates nothing but possession of an emailed token, which is exactly what a person locked out with no reachable administrator has.
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, a
mustChangePasswordflag, and the organization the user last landed in - written at sign-in and never refreshed, so a membership granted mid-session is invisible until the person signs out and back in - which is a preference, not an authority: what you may do is decided in the organization each request addresses. See Organizations. - When the token expires (24 hours by default), 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 24-hour lifetime as panel sessions, so any long-running automation has to refresh. For anything unattended, prefer a management API key: it is built for this, and it carries an explicit permission set instead of a whole user’s authority.
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: where the Google credentials come from
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are issued by Google, not by Polyant:
-
console.cloud.google.com → a project (any) → APIs & Services → OAuth consent screen. Configure it once; an Internal app is enough when every user is in your Google Workspace, and an External one needs at least the app name, a support email and a privacy-policy URL before it will issue credentials.
-
APIs & Services → Credentials → Create credentials → OAuth client ID → application type Web application.
-
Under Authorized redirect URIs add exactly:
<panel origin>/api/auth/callback/googleThat is the admin panel’s origin, not the engine’s, and the path is fixed by Auth.js. A mismatch here is the single most common failure: Google answers
redirect_uri_mismatchon the callback, after the account has already been chosen, so it reads as a Polyant error rather than a console setting. Add one URI per origin you sign in from -http://localhost:3000/...for local development alongside the production one. -
Copy the Client ID and Client secret into
packages/web/.env.local(Next.js does not read the monorepo-root.env), then restart the panel.
The secret can be re-read from that console page later, unlike most credentials in this product.
Who may sign in with Google
Managed services. Federated sign-in is available with Polyant managed services. The open-source build offers email and password only.
Two rules decide it, and both have to admit the account. Neither is an environment variable: which domains may sign in is a question about a tenant, and one list for a whole installation cannot answer it for a second one.
1. The organization’s SSO domain list
Each organization can declare the domains it admits, from Organizations in the Admin Console. It is edited by a platform admin and by nobody else - a permission that let an organization’s own administrator widen this would let them admit a domain and then admit themselves from it.
The rule is membership-scoped: only the organizations the account already belongs to are consulted. That follows from when the check runs - signing in does not choose an organization, so the only lists that can be known are the ones the account is already in. Three consequences worth reading twice:
- An organization that declares no domains restricts nothing.
- Someone who belongs to two organizations is admitted when either admits them. One tenant’s list cannot revoke a person’s access to another tenant.
- Someone who belongs to no organization is not restricted by this rule at all - there is no list to consult. What governs them is rule 2.
Matching is a case-insensitive comparison of the whole domain part, so sub.acme.com is not covered by acme.com.
2. Entry is by invitation
An account with no membership and no valid invitation is refused. This is not configurable: it is what “entry is by invitation” means, and a switch would make the guarantee true only where somebody remembered to turn it on. It is also what closes the door rule 1 leaves open, which is why the two belong together.
An invitee is admitted before they have a membership - the pending invitation is what lets them reach the accept page at all. Once they accept, rule 1 governs them like any other member.
Two ways in remain, and they are the intended ones:
- Invite them from Members and roles. See Invitations.
- Create the account and the membership first, from the Admin Console, and have the person sign in with Google afterwards: the Google identity links to the account that already exists. This is how you onboard someone on a deployment without outbound email.
The one exemption
A platform admin is exempt from both rules, and has to be: they hold no membership in any organization, so rule 1 would find no list to admit them and rule 2 no invitation - and the list is edited from the very console a refusal would shut them out of. The exemption reads users.is_platform_admin from the database, the same live flag every authorization decision reads; it is never a token claim and never a configured address.
To disable Google sign-in entirely, leave GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET unset - the button disappears.
A refused sign-in says only that. The login page reports that the account cannot sign in and that access is by invitation. It deliberately does not say which rule refused: telling a stranger whether a domain is admitted, or whether an invitation exists, answers questions they should not be able to ask. The distinction is in the panel’s server log, as
federated sign-in refused (…).
Setting it up on a new installation
The order matters, and none of it needs an environment change after the first boot:
- Sign in as the first administrator with email and password - the account seeded at first boot (see Installation and Setup). A password sign-in never passes through these rules; they gate Google sign-in only.
- Open Admin Console → Organizations and set the organization’s SSO domains.
- Invite people, or create their accounts and memberships.
There is no window in which the installation is locked out of its own configuration: the password path is always there, and a platform admin is exempt from both rules.
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.