Skip to content

Auth

Inbound authentication, tenant derivation, and isolation defenses. Source: felix.auth in felix-run/felix.

FELIX_AUTH_MODE:

Mode Behavior
none Anonymous principal (tenant_id=default). Requires FELIX_ALLOW_INSECURE=true outside development when binding a non-loopback host
api_key `Authorization: Bearer
jwt Bearer JWT verified via FELIX_JWT_VERIFIERS + JWKS (FELIX_JWKS_PUBLIC for self-issued)

Unauthenticated in every mode: the probes /health, /live, /ready, and everything under /.well-known/*. That set is deliberately short.

  • /metrics is not in it. Its label values carry tenant-supplied manifest ids and remote MCP tool names, so an anonymous scrape discloses every tenant’s manifests and tools. It is not rate-limit exempt either. See observability for the credential a scraper needs.
  • /docs and /openapi.json are not in it unless the operator opts in with FELIX_DOCS_PUBLIC=true — they describe every route, management ones included, and were the one map of the surface an unauthenticated caller could read. Setting it on an authenticated deployment logs a warning at startup. Under FELIX_AUTH_MODE=none everything is public anyway.
  • /redoc does not exist. FastAPI’s Swagger and ReDoc are both disabled (docs_url=None, redoc_url=None); /docs is a Scalar page the harness mounts itself, under one CSP.

Missing credentials in api_key / jwt → 401 (fail closed). Mint self-issued tokens with felix mint-jwt, or let people get their own with GitHub login or Google sign-in.

A person runs felix login, enters a code at github.com, and gets a short-lived Felix JWT for the tenant their GitHub org maps to. GitHub OAuth apps issue no OIDC ID token, so there is nothing for a verifier to point at. Felix runs the device flow on the caller’s behalf instead, checks membership, and mints a self-issued token. The GitHub token is used for two reads and dropped. There is no user table: the org map says who gets in, and signup, when it is on, adds the accounts the operator invited.

FELIX_AUTH_MODE=jwt
FELIX_JWT_VERIFIERS=self:felix-self
FELIX_JWKS_PRIVATE=... # RSA PEM; FELIX_JWKS_PUBLIC is its public half
FELIX_ALLOWED_TENANTS=acme
FELIX_GITHUB_CLIENT_ID=Ov23li... # OAuth app with "Enable Device Flow" ticked; no secret needed
FELIX_GITHUB_ORG_TENANTS='{"acme-corp": {"id": 12345678, "tenant": "acme", "scopes": ["jobs:read", "manifests:write"]}}'
  • Orgs are pinned by numeric id (gh api orgs/acme-corp --jq .id). A renamed or deleted org’s name can be registered by anyone, and matching on the name would hand that org’s members your tenant. A mismatch is refused and logged.
  • Only an active membership counts. A pending invitation is not membership. The app asks for read:org. An org with OAuth app access restrictions must approve the app before its members can log in (org_access_restricted).
  • Two orgs on one tenant merge their scopes. Orgs on different tenants make a login ambiguous (409 tenant_ambiguous). Device codes are single-use, so that flow is spent; run felix login --tenant <one>.
  • The subject is github:<numeric user id>, never the renameable login. The token also carries idp: github and github_login.
  • Tokens last FELIX_GITHUB_LOGIN_TTL_SECONDS (default 8h, at most 24h). Nothing revokes one early, so a member removed from the org keeps access until it expires. Rotating the key revokes everything.
  • Startup refuses a login that would mint tokens this process rejects. One probe token per mapped tenant goes through the configured verifiers, which catches a mismatched key pair, a missing self:felix-self, a tenant outside FELIX_ALLOWED_TENANTS, or a fixed: verifier ahead of it that would override the tenant.

POST /auth/github/device and POST /auth/github/token are public, and only those two exact paths, only while FELIX_GITHUB_CLIENT_ID is set.

Starting a flow spends the OAuth app’s GitHub quota, so starts have two hourly buckets of their own: one per client address (FELIX_GITHUB_DEVICE_STARTS_PER_HOUR, default 10, with an IPv6 client keyed by its /64), and one for the whole deployment (FELIX_GITHUB_DEVICE_STARTS_PER_HOUR_TOTAL, default 300), because the quota is shared and a botnet is many clients. A start its client’s bucket refuses does not count toward the total. Reaching the deployment cap refuses every new login until the hour turns (issued tokens are unaffected), so size it to your login volume; without FELIX_REDIS_URL both buckets are per process. Every token minted is audited in its tenant as github_login. The REST API has the request and refusal shapes.

felix login --save keeps one token per server in ~/.config/felix/token, and hands each back only to the server that minted it. The file and its directory must be yours alone, or it is not used. felix login refuses plain http:// except to localhost, since the token comes back over that connection; --insecure overrides that.

An account in none of the mapped orgs is not_a_member — unless signup is on. With FELIX_GITHUB_SIGNUP=invite, the accounts the operator lists sign in to a personal tenant of their own, and anyone else outside the orgs is refused with 403 not_invited.

FELIX_GITHUB_SIGNUP=invite # default off
FELIX_GITHUB_SIGNUP_LOGINS=octocat:583231,hubot # login, or login:<numeric id>
FELIX_GITHUB_SIGNUP_SCOPES=memory:read,audit:read
  • The tenant is gh-<numeric GitHub id>, never the login: a login can be renamed and then registered by someone else, and a tenant must not follow it.
  • Pin each invite as login:<id> (gh api users/octocat --jq .id). A pinned invite matches on the id alone, so a renamed account keeps it and a re-registered login does not inherit it. A bare login matches case-insensitively, and each match logs the id to pin.
  • Org membership wins. An invited account that is also an active member of a mapped org signs in to the org’s tenant, as it did before.
  • Only Felix’s own sign-in can claim a personal tenant. It is in no FELIX_ALLOWED_TENANTS, so the verifier admits a gh-<id> claim only from a self:felix-self token with idp: github and subject github:<id>. Any other issuer, or any other subject, is refused.
  • Startup refuses invite with an empty list, signup without scopes, admin or * among them (they are what a stranger’s token carries), and any configured tenant shaped like a personal one.
  • There is no open mode yet. Every personal tenant spends the deployment’s model credentials, and nothing caps one tenant’s total spend; admitting any GitHub account waits for that cap.

Changing the list is a config edit and a restart. GET /auth/methods reports the mode as github_signup, and chat-ui’s sign-in card says when a deployment is invite-only. The first github_login audited in a personal tenant carries signup: true.

With a GitHub App behind FELIX_GITHUB_CLIENT_ID, a browser signs in by redirect instead of a pasted code: GET /auth/github/authorize?return_to=… → GitHub → /auth/github/callback → back to the app, which collects its token once with POST /auth/github/exchange. It is on while FELIX_GITHUB_CLIENT_SECRET, FELIX_GITHUB_TOKEN_KEY (32 bytes, base64) and FELIX_GITHUB_REDIRECT_ORIGINS are all set, and GET /auth/methods then reports github_redirect: true.

  • The only return target is an allowed origin. return_to must be on an origin in FELIX_GITHUB_REDIRECT_ORIGINS; the callback GitHub redirects to is <origin><FELIX_GITHUB_CALLBACK_PATH> (behind chat-ui’s proxy, /api/auth/github/callback), and each such URL must be registered on the App.
  • Only the browser that started a sign-in can finish it. state and the PKCE verifier live in a sealed, HttpOnly cookie; a callback carrying another state is refused.
  • The token never rides a URL. The callback leaves it in a sealed HttpOnly cookie for one page load, and /exchange hands it over and clears it. Every outcome of the callback is a redirect back with a fragment, #felix_login=ok or #felix_login_error=<code>, which never reaches a server or a log.

The device flow keeps working with the App’s client id, for felix login and the terminal.

A sign-in through a GitHub App with expiring user tokens also keeps the person’s GitHub refresh token, sealed with FELIX_GITHUB_TOKEN_KEY and bound to its tenant, user and column, so Felix can act as them on a repository they open (next section). GET /github/connection says whether Felix holds one for you; DELETE /github/connection forgets it and withdraws the App’s grant at GitHub — chat-ui’s sign-out calls it. An operator holding github:admin lists them with GET /github/connections and revokes anyone’s with DELETE /github/connections/{github_user_id}. A Felix token itself still lasts its TTL: nothing revokes one early.

A workflow has no person to approve a code, but it does have an identity: a job granted id-token: write can ask GitHub for an OIDC ID token that names its repository, ref, workflow file and event. POST /auth/github/actions checks GitHub’s signature on it and mints a Felix token for the tenant the repository’s owner maps to. The job keeps no stored secret. The exchange is on while FELIX_GITHUB_OIDC_AUDIENCE is set, and it needs no OAuth app.

FELIX_GITHUB_OIDC_AUDIENCE=https://felix.example.com # this deployment's own URL
FELIX_GITHUB_ORG_TENANTS='{"acme-corp": {"id": 12345678, "tenant": "acme", "scopes": [],
"actions": {"repositories": {"deploy": 87654321},
"refs": ["refs/heads/main"],
"environments": ["production"],
"scopes": ["manifests:write"]}}}'

A valid ID token proves only that some workflow ran in a repository, so the org’s actions block says which runs may log in:

  • Repositories are {name: id} (gh api repos/acme-corp/deploy --jq .id), matched on the id, since a deleted repository’s name can be taken by any member who can create one. The org alone is not enough: an outside collaborator with write access to one repository can run a workflow there.

  • At least one narrowing is required. Every narrowing you set must match:

    • refs (refs/heads/main, refs/tags/v*);
    • workflows, which matches job_workflow_ref, the workflow file that actually ran (for a reusable workflow, the callee): acme-corp/deploy/.github/workflows/ship.yml@*;
    • environments.

    Patterns are case-sensitive globs, and * also matches /. An environment with required reviewers is the strongest gate GitHub offers.

  • pull_request_target, issue_comment and workflow_run are refused unless listed in events. They run on the base branch while acting on outside input, so a ref of main says nothing about the code they execute.

  • The block has its own scopes; it never inherits the org’s.

  • The audience must be this deployment’s own https URL. Startup refuses GitHub’s default (https://github.com/<owner>), which tokens meant for other services carry. Keep it unique per deployment, or a token captured by staging can be replayed to production.

The token lasts FELIX_GITHUB_OIDC_TTL_SECONDS (default 15m, at most 1h). Its subject is github-actions:<repository id>:<job_workflow_ref>. It carries idp: github-actions and github_actor, never github_login, because whoever triggered the run did not authenticate. Every exchange is audited in its tenant as github_actions_login, with the repository, ref, workflow file, environment, event, sha, run and actors. The ID token is never logged or audited. It is not single-use either: replaying one within its few minutes of life mints another token for the same run.

In the workflow, with nothing to install, logging in inside the step that uses the token:

permissions:
id-token: write # what lets the job ask GitHub for an ID token
contents: read
steps:
- name: Call Felix
env:
FELIX_URL: https://felix.example.com
run: |
id_token=$(curl -sS --fail-with-body -G "$ACTIONS_ID_TOKEN_REQUEST_URL" \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
--data-urlencode "audience=$FELIX_URL" | jq -r .value)
token=$(jq -n --arg t "$id_token" '{id_token: $t}' \
| curl -sS --fail-with-body -X POST "$FELIX_URL/auth/github/actions" \
-H 'content-type: application/json' -d @- | jq -r .access_token)
echo "::add-mask::$token"
curl -sS --fail-with-body "$FELIX_URL/manifests" -H "Authorization: Bearer $token"

Nothing registers the Felix token with the job log’s masking for you, so ::add-mask:: it before anything could print it. Keep it in the step that uses it. Writing it to $GITHUB_ENV hands it to every later step, including any third-party action, and masking only hides it from the log. When a later step does need it, pass it as a step output to just that step. --fail-with-body fails the step on a refusal and still shows the error (workflow_not_granted, tenant_ambiguous and its tenants).

Or, from Python, with felix-client (httpx and nothing else):

import asyncio
from felix_client import github_actions_login
token = asyncio.run(github_actions_login("https://felix.example.com"))
print(f"::add-mask::{token.access_token}")

The audience defaults to the server URL. Pass audience= if FELIX_GITHUB_OIDC_AUDIENCE is something else, which only makes sense when both name the same server. The ID token is good there for a few minutes, wherever it is sent.

Where the CLI is installed, felix login --github-actions [--audience …] does the same. Capture its output, as in token=$(felix login --github-actions): stdout is the token alone. In a job it also writes ::add-mask:: on stderr, but mask the captured value yourself as well.

For machines outside GitHub Actions (cron, another CI), mint a token for them with felix mint-jwt.

For a deployment that should let a few named people in, without opening sign-in to the public. Only the Google accounts the operator lists get a token, and no one can sign themselves up. The motivating case is a family install where a child’s account reaches one supervised agent and nothing else.

A browser signs in by redirect, the same shape as GitHub’s: GET /auth/google/authorize?return_to=… → Google → /auth/google/callback → back to the app, which collects its token once with POST /auth/google/exchange. Redirect is the only way in; there is no device flow and no CLI login.

FELIX_AUTH_MODE=jwt
FELIX_JWT_VERIFIERS=self:felix-self
FELIX_JWKS_PRIVATE=... # RSA PEM; FELIX_JWKS_PUBLIC is its public half
FELIX_ALLOWED_TENANTS=family,kids
FELIX_GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com
FELIX_GOOGLE_CLIENT_SECRET=...
FELIX_GOOGLE_COOKIE_KEY=... # openssl rand -base64 32
FELIX_GOOGLE_REDIRECT_ORIGINS=https://chat.example.com
FELIX_GOOGLE_ACCOUNTS='{
"parent@gmail.com": {"tenant": "family", "scopes": ["sessions:read"]},
"kid@gmail.com": {"tenant": "kids", "scopes": [], "manifests": ["kids"], "sub": "1084…"}
}'

Sign-in is on while the client id, the secret, the cookie key and at least one redirect origin are all set, and GET /auth/methods then reports google_redirect: true.

In Google Cloud, create an OAuth client of type Web application and register <origin><FELIX_GOOGLE_CALLBACK_PATH> as its redirect URI. Behind chat-ui’s proxy that is /api/auth/google/callback. Leave the consent screen in Testing and add each listed account as a test user. Google then refuses everyone else before Felix is ever asked, so you get a second allowlist for free. A supervised (Family Link) account may need a parent to allow third-party “Sign in with Google” in Family Link.

Who gets in:

  • An entry is keyed by the Google-verified email. Matching ignores case, and Google must report email_verified: true. Any other account is sent back as #felix_login_error=not_allowed, and the harness log names its email and Google account id.

  • sub pins the entry to one Google account. An address can be recycled or moved to another account; the id cannot. Sign in once, copy the id from the log line, and add it.

  • What is checked on Google’s answer: the ID token comes from Google’s token endpoint, never from the browser. Felix verifies:

    • its RS256 signature against Google’s published keys;
    • iss and aud (your client id), exp and iat;
    • a nonce that only the browser which started this sign-in holds.

    state, the nonce and the PKCE verifier live in a sealed HttpOnly cookie.

  • The token is self-issued like GitHub’s: sub is google:<id>, with idp: google and the email. It lasts FELIX_GOOGLE_LOGIN_TTL_SECONDS (8 hours by default), and nothing revokes one early. Each sign-in is audited as google_login in its tenant.

  • The person, for the client to show. Sign-in asks for openid email profile. /exchange returns the verified email and the name and picture Google reports alongside the token, and chat-ui draws them in its account chip. They are handed over once and never stored or minted into the token. A picture is passed only if it is an https URL on *.googleusercontent.com.

  • Secrets from a backend. FELIX_GOOGLE_CLIENT_SECRET and FELIX_GOOGLE_COOKIE_KEY hydrate from FELIX_SECRETS_BACKEND as GOOGLE_CLIENT_SECRET and GOOGLE_COOKIE_KEY, like GitHub’s pair, and are masked like any credential.

manifests pins the account to agents. The token carries a felix_manifests claim, and every other manifest refuses it (manifest gates). Boot refuses a configuration that would let a pinned account reach around its pin:

  • Write scopes. A pinned account may carry only <area>:read scopes. A write scope would let it edit the pinned manifest, schedule a job or run an eval.
  • A shared tenant. A pinned account may not share a tenant with an unpinned one. Threads, history and search are shared within a tenant, so a child in a parent’s tenant could read the parent’s conversations.

The bundled kids manifest is what such an account is meant for. It has no tools and stores no long-term memory facts. Personal information is blocked in both directions, and a child_appropriate judge scores every reply before any of it is released. If the judge’s model call fails, it falls back to a keyword heuristic, so watch the judge’s route.

A person opens one of their own repositories in a thread, and the agent works on a checkout that belongs to that thread alone. Every GitHub read and the clone act as the person, with a token minted from their stored connection; no route returns a GitHub token.

Method Path Answer
GET /github/repos?q= the repositories your App authorization reaches, the App’s installations, and install_url
POST /chat/sessions/{thread_id}/workspace/repo {full_name, ref?} → 202 {state: "cloning", …}
GET /chat/sessions/{thread_id}/workspace/repo {state, repo, base, branch, ahead, dirty, error}
GET /chat/sessions/{thread_id}/workspace/repo/files?prefix=&limit= {state, files: [{path, kind, size, status}], truncated}
DELETE /chat/sessions/{thread_id}/workspace/repo 204; 503 workspace_unavailable when a hosted sandbox could not be cleared (the checkout is kept)
  • The intersection, and only that. An App’s user token sees repositories where the App is installed and the person has access. A repository missing from the list is one the App was not installed on: install_url (from FELIX_GITHUB_APP_SLUG) is where to fix that.

  • The checkout is the thread’s workspace. Every harness workspace tool, and the shell tool, works in it and nowhere else. A checkout still cloning, failed or expired stops the tools rather than falling back to the thread’s workspace scope under FELIX_WORKSPACE_ROOT, and the checkout root (FELIX_REPO_CHECKOUT_ROOT, default FELIX_DATA_DIR/checkouts) is never inside it.

  • Publishing is as the person. github_publish: {auth: person, branch_prefix: …} publishes to the thread’s repository with the opener’s token, behind its approval as always.

  • Bounded. A repository over FELIX_REPO_CLONE_MAX_MB (500) is refused before cloning; a checkout unused for FELIX_REPO_CHECKOUT_TTL_DAYS (14) is removed by the worker.

  • Under the hosted backend the checkout is the thread’s sandbox. It is cloned there (into an empty workspace only), listed and published from there, and destroyed with it on removal or expiry. Nothing of the repository is on the harness host.

  • The files are listed, not served. The listing is git’s: tracked files and untracked ones it does not ignore, each with its lstat size and status (clean, modified, added, deleted, untracked, conflicted). A symlink is listed as one and never followed. prefix names a directory (a literal path, never a glob); limit defaults to 2,000 and caps at 10,000, with truncated saying the list was cut. A clone still running is 409 checkout_cloning.

In chat-ui, the side pane’s Files tab has a Repository on the harness section that opens one and, once it is ready, draws its files as a tree opened onto what git reports changed, re-read when each run settles. Files there do not open: no route serves a checkout file’s contents. /harness/github shows your connection and what it reaches.

Routes call enforce_inbound_auth (via prepare_tenant_invoke):

  • inbound.allow_anonymous: false → reject anonymous
  • inbound.required_scopes → all must be present on the principal → else 403
  • a credential carrying a felix_manifests claim (a list of names) → the manifest must be in it → else 403. A claim that is not a list of strings is refused, not ignored.

The same check guards every surface that runs an agent: chat, /v1, A2A, MCP, side questions, /chat/compact, a rewind’s summary, and each child delegation compiles. A durable run stores the pin and is held to it on resume. A pinned manifest’s own spec.sub_agents are the operator’s choice and are not checked again, as with auth.inbound. Google sign-in mints the claim for an account with manifests. An api-key entry or another IdP’s token that carries it is held to it as well. GET /v1/models lists only the pinned manifests to such a credential, so a client never offers it an agent it would be refused, or defaults to one.

Management routes call require_mgmt_scopes with the scopes in felix.auth.mgmt — audit:read, manifests:write, approvals:write, and the rest of the pairs listed in the Management API.

Auth middleware installs a contextvars-backed RequestContext: settings, principal (tenant / scopes / subject), and a fresh LimitState. Tool wrappers and patterns read it via get_context() / try_get_context() — no parameter threading, harder to tamper.

Each verifier in FELIX_JWT_VERIFIERS takes the tenant one way, set with ;tenant=:

Mode Tenant
claim (default) The token’s tenant_id, custom:tenant_id or tid claim, in that order. No claim → refused; it never falls back to a shared tenant. The value must be in FELIX_ALLOWED_TENANTS when that is set, and outside development a claim-mode verifier with no allowlist refuses to start
issuer The first DNS label of the issuer host. Two issuers that collapse to one tenant refuse to start
fixed:<tenant> Always that tenant

Anonymous callers and api_key keys without a tenant use default.

POST /internal/* is self-authenticating: shared secret header (FELIX_CONSUMER_SHARED_SECRET / X-Felix-Consumer-Secret) with constant-time compare. Required when auth_mode ≠ none (and in production).

Caller-supplied thread suffixes (body.thread_id, x-thread-id) reject : and #. The server always prefixes {tenant_id}:.

Manifest MCP / peer / container auth refs resolve outbound headers (bearer literals or OAuth client-credentials). Tokens may be cached encrypted at rest when FELIX_OAUTH_CACHE_KEY is set. Outbound hosts pass SSRF allow-list checks before a bearer is attached.