SkillsAboutProjectsInsightsContact
DevOps13 min read · 2,294 words

From JWT Hand-Rolled to Federal-Grade: Refactoring the OAOS Auth Layer with Keycloak and OpenFGA

TL;DR The OAOS backend inherited a manual JWT-minting auth system wired to a database that no longer existed. We ripped it out and rebuilt it as a Keycloak + OpenFGA gateway; proper authentication, fine-grained authorization, compliance-ready from the ground up. This post covers the architecture decisions, the refactor, and the debugging gauntlet that stood between us and a green system.

ChrisFull-Stack Engineer & Digital Marketer
Sep 6, 2026Last updated Sep 6, 2026
Share

The Problem with "Roll Your Own Auth"

Most projects start the same way: someone needs login, they write a function that checks a password, mints a JWT, and ships it. It works. It's fast. And it quietly accumulates every security burden that enterprise auth solutions exist to solve.

The OAOS project — an enterprise application requiring PII, PFI, and federal-grade access control — had exactly this problem. The original backend was a password-in, JWT-out system wired to a users database that had since been decommissioned. The middleware handling auth was manual, undocumented, and brittle. When the database it depended on stopped existing, the auth layer became a liability.

That's not a codebase problem. That's an architecture problem. You don't patch it — you replace it.

The replacement: Keycloak handling authentication, OpenFGA handling authorization, FastAPI as the gateway between them, PostgreSQL for user data, and Next.js consuming all of it from the frontend. The goal was an auth system you could put in front of a federal auditor and not sweat.

Why These Tools Specifically

Before getting into the work, it's worth explaining why this stack and not something simpler.

Keycloak is Red Hat's open-source Identity and Access Management platform. It handles authentication via OpenID Connect and OAuth 2.0, provides a full admin UI, supports multi-factor authentication, user federation, and — critically — it's self-hosted. For a system with PII and PFI requirements, keeping identity infrastructure under your own control is not a nice-to-have. Managed auth services like Auth0 or Cognito are fine for most applications. They're harder to justify when your compliance requirements include knowing exactly where user credentials live and who can touch them.

OpenFGA is the open-source implementation of Google Zanzibar — the authorization system that powers Google Drive's sharing model. It handles fine-grained authorization: not just "is this user an admin," but "does this specific user have write access to this specific resource." That's the difference between coarse RBAC (role-based access control, which Keycloak handles) and fine-grained authorization (which OpenFGA handles). PII and federal compliance requirements frequently require the latter — audit trails need to show not just that a user had a role, but that a user had access to a specific record.

FastAPI sits between Keycloak and the application data, validating tokens, enforcing authorization decisions from OpenFGA, and exposing clean endpoints to the Next.js frontend. It's the gateway — the piece that everything flows through.

The architecture looks like this:

Next.js Frontend

FastAPI Gateway
↙ ↘
Keycloak OpenFGA
(Who are you?) (What can you do?)

PostgreSQL
(Your actual data)

FastAPI gateway architecture Keycloak authentication OpenFGA authorization PostgreSQL
FastAPI gateway architecture Keycloak authentication OpenFGA authorization PostgreSQL

Step One: Burn the Old File Structure

The first thing that had to go was the file structure. The old layout had auth logic scattered across the project with no clear separation of concerns — route handlers touching auth middleware touching database calls in a way that made it impossible to reason about what any single file was responsible for.

The refactored structure separates concerns by function:

app/
├── core/
│ ├── config.py ← environment config, pulled from .env
│ ├── security.py ← token validation, Keycloak public key fetch
│ └── database.py ← SQLAlchemy engine and session management
├── auth/
│ ├── keycloak.py ← OIDC discovery, token introspection
│ ├── openfga.py ← authorization check client
│ └── dependencies.py ← FastAPI dependency injection for auth
├── users/
│ ├── router.py ← user endpoints
│ ├── models.py ← SQLAlchemy ORM models
│ ├── schemas.py ← Pydantic request/response schemas
│ └── service.py ← business logic, database queries
├── api/
│ └── v1/
│ └── router.py ← top-level API router
└── main.py ← app entrypoint

Every layer has one job. core/ owns infrastructure. auth/ owns the identity and authorization integrations. users/ owns user domain logic. Nothing crosses those boundaries without going through a defined interface.

This separation also solved a specific bug that almost caused an hour of lost debugging: two auth.py files were coexisting in different directories, both getting imported, both partially loaded, and neither one actually working. Python's import system was resolving them non-deterministically depending on how the module path was constructed. The refactor gave each file an unambiguous home and an unambiguous import path.

Step Two: Wiring Keycloak

Keycloak runs as a separate service. The FastAPI gateway needs to be able to validate tokens that Keycloak issues — which means it needs Keycloak's public key to verify the JWT signature.

The connection happens through OIDC discovery:

# core/security.py
import httpx
from jose import jwt, JWTError
from app.core.config import settings

