Files
LocalAI/docs/content/features/authentication.md
mudler's LocalAI [bot] 58ea2f5d79 feat(ui): give Operate and Studio a front door, and fix two layout regressions (#11305)
* feat(ui): give Operate a front door and fold six rail groups into four

Opening Operate ran firstVisiblePath() and landed on Backends, because
Backends happens to be written first in operateConsole.groups. The section
that should answer "is anything wrong" opened on a package manager, and
nothing was reported until you visited it.

Adds /app/operate. Its one irreplaceable block is "Needs attention", which
is empty when nothing is wrong and says so in a line rather than rendering a
reassuring green panel. It collects stale backends, failed operations and
unhealthy nodes. Everything else on the page is a summary you could already
assemble by visiting four others.

The rail regroups from six headings to four: Inference and Activity were both
"the runtime right now", Access and System were both administration. No
destination is removed and no gate changes, so isConsoleItemVisible and
consolePaths are untouched. Overview leads the first group, which is what
makes firstVisiblePath() return it without knowing it exists.

Rail entries now carry a signal beside the label. This does not replace the
sidebar badge and is not built as if it does: the badge stays on the
always-visible sidebar entry for the reason recorded in Sidebar.jsx, that the
rail exists only on Operate routes and can be collapsed. The signals are
orientation while inside Operate, so they are aria-hidden and nothing urgent
depends on them alone.

OperateSummaryContext polls once for the whole console, following
OperationsContext, which exists because per-consumer setInterval against one
endpoint was the defect it fixed. It is mounted by ConsoleLayout for the
Operate console only, so "poll only while in Operate" needs no route check.
Built on usePolling, so it pauses on a hidden tab. Operations are read from
OperationsContext rather than polled a second time, and each source degrades
to no-signal on its own so one dead endpoint cannot blank the rest. It reads
the cached GET /api/backends/upgrades and never the POST that forces a real
registry check.

Traces and Usage get no signal yet: /api/traces returns the list, so a count
would mean fetching every trace to render one number. A counts endpoint is
the honest fix and is scoped separately.

Full e2e suite green (369 passed, 4 skipped), including a render-smoke entry
for the new route.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): open Studio on what this machine can actually make

Studio was a tab strip over six generators that opened on Images, which was
never a decision, only the first entry in BASE_TABS. Nothing said which
modalities this installation could run, so the way to learn that video had no
model was to pick the tab and find an empty select.

Adds an overview tab and makes it the fallback. Explicit tabs still win, so
existing deep links keep working; anything unrecognised or gated now lands on
the overview rather than Images.

Each tab carries a dot: filled when an installed model advertises that
modality, hollow when nothing serves it. That is the feature in one detail,
turning the strip from navigation into a report of what the machine can do
before anything is clicked. The dot is aria-hidden because the overview states
the same facts in words and the dots change as models load.

Two kinds of unavailable, which had to stop looking alike:
  - switched off, via a permission: no tab and no lane, unchanged
  - available with no model: a lane, and a route to installing one

Studio now owns one MODALITIES table so the tab strip and the overview cannot
disagree about what exists, and calls useModels() once, unfiltered, grouping in
the browser. useModels(capability) fetches the whole list and filters locally,
so a hook per modality would have been six identical requests to
/api/models/capabilities on every mount. There is a test for that.

Recent outputs read every localStorage store through a new
readAllMediaHistory(), which avoids mounting five hooks that carry save timers
the overview has no use for. 3D is read separately through use3DHistory rather
than folded in: its entries are GLB blobs in IndexedDB, so they cannot come
from the same synchronous read.

Typical cost is the median of this machine's own history, not a guess, and
renders as a dash when there is nothing to go on.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): stop the stat cards and the console rail breaking on small screens

Two unrelated causes behind one report that /app/manage looks wrong when the
window is narrow.

The stat cards were being laid out by the wrong rule at every width. Two
different components both claimed `.stat-grid`: the dashboard card strip that
holds .stat-card children, and the detail-pane StatGrid the split views
introduced further down App.css. Being later, the second won every shared
property, so the cards got its 120px columns and its 1px hairline gap in place
of their own 180px columns and spacing-md. Four cards were packed onto a row
that fits two, labels wrapped to three lines and clipped, and the icon crowded
the value. Renamed the strip to `.stat-cards`, after the children it actually
holds, which also removes the mismatch of a `.stat-grid` container full of
`.stat-card`s. The split-view component keeps `.stat-grid` and its BEM parts.

