Centralised Identity with authentik

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.


Table of contents

  1. Target architecture
  2. Install authentik
  3. Put it behind HTTPS (reverse proxy)
  4. Initial configuration
  5. Enable self sign-up (enrollment) and password recovery
  6. Add "Sign in with Google"
  7. Register a web application in authentik
  8. Backend: FastAPI integration
  9. Frontend: React integration
  10. Frontend: Svelte integration
  11. Authorization: groups, roles and access control
  12. Migrating existing fastapi-users apps
  13. Configuration as code (blueprints) for new apps
  14. Local development and environments
  15. Operations: email, backups, upgrades, monitoring, security
  16. Alternatives and advanced topics
  17. Checklist: onboarding a new application

1. Target architecture

[Diagram]

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:

Section 16 covers a more locked-down alternative for sensitive apps: the Backend-for-Frontend (BFF) pattern, where tokens never reach the browser.


2. Install authentik

2.1 Requirements

Recent authentik releases need only PostgreSQL. Redis is no longer part of the stack, and the official compose file runs just postgresql, server and worker.

2.2 Download the compose file and generate secrets

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.

2.3 Configure email (SMTP)

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>"

2.4 Start it

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

3. Put it behind HTTPS (reverse proxy)

OIDC and Google both require HTTPS in production. Use whichever proxy you already run.

Option A: Caddy (automatic Let's Encrypt)

/etc/caddy/Caddyfile:

auth.example.com {
    reverse_proxy 127.0.0.1:9000
}

Option B: Nginx

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.


4. Initial configuration

4.1 Create the first admin

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:

  1. Directory → Users → Create: create a personal admin account for each administrator.
  2. Add those users to the authentik Admins group.
  3. Log in as your personal admin and set up MFA on it (user settings → MFA devices → TOTP or WebAuthn).
  4. Deactivate akadmin, or keep it as a break-glass account with a long random password in your vault.

4.2 Branding

System → Brands → authentik-default → Edit:

The 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.

4.3 Understand the key concepts (5-minute read)

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 Google
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

5. Enable self sign-up (enrollment) and password recovery

A fresh install has no sign-up. The login page only authenticates existing users. We add:

5.1 Create the enrollment flow from the bundled example blueprint

  1. Customization → Blueprints → Create.
  2. Name: enrollment-email-verification.
  3. Path: pick example/flows-enrollment-email-verification.yaml from the dropdown.
  4. Save. Then click Apply (the run icon) on the new blueprint.

This creates a flow (slug similar to default-enrollment-flow) with these stages:

  1. Prompt: username, name, email, password, password repeat
  2. User write: creates the user, inactive until verified
  3. Email: sends a verification link, and the user is activated when they click it
  4. User login: signs them in and continues to the app they came from

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):

5.2 Create the recovery flow

Do the same with blueprint path example/flows-recovery-email-verification.yaml, and apply it.

5.3 Wire them into the login page

  1. Flows & Stages → Stages → default-authentication-identification → Edit:
    • User fields: Username and Email, so users can type either.
    • Enrollment flow: the enrollment flow from 5.1. This adds a "Need an account? Sign up." link.
    • Recovery flow: the recovery flow from 5.2. This adds a "Forgot username or password?" link.
    • Sources: leave this empty for now. Section 6 adds Google.
  2. System → Brands → 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:


6. Add "Sign in with Google"

6.1 Google Cloud Console

  1. Go to https://console.cloud.google.com/ and create a project, e.g. our-apps-auth.
  2. APIs & Services → OAuth consent screen (also called "Google Auth Platform → Branding / Audience"):
    • User type:
      • Internal if only your Google Workspace users should log in.
      • External for any Google account. Publish the app ("In production"), otherwise only listed test users can log in.
    • App name: e.g. "Our Apps". Add a support email and developer contact.
    • Authorized domains: example.com, the parent domain of auth.example.com.
    • Scopes: 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.
  3. APIs & Services → Credentials → Create credentials → OAuth client ID:
    • 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.

6.2 Create the Google source in authentik

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.

6.3 Skip the username prompt for Google sign-ups

By default, a first-time Google user is asked to choose a username. To use their email instead:

  1. Customization → Policies → Create → Expression Policy
    • Name: source-enrollment-username-from-email
    • Expression:
      email = 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
  2. Flows & Stages → Flows → 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.
  3. Edit that stage binding and make sure "Evaluate when stage is run" is enabled. The policy needs 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

