A step-by-step guide to replacing per-app fastapi-users with a single authentik identity provider (IdP) shared by all our React / Svelte + FastAPI applications, with "Sign in with Google" and local email/password sign-up.
Written against authentik 2026.8.x (compose file tag
2026.8.3). The authentik admin UI moves things around between releases. If a menu path here doesn't match your version, search for it in the official docs.
Responsibilities:
| Concern | Before (fastapi-users) | After (authentik) |
|---|---|---|
| Sign-up / sign-in UI | Each app | authentik's hosted login page (one shared, brandable UI) |
| Passwords, reset, email verification, MFA | Each app | authentik |
| Google login | Each app | authentik (configured once, works for every app) |
User identity (sub, email, name, groups) |
Each app's user table |
authentik, delivered in the token |
| App-specific data (profile, prefs, FKs) | Each app | Each app, in a thin local users table keyed by the authentik sub |
| Authorization (who can use which app/role) | Each app | authentik groups plus policies, checked again in FastAPI |
The standard we use: OpenID Connect (OIDC). Every web app gets its own authentik application with an OAuth2/OIDC provider:
Authorization: Bearer <token> to its API.iss, aud and exp, then reads the user from the claims.Section 16 covers a more locked-down alternative for sensitive apps: the Backend-for-Frontend (BFF) pattern, where tokens never reach the browser.
auth.example.com, pointing at the host.Recent authentik releases need only PostgreSQL. Redis is no longer part of the stack, and the official compose file runs just
postgresql,serverandworker.
sudo mkdir -p /opt/authentik && sudo chown $USER /opt/authentik
cd /opt/authentik
wget https://docs.goauthentik.io/compose.yml # or: curl -O https://docs.goauthentik.io/compose.yml
# Secrets (never commit .env)
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env
# Pin the version explicitly so upgrades are deliberate
echo "AUTHENTIK_TAG=2026.8.3" >> .env
# Bind only to localhost; the reverse proxy (section 3) is the public entry point
echo "COMPOSE_PORT_HTTP=127.0.0.1:9000" >> .env
echo "COMPOSE_PORT_HTTPS=127.0.0.1:9443" >> .env
# Optional: send anonymous error reports to authentik devs
# echo "AUTHENTIK_ERROR_REPORTING__ENABLED=true" >> .env
Back up
AUTHENTIK_SECRET_KEY. It signs cookies and tokens and encrypts some stored data. If you lose or change it, sessions break.
Add these to .env. Email verification and password recovery depend on them.
AUTHENTIK_EMAIL__HOST=smtp.example.com
AUTHENTIK_EMAIL__PORT=587
AUTHENTIK_EMAIL__USERNAME=no-reply@example.com
AUTHENTIK_EMAIL__PASSWORD=********
AUTHENTIK_EMAIL__USE_TLS=true
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
AUTHENTIK_EMAIL__FROM="Our Apps <no-reply@example.com>"
docker compose pull
docker compose up -d
docker compose ps # server, worker and postgresql should be healthy
docker compose logs -f server
Test email once the stack is up:
docker compose exec worker ak test_email you@example.com
OIDC and Google both require HTTPS in production. Use whichever proxy you already run.
/etc/caddy/Caddyfile:
auth.example.com {
reverse_proxy 127.0.0.1:9000
}
upstream authentik {
server 127.0.0.1:9000;
keepalive 10;
}
map $http_upgrade $connection_upgrade_keepalive {
default upgrade;
'' '';
}
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / {
proxy_pass http://authentik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade; # websockets are required
proxy_set_header Connection $connection_upgrade_keepalive;
}
}
By default authentik trusts X-Forwarded-* headers from private IP ranges. If the proxy runs somewhere else, set AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS.
Open https://auth.example.com/if/flow/initial-setup/. The trailing slash matters. Set the email and password for the built-in akadmin user.
Then harden it:
authentik Admins group.akadmin, or keep it as a break-glass account with a long random password in your vault.System → Brands → authentik-default → Edit:
auth.example.comThe login page is what every user of every app will see, so make it look like yours. For deeper customisation, Flows & Stages → Flows → edit a flow → Appearance controls the background and layout.
| authentik concept | What it is | What we use it for |
|---|---|---|
| Flow | A sequence of stages (login, enrollment, recovery…) | Login, sign-up, password reset, Google sign-up |
| Stage | One step: identification, password, prompt, email, MFA, user write, user login | Building the flows |
| Policy | A rule (expression, password strength, reputation…) bound to flows, stages or applications | Username-from-email, password rules, app access |
| Source | An external identity provider users can log in with | |
| Provider | How an app talks to authentik (OAuth2/OIDC, SAML, proxy, LDAP) | One OAuth2/OIDC provider per web app |
| Application | The app as users see it; links to a provider and holds access bindings | One per web app |
| Group | Collection of users; can carry attributes | App access and roles |
A fresh install has no sign-up. The login page only authenticates existing users. We add:
enrollment-email-verification.example/flows-enrollment-email-verification.yaml from the dropdown.This creates a flow (slug similar to default-enrollment-flow) with these stages:
If the example file isn't in your version's dropdown, download it from the docs ("Flows → Examples") and import it via Flows & Stages → Flows → Import.
Recommended tweaks (Flows & Stages → Stages):
name field as required, since apps will show it.Do the same with blueprint path example/flows-recovery-email-verification.yaml, and apply it.
default-authentication-identification → Edit:Username and Email, so users can type either.authentik-default → Default flows → Recovery flow: set the recovery flow here too.Test: open https://auth.example.com/ in a private window, sign up, and verify the email.
Open or invite-only? If your apps shouldn't be open to the whole internet, choose one of these:
- (a) Keep sign-up open, but gate each application by group (section 11).
- (b) Add an expression policy to the enrollment flow that allows only certain email domains.
- (c) Use authentik invitations (Invitation stage) instead of open enrollment.
our-apps-auth.example.com, the parent domain of auth.example.com.openid, .../auth/userinfo.email, .../auth/userinfo.profile. These are non-sensitive, so Google doesn't need to review the app. Uploading a logo can trigger brand verification.Application type: Web application
Name: authentik
Authorized redirect URI: https://auth.example.com/source/oauth/callback/google/
The last path segment (google) must equal the slug of the authentik source you create next.
Copy the Client ID and Client secret.
The only redirect URI Google ever sees is authentik's. You never need to touch Google again when you add new apps. That is the main benefit of centralising.
Directory → Federation and Social login → Create → Google OAuth Source:
| Field | Value |
|---|---|
| Name | Google |
| Slug | google (must match the redirect URI above) |
| Enabled | ✓ |
| User matching mode | See below |
| Consumer key | Google Client ID |
| Consumer secret | Google Client secret |
| Scopes (additional) | leave empty (openid email profile is the default) |
| Authentication flow | default-source-authentication |
| Enrollment flow | default-source-enrollment |
User matching mode decides what happens when someone signs in with Google and a local user already has that email:
| Mode | Behaviour | When to use |
|---|---|---|
| Link to a user with identical email address | Google login attaches to the existing account | Recommended for us, only if local sign-up verifies email (section 5.1). Users who signed up with a password can then also use Google. |
| Use the user's email address, but deny enrollment when the email already exists | Refuses and asks the user to log in with their password first, then connect Google from user settings | Stricter, and safer if local emails might be unverified |
| Link users on unique identifier | Always creates a new user per Google identity | Rarely what you want |
Security note: "link by email" is safe only if every path that creates an account with that email has proven ownership of it. Google verifies emails, and our local enrollment flow sends a verification link. Don't enable email linking if any unverified path exists, such as imported users with unverified emails.
By default, a first-time Google user is asked to choose a username. To use their email instead:
source-enrollment-username-from-emailemail = request.context["prompt_data"]["email"]
# Use the full email as username (unique and predictable)
request.context["prompt_data"]["username"] = email
# Return False so the prompt stage is skipped entirely
return False
default-source-enrollment → Stage Bindings. Expand the binding for the prompt stage (default-source-enrollment-prompt), then Bind existing policy → source-enrollment-username-from-email.prompt_data, which only exists at run time.Optional: restrict Google sign-ups to your Workspace domain. Bind this expression policy to the default-source-enrollment flow itself:
# Only allow @example.com Google accounts to create an account
info = request.context.get("oauth_userinfo", {})
if info.get("hd") != "example.com":
ak_message("Please sign in with your example.com Google account.")
return False
return True
Flows & Stages → Stages → default-authentication-identification → Edit → Source settings → Sources: move Google into the selected list. Optionally tick Show sources' labels so it reads "Sign in with Google" rather than only an icon.
Test in a private window. The login page should now show email/username, a password field, "Sign up", "Forgot password?" and Google.
Do this once per web app (and per environment, see section 14). Frontend and backend of the same app share one authentik application.
Why one per app instead of one for everything? Each app then has its own aud (audience), so a token for app A is rejected by app B. You also get per-app access rules and redirect URIs, and a per-app entry in the user's app launcher.
Applications → Applications → Create with provider:
Inventory (whatever users should see)inventory. This becomes part of the issuer URL, so pick it carefully and don't change it.https://inventory.example.com, used by authentik's user dashboard at /if/user/.| Field | Value | Why |
|---|---|---|
| Authorization flow | default-provider-authorization-implicit-consent |
First-party apps shouldn't show a "Do you allow…?" consent screen |
| Invalidation flow | default-provider-invalidation-flow |
Used on logout |
| Client type | Public | SPAs can't keep a secret, so they use PKCE instead |
| Client ID | keep the generated one, or set a readable one like inventory |
Used as aud in tokens |
| Redirect URIs | Strict: https://inventory.example.com/auth/callbackStrict: https://inventory.example.com/ (post-logout) |
Must match exactly what the SPA sends |
| Signing key | authentik Self-signed Certificate |
Required. It makes tokens RS256/ES256 signed and verifiable via JWKS. Without it, tokens are HS256-signed with the client secret, and the backend can't verify them properly. |
| Access token validity | minutes=10 |
Short-lived. The SPA refreshes it. |
| Refresh token validity | days=30 |
How long a user stays logged in without re-entering credentials |
| Scopes | openid, email, profile, offline_access |
offline_access makes authentik issue refresh tokens. profile includes groups. |
| Subject mode | Based on the User's UUID | Same sub for a person across all apps. Use the same mode for every provider. |
| Include claims in id_token | ✓ | Convenience for the frontend |
| Issuer mode | Each provider has a different issuer (default) | Issuer becomes https://auth.example.com/application/o/<slug>/ |
For slug inventory:
| Name | Value |
|---|---|
| Issuer / Authority | https://auth.example.com/application/o/inventory/ (trailing slash!) |
| Discovery | https://auth.example.com/application/o/inventory/.well-known/openid-configuration |
| JWKS | https://auth.example.com/application/o/inventory/jwks/ |
| Authorize | https://auth.example.com/application/o/authorize/ |
| Token | https://auth.example.com/application/o/token/ |
| Userinfo | https://auth.example.com/application/o/userinfo/ |
| End session (logout) | https://auth.example.com/application/o/inventory/end-session/ |
| Client ID | e.g. inventory |
Client libraries only need the authority and client ID. They read everything else from discovery.
authentik access tokens are JWTs. A typical payload:
{
"iss": "https://auth.example.com/application/o/inventory/",
"aud": "inventory",
"sub": "5b0d7c6e-8f0b-4c4f-9a5e-2f6a3f0d9c11",
"exp": 1790000000,
"iat": 1789999400,
"email": "asha@example.com",
"email_verified": true,
"name": "Asha Rao",
"given_name": "Asha Rao",
"preferred_username": "asha@example.com",
"groups": ["inventory-users", "inventory-admins"]
}
Check how
email_verifiedis populated in your version (Customization → Property Mappings → "authentik default OAuth Mapping: OpenID 'email'") before your code relies on it.
Custom claims: create Customization → Property Mappings → Create → Scope Mapping, e.g. scope name app_roles:
# Expose a per-user attribute set in authentik (user or group attributes)
return {
"department": request.user.attributes.get("department"),
}
Then add the mapping to the provider's scopes, and request that scope from the frontend.
The backend's job:
sub to a local user row, creating it just-in-time on first request.It does not register, log in, hash passwords or send emails any more.
pip install "pyjwt[crypto]" httpx pydantic-settings
# remove: fastapi-users, fastapi-users-db-sqlalchemy
# .env of the FastAPI app
OIDC_ISSUER=https://auth.example.com/application/o/inventory/
OIDC_CLIENT_ID=inventory
CORS_ORIGINS=["https://inventory.example.com","http://localhost:5173"]
app/auth/oidc.py)This module is identical across all our apps, so put it in a shared internal package (see 8.7).
from functools import lru_cache
import httpx
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class OIDCSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OIDC_", env_file=".env", extra="ignore")
issuer: str # must end with "/" exactly as authentik reports it
client_id: str # expected "aud"
leeway_seconds: int = 30
@lru_cache
def get_oidc_settings() -> OIDCSettings:
return OIDCSettings()
@lru_cache
def get_jwks_client() -> PyJWKClient:
settings = get_oidc_settings()
discovery = httpx.get(
f"{settings.issuer}.well-known/openid-configuration", timeout=10
).raise_for_status().json()
# Keys are cached; PyJWKClient refetches automatically on an unknown "kid" (key rotation)
return PyJWKClient(discovery["jwks_uri"], cache_keys=True, lifespan=3600)
class TokenClaims(BaseModel):
sub: str
email: str | None = None
email_verified: bool | None = None
name: str | None = None
preferred_username: str | None = None
groups: list[str] = Field(default_factory=list)
_bearer = HTTPBearer(auto_error=False)
_unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Deliberately a plain `def`: FastAPI runs it in a threadpool, so the
# (rare, cached) JWKS HTTP fetch never blocks the event loop.
def get_claims(creds: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> TokenClaims:
if creds is None:
raise _unauthorized
settings = get_oidc_settings()
try:
signing_key = get_jwks_client().get_signing_key_from_jwt(creds.credentials)
payload = jwt.decode(
creds.credentials,
signing_key.key,
algorithms=["RS256", "ES256"],
audience=settings.client_id,
issuer=settings.issuer,
leeway=settings.leeway_seconds,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
except jwt.PyJWTError as exc:
raise _unauthorized from exc
return TokenClaims.model_validate(payload)
def require_groups(*allowed: str):
"""Dependency factory: user must be in at least one of `allowed` groups."""
def checker(claims: TokenClaims = Depends(get_claims)) -> TokenClaims:
if not set(allowed) & set(claims.groups):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions")
return claims
return checker
app/auth/users.py)Apps still need a local users row for foreign keys and app-specific data. It mirrors identity fields from the token and is keyed by auth_sub.
import uuid
from datetime import datetime
from fastapi import Depends
from sqlalchemy import DateTime, String, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from app.auth.oidc import TokenClaims, get_claims
from app.db import Base, get_session # your existing async session dependency
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
auth_sub: Mapped[str | None] = mapped_column(String(255), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(320), index=True)
name: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ...app-specific columns (preferences, tenant_id, etc.)
async def _find_or_create(session: AsyncSession, claims: TokenClaims) -> User:
user = await session.scalar(select(User).where(User.auth_sub == claims.sub))
if user is None and claims.email:
# One-time link for accounts that existed before authentik (see section 12)
user = await session.scalar(
select(User).where(User.email == claims.email, User.auth_sub.is_(None))
)
if user is None:
user = User()
session.add(user)
user.auth_sub = claims.sub
return user
async def get_current_user(
claims: TokenClaims = Depends(get_claims),
session: AsyncSession = Depends(get_session),
) -> User:
try:
user = await _find_or_create(session, claims)
except IntegrityError: # two first requests raced; the other one won
await session.rollback()
user = await session.scalar(select(User).where(User.auth_sub == claims.sub))
# Keep mirrored identity fields fresh (authentik is the source of truth)
if (user.email, user.name) != (claims.email, claims.name):
user.email, user.name = claims.email, claims.name
user.last_seen_at = func.now()
await session.commit()
return user
To avoid a DB write on every request, update
last_seen_atonly when it's older than a few minutes, or drop it.
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.oidc import TokenClaims, require_groups
from app.auth.users import User, get_current_user
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # the SPA origins
allow_methods=["*"],
allow_headers=["Authorization", "Content-Type"],
)
@app.get("/api/me")
async def me(user: User = Depends(get_current_user)):
return {"id": user.id, "email": user.email, "name": user.name}
@app.delete("/api/items/{item_id}")
async def delete_item(
item_id: int,
user: User = Depends(get_current_user),
_: TokenClaims = Depends(require_groups("inventory-admins")),
): ...
Swagger UI (/docs): the HTTPBearer scheme adds an Authorize button. Paste an access token copied from the SPA's dev tools. You can also wire Swagger to authentik via swagger_ui_init_oauth with PKCE if you add /docs/oauth2-redirect to the provider's redirect URIs.
fastapi-users routers: /auth/register, /auth/jwt/login, /auth/forgot-password, /auth/verify, /users/*.UserManager, password hashing, the JWT secret, and verification/reset email code./application/o/introspect/ endpoint for sensitive operations.Put oidc.py, and optionally the User mixin plus get_current_user, into an internal package, e.g. our-auth on your private package index or a git dependency. Each new app then needs pip install our-auth and two env vars.
We use oidc-client-ts with its React wrapper react-oidc-context. Both are standards-based and maintained, and they handle PKCE, token storage and refresh.
npm install oidc-client-ts react-oidc-context
# .env
VITE_OIDC_AUTHORITY=https://auth.example.com/application/o/inventory/
VITE_OIDC_CLIENT_ID=inventory
VITE_API_URL=https://api.inventory.example.com
src/auth/config.ts)import { WebStorageStateStore, type UserManagerSettings } from "oidc-client-ts";
export const oidcConfig: UserManagerSettings = {
authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
post_logout_redirect_uri: `${window.location.origin}/`,
response_type: "code", // Authorization Code + PKCE (PKCE is automatic)
scope: "openid profile email offline_access",
automaticSilentRenew: true, // uses the refresh token before expiry
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
};
src/main.tsx)import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "react-oidc-context";
import { oidcConfig } from "./auth/config";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AuthProvider
{...oidcConfig}
// strip ?code=&state= from the URL after the callback is processed
data-removed={() =>
window.history.replaceState({}, document.title, window.location.pathname)
}
>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</React.StrictMode>,
);
src/App.tsx)import { useEffect, type ReactNode } from "react";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "react-oidc-context";
function RequireAuth({ children }: { children: ReactNode }) {
const auth = useAuth();
const location = useLocation();
useEffect(() => {
if (!auth.isLoading && !auth.isAuthenticated && !auth.activeNavigator && !auth.error) {
void auth.signinRedirect({ state: { returnTo: location.pathname + location.search } });
}
}, [auth.isLoading, auth.isAuthenticated, auth.activeNavigator, auth.error]);
if (auth.error) return <p>Authentication error: {auth.error.message}</p>;
if (!auth.isAuthenticated) return <p>Loading…</p>;
return <>{children}</>;
}
function AuthCallback() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (auth.isAuthenticated) {
const state = auth.user?.state as { returnTo?: string } | undefined;
navigate(state?.returnTo ?? "/", { replace: true });
}
}, [auth.isAuthenticated]);
if (auth.error) return <p>Sign-in failed: {auth.error.message}</p>;
return <p>Signing you in…</p>;
}
function Header() {
const auth = useAuth();
if (!auth.isAuthenticated) {
return (
<>
{/* Both go to the authentik page, which has "Sign up" and "Sign in with Google" */}
<button data-removed={() => auth.signinRedirect()}>Sign in</button>
<button data-removed={() => auth.signinRedirect()}>Sign up</button>
</>
);
}
return (
<>
<span>{auth.user?.profile.name ?? auth.user?.profile.email}</span>
<a href="https://auth.example.com/if/user/" target="_blank">Account</a>
<button data-removed={() => auth.signoutRedirect()}>Sign out</button>
</>
);
}
export default function App() {
return (
<>
<Header />
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/" element={<Home />} />
<Route path="/app/*" element={<RequireAuth><Dashboard /></RequireAuth>} />
</Routes>
</>
);
}
Sign-up UX: "Sign up" and "Sign in" both redirect to authentik. The user picks Sign up or Sign in with Google there. After enrollment and email verification, authentik resumes the original authorization request and returns the user to the app, already logged in.
Account management: password change, MFA setup and linked Google account live in authentik's user interface at https://auth.example.com/if/user/. Link to it from each app instead of building profile/password screens.
// src/api.ts — works inside and outside React components
import { User } from "oidc-client-ts";
import { oidcConfig } from "./auth/config";
function getAccessToken(): string | undefined {
const raw = sessionStorage.getItem(`oidc.user:${oidcConfig.authority}:${oidcConfig.client_id}`);
return raw ? User.fromStorageString(raw).access_token : undefined;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
Authorization: `Bearer ${getAccessToken()}`,
},
});
if (res.status === 401) {
// token expired and could not be renewed; force a fresh login
window.location.assign("/app");
throw new Error("Unauthorized");
}
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<T>;
}
If you use TanStack Query or axios, put the same Authorization header logic in an interceptor or query function.
Use the same library, oidc-client-ts, without the React wrapper. This works for plain Svelte (Vite) and for SvelteKit in SPA mode. For SvelteKit, disable SSR on authenticated routes (export const ssr = false in the relevant +layout.ts), or use the BFF approach in section 16.
npm install oidc-client-ts
src/lib/auth.ts)import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts";
import { writable, derived } from "svelte/store";
export const userManager = new UserManager({
authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
post_logout_redirect_uri: `${window.location.origin}/`,
response_type: "code",
scope: "openid profile email offline_access",
automaticSilentRenew: true,
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
export const user = writable<User | null>(null);
export const isAuthenticated = derived(user, ($u) => !!$u && !$u.expired);
export const authReady = writable(false);
userManager.events.addUserLoaded((u) => user.set(u));
userManager.events.addUserUnloaded(() => user.set(null));
userManager.events.addSilentRenewError(() => user.set(null));
/** Call once at app start. Returns the path to navigate to after a login callback. */
export async function initAuth(): Promise<string | null> {
try {
if (window.location.pathname === "/auth/callback") {
const u = await userManager.signinRedirectCallback();
user.set(u);
return (u.state as { returnTo?: string } | undefined)?.returnTo ?? "/";
}
const existing = await userManager.getUser();
user.set(existing && !existing.expired ? existing : null);
return null;
} finally {
authReady.set(true);
}
}
export const login = (returnTo = window.location.pathname + window.location.search) =>
userManager.signinRedirect({ state: { returnTo } });
export const logout = () => userManager.signoutRedirect();
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const u = await userManager.getUser();
const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init.headers, Authorization: `Bearer ${u?.access_token}` },
});
if (res.status === 401) {
await login();
throw new Error("Unauthorized");
}
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<T>;
}
src/App.svelte)<script lang="ts">
import { onMount } from "svelte";
import { user, isAuthenticated, authReady, initAuth, login, logout } from "./lib/auth";
onMount(async () => {
const returnTo = await initAuth();
if (returnTo) {
// Plain Svelte: rewrite the URL. SvelteKit: goto(returnTo, { replaceState: true })
history.replaceState({}, "", returnTo);
}
});
</script>
{#if !$authReady}
<p>Loading…</p>
{:else if $isAuthenticated}
<header>
{$user?.profile.name ?? $user?.profile.email}
<a href="https://auth.example.com/if/user/" target="_blank">Account</a>
<button on:click={logout}>Sign out</button>
</header>
<!-- protected app -->
{:else}
<button on:click={() => login()}>Sign in / Sign up</button>
{/if}
(With Svelte 5 you can use data-removed={logout} and runes. The store-based module works in both Svelte 4 and 5.)
Use two layers:
groups claim, or from custom claims.For each app <slug> create groups in Directory → Groups:
<slug>-users: may log in to the app<slug>-admins: admin features in the app<slug>-editors, <slug>-viewers, …Put org-wide groups (e.g. staff) above them with group parents if useful.
Applications → Applications → <slug> → Policy / Group / User Bindings → Bind existing group → <slug>-users.
With a binding in place, a user outside the group who tries to log in to that app gets an authentik "permission denied" page. No token is issued. Without bindings, the app is open to every authentik user.
Automatic membership on sign-up. For self-service public apps, you can:
Use require_groups("<slug>-admins") from section 8.3. Always check in the backend. The frontend can hide buttons, but it isn't a security boundary.
Give app owners permission to manage only their app's groups in authentik (RBAC: Directory → Roles) instead of making them authentik admins.
Plan the migration per app. Overview:
auth_sub to the app's user table:# alembic migration
op.add_column("user", sa.Column("auth_sub", sa.String(255), nullable=True))
op.create_unique_constraint("uq_user_auth_sub", "user", ["auth_sub"])
Keep the existing id (UUID) as the primary key, so all existing foreign keys continue to work.get_current_user finds their old row by email and fills in auth_sub. From then on, matching is by sub only.hashed_password, is_verified and similar columns, and remove fastapi-users.Options, from simplest to most seamless:
A. Don't import; let users re-register. Users sign up in authentik with the same email, or use Google. The email link in step 4 connects them to their old data. This is the simplest option, and fine for small or internal user bases.
B. Bulk-create users via the authentik API, then send password-reset emails. Create a token in Directory → Tokens and App passwords with intent API, then run a script:
import httpx
AK = "https://auth.example.com/api/v3"
HEADERS = {"Authorization": "Bearer <api-token>"}
for u in old_users: # rows from each app's fastapi-users table
r = httpx.post(f"{AK}/core/users/", headers=HEADERS, json={
"username": u.email,
"email": u.email,
"name": u.name or u.email,
"is_active": u.is_active,
"groups": [inventory_users_group_pk],
"attributes": {"migrated_from": "inventory"},
})
if r.status_code == 400 and "unique" in r.text:
continue # same person already imported from another app
r.raise_for_status()
Then have users use "Forgot password?", or trigger recovery emails from the admin UI. Users whose Google account has the same email can simply use Sign in with Google.
C. Import password hashes. fastapi-users hashes with bcrypt or argon2 via pwdlib/passlib. authentik is Django-based and stores hashes in Django's format (bcrypt$…, argon2$argon2id$…). Carrying hashes over requires converting the format and confirming that the hasher is enabled in your authentik version. Test this on a staging instance first. If it doesn't work cleanly, fall back to B.
Duplicates across apps: the same person probably exists in several apps' user tables. authentik gives them one account, and each app links its own row by email on first login. That is the goal of the exercise.
/auth/jwt/login endpoint returning 410 Gone with a message pointing to the new login, rather than 404.Clicking through the UI for every app and environment is error-prone. authentik blueprints are YAML files that declare objects idempotently. Keep them in git, e.g. an authentik-config repo.
blueprints/app-inventory.yaml:
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: app-inventory
context:
slug: inventory
name: Inventory
base_url: https://inventory.example.com
entries:
- model: authentik_core.group
id: users-group
identifiers:
name: inventory-users
- model: authentik_core.group
identifiers:
name: inventory-admins
- model: authentik_providers_oauth2.oauth2provider
id: provider
identifiers:
name: !Format ["%s-provider", !Context slug]
attrs:
client_type: public
client_id: !Context slug
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
sub_mode: user_uuid
include_claims_in_id_token: true
access_token_validity: minutes=10
refresh_token_validity: days=30
redirect_uris:
- matching_mode: strict
url: !Format ["%s/auth/callback", !Context base_url]
- matching_mode: strict
url: !Format ["%s/", !Context base_url]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
- model: authentik_core.application
id: app
identifiers:
slug: !Context slug
attrs:
name: !Context name
provider: !KeyOf provider
meta_launch_url: !Context base_url
- model: authentik_policies.policybinding
identifiers:
target: !KeyOf app
group: !KeyOf users-group
attrs:
order: 0
Field and model names change occasionally between authentik versions. The easiest way to get a correct template for your version is to create one app by hand, then export it (Flows & Stages → Flows → Export, or
ak export_blueprint). Diff it against the file above.
# compose.override.yml
services:
server:
volumes: ["./blueprints/custom:/blueprints/custom"]
worker:
volumes: ["./blueprints/custom:/blueprints/custom"]
Files under /blueprints/ are discovered, applied, and re-applied when they change.docker compose exec worker ak apply_blueprint /blueprints/custom/app-inventory.yaml.Also capture the shared setup as blueprints: enrollment/recovery flows, the Google source (keep the secret in an env var via !Env), the username policy, and the brand. Then a new staging or prod instance can be rebuilt from git.
Recommended layout:
| Environment | authentik instance | Notes |
|---|---|---|
| Production | auth.example.com |
Real users, real Google client |
| Staging / Dev | auth.staging.example.com |
Separate Google OAuth client, separate DB. Developers log in here from localhost. |
For each app, create a separate authentik application per environment, e.g. inventory in prod and inventory-dev in staging. The dev application's provider lists the local redirect URIs:
Strict: http://localhost:5173/auth/callback
Strict: http://localhost:5173/
or a regex like ^http://localhost:\d+/auth/callback$ (escape dots in real hostnames: \.). Never put localhost redirect URIs on production providers.
Developers then only change .env:
VITE_OIDC_AUTHORITY=https://auth.staging.example.com/application/o/inventory-dev/
VITE_OIDC_CLIENT_ID=inventory-dev
OIDC_ISSUER=https://auth.staging.example.com/application/o/inventory-dev/
OIDC_CLIENT_ID=inventory-dev
Fully offline development: run authentik locally with the same compose.yml on http://localhost:9000, and apply the same blueprints. Google login needs http://localhost:9000/source/oauth/callback/google/ on a dev Google client. Google allows http://localhost redirect URIs.
Tests: in FastAPI tests, override the dependency so they don't need authentik:
app.dependency_overrides[get_claims] = lambda: TokenClaims(
sub="test-user", email="t@example.com", name="Test", groups=["inventory-admins"]
)
CORS for the token endpoint: the SPA calls authentik's token endpoint directly from the browser. authentik allows cross-origin calls from origins that appear in the provider's redirect URIs. If you see CORS errors, the SPA's origin is usually missing from the redirect URIs.
authentik is now a single point of failure for every app, so treat it as production infrastructure.
# nightly cron
docker compose exec -T postgresql pg_dump -U authentik -d authentik -Fc > /backups/authentik-$(date +%F).dump
tar czf /backups/authentik-files-$(date +%F).tgz data custom-templates certs .env
Test a restore on staging periodically. .env contains AUTHENTIK_SECRET_KEY, and restores won't work fully without it.
AUTHENTIK_TAG in .env, then run docker compose pull && docker compose up -d.GET /-/health/live/ and GET /-/health/ready/ on port 9000. Point your uptime monitor at them.9300 (/metrics).default-authentication-mfa-validation stage's "Not configured action", or bind a policy so that members of authentik Admins must set up TOTP/WebAuthn. Consider offering MFA or passkeys to all users./var/run/docker.sock mount from the worker service in compose.yml. It gives the container root-equivalent access to the host./if/admin/) by network or VPN if feasible.The server and worker containers are stateless and can scale horizontally behind a load balancer. PostgreSQL becomes the component that needs HA: a managed Postgres, or Patroni. authentik also provides an official Helm chart for Kubernetes.
For apps handling sensitive data, let FastAPI be a confidential OIDC client. The browser only gets an HttpOnly session cookie. This protects against token theft via XSS, at the cost of CSRF handling and server-side sessions.
https://inventory.example.com/api/auth/callback.from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
from fastapi.responses import RedirectResponse
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, https_only=True, same_site="lax")
oauth = OAuth()
oauth.register(
"authentik",
server_metadata_url=f"{OIDC_ISSUER}.well-known/openid-configuration",
client_id=OIDC_CLIENT_ID,
client_secret=OIDC_CLIENT_SECRET,
client_kwargs={"scope": "openid email profile", "code_challenge_method": "S256"},
)
@app.get("/api/auth/login")
async def login(request: Request):
return await oauth.authentik.authorize_redirect(request, request.url_for("auth_callback"))
@app.get("/api/auth/callback")
async def auth_callback(request: Request):
token = await oauth.authentik.authorize_access_token(request)
request.session["user"] = dict(token["userinfo"]) # sub, email, name, groups
return RedirectResponse("/")
@app.get("/api/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse(f"https://auth.example.com/application/o/{APP_SLUG}/end-session/")
The SPA then calls /api/... on the same origin with cookies. No OIDC library is needed in the frontend. SvelteKit apps can do the same in hooks.server.ts.
When one FastAPI backend calls another without a user, use the OAuth2 client credentials grant against authentik's token endpoint. authentik supports this with a service account plus app password, or with JWTs from a trusted source. The receiving API validates the token the same way as in section 8, with the aud being the called app. See the authentik docs section "Machine-to-machine authentication".
signoutRedirect() ends the authentik session by running the provider's invalidation flow, and clears the app's local tokens.Everything in sections 5–6 applies to every app automatically. Later you can add, in authentik only:
No app code changes.
In authentik (or via blueprint, section 13):
<slug>-users, <slug>-admins (+ other roles)<slug> with an OAuth2/OIDC provider:https://<app-domain>/auth/callback and https://<app-domain>/openid email profile offline_access<slug>-users to the application (or deliberately leave open)<slug>-dev application on staging with localhost redirect URIsBackend (FastAPI):
pyjwt[crypto], httpx)OIDC_ISSUER=https://auth.example.com/application/o/<slug>/ and OIDC_CLIENT_ID=<client-id>users table with unique auth_sub; use Depends(get_current_user) / require_groups(...)Frontend (React / Svelte):
oidc-client-ts (+ react-oidc-context for React)VITE_OIDC_AUTHORITY, VITE_OIDC_CLIENT_ID, VITE_API_URL/auth/callback route, route guard, Sign in / Sign up / Sign out buttons, link to https://auth.example.com/if/user/Authorization: Bearer <access_token> on every API callVerify:
<slug>-users is denied/api/me returns 401 without a token and 200 with one; admin endpoint returns 403 for non-admins