The expanded console rail had no bounded height. Thirteen destinations stacked
in one column is taller than a phone, so opening the menu pushed the page's own
heading past the fold: the menu replaced the page rather than annotating it.
Capped at 55vh with internal scrolling below 768px, so the content behind stays
reachable.

Both are asserted on behaviour rather than markup: no stat-card label may be
clipped, the card gap must not be the detail pane's hairline, and expanding the
rail must leave the page heading on screen.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): retemper the palette to localai.io and add the lane primitive

The token half of the style transfer, plus the shared list idiom the two
overviews had each grown their own copy of.

theme.css moves from Nord to the website's palette, variable names preserved so
every consumer moves with it: ground #13171f -> #0d1117, accent frost cyan
#88c0d0 -> action blue #4f8cff, success sage -> mint #56d6a4, warning -> the
amber #f1b95d the site spends only on the thing asking for a decision. Eyebrows
go mint. Dividers become an opaque #29384a hairline rather than alpha over a
varying surface, which is what makes stacked surfaces read crisply on the site.

Light is derived, not inverted. The site ships one theme and never had to
answer this, but the app does: blue darkens to #2f62d8, mint to #0d8b60 and
amber to #8a5d0b, all clearing 4.5:1 on a cool paper ground, where the
dark-mode values sit near 2:1. Same three roles, different values.

Three files restate the palette because CSS variables cannot reach them:
cmTheme.js (the whole CodeMirror theme), VoiceVisualizer and WaveformPlayer
(canvas). Left alone they would have quietly kept the app half-Nord.

The `.lane` primitive replaces the near-identical row CSS that OperateOverview
and StudioOverview had each written: a full-bleed row on a hairline that insets
on hover, with no card and no shadow. Callers supply only the column template.
Both pages now use it, along with `.lane-head` for section rhythm and a
`.page-pad` container for top-level pages outside a console shell — without
which Studio sat flush against the sidebar with its eyebrow clipped.

Studio's tab strip wraps rather than running off the edge at narrow widths.

Full e2e suite: 386 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): put Home's resident models on lanes and give the footer one line

Home's status line was three chips saying a thing was true. It now reports
figures: how many models are resident, how many nodes are healthy, what share
of memory is in use, set in tabular monospace so the digits line up. A chip
answers whether; a figure answers how much, which is what someone opening the
page at a glance is after.

Resident models move from status chips to lanes, with the id set in a new
`.lane__name--id` because an id is something you might type or paste and the UI
face makes it read as a label. /api/system-information carries only the id, so
there is deliberately no backend or memory column: inventing one would mean a
server change this does not make.

The footer was three centred rows and cost the bottom sixth of every page for
chrome. It is one line now, version left and links right, wrapping to centred
when the viewport is too narrow to hold both. Every link it had, it keeps.

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): correct three contrast failures and stop a guaranteed-404 poll

A contrast audit of the new palette found three values below WCAG AA, one of
which the previous commit message claimed was fine:

- White on the #4f8cff button is 3.22:1, which is large-text only. The website
  does exactly this, but a button label in an app is not large text, so the
  label goes to dark ink at 5.88:1. Light mode keeps white, which is 5.44:1 on
  its darker blue.
- Light-mode success was 4.08:1 on paper, not the 4.5 claimed. Darkened to
  #0a734f, 5.56:1.
- Nord red was already 4.28:1 on raised surfaces, a pre-existing miss carried
  over unexamined. Lifted to #c96f78, 5.02:1.

Lanes gain the two states they were missing: a 44px target on coarse pointers,
matching what EntityRail already does so the two list idioms feel the same
under a thumb, and a reduced-motion variant that keeps the background feedback
while dropping the hover inset, which is a position change.

The Operate summary no longer asks for /api/nodes on a single-node install. The
cluster API answers 503 when distributed mode is off, so it was a guaranteed
miss every fifteen seconds; it is now gated on useDistributedMode, the same
condition the rail already uses for the Nodes entry.

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): restore the gap between overview blocks, and stop claiming zero nodes

Two defects a design review surfaced.

`.lane-head:first-child { margin-top: 0 }` was meant to stop the first block on
a page carrying a top margin. But every <section> makes its lane-head a first
child, so the reset applied to all of them and the gap between blocks vanished:
"Sections" sat flush against the attention row above it. The header supplies its
own bottom margin, so a uniform top margin is correct everywhere.

The Cluster summary read "0 nodes" on a single-node install, which looks like a
fault when the cluster API is simply switched off. It now says "Single node".

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): open dark by default, and stop clipping the collapsed sidebar footer

Dark is the identity rather than a preference: localai.io ships one theme and
it is this one, so an install should look like LocalAI before anyone has chosen
anything. The OS setting no longer selects light on first load. The toggle
still does, and a stored choice wins forever after, which the tests assert
both ways.

The collapsed sidebar footer stacked its controls but kept the expanded row's
inline padding, so their edges were clipped against the 51px rail.

Full e2e suite: 394 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(api): count traces server-side and give the Operate overview real totals

The overview's headline block had no source. /api/traces returns the trace
list, so "37 errors in 24h" meant fetching every buffered exchange to count it
in the browser — waste that grows with the buffer, to produce three integers.

Adds GET /api/traces/summary: totals, failures, p95 and a bucketed series for
sparklines, over a window that defaults to 24 hours and is capped at a week.

Deliberate calls, each with a spec:
- A 4xx is the caller getting it wrong, not the installation being unhealthy,
  so only 5xx and transport errors count as failures.
- p95 is a nearest-rank percentile rather than the slowest request, which is
  what a max would report and what makes latency panels lie.
- Buckets are oldest-first so a sparkline reads left to right, and the slice is
  never nil: nil serialises as null and breaks .map() on the other side, which
  is a silent runtime error rather than an empty chart.
- Exchanges outside the window are not counted at all.

The route is registered before /api/traces/:id so "summary" is not captured as
a trace ID.

On the client, Traces and Usage gain the rail signals they were shipped
without, the Observability section summary now states counts instead of listing
its destinations, and an installation that has served nothing says so rather
than showing three zeroes dressed as telemetry.

Sparkline is a bare stroke with an emphasised endpoint and no axes: the figure
above it already states the value, so its only job is the shape.

Go: 185 middleware specs pass. Full e2e suite: 396 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): stop the memory chart calling a trade-off an error

The VRAM-by-context chart rendered any build over the limit in error red, and
escalated the verdict to the error tone as soon as two context sizes crossed
it. But an over-limit build still installs — #11288 keeps a test on exactly
that — so red overstates what is happening. A model that fits at 32k and not
64k is a trade-off, not a fault.

Over-limit bars and the limit line now use the warning tone, which is the
constraint colour used everywhere else in this branch: know what you are doing,
not you may not. The error tone is reserved for "fits nowhere", where the model
genuinely cannot run on this host.

Full e2e suite: 397 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): give the new surfaces orchestrated motion

Uses the reveal system already in the codebase rather than adding a library:
pageReveal, .reveal-stagger and staggerStyle() were built for exactly this, and
anime.js would be ~17KB duplicating four lines of CSS for list reveals.

The overview's headline figures, attention rows and section lanes stagger in,
as do Studio's modality lanes and recent outputs, so a page assembles in the
order it is read instead of appearing all at once.

Two additions beyond stagger. Rail signals transition on opacity when a poll
lands, so a number changing reads as an update rather than a jump cut, and it
stays on the compositor so it cannot reflow the rail. The attention block
animates its left edge in — the one thing on the page that should announce
itself, and on the border rather than the text so nothing moves under a reader.

Both are dropped entirely under prefers-reduced-motion, alongside the lane
hover inset already handled.

Full e2e suite: 397 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): put the generators on a hairline field stack and record the request

The workbench treatment from the mocks, applied where it costs least: both
changes land on shared surfaces, so all six generators get them at once rather
than drifting apart page by page.

The control column stops being a shadowed card of boxed groups and becomes a
hairline field stack — the panel is the page's left half, not an object
floating on it — with uppercase micro-labels matching the eyebrow treatment
used elsewhere. Because .media-controls is shared, Images, Video, 3D, Speech,
Sound and Audio FX all move together.

RequestPanel shows the request the form actually built, with a copy-as-curl.
LocalAI is API-first and Studio is the best place in the app to teach its own
endpoints: the form stops being a black box, and a result worth keeping can be
reproduced from a shell without reverse-engineering which fields the page sent.
It records what was sent rather than what the form currently holds, and renders
nothing until a request has been made — a panel describing a request nobody
made is a tutorial, not a record. Wired into Images and Speech.

Full e2e suite: 401 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): make Chat a transcript instead of a bubble thread

Rounded, filled, asymmetric bubbles fight a system built on hairlines, and they
carry the speaker in shape and side rather than in words. The assistant side
had already given up its bubble; this finishes the job.

Both roles now run full width down one column, separated by a rule, each with a
mono role label. The user turn keeps a left edge in the action tone so the two
are still told apart at a glance, without a fill or a corner radius. The
avatars go: the accent and the label carry the speaker, so the glyph was
decoration once neither side had a bubble.

Saying who is speaking in words rather than in geometry is also what survives
being read aloud, printed, or looked at by someone who cannot pick the sides
apart by colour.

Full e2e suite: 404 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): dress the API reference in LocalAI's palette

The Swagger page was the last surface still shipping in someone else's colours,
which is conspicuous now that everything it links from is dark.

Swagger UI has no theming hook, so rather than fork it we serve our own index
ahead of the library's wildcard and restate the palette over its stylesheet.
The library's own bundle and assets are still what load, so a swagger-ui
upgrade cannot silently break the page — this is a skin, not a fork.

Two things needed real care. Swagger tints the entire operation row per method
via .opblock.opblock-post and friends, so the palette had to match that
specificity rather than reach for !important; the method now lives on one edge
instead of washing across the row, because a page where every row is a status
colour has no status colour left. And the filled method chip put white on pale
green, which was the least readable thing on the page — it is an outlined mono
chip now, carrying the method in its border and text.

Palette values are copied from theme.css rather than referenced: this page is
served by Go and never sees the app's CSS. The comment says so, and says to
keep them in step.

Go: routes and middleware suites pass. Full e2e suite: 405 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): make tall split-view pages reachable, repair the Agents header, scale titles

Three things found by actually using the app rather than measuring it.

**Host was unusable.** The shell above a split view is overflow:hidden so the
document cannot grow, which left anything taller than the viewport simply
unreachable — and Host stacks a resources card, four stat cards and a tab bar
above its split, so the bottom of the pane fell off at every window height with
nothing to scroll. Every sweep I ran for this was horizontal, which is why it
kept coming back clean.

The page now scrolls inside the pinned shell. The pane keeps its own scroller:
letting it grow instead pushes the document taller and stretches the rail to
match, which is the regression e2e/discover-height.spec.js exists to catch, and
which the first version of this fix duly caused.

**The Agents header controls were unstyled** — "Create Agent" was rendering
with the browser's default chrome. The markup had been mangled at some point:
six unrelated classes merged into one string on the link, and the label and
button left with none at all and empty icons. Repaired, with the inline flex
replaced by a shared .header-actions class.

**Page titles take the editorial scale from the site**: larger, tracked at
-0.04em, on a line height near 1, so a two-word title reads as a statement
rather than a label. The typeface is unchanged — DESIGN.md keeps the existing
type system — so the whole difference is scale, tracking and leading, which is
where the site gets its voice from. This was the biggest reason the running app
still did not look like the mocks.

Full e2e suite: 404 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 00:16:08 +02:00

18 KiB

+++ disableToc = false title = "Authentication & Authorization" weight = 85 url = '/features/authentication' +++

LocalAI supports two authentication modes: legacy API key authentication (simple shared keys) and a full user authentication system with roles, sessions, OAuth, and per-user usage tracking.

Legacy API Key Authentication

The simplest way to protect your LocalAI instance is with API keys. Set one or more keys via environment variable or CLI flag:

# Single key
LOCALAI_API_KEY=sk-my-secret-key localai run

# Multiple keys (comma-separated)
LOCALAI_API_KEY=key1,key2,key3 localai run