async def get_keycloak_public_key() -> str:
url = (
f"{settings.KEYCLOAK_URL}/realms/"
f"{settings.KEYCLOAK_REALM}/protocol/openid-connect/certs"
)
async with httpx.AsyncClient(verify=settings.CA_CERT_PATH) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()

async def decode_token(token: str) -> dict:
jwks = await get_keycloak_public_key()
try:
payload = jwt.decode(
token,
jwks,
algorithms=["RS256"],
audience=settings.KEYCLOAK_CLIENT_ID,
)
return payload
except JWTError as e:
raise ValueError(f"Token validation failed: {e}")

Note verify=settings.CA_CERT_PATH — that's the self-signed TLS piece. Keycloak is running with a certificate signed by a custom CA, not a public CA. By default, httpx and requests will reject that connection with a certificate verification error. Pointing them at the CA certificate file instead of disabling verification entirely (verify=False) is the correct approach — you get TLS without shipping your production service with certificate verification turned off.

Getting that CA cert path right was one of the infrastructure gremlins. The cert existed. The path was wrong. The error message was an SSL handshake failure with no indication that the cert itself was valid and the path was the actual problem. Fix: explicit path verification in the config, logged on startup.

The DNS Problem

Before the TLS issue was even reachable, there was a DNS problem. The FastAPI container and the Keycloak container were on different subnets. ICMP was redirecting cross-subnet traffic in a way that made the containers appear reachable from the host but not from each other.

The FastAPI service was trying to reach keycloak.internal and getting connection refused — not because Keycloak wasn't running, but because the DNS name wasn't resolving inside the container network. The fix was explicit service discovery through Docker's internal DNS with a shared network definition, and confirming resolution with a curl from inside the FastAPI container before trusting that the application-level connection would work.

The lesson: always verify network connectivity at the transport layer before debugging the application layer. A curl from inside the container saves an hour of reading FastAPI stack traces.

Step Three: Connecting to the Users Database

The old system was wired to a database that no longer existed. The new system needs its own user records — not for authentication (Keycloak owns that) but for application-level user data: preferences, profile information, audit records, anything the application needs beyond what Keycloak's identity token provides.

SQLAlchemy handles the ORM layer:

# core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings

engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
)

AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)

async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

pool_pre_ping=True is not optional for a system like this. It tells SQLAlchemy to verify the database connection is alive before using it from the pool. Without it, stale connections from pool recycling produce cryptic errors that look like database failures but are actually connection management failures.

The user model maps to a users table that stores application-specific data keyed to Keycloak's sub claim — the unique identifier Keycloak assigns to each user. This is the bridge between Keycloak's identity layer and the application's data layer: Keycloak owns who you are, PostgreSQL owns what the application knows about you.

# users/models.py
from sqlalchemy import Column, String, DateTime
from sqlalchemy.orm import DeclarativeBase
import uuid

class Base(DeclarativeBase):
pass

class User(Base):
__tablename__ = "users"

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
keycloak_sub = Column(String, unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=False)
created_at = Column(DateTime, nullable=False)
updated_at = Column(DateTime, nullable=False)

Step Four: The Get Current User Function

This is the function that ties the whole auth flow together — the FastAPI dependency that every protected endpoint will call to resolve who is making the request:

# auth/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token
from app.core.database import get_db
from app.users.service import get_or_create_user

bearer = HTTPBearer()

async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
db: AsyncSession = Depends(get_db),
):
token = credentials.credentials
try:
payload = await decode_token(token)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)

keycloak_sub = payload.get("sub")
email = payload.get("email")

if not keycloak_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing subject claim",
)

user = await get_or_create_user(db, keycloak_sub=keycloak_sub, email=email)
return user

Every protected endpoint gets this dependency injected automatically. FastAPI extracts the Bearer token from the Authorization header, validates it against Keycloak's public key, extracts the sub and email claims, and either fetches or creates the corresponding user record in PostgreSQL. If any step fails, a proper 401 is returned.

What's still being finalized: CA cert verification in the decode path to handle the self-signed certificate cleanly in all deployment environments, and the get_or_create_user service function's full implementation with proper conflict handling for simultaneous first-logins.

The Debug Gauntlet (Everything That Broke Along the Way)

Refactors of this scope don't go smoothly. Here's the complete list of gremlins encountered and how they were resolved — because the debugging is where the actual learning lives:

