Authentication
September 12, 2026 · View on GitHub
Malloyyo signs users in one of two ways, decided by the environment rather than by a setting:
| Deployment | Sign-in |
|---|---|
| Self-hosted / open source — the default, and all of this guide | NextAuth, exactly as documented below |
| Malloyyo-hosted — an instance Malloyyo runs for one customer | A managed provider, configured by the control plane — see the note below |
If you run Malloyyo yourself, the hosted path does not exist for you: it is off unless a control plane turns it on, and nothing about NextAuth changes when it does. The rest of this guide is written for your deployment.
Malloyyo signs users in with Auth.js (NextAuth v5), backed
by the Drizzle Postgres adapter (src/auth.ts). Three OAuth / OIDC providers are
supported:
| Provider | Enable with |
|---|---|
AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET | |
| Okta | AUTH_OKTA_CLIENT_ID (+ secret, issuer) |
| Microsoft Entra ID (Azure AD) | AUTH_MICROSOFT_ENTRA_ID_ID (+ secret, optional issuer) |
Every provider is opt-in and off until its env vars are set — configure any subset you want. Sign-in is disabled entirely until at least one provider is configured.
How it fits together
- Sign-in buttons. The landing page renders one "Sign in with …" button per
provider that's configured in the environment (it reads the list from
/api/me, which callsconfiguredAuthProviders()insrc/lib/auth-providers.ts). Adding a provider's env vars makes its button appear — no code or UI change is needed. Auth.js's built-in page at/api/auth/signinis the fallback and also lists every configured provider. - Redirect (callback) URI. Every provider posts back to
<APP_BASE_URL>/api/auth/callback/<provider-id>. The provider IDs aregoogle,okta, andmicrosoft-entra-id. You must register the exact URI — including the scheme and host — in each provider's console, or the provider rejects sign-in (e.g. Google'sredirect_uri_mismatch). Register both your production URL andhttp://localhost:3000/...for local dev. AUTH_SECRET. Signs session tokens and OAuth state. Generate one withopenssl rand -base64 32. Required regardless of provider.- Who gets in: membership is data, not configuration. Authentication
(a provider vouching for an identity) and membership (may this person use
this instance) are separate questions. Membership lives on the
usersrow —statusispending,active, ordisabled;roleisowner,admin, ormember— and every request re-reads the row (src/lib/authorize.ts), so revoking someone takes effect on their next request, with no env edit and no redeploy.- The first sign-in on a fresh instance becomes the
owner— active, admin, no configuration needed. - Everyone after that is admitted by the instance's access policy
(an admin setting, not an env var):
invite(the default) leaves newcomerspendinguntil an admin approves them, and admits invited addresses immediately;openadmits anyone who can authenticate — with Google that is every Google account on earth, so the server logs a loud warning at startup when the policy isopenand sign-in is enabled. EMAIL_ALLOW_LISTis retired. An existing database's list is migrated once on upgrade — listed users stay active, listed addresses that never arrived become open invitations, and every other existing user isdisabled, exactly the set the list was already refusing. After that the variable is never read; leaving it set logs a startup warning naming any disagreement with the database.BREAK_GLASS_EMAIL(optional, one address) is the emergency door: it admits that address as an admin even when the database says no — for when you disable your own account or lose the last admin. Set it, sign in, fix the data, unset it. It is deliberately a different variable from the old list: a leftover value is inert instead of quietly changing meaning.APP_ADMIN_EMAILS(comma-separated) still marks already-admitted users as admins (create datasets, publish, see everything). It grants a role; it cannot admit anyone.
- The first sign-in on a fresh instance becomes the
- First sign-in creates the user row and records the membership decision
(
createUserevent insrc/auth.ts→src/lib/admission.ts). New providers store anaccountsrow automatically via the Drizzle adapter — no schema or migration change.
Programmatic access: API tokens
Sign-in above is for people in browsers. Two other credentials authenticate the
same person without one, and both resolve through src/lib/bearer-auth.ts:
- An OAuth access token — what
malloyyo loginand a claude.ai connection obtain (Authorization Code + PKCE;src/lib/oauth/). Interactive, 24 h, refreshable, and scoped by what the client asked for and the person approved:loginasks formcp publish, a claude.ai connection asks formcpand therefore cannot publish. A credential delegated to a third party for querying must not also be able to overwrite a model. - A personal API token — minted in the UI at
/settings/tokensand handed to the CLI as$MALLOYYO_TOKEN(src/lib/api-tokens.ts). This is the credential for CI, where there is no browser and a 24 h expiry would mean a red pipeline every morning.
Any member may mint one for themselves, and only for themselves — the
routes under /api/tokens scope every read and write to the session's own user
id, so an admin can neither see nor create someone else's. That is safe because
a token is never more than its owner: every request re-reads the users row and
re-runs authorize(), exactly like a session, so the same instant revocation
applies. Its scopes (publish, mcp) only narrow it further, and publishing
additionally requires owning the dataset or being an admin.
Both credential kinds carry scopes from the same vocabulary
(src/lib/api-token-scopes.ts), which is why the consent screen and the token
form describe them in the same words. A stored grant whose scope string this
build does not recognize — every grant issued before publishing had a scope of
its own included — is read as mcp alone, never as more: grantedScopes()
resolves an unknown vintage to the narrowest reading. Someone whose saved
malloyyo login predates the change signs in once more; the CLI's 403 says so.
To cut off a person, disable the person: status = disabled refuses every
token they hold on its next request, with nothing to revoke one by one.
→ API tokens is the full guide: minting, the CI recipe, credential precedence, the token format, rotation, and a troubleshooting table.
Enabled when AUTH_GOOGLE_ID is set. Create an OAuth client in the Google Cloud
Console.
- Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID → Web application.
- Add Authorized redirect URIs:
https://<your-domain>/api/auth/callback/google http://localhost:3000/api/auth/callback/google - Set the env vars:
AUTH_GOOGLE_ID=<client-id>.apps.googleusercontent.com AUTH_GOOGLE_SECRET=<client-secret>
Miss the redirect URI and Google rejects sign-in with redirect_uri_mismatch.
Okta
Optional. Enabled when AUTH_OKTA_CLIENT_ID is set (all three vars are needed to
work).
- Okta admin console → Applications → Create App Integration → OIDC - OpenID Connect → Web Application.
- Add Sign-in redirect URIs:
https://<your-domain>/api/auth/callback/okta http://localhost:3000/api/auth/callback/okta - Set the env vars — the issuer is your Okta org URL:
AUTH_OKTA_CLIENT_ID=0oaxxxxxxxxxxxxxxxx AUTH_OKTA_CLIENT_SECRET=<client-secret> AUTH_OKTA_ISSUER=https://yourorg.okta.com
Microsoft Entra ID (Azure AD)
Optional. Enabled when AUTH_MICROSOFT_ENTRA_ID_ID is set.
- portal.azure.com → Microsoft Entra ID → App
registrations → New registration.
- Supported account types — this is the one real decision (see below).
- Redirect URI — platform Web:
https://<your-domain>/api/auth/callback/microsoft-entra-id http://localhost:3000/api/auth/callback/microsoft-entra-id
- Certificates & secrets → New client secret. Copy the secret value (not the secret ID) — it's shown only once.
- The Application (client) ID on the app's Overview page is your client ID.
- Set the env vars:
AUTH_MICROSOFT_ENTRA_ID_ID=<application-client-id> AUTH_MICROSOFT_ENTRA_ID_SECRET=<client-secret-value> # Optional — see "Which accounts?" below: AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0/
Which accounts? (the issuer decision)
This is the only design choice, and it must match the Supported account types you picked during registration:
| You want to allow | Registration account type | AUTH_MICROSOFT_ENTRA_ID_ISSUER |
|---|---|---|
| One specific organization (recommended for a known customer) | Single tenant | https://login.microsoftonline.com/<tenant-id>/v2.0/ |
| Any work/school (Azure AD) org, but not personal accounts | Multitenant | https://login.microsoftonline.com/organizations/v2.0/ |
| Any Microsoft account, including personal | Multitenant + personal | leave unset (defaults to common) |
- The issuer and the registration's Supported account types must agree, and
the more restrictive of the two wins: a single-tenant registration rejects
outsiders at Microsoft even if the issuer is
common, and a narrow issuer restricts a multitenant registration. Set both to the audience you want. - Leaving the issuer unset defaults to
common(notorganizations) — with a multitenant+personal registration this admits any Microsoft account, personal included. To allow work/school orgs but not personal accounts, set the issuer to the.../organizations/v2.0/authority. The instance's access policy (invite/approve, in the admin UI) narrows further. - Setting the issuer to your Directory (tenant) ID locks sign-in to that one
organization — the equivalent of the single-Okta-org setup. The tenant ID is
on the Entra Overview page. Note the trailing
/v2.0/.
Setting the variables
- Local dev: copy
.env.local.example(which lists all three provider blocks) intolocal/<instance>and fill in the ones you use. - Vercel: set the vars per environment in the dashboard (Project → Settings → Environment Variables → tick Production and/or Preview).
- Docker / self-host: see Self-hosting with Docker.
Set APP_BASE_URL to the public URL the instance is served at — it's what OAuth
redirects resolve against. Behind a reverse proxy, use the external https://…
URL.
This is a security control, not just configuration. When it is set, it pins
every origin the instance hands out: the Auth.js redirects (via AUTH_URL,
which is defaulted from it), the OAuth discovery documents (issuer,
authorization_endpoint, token_endpoint), the WWW-Authenticate header on
/mcp 401s, and the /ltool/<slug> share links given to MCP clients. Leave it
unset and all of those are derived from the request's Host /
X-Forwarded-Host instead — headers the caller chooses unless the proxy in
front overwrites them, which Fly, Railway, Coolify, and a default nginx do not.
A spoofed value points a programmatic MCP client's authorization_endpoint at
attacker infrastructure. Set it in production; leaving it unset is only
appropriate for local development.
Troubleshooting
A provider is only enabled once all its required env vars are set — a half-configured provider is not registered and shows no button (rather than a button that fails at the identity provider). On startup the server logs which providers are live and, for any half-configured one, exactly which var is missing:
[auth] Okta sign-in is disabled — set the missing env var(s): AUTH_OKTA_ISSUER. See docs/authentication.md.
[auth] Sign-in enabled: Google, Microsoft.
If a provider's button doesn't appear, check that log line first. If no provider is configured, sign-in is disabled and the landing page says so.
A note on Malloyyo-hosted instances
Malloyyo also runs instances as a hosted service. Those sign in through a managed identity provider — registered as an additional Auth.js provider, so sessions work identically — configured by the control plane that provisions them.
None of it applies to a self-hosted install. The path is inert unless the control plane sets the environment variables that enable it, so everything in this guide is simply how authentication works for you. There is nothing to disable and nothing to opt out of.