Clients provide the key via any of these methods:

  • Authorization: Bearer <key> header
  • x-api-key: <key> header
  • xi-api-key: <key> header
  • token cookie

Legacy API keys grant full admin access - there is no role separation. For multi-user deployments with role-based access, use the user authentication system instead.

API keys can also be managed at runtime through the [Runtime Settings]({{%relref "features/runtime-settings" %}}) interface.

User Authentication System

The user authentication system provides:

  • User accounts with email, name, and avatar
  • Role-based access control (admin vs. user)
  • Session-based authentication with secure cookies
  • OAuth login (GitHub) and OIDC single sign-on (Keycloak, Google, Okta, Authentik, etc.)
  • Per-user API keys for programmatic access
  • Admin route gating - management endpoints are restricted to admins
  • Per-user usage tracking with token consumption metrics

Enabling Authentication

Set LOCALAI_AUTH=true or provide a GitHub OAuth Client ID or OIDC Client ID (which auto-enables auth):

# Enable with SQLite (default, stored at {DataPath}/database.db)
LOCALAI_AUTH=true localai run

# Enable with GitHub OAuth
GITHUB_CLIENT_ID=your-client-id \
GITHUB_CLIENT_SECRET=your-client-secret \
LOCALAI_BASE_URL=http://localhost:8080 \
localai run

# Enable with OIDC provider (e.g. Keycloak)
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm \
LOCALAI_OIDC_CLIENT_ID=your-client-id \
LOCALAI_OIDC_CLIENT_SECRET=your-client-secret \
LOCALAI_BASE_URL=http://localhost:8080 \
localai run

# Enable with PostgreSQL
LOCALAI_AUTH=true \
LOCALAI_AUTH_DATABASE_URL=postgres://user:pass@host/dbname \
localai run

Configuration Reference