6.4 Show the Google button on the login page

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.


7. Register a web application in authentik

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.

7.1 Create application + provider (wizard)

Applications → Applications → Create with provider:

  1. Application
    • Name: Inventory (whatever users should see)
    • Slug: inventory. This becomes part of the issuer URL, so pick it carefully and don't change it.
    • Launch URL: https://inventory.example.com, used by authentik's user dashboard at /if/user/.
  2. Provider type: OAuth2/OpenID Connect
  3. Provider configuration:
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/callback
Strict: 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>/
  1. Bindings (access control): add the group(s) allowed to use the app. See section 11. If you add no bindings, every authentik user can access the app.

7.2 Values you'll hand to developers

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.

7.3 What's in the access token

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_verified is 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.


8. Backend: FastAPI integration

The backend's job:

  1. Verify the bearer token.
  2. Map sub to a local user row, creating it just-in-time on first request.
  3. Enforce roles and groups.

It does not register, log in, hash passwords or send emails any more.

8.1 Dependencies

pip install "pyjwt[crypto]" httpx pydantic-settings
# remove: fastapi-users, fastapi-users-db-sqlalchemy

8.2 Settings

# .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"]

8.3 Token verification (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

8.4 Local user table + just-in-time provisioning (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_at only when it's older than a few minutes, or drop it.

8.5 Using it in routes

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.

8.6 Things the backend no longer does

8.7 Share the code

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.


9. Frontend: React integration

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

9.1 Environment

# .env
VITE_OIDC_AUTHORITY=https://auth.example.com/application/o/inventory/
VITE_OIDC_CLIENT_ID=inventory
VITE_API_URL=https://api.inventory.example.com

9.2 Auth config (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 }),
};

9.3 Provider (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>,
);

9.4 Routes, guard and callback (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.

9.5 Calling the API

// 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.


10. Frontend: Svelte integration

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

10.1 Auth module (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>;
}

10.2 App shell (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.)


11. Authorization: groups, roles and access control

Use two layers:

  1. Can this person use app X at all? authentik enforces this at login using application bindings.
  2. What can they do inside app X? FastAPI enforces this from the groups claim, or from custom claims.

11.1 Naming convention

For each app <slug> create groups in Directory → Groups:

Put org-wide groups (e.g. staff) above them with group parents if useful.

11.2 Gate the application

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:

11.3 Enforce in FastAPI

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.

11.4 Admin delegation

Give app owners permission to manage only their app's groups in authentik (RBAC: Directory → Roles) instead of making them authentik admins.


12. Migrating existing fastapi-users apps

Plan the migration per app. Overview:

  1. Create the authentik application for the app (section 7).
  2. Import users into authentik (below).
  3. Add 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.
  4. Deploy the new auth code (sections 8–10). On a user's first authentik login, get_current_user finds their old row by email and fills in auth_sub. From then on, matching is by sub only.
  5. After all active users have migrated, drop hashed_password, is_verified and similar columns, and remove fastapi-users.

12.1 Importing users

Options, from simplest to most seamless:

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.

12.2 Cut-over tips


13. Configuration as code (blueprints) for new apps

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.

13.1 Template: one blueprint per app

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.

13.2 Applying blueprints

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.


14. Local development and environments

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.


15. Operations: email, backups, upgrades, monitoring, security

authentik is now a single point of failure for every app, so treat it as production infrastructure.

Backups

# 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.

Upgrades

  1. Read the release notes for each version between yours and the target, especially the "Breaking changes" sections.
  2. Upgrade one minor version at a time (e.g. 2026.6 → 2026.8), staging first.
  3. Back up, then change AUTHENTIK_TAG in .env, then run docker compose pull && docker compose up -d.

Monitoring

Security hardening

High availability (later)

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.


16. Alternatives and advanced topics

16.1 BFF pattern (tokens never in the browser)

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.

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.

16.2 Service-to-service calls

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".

16.3 Logout semantics

16.4 Other login options, configured once for all apps

Everything in sections 5–6 applies to every app automatically. Later you can add, in authentik only:

No app code changes.


17. Checklist: onboarding a new application

In authentik (or via blueprint, section 13):

Backend (FastAPI):

Frontend (React / Svelte):

Verify: