Skip to content

Route Authentication

Fullfinity provides three authentication modes for API routes, allowing fine-grained control over who can access each endpoint.

Users are classified by their group membership:

Group IdentifierNameDescription
core_internalInternalBackend users with full system access
core_portalPortalExternal users with limited access (customers, vendors)
core_publicPublicGuest/anonymous users

A user may additionally be a service account (User.service_account) — a login-less account an integration acts as, holding one or more API keys. This is orthogonal to the groups above: a service account is classified by its groups exactly like anyone else, so a route guarded by requires_internal accepts a service account holding core_internal. What the flag restricts is enforced on the read path: a session token belonging to a service account authenticates nobody, so it can act only by presenting a key.

User.name_search — the search behind every relation picker bound to User — offers only the accounts a person acts through. Two populations are left out:

Left outWhy
Service accounts (service_account)A credential’s identity. It cannot hold a session, so it is nobody’s salesperson, assignee or approver.
External logins (core_portal, core_public)A customer or a visitor, not a colleague.

So a picker offers exactly the accounts holding Internal — a total question, because User Type is a mandatory category (below): no account is without one. An employee who also holds a portal login on their own customer record is still offered.

Your own User relations inherit this with no filter= of their own — model the customer side of a document as a Contact, which is where the portal login points anyway (User.contact).

A picker that genuinely wants one of these populations opts back in with the field’s ctx. The two are independent, so an administration screen listing integrations is not also handed the customer list:

- type: field
name: account
properties:
widget: DataCombo
ctx:
include_service_accounts: true # and/or: include_external_users: true

The scope applies to pickers only — a List of User records still shows every account, so administration screens are unaffected.

Groups are presented by category, and a category whose groups set category_required must be answered: the access picker offers no “No Access” option for it, and saving a user who holds no group from it — directly or through the implication chain — is refused with a message naming the category and its choices.

User Type ships marked. Internal, Portal or Public is therefore a property every account has, which is what lets a picker ask for staff with a single positive test instead of listing the types it does not want.

Mark every group in the category, since a category is a label the groups share rather than a record of its own:

- data_type: Group
name: Internal
identifier: core_internal
category: User Type
category_required: true

Implication counts as an answer: granting Settings (which implies Access Management, which implies Internal) satisfies User Type, exactly as the form shows it selected.

Use these FastAPI dependencies to protect your routes:

Restricts access to internal users only. Use for admin panels, settings, and sensitive operations.

from fastapi import Depends
from fullfinity.engine.middleware import requires_internal
@router.post("/api/settings/update", dependencies=[Depends(requires_internal)])
async def update_settings(request: Request):
# Only internal users can access this
pass

Behavior:

  • Returns 401 if no valid token and none can be renewed from the request’s refresh cookie
  • Returns 403 if user is not in core_internal group

Allows any authenticated user (internal or portal). Use for features accessible to all logged-in users.

from fastapi import Depends
from fullfinity.engine.middleware import requires_user
@router.get("/api/my-profile", dependencies=[Depends(requires_user)])
async def get_profile(request: Request):
# Internal and portal users can access this
pass

Behavior:

  • Returns 401 if no valid token and none can be renewed from the request’s refresh cookie
  • Returns 403 if user only has core_public group (guest user)

Allows public access with optional authentication. Tries to authenticate if a token is present, otherwise falls back to guest user. An expired session is renewed from the browser’s refresh cookie before that fallback (see A session renews itself), so an aged-out token shows a signed-in visitor their own page, not the guest one.

from fastapi import Depends
from fullfinity.engine.middleware import allows_public
from fullfinity.engine.context import env_ctx
@router.get("/api/products", dependencies=[Depends(allows_public)])
async def list_products(request: Request):
# Anyone can access, but authenticated users get personalized results
env = env_ctx.get()
user = env.user # Either the authenticated user or guest user
pass

Behavior:

  • Never returns 401/403
  • Always succeeds with either authenticated user or guest user context
  • Guest is the fallback only when there is genuinely no session to renew
  • Useful for pages that work for everyone but offer personalization when logged in

For routes that need no authentication at all (no user context):

@router.get("/api/health")
async def health_check():
# No authentication, no user context
return {"status": "ok"}

Guards the database-management plane: creating, deleting, backing up and restoring whole databases. These endpoints cannot use the dependencies above, because they run before there is a database to authenticate a user against — on a brand-new install there are no users yet. They are gated by a server-level key instead.

from fastapi import Depends
from fullfinity.engine.control_panel_key import requires_control_panel
@router.post("/my-database-operation", dependencies=[Depends(requires_control_panel)])
async def do_something_drastic():
# Only a caller holding the control panel key reaches this
pass

Behavior:

  • Returns 401 if the request carries neither a valid key nor a valid panel session
  • Returns 503 if the server has no key at all, so the plane is disabled entirely

The key is CONTROL_PANEL_KEY in the server configuration. Set it yourself to choose the value. If it is unset, the server generates one at startup, prints it to the log, and saves it, so it stays the same across restarts:

─────────────────────────────────────────────────────────────
Control panel key (database management)
kZ8f2Qw…
No CONTROL_PANEL_KEY was configured, so one was generated.
Saved to config.local.yaml, so it stays the same across
restarts. Edit that file to change it.
─────────────────────────────────────────────────────────────

Where it is saved depends on the environment, because the two config files swap roles:

Saved toWhy
Official container imageconfig.yamlBind-mounted from the host, so it survives redeploys. config.local.yaml is regenerated from the environment on every start.
Anywhere elseconfig.local.yamlGitignored, so a generated key never lands in a file you might commit.

The server distinguishes the two by FULLFINITY_CONTAINER, which our entrypoint exports — not by guessing from the host.

A container config file only persists if it is mounted. docker compose restart keeps the container and its filesystem, but docker compose up -d or an image update starts from a fresh container layer — so a config.yaml baked into the image, rather than bind-mounted from the host, takes the saved key with it. The server checks for this (a bind mount is a separate filesystem, so its device id differs from the container root) and warns instead of claiming it saved something durable:

Saved to /app/config.yaml, but that file is NOT mounted from
the host: it will be LOST when the container is recreated
(docker compose up, an image update), and a new key issued.
Mount it, or set CONTROL_PANEL_KEY in your environment to pin
a value of your own.

The shipped docker-compose.yml mounts it, so this warning means your deployment has diverged from it.

It is never written to the filestore. Backup tooling archives the filestore off-box, so a key there would travel into object storage and into any environment restored from a backup, where it would still be valid against production. If neither config file is writable (a Kubernetes ConfigMap is always read-only) the key is kept in the process environment instead: it still works and still reaches every worker, but it changes on restart, and the startup banner says so.

There is no default value and no bypass — with no key in force, every request on this plane is refused. A CHANGE-ME… placeholder counts as no key, so an operator who skips that line in the shipped config gets a closed plane rather than a published credential. And the key is never derived from SECRET_KEY, which itself falls back to a published literal when unset.

If you lose it, read it out of the config file it was saved to, or out of the log:

Terminal window
grep CONTROL_PANEL_KEY config.local.yaml # or config.yaml in a container
docker compose logs fullfinity | grep -A6 "Control panel key"

A caller authenticates either by sending the key in an X-Control-Panel-Key header (scripts, curl) or by exchanging it once at /web/control-panel/authenticate for a short-lived, HttpOnly session cookie (what the browser control panel does). Rotating the key immediately invalidates every outstanding session.

Reach for this only for genuine bootstrap operations that cannot have a user context. Anything an operator does after signing in belongs behind requires_internal.

DependencyInternalPortalPublic/GuestAnonymous
requires_internal❌ 403❌ 403❌ 401
requires_user❌ 403❌ 401
allows_public✅ (as guest)✅ (as guest)
requires_control_panelkey/session only — user identity is not consulted
(none)✅ (no user ctx)
# Module installation - internal admins only
@router.post("/api/modules/install", dependencies=[Depends(requires_internal)])
async def install_module(request: Request, module_name: str):
pass
# User management
@router.get("/api/users", dependencies=[Depends(requires_internal)])
async def list_users(request: Request):
pass
# View own orders - any logged-in user
@router.get("/api/orders", dependencies=[Depends(requires_user)])
async def my_orders(request: Request):
env = env_ctx.get()
user = env.user
# Filter by user's company/permissions
pass
# Submit support ticket
@router.post("/api/tickets", dependencies=[Depends(requires_user)])
async def create_ticket(request: Request):
pass
# Product catalog - visible to everyone
@router.get("/api/catalog", dependencies=[Depends(allows_public)])
async def catalog(request: Request):
env = env_ctx.get()
user = env.user
# Show prices based on user type (guest sees retail, portal sees wholesale)
pass
# Website pages
@router.get("/{path:path}", dependencies=[Depends(allows_public)])
async def web_page(request: Request, path: str):
pass

Both arrive on Authorization, and the prefix decides how each is resolved:

  • ff_… — an API key. Resolved from its own table by hash, belongs to one user, and carries that user’s groups, companies and record rules. It is never written to the session store, so it cannot be refreshed and does not expire unless given an expiry date. Revoking or archiving it — or deactivating its account — refuses it on the next call.
  • anything else — a session token. Resolved from the session store as described below.

The two are disjoint by construction: a key is never a session, and a session token never matches the key prefix, so neither path can be handed the other’s credential. Routes are unaffected — the same requires_internal / requires_user dependencies apply, because by the time they run, both credentials have resolved to an ordinary user.

A login issues an access token (a short-lived session handle) and a refresh token. Both are scoped to the database the user authenticated against:

  • The access token is stored in the shared auth_valkey store as {"user_id", "db"}, bound to the database it was minted in.
  • The refresh token (a JWT) carries a db claim and only mints a new access token when that claim matches the request’s current database.

On every request the token is resolved for the request’s current database only (resolve_auth_session(token, db)). A token minted for one database is never honored against another. This matters because the same user id can refer to different people in different databases — without the binding, a session for “user 5 in database A” would silently authenticate as “user 5 in database B”.

Consequences:

  • Dropping (or dropping and recreating) a database invalidates the sessions that were minted against it. The next request resolves to no session and the user is sent to a fresh login — they are never let into a different database on the old token.
  • Switching the active database mid-session (a new db cookie) requires a fresh login; the previous database’s token does not carry over.
  • A stale db cookie pointing at a dropped database is detected separately by the middleware and surfaces as a 409 DATABASE_NOT_FOUND (XHR) or, for a navigation, a redirect — to the database selector, or to / when DEFAULT_DB names a database to fall back to. Either way it is distinct from a 401 TOKEN_INVALID_OR_EXPIRED, so the client routes the user to a live database instead of prompting an impossible in-place re-login.

A session renews itself, and outlives the session store

Section titled “A session renews itself, and outlives the session store”

The access token is short-lived and registered in auth_valkey; the refresh token is long-lived, held only by the browser as an HttpOnly cookie, and signed rather than stored. That difference is what makes renewal work without the user seeing anything, and it happens on the request that finds the session dead — every route, whatever its auth mode:

  1. A request arrives with an access token the store no longer has: it aged out, or the store was restarted.
  2. Before answering, the framework reads the request’s own refresh_token cookie, verifies its signature and db claim, mints a new pair, and registers the new access token. The request then proceeds as that user — a requires_user route serves its page instead of redirecting to login, and an allows_public route serves the visitor’s personalised page instead of the guest one.
  3. The response carries the new HttpOnly cookies, so the browser is signed in again for the next request too.

This matters most for pages, and that is why it is not left to the client. An HTTP client can meet a 401, post to /auth/refresh, and replay its request; a browser navigating to a website or portal page has nothing to replay, because the page it asked for is already being answered. Renewing in the request path is what makes an expiry survivable for both.

Requests racing the same expiry — the several a page load fires at once, all carrying the same dead token — share a single renewal, so the browser is not handed one cookie while other freshly registered sessions are stranded behind it.

/auth/refresh remains available for callers that manage their own tokens, and as the client’s fallback. It takes the refresh token from the request body or the cookie, and returns the new pair both in the body and as cookies — a browser acts on the cookies and cannot read an HttpOnly one to put it in a body; a non-browser caller reads the body.

An API key is never renewed this way. It is a durable credential of its own with no session behind it, so a request presenting one is answered on the key alone and never as whoever the browser’s cookies belong to.

Because renewal needs nothing from the store to succeed, losing the store does not sign anyone out — the next request rebuilds its session from the signed refresh token and re-registers it. So auth_valkey needs no persistence, and the reference docker-compose.yml deliberately runs Valkey with snapshotting off: nothing it holds is a source of truth, and restoring a stale copy of a cache is worse than starting from an empty one.

What does end a session: a refresh token past its own expiry (REFRESH_TOKEN_EXPIRE_DAYS), a db claim that no longer matches the request’s database, and a deleted or archived user. Logging out clears the browser’s cookies, which ends the session for that browser.

Custom auth code — an SSO callback, a signed magic link, a route that provisions and enters an environment — needs to establish a session without going through the password endpoint. Two helpers in fullfinity.engine.api.auth do it:

from fastapi.responses import RedirectResponse
from fullfinity.engine.api.auth import establish_session, mint_session
# Attach a session to any response — the browser lands already signed in.
response = RedirectResponse(url="/", status_code=303)
establish_session(response, user_id)
return response

mint_session(user_id, db=None) mints and registers the token pair and returns the payload; establish_session(response, user_id, db=None) is the common case — mint, then set the HttpOnly cookies on a response you are returning. Use establish_session when you are navigating the browser, and mint_session when you are answering an XHR and want the tokens in the body.

Pass db when minting for a database other than the one your code is running in. Sessions are database-bound, so a token minted under the wrong name authenticates nowhere — and this is silent, since the token is perfectly valid, just not for the database the next request resolves.

To resolve sessions, or to work with the store directly, use the other helpers in the same module — store_auth_session(token, user_id, db, ttl_seconds), resolve_auth_session(token, db) and current_session_db(request) — rather than reading or writing auth_valkey yourself, so the database binding is always applied.

Route authentication works alongside model-level permissions:

  1. Route level: Controls who can call the endpoint (requires_internal, etc.)
  2. Model level: Controls CRUD operations per group (see Model Access)
  3. Record level: Filters visible records per group (see Record Rules)
@router.get("/api/invoices", dependencies=[Depends(requires_user)])
async def list_invoices(request: Request):
# Route allows portal + internal
# But Invoice model access rules determine what each user can read
invoices = await Invoice.filter().all() # Automatically filtered by record rules
return invoices