FastAPI Keycloak debugging errors DNS TLS import path resolution
FastAPI Keycloak debugging errors DNS TLS import path resolution
ProblemCauseFix
Bracketed paste modeTerminal inserting escape sequences around pasted textdisabled bracketed paste in zsh config
Rogue tilde~ in a path expanding incorrectly in a subprocess callReplaced with explicit $HOME expansion
DNS resolution failureFastAPI and Keycloak containers on different subnetsShared Docker network with explicit service names
Cross-subnet ICMP redirectsDocker network topology routing traffic through the hostFixed bridge network config, verified with curl from inside container
Self-signed TLS rejectionhttpx rejecting Keycloak's self-signed certPassed CA cert path to verify= parameter
zsh ! expansionzsh treating ! in a command string as a history eventWrapped command in single quotes
Two colliding auth.py filesBoth files in different directories, both getting partially importedRenamed files according to new structure with unambiguous module paths
Empty engine fileSQLAlchemy engine initialized before config was loadedMoved engine creation behind a function call, lazy initialization
Import path errorsRelative imports breaking when running from project root vs module rootStandardized all imports to absolute paths from app/
Bugs I've run into

Nine separate failure modes, all independent, all requiring a different debugging approach. This is what infrastructure work actually looks like.

What Compliance Actually Requires

The goal isn't just working auth. It's compliant auth — specifically:

PII compliance means user data is handled with explicit access controls, audit logging on who accessed what and when, and the ability to produce a complete access record on demand. OpenFGA's relationship model gives you the granularity to log not just "admin accessed user records" but "this specific user accessed this specific record at this timestamp." That's the audit trail regulators ask for.

PFI compliance (Payment Financial Information) means sensitive financial data is gated behind authorization checks that are verifiable, not just configured. Keycloak roles establish coarse access (this user is a payment processor). OpenFGA tuples enforce fine-grained access (this payment processor can access records for these accounts, not all accounts).

Federal-grade means the underlying primitives are sound: token validation uses public key cryptography (RS256, not HS256), TLS is enforced on all internal service communication, credentials are never embedded in application code, and the identity provider is self-hosted rather than delegated to a third party.

None of this is exotic. It's the application of well-established standards — OpenID Connect, OAuth 2.0, Zanzibar-model authorization — to a compliance context that requires them explicitly.

PII PFI federal grade authentication compliance Keycloak OpenFGA RBAC stack
PII PFI federal grade authentication compliance Keycloak OpenFGA RBAC stack

Current State and What's Next

The system is green. Keycloak is authenticating. FastAPI is validating tokens. PostgreSQL is receiving user records. OpenFGA is deployed and the authorization model is defined.

Two things remaining before the auth layer is fully production-ready:

CA cert verification finalization — the current deployment works with the cert path configured. The remaining work is ensuring the cert is properly distributed across all service environments (local dev, staging, production) and that the verification path is tested explicitly in the CI pipeline rather than assumed.

Get current user completion — the dependency injection is written and working. The get_or_create_user service function needs its edge case handling finalized: simultaneous first-login race conditions, handling the case where Keycloak provides an email that already exists in the database under a different sub, and proper error propagation to the frontend.

After those two pieces, the frontend integration is next — wiring the Next.js client to send Keycloak tokens with every request and handling the token refresh flow cleanly.

Conclusion

Rolling your own auth feels like the fastest path when you're moving fast. It is — until the database it depends on disappears, or an auditor asks you to explain your session invalidation strategy, or you need to answer "who accessed this record and when" with something other than hope.

Keycloak and OpenFGA aren't more complex than the alternative. They're more explicit. Every decision about who can authenticate, how tokens are validated, and who can access what is documented in a configuration that can be reviewed, audited, and tested — not buried in custom middleware that leaves with the engineer who wrote it.

The refactor was the harder path in week one. It's the easier path for every week after.

FAQ

What's the difference between Keycloak and OpenFGA in this stack? Keycloak handles authentication — proving who you are. OpenFGA handles authorization — determining what you're allowed to do. Keycloak issues the token. OpenFGA answers the question "can this token holder do this specific thing to this specific resource." They solve different problems and are designed to work together.

Why not just use Keycloak's built-in authorization? Keycloak includes basic RBAC and some authorization policy support. OpenFGA's relationship-based model is significantly more expressive and better suited to the fine-grained, resource-level access control that PII and federal compliance requirements demand. Keycloak handles the identity side well. OpenFGA handles the authorization side better at scale.

Why FastAPI instead of Django or Express? FastAPI's async-first architecture, Python type hints with Pydantic validation, and automatic OpenAPI documentation make it well-suited for a gateway layer that needs to do a lot of async I/O (calling Keycloak, calling OpenFGA, querying the database) without blocking. The type safety also helps catch integration bugs at development time rather than runtime.

Is self-hosted Keycloak harder to maintain than a managed service? Yes, operationally. You own the upgrades, the backups, and the availability. For systems with PII and federal compliance requirements, the tradeoff is often worth it: full control over where credentials are stored, what versions are running, and how the system is configured. Managed services abstract those decisions, which is convenient until a compliance auditor asks about them.

Found this useful?

Share it with someone building something real.

Original Written By

Chris Norton
Full-Stack Engineer · Digital Marketer · Freelancer

I build things that ship and write about what I learn in the process. From DevOps pipelines to email sequences, I care about the full stack — code, copy, and the machinery between.