The backend supports several authentication mechanisms so the web app, CLI,
native clients, and AI agents can all authenticate appropriately. The auth code
lives in internal/auth, split into focused subpackages:
| Package | Role |
|---|---|
internal/auth/idp |
Identity-provider registry and the Google / GitHub / generic-OIDC web-login providers. |
internal/auth/session |
The encrypted vx_session cookie. |
internal/auth/oauthserver |
The embedded OAuth 2.1 Authorization Server (built on ory/fosite). |
internal/auth/authkit |
The shared JWT verifier behind both bearer-token surfaces. |
internal/auth/mcptoken |
Adapts authkit to the MCP SDK’s token-verifier contract. |
For the keys referenced here, see Configuration → Authentication.
Identity providers (stored in the database)
Section titled “Identity providers (stored in the database)”Web login is driven by the provider rows in the instance_auth_providers
table, which an instance admin edits under Settings → Authentication in
the admin area. Each row has a type, a slug, a display name, a client ID, an
encrypted client secret, an enabled flag and a sort order:
google: Google OIDC, reached directly viaaccounts.google.comdiscovery. At most one row.github: GitHub OAuth2 (not OIDC; claims come from the GitHub REST API). At most one row.oidc: any generic, OIDC-compliant issuer (Keycloak, Authentik, Zitadel, Auth0, …). Any number of rows, each with its own issuer URL, which drives/.well-known/openid-configurationdiscovery.
A resolver builds the enabled providers from those rows and caches the result
against a shared settings version, so a change made in the admin area is picked
up by every replica on the next sign-in, with no restart. Every provider uses
the same callback, derived from frontend.base_url:
<frontend.base_url>/api/v1/auth/callback (or
http://localhost:<server.port>/api/v1/auth/callback in local development).
The enabled, successfully built set is exposed at
GET /api/v1/auth/providers, which the frontend login screen renders its
provider picker from, rather than hardcoding a list. A sign-in starts at
GET /api/v1/auth/login?provider=<slug>.
While no provider is enabled the instance is in setup mode: the server
logs a one-time SETUP URL at startup, and its token is exchanged for a short
setup session that can reach the provider and allowlist settings only. See
First-run setup.
Sessions
Section titled “Sessions”After the OAuth flow, the web app authenticates with an httpOnly session cookie
named vx_session. Its payload is encrypted with AES-256-GCM using
auth.session_encryption_key — a hex-encoded secret that must decode to
exactly 32 bytes (64 hex characters). The same secret also backs (via
domain separation) the HMAC that signs the short-lived vx_state OAuth state
cookie. When the key is empty, cookie session auth is disabled (stub/test
mode). Generate a production value with:
openssl rand -hex 32Dev login bypass
Section titled “Dev login bypass”For local development, set auth.dev_login_enabled: true to enable
POST /api/v1/auth/dev/login, which mints a session without an external IdP.
The endpoint responds only when the flag is true and the environment is
detected as local development — frontend.base_url pointing at localhost or
127.0.0.1. The detection is fail-closed (an empty value counts as
production), so the flag has no effect in real deployments even if left on.
Sign-in allowlist
Section titled “Sign-in allowlist”The access allowlist restricts who may use the instance, by email domain and/or
exact address. It is stored in the database (instance_auth_allowlist) and
edited under Settings → Authentication in the admin area. When both
lists are empty (the default), access is open: anyone the providers accept can
sign in.
Rules:
- A user is allowed if either list matches.
- A domain matches the part after the last
@exactly (case-insensitive):a@sub.example.comdoes not match domainexample.com. - An email entry matches the full address exactly.
- When the allowlist is active, sign-in also requires a provider-verified email. An unverified IdP account is denied even if its claimed address is listed.
- Root instance admins (
auth.instance_admins) are exempt from the lists. Admins granted in the database are not.
The allowlist is enforced at sign-in, at the MCP OAuth consent-attach re-check, and on every authenticated request of a signed-in user, so tightening it takes effect within seconds and without a restart.
The auth.access_allowlist key in config.yaml is deprecated: since v0.15.0
it is only imported at startup while no allowlist is stored.
Instance admins
Section titled “Instance admins”An instance admin is either a root admin, listed in auth.instance_admins
(matched case-insensitively), or an admin granted in the database by a root
admin. Both are exposed as is_instance_admin on GET /api/v1/auth/me and
unlock the /api/v1/admin portal, which is guarded by instance-admin
middleware; is_root_instance_admin on the same response marks a root admin.
Only a root admin may grant or revoke the role, and a root admin cannot be
suspended, deleted or revoked. With no root admin and no grant, the feature is
dormant.
auth: instance_admins: - admin@example.comAPI keys
Section titled “API keys”Long-lived API keys authenticate non-browser clients (the CLI, Claude Code
hooks) and are sent as a bearer token. They are managed through the API-keys
endpoints and persisted in the database. A separate admin key,
security.backoffice_admin_api_key, gates the back-office endpoints (/bo/*)
and is unrelated to JWTs and regular API keys.
The embedded OAuth 2.1 Authorization Server
Section titled “The embedded OAuth 2.1 Authorization Server”VibeXP ships its own OAuth 2.1 Authorization Server, embedded in the
backend process (internal/auth/oauthserver, built on
ory/fosite). It is what mints the bearer JWTs
the MCP endpoint accepts — no external
identity platform is involved in token issuance. All AS state (registered
clients, authorization codes, tokens, PKCE, signing keys, login sessions) is
persisted in Postgres, so it works across replicas.
The AS is mounted when auth.oauth_as.issuer_url is set; empty disables it
(except in local development — see zero-config mode below). The issuer URL is
the AS’s public base URL and becomes the token iss claim and the metadata
issuer.
Endpoints
Section titled “Endpoints”| Endpoint | Purpose |
|---|---|
GET /oauth2/authorize |
Authorization endpoint (authorization-code + PKCE). |
POST /oauth2/token |
Token endpoint (authorization_code and refresh_token grants). |
POST /oauth2/register |
Dynamic Client Registration (RFC 7591). |
GET /oauth2/jwks.json |
The JWKS with the active + previous RS256 signing keys. |
GET /.well-known/oauth-authorization-server |
AS metadata (RFC 8414), advertising all of the above. |
These are protocol routes: they are public by design (clients reach them before
they hold a token), protected by the protocol itself (PKCE, client/redirect
validation, a consent CSRF token) and rate-limited per IP like the auth routes
(rate_limit.auth_per_minute). They are mounted only when the AS is enabled,
and are intentionally absent from openapi.yaml.
Dynamic Client Registration
Section titled “Dynamic Client Registration”MCP clients self-register via POST /oauth2/register. Only public clients
are supported (token_endpoint_auth_method: none — PKCE stands in for a client
secret), with authorization_code / refresh_token grants. Redirect URIs must
be absolute and fragment-free: https URLs, loopback http addresses
(127.0.0.1, ::1, localhost — for native apps), or custom private-use URI
schemes.
Token lifetimes
Section titled “Token lifetimes”| Setting | Default |
|---|---|
auth.oauth_as.access_token_ttl |
15m |
auth.oauth_as.refresh_token_ttl |
720h (30 days) |
auth.oauth_as.auth_code_ttl |
10m |
All must be positive, and the refresh TTL must exceed the access TTL —
validated at startup so the server fails closed rather than minting unbound or
never-expiring tokens. Access tokens are RS256-signed JWTs whose sub is the
internal VibeXP user ID and whose aud is mcp.resource_uri (RFC 8707).
Key rotation and cleanup
Section titled “Key rotation and cleanup”- Coordinated key rotation. The RS256 signing key rotates every
auth.oauth_as.key_rotation_interval(default720h). Rotation is coordinated through the database so multiple replicas never double-rotate: a replica that sees a peer rotating drops its cache and re-checks under a lock. Previous keys stay in the JWKS until retired, so freshly rotated tokens keep verifying. - Retention/cleanup. Every
auth.oauth_as.cleanup_interval(default1h) the AS prunes expired authorization codes, access and refresh tokens, PKCE and login sessions, and retired signing keys.
HTTPS-only in production
Section titled “HTTPS-only in production”Every AS endpoint is behind an HTTPS-enforcing middleware: plain-HTTP requests
are rejected with 403 unless the process is running in local development
(the same frontend.base_url-on-localhost heuristic as dev login, fail-closed).
A request counts as HTTPS when TLS terminates at the process (r.TLS) or
an upstream reverse proxy forwards X-Forwarded-Proto: https — VibeXP assumes
no specific platform, so either signal is accepted.
Zero-config local mode
Section titled “Zero-config local mode”On a fresh checkout with the example config, the AS “just works” with zero auth configuration:
- When the environment is local development and
auth.oauth_as.issuer_urlis empty, it is auto-derived ashttp://localhost:<server.port>. mcp.resource_uriis auto-derived as<issuer>/mcp/v1/common, andmcp.oauth_issuerdefaults to the AS issuer.- Because the AS is enabled,
/api/v1JWT acceptance auto-wires too:auth.api_oauth.issuerfills with the AS issuer andauth.api_oauth.audiencespins to[mcp.resource_uri], so native-CLI login works against the REST API with no extra config.
Explicit configuration always wins — a value already set is never overwritten —
and pointing mcp.oauth_issuer at an external issuer without enabling the AS
opts out of auto-enablement. Production never auto-enables: the AS stays
strictly opt-in and the backend never guesses a public issuer.
The SPA consent flow
Section titled “The SPA consent flow”The AS never authenticates anyone itself — it delegates login to the app:
GET /oauth2/authorizevalidates the client and stashes the request as a user-less login session, then redirects the browser to the SPA consent page:<frontend.base_url>/oauth/consent?login=<id>.- If the user is not signed in to VibeXP, the SPA sends them to
/login?return_to=<consent-url>— the validated, same-originreturn_tobrings them back to the consent page after login. An authenticated app user is required before consent. - The consent page calls
GET /api/v1/oauth/consentfor the client details (name, redirect host, scopes) andPOST /api/v1/oauth/consent/attachto bind the logged-in app user to the login session — the one AS route behind the standard session-auth middleware, so signing out gates MCP authorization. - The approve/deny decision goes to
POST /api/v1/oauth/consent(CSRF-bound to the login session); on approval the AS issues the authorization code and redirects back to the client.
All issuance, CSRF, and redirect validation stays server-side; the SPA only renders the screen.
Bearer-JWT verification (authkit)
Section titled “Bearer-JWT verification (authkit)”Both bearer-token surfaces verify tokens with the shared
internal/auth/authkit verifier: JWKS-backed signature verification with the
keys fetched from <issuer>/oauth2/jwks.json (the jwks_uri the embedded AS
publishes in its metadata), an RS256 algorithm pin (defense against
algorithm-substitution), registered-claim validation with 60s clock-skew
leeway, a pluggable audience policy, and resolution of the token subject to an
internal VibeXP user.
- MCP (
/mcp/v1/common):internal/auth/mcptokenwrapsauthkitwith a strict RFC 8707 audience requirement — the tokenaudmust includemcp.resource_uri. The tokensubis the internal user ID (the embedded AS mints it that way), resolved directly. - API surface (
/api/v1/*): bearer JWTs are accepted alongside session cookies and API keys, with two ways to wire it:- Embedded AS (auto, the default self-hosted case). When the embedded AS
is enabled and
auth.api_oauth.issueris left empty, VibeXP auto-derives it:api_oauth.issueris set to the AS issuer andapi_oauth.audiencesis pinned to[mcp.resource_uri]. So the native CLI’s browser login (vibexp auth login) works on/api/v1with no manual config, and the effective policy is “require the MCP resource URI audience”. (The CLI’s token carries exactly that audience, which is why it is now accepted.) - External IdP (manual override). Set
auth.api_oauth.issuerexplicitly to point/api/v1at an external issuer; an explicit issuer always wins over the auto-wiring. Withauth.api_oauth.audiencesleft empty in this case, the default policy is “accept any audience except the MCP resource URI”, so MCP-bound tokens stay MCP-only.
- Embedded AS (auto, the default self-hosted case). When the embedded AS
is enabled and
Request resolution
Section titled “Request resolution”The authentication middleware resolves the caller from — in effect — a session
cookie, a bearer JWT, or an API key, and binds the resolved subject to the
request context (see internal/contextkeys). Downstream handlers and services
read the authenticated identity from the context rather than re-parsing headers.