Environment Variable Default Description
LOCALAI_AUTH false Enable user authentication and authorization
LOCALAI_AUTH_DATABASE_URL {DataPath}/database.db Database URL - postgres://... for PostgreSQL, or a file path for SQLite
GITHUB_CLIENT_ID GitHub OAuth App Client ID (auto-enables auth when set)
GITHUB_CLIENT_SECRET GitHub OAuth App Client Secret
LOCALAI_OIDC_ISSUER OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com)
LOCALAI_OIDC_CLIENT_ID OIDC Client ID (auto-enables auth when set)
LOCALAI_OIDC_CLIENT_SECRET OIDC Client Secret
LOCALAI_BASE_URL Base URL for OAuth callbacks (e.g. http://localhost:8080)
LOCALAI_ADMIN_EMAIL Email address to auto-promote to admin role on login
LOCALAI_REGISTRATION_MODE approval Registration mode: open, approval, or invite
LOCALAI_DISABLE_LOCAL_AUTH false Disable local email/password registration and login (for OAuth/OIDC-only deployments)

Note: network-backed storage. File-based SQLite relies on POSIX file locking, which is unreliable over network filesystems (SMB/CIFS/NFS, e.g. Azure Files / Azure Container Apps shared volumes). On such storage the auth DB can fail to migrate with database is locked. Use PostgreSQL (LOCALAI_AUTH_DATABASE_URL=postgres://...) when the data directory lives on shared or network storage, or place database.db on a local volume.

Disabling Local Authentication

If you want to enforce OAuth/OIDC-only login and prevent users from registering or logging in with email/password, set LOCALAI_DISABLE_LOCAL_AUTH=true (or pass --disable-local-auth):

# OAuth-only setup (no email/password)
LOCALAI_DISABLE_LOCAL_AUTH=true \
GITHUB_CLIENT_ID=your-client-id \
GITHUB_CLIENT_SECRET=your-client-secret \
LOCALAI_BASE_URL=http://localhost:8080 \
localai run

When disabled:

  • The login page will not show email/password forms (the UI checks the providers list from /api/auth/status)
  • POST /api/auth/register returns 403 Forbidden
  • POST /api/auth/login returns 403 Forbidden
  • OAuth/OIDC login continues to work normally

Roles

There are two roles:

  • Admin: Full access to all endpoints, including model management, backend configuration, system settings, traces, agents, and user management.
  • User: Access to inference endpoints only - chat completions, embeddings, image/video/audio generation, TTS, MCP chat, and their own usage statistics.

The first user to sign in is automatically assigned the admin role. Additional users can be promoted to admin via the admin user management API or by setting LOCALAI_ADMIN_EMAIL to their email address.

Registration Modes

Mode Description
open Anyone can register and is immediately active
approval New users land in "pending" status until an admin approves them. If a valid invite code is provided during registration, the user is activated immediately (skipping the approval wait). (default)
invite Registration requires a valid invite link generated by an admin. Without one, registration is rejected.

Admins can generate single-use, time-limited invite links from the Users → Invites tab in the web UI, or via the API:

# Create an invite link (default: expires in 7 days)
curl -X POST http://localhost:8080/api/auth/admin/invites \
  -H "Authorization: Bearer <admin-key>" \
  -H "Content-Type: application/json" \
  -d '{"expiresInHours": 168}'

# List all invites
curl http://localhost:8080/api/auth/admin/invites \
  -H "Authorization: Bearer <admin-key>"

# Revoke an unused invite
curl -X DELETE http://localhost:8080/api/auth/admin/invites/<invite-id> \
  -H "Authorization: Bearer <admin-key>"

# Check if an invite code is valid (public, no auth required)
curl http://localhost:8080/api/auth/invite/<code>/check

Share the invite URL (/invite/<code>) with the user. When they open it, the registration form is pre-filled with the invite code. Invite codes are single-use - once consumed, they cannot be reused. Expired or used invites are rejected.

For GitHub OAuth, the invite code is passed as a query parameter to the login URL (/api/auth/github/login?invite_code=<code>) and stored in a cookie during the OAuth flow.

Admin-Only Endpoints

When authentication is enabled, the following endpoints require admin role:

Model & Backend Management:

  • GET /api/models, POST /api/models/install/*, POST /api/models/delete/*
  • GET /api/backends, POST /api/backends/install/*, POST /api/backends/delete/*
  • GET /api/operations, POST /api/operations/*/cancel, POST /api/operations/*/dismiss
  • GET /api/operations/history, DELETE /api/operations/history
  • GET /models/available, GET /models/galleries, GET /models/jobs/*
  • GET /backends, GET /backends/available, GET /backends/galleries

System & Monitoring:

  • GET /api/traces, GET /api/traces/summary, GET /api/traces/{id}, POST /api/traces/clear
  • GET /api/backend-traces, GET /api/backend-traces/{id}, POST /api/backend-traces/clear
  • GET /api/backend-logs/*, POST /api/backend-logs/*/clear
  • GET /api/resources, GET /api/settings, POST /api/settings
  • GET /system, GET /backend/monitor, POST /backend/shutdown, POST /backend/load

P2P:

  • GET /api/p2p/*

Agents & Jobs:

  • All /api/agents/* endpoints
  • All /api/agent/tasks/* and /api/agent/jobs/* endpoints

User-Accessible Endpoints (all authenticated users):

  • POST /v1/chat/completions, POST /v1/embeddings, POST /v1/completions
  • POST /v1/images/generations, POST /v1/audio/*, POST /tts, POST /vad, POST /video
  • GET /v1/models, POST /v1/tokenize, POST /v1/detokenize, POST /v1/detection
  • POST /v1/mcp/chat/completions, POST /v1/messages, POST /v1/responses
  • POST /stores/*, GET /api/cors-proxy
  • GET /version, GET /api/features, GET /swagger/*, GET /metrics
  • GET /api/auth/usage (own usage data)

Web UI Access Control

When auth is enabled, the React UI sidebar dynamically shows/hides sections based on the user's role:

  • All users see: Home, Chat, Images, Video, TTS, Sound, Talk, Usage, API docs link
  • Admins also see: Discover, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)

Admin-only pages are also protected at the router level - navigating directly to an admin URL redirects non-admin users to the home page.

GitHub OAuth Setup

  1. Create a GitHub OAuth App at Settings → Developer settings → OAuth Apps → New OAuth App
  2. Set the Authorization callback URL to {LOCALAI_BASE_URL}/api/auth/github/callback
  3. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables
  4. Set LOCALAI_BASE_URL to your publicly-accessible URL

OIDC Setup

Any OIDC-compliant identity provider can be used for single sign-on. This includes Keycloak, Google, Okta, Authentik, Azure AD, and many others.

Steps:

  1. Create a client/application in your OIDC provider
  2. Set the redirect URL to {LOCALAI_BASE_URL}/api/auth/oidc/callback
  3. Set the three environment variables: LOCALAI_OIDC_ISSUER, LOCALAI_OIDC_CLIENT_ID, LOCALAI_OIDC_CLIENT_SECRET

LocalAI uses OIDC auto-discovery (the /.well-known/openid-configuration endpoint) and requests the standard scopes: openid, profile, email.

Provider examples:

# Keycloak
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm

# Google
LOCALAI_OIDC_ISSUER=https://accounts.google.com

# Authentik
LOCALAI_OIDC_ISSUER=https://authentik.example.com/application/o/localai/

# Okta
LOCALAI_OIDC_ISSUER=https://your-org.okta.com

For OIDC, invite codes work the same way as GitHub OAuth - the invite code is passed as a query parameter to the login URL (/api/auth/oidc/login?invite_code=<code>) and stored in a cookie during the OAuth flow.

User API Keys

Authenticated users can create personal API keys for programmatic access:

# Create an API key (requires session auth)
curl -X POST http://localhost:8080/api/auth/api-keys \
  -H "Cookie: session=<session-id>" \
  -H "Content-Type: application/json" \
  -d '{"name": "My Script Key"}'

User API keys inherit the creating user's role. Admin keys grant admin access; user keys grant user-level access.

Auth API Endpoints

Method Endpoint Description Auth Required
GET /api/auth/status Auth state, current user, providers No
POST /api/auth/logout End session Yes
GET /api/auth/me Current user info Yes
POST /api/auth/api-keys Create API key Yes
GET /api/auth/api-keys List user's API keys Yes
DELETE /api/auth/api-keys/:id Revoke API key Yes
GET /api/auth/usage User's own usage stats Yes
GET /api/auth/usage/sources User's own per-API-key / per-source breakdown Yes
GET /api/auth/admin/users List all users Admin
PUT /api/auth/admin/users/:id/role Change user role Admin
DELETE /api/auth/admin/users/:id Delete user Admin
GET /api/auth/admin/usage All users' usage stats Admin
GET /api/auth/admin/usage/sources All users' per-API-key / per-source breakdown Admin
POST /api/auth/admin/invites Create invite link Admin
GET /api/auth/admin/invites List all invites Admin
DELETE /api/auth/admin/invites/:id Revoke unused invite Admin
GET /api/auth/invite/:code/check Check if invite code is valid No
GET /api/auth/github/login Start GitHub OAuth No
GET /api/auth/github/callback GitHub OAuth callback (internal) No
GET /api/auth/oidc/login Start OIDC login No
GET /api/auth/oidc/callback OIDC callback (internal) No

Usage Tracking

When authentication is enabled, LocalAI automatically tracks per-user token usage for inference endpoints. Usage data includes:

  • Prompt tokens, completion tokens, and total tokens per request
  • Model used and endpoint called
  • Request duration
  • Timestamp for time-series aggregation

Viewing Usage

Usage is accessible through the Usage page in the web UI (visible to all authenticated users) or via the API:

# Get your own usage (default: last 30 days)
curl http://localhost:8080/api/auth/usage?period=month \
  -H "Authorization: Bearer <key>"

# Admin: get all users' usage
curl http://localhost:8080/api/auth/admin/usage?period=week \
  -H "Authorization: Bearer <admin-key>"

# Admin: filter by specific user
curl "http://localhost:8080/api/auth/admin/usage?period=month&user_id=<user-id>" \
  -H "Authorization: Bearer <admin-key>"

Period values:

  • day - last 24 hours, bucketed by hour
  • week - last 7 days, bucketed by day
  • month - last 30 days, bucketed by day (default)
  • all - all time, bucketed by month

Response format:

{
  "usage": [
    {
      "bucket": "2026-03-18",
      "model": "gpt-4",
      "user_id": "abc-123",
      "user_name": "Alice",
      "prompt_tokens": 1500,
      "completion_tokens": 800,
      "total_tokens": 2300,
      "request_count": 12
    }
  ],
  "totals": {
    "prompt_tokens": 1500,
    "completion_tokens": 800,
    "total_tokens": 2300,
    "request_count": 12
  }
}

Usage Dashboard

The web UI Usage page provides:

  • Period selector - switch between day, week, month, and all-time views
  • Summary cards - total requests, prompt tokens, completion tokens, total tokens
  • By Model table - per-model breakdown with visual usage bars
  • By User table (admin only) - per-user breakdown across all models
  • Sources tab - per-API-key and per-source breakdown (described below)

Per-API-key Breakdown

The Sources tab on the Usage page surfaces a third dimension of the same data: traffic broken down by API key and by request source. Three source classes are tracked:

  • API key - request authenticated with a named user API key (Authorization: Bearer lai-..., x-api-key, or token cookie). Each key shows up with its label (snapshotted at write time, so revoked keys still display the original name).
  • Web UI - request authenticated with a browser session cookie.
  • Legacy - request authenticated with an env-configured LOCALAI_API_KEY. Visible to admins only.

The Sources tab is visible to every authenticated user. Non-admins see only their own keys plus their own Web UI traffic (legacy is filtered server-side). Admins see every key from every user.

The tab is laid out as:

  • A source mix ribbon showing the percentage split across the three classes.
  • A top-N + Other stacked time chart (top 7 sources by total tokens; the rest roll up).
  • A searchable, sortable table of every key plus the Web UI and Legacy pseudo-rows. Click a row to filter the chart to that source.

Endpoints

Method Path Auth Description
GET /api/auth/usage/sources Self Caller's per-source breakdown. Excludes legacy.
GET /api/auth/admin/usage/sources Admin All users' per-source breakdown. Accepts user_id and api_key_id filters. Includes legacy.

Both endpoints accept the same period parameter (day, week, month, all) as /api/auth/usage.

# Your own per-source usage for the last week
curl "http://localhost:8080/api/auth/usage/sources?period=week" \
  -H "Authorization: Bearer <key>"

# Admin: filter to a single API key across all users
curl "http://localhost:8080/api/auth/admin/usage/sources?period=month&api_key_id=<key-id>" \
  -H "Authorization: Bearer <admin-key>"

Response shape:

{
  "buckets": [
    { "bucket": "2026-05-19", "source": "apikey",
      "api_key_id": "uuid", "api_key_name": "ci-runner",
      "total_tokens": 20000, "request_count": 142, "...": "..." },
    { "bucket": "2026-05-19", "source": "web",
      "total_tokens": 300, "request_count": 11, "...": "..." }
  ],
  "totals": {
    "by_source": {
      "apikey": { "tokens": 1234567, "requests": 8420 },
      "web":    { "tokens":   92000, "requests":   211 }
    },
    "by_key": [
      { "api_key_id": "uuid", "api_key_name": "ci-runner",
        "tokens": 2100000, "requests": 8420,
        "last_used": "2026-05-20T12:34:56Z" }
    ],
    "grand_total": { "tokens": 1334777, "requests": 8645 }
  },
  "truncated": false
}

The by_key list is server-sorted by tokens descending and capped at 200 entries. When more keys would qualify, the response sets "truncated": true so the UI can show a notice.

Migration of pre-feature data

Usage rows recorded before this feature have no source column. On startup, InitDB backfills them as legacy when the synthetic legacy-api-key user_id was used, and web for everything else. The migration is idempotent; existing aggregations remain correct after the upgrade.

Combining Auth Modes

Legacy API keys and user authentication can be used simultaneously. When both are configured:

  1. User sessions and user API keys are checked first
  2. Legacy API keys are checked as fallback - they grant admin-level access
  3. This allows a gradual migration from shared API keys to per-user accounts

Build Requirements

The user authentication system requires CGO for SQLite support. It is enabled with the auth build tag, which is included by default in Docker builds.

# Building from source with auth support
GO_TAGS=auth make build

# Or directly with go build
go build -tags auth ./...

The default Dockerfile includes GO_TAGS="auth", so all Docker images ship with auth support. When building from source without the auth tag, setting LOCALAI_AUTH=true has no effect - the system operates without authentication.