DEV Community

Gaberial Sofie
Gaberial Sofie

Posted on

Attack Surface of Hand-Rolled Auth: Consolidating Keycloak FastAPI Role Based Access Control Across 40 Endpoints

The exposure

Our FastAPI service enforced authorization the way a lot of services quietly do: every route decoded its own JWT, pulled the role claim, and made a local if user.role == 'admin' decision. Across roughly 40 endpoints and three engineers, that is not one access-control mechanism — it is 40 independent enforcement points, each of which can drift, and each of which is a place where a control can simply be absent.

The failure mode showed up in review: an admin-only endpoint with no role check at all. It had shipped weeks earlier and passed every test, because the tests asserted the happy path and nobody wrote a test for a control that was never there. The important thing is not the individual mistake. The important thing is the property of the system — authorization with no single point of enforcement fails open by omission, and omission is invisible. "The check is supposed to be there" is a hope, not a control.

Threat model

Before changing anything, it helps to be precise about what we were actually defending against, because that framing dictates which controls are worth the effort.

  • Broken access control — the top item on the OWASP Top 10 (A01:2021) — is exactly the class of defect we had: an authenticated user reaching an endpoint their role should not permit. Distributed, hand-written checks maximize the surface for this.
  • Privilege escalation via a missing or wrong check. A guest or user token reaching an admin route because one of 40 checks was omitted, misspelled ("admin" vs "administrator"), or copied from the wrong template.
  • Inconsistent token validation. Every service hand-parsing JWTs means signature verification, aud, iss, and expiry handling can differ per route. Any route that validates loosely widens the blast radius of a leaked or forged token.
  • No audit trail. When someone asks "who can reach this?", the answer being a grep across the codebase is itself a finding — you cannot attest to a control you have to reconstruct by reading source.

The residual-risk question that drove the design: if one endpoint is written wrong, how much does that cost us? With per-route checks, one mistake is one breach. The goal was to make the default path safe so that the cost of a single mistake drops toward zero.

Controls we added

The governing principle is centralizing the authorization decision into a single, hardened authority and making the safe integration the easy one — defense in depth backed by least privilege, rather than 40 bespoke gates. Keycloak, the open-source identity and access management server, becomes that authority: it owns identity, roles, and token issuance, and every service is reduced to validating what Keycloak signed. It speaks OpenID Connect, the identity layer over OAuth 2.0, so the integration is standard rather than bespoke, and the Keycloak securing-applications guide is explicit about how a relying party should validate issued tokens. For the FastAPI wiring itself I cross-referenced the official docs against a thorough third-party walkthrough of the realm/client/role setup →, which screenshots each console step.

Control 1 — a realm as the trust boundary

A realm is an isolated space for users, roles, and clients. In the admin console (typically http://localhost:8080 locally; older builds used the /auth context path) you open the dropdown top-left, choose Add realm, and name it. Treat the realm as a security boundary, not a namespace: everything inside shares an issuer and a signing key, and nothing outside it is in scope. That boundary is what lets you reason about blast radius at all.

Control 2 — a confidential OIDC client, scoped deliberately

Under Clients → Create client, set a client ID and keep the type OpenID Connect. Two settings carry real security weight:

  • Client authentication. Off for public clients (browser/SPA) that cannot hold a secret; on for confidential backends that can. Our FastAPI backend is confidential, so this is on and it holds a client secret. Getting this wrong is a genuine exposure, not a preference.
  • Valid Redirect URIs. This is an allow-list, and it is a control, not a convenience field. An overly broad redirect URI is an open-redirect and token-exfiltration vector, so it is set to the exact callback the app uses — no wildcards, no trailing-slash ambiguity.

Control 3 — three roles, least privilege by construction

Under Roles → Add Role we defined the minimum viable set:

  • admin — full administrative access; the smallest possible population.
  • user — day-to-day application access, no configuration or user management.
  • guest — read-only access to non-protected data.

Roles are then assigned per user under Role Mappings. The model is: define once, assign narrowly, enforce centrally. The Keycloak Server Administration Guide is the canonical reference for the realm, client, and role screens if you want the exhaustive version.

Control 4 — a single enforcement point that fails closed

This is the control that actually retires the exposure. The fastapi-keycloak library wraps the OIDC handshake so that protecting a route is one dependency injection rather than hand-written parsing:

from fastapi import FastAPI, Depends
from fastapi_keycloak import FastAPIKeycloak, OIDCUser

app = FastAPI()
keycloak = FastAPIKeycloak(
    server_url="http://localhost:8080/auth",
    client_id="your-client-id",
    client_secret="your-client-secret",
    realm="your-realm",
    callback_uri="http://localhost:8000/callback"
)

@app.get("/protected")
def protected_route(user: OIDCUser = Depends(keycloak.get_current_user)):
    return {"message": f"Hello, {user.username}"}
Enter fullscreen mode Exit fullscreen mode

The security-relevant property is that Depends(keycloak.get_current_user) validates the token signature and claims against Keycloak and, on failure, rejects the request — it fails closed. The endpoint receives a typed, already-validated OIDCUser; role gates read from that object. There is no route-local JWT parsing left to get subtly wrong, and the token-validation logic is one implementation rather than 40.

The migration: reducing risk without a big-bang cutover

Replacing authorization on a live service is itself a high-risk change, so we sequenced it to keep the system defensible at every step:

  1. Run Keycloak in parallel. Both auth paths coexisted for a sprint. New endpoints used the Keycloak dependency; existing routes kept their checks. Because both sides spoke JWTs, a request carried a token either could interpret, so there was no flag-day.
  2. Mirror existing roles first. We recreated admin, user, and guest to match the old names exactly, so claims mapped one-to-one and we changed the enforcement mechanism without simultaneously changing the policy — one variable at a time.
  3. Migrate highest-risk routes first. The admin endpoints, where a missing control is a real incident, moved first. Each migration was a small, reviewable PR: delete the manual parsing, inject the dependency, gate on the role.
  4. Delete the legacy path only after the last route moved. A dormant second auth system is standing attack surface. Once every route was on Keycloak, the home-grown decoder was removed entirely rather than left "just in case."

Verifying the control, not just shipping it

Centralizing authorization is only worth anything if you can demonstrate it holds. Because the user authenticates against Keycloak, Keycloak issues signed tokens, and the service only validates them, the service never sees a credential — the sensitive material lives in one hardened system and everything downstream verifies signatures and reads claims. That separation is what makes the control auditable: "who can reach this?" becomes a query against role mappings in the console instead of a code archaeology exercise. We paired that with negative tests — a user token against an admin route must return 403 — so the absence of a check is now something a test can catch rather than something that ships silently.

Residual risk / what we're still watching

Centralizing the authorization decision removes a whole class of omission defects, but it does not make the system risk-free — it relocates the risk, and honesty about where it went is the point.

  • Keycloak is now a high-value single point. The blast radius of a realm compromise or a signing-key leak is the whole platform. That trades many small risks for one concentrated one, which is the right trade only if the concentrated point is hardened, patched, and monitored. We track Keycloak security advisories directly rather than waiting for a dependency bump.
  • Redirect-URI and audience configuration remain security-sensitive. A later loosening of an allow-list, or a mismatched aud mapping, quietly reintroduces exposure. These live in reviewed configuration, not ad-hoc changes.
  • Public vs confidential client drift. A client accidentally set public that needs to hold a secret is a real weakness; we assert client type in configuration review.
  • Token lifetime and revocation. Short access-token TTLs bound the window a leaked token is useful; we are still tuning refresh-token handling and session invalidation on role change, since a demoted user holding a valid token is a genuine residual gap until the token expires.
  • Group and multi-service rollout. We are moving to Keycloak groups for team-level role assignment and extending the same realm to a second and third service. Each new relying party is new attack surface that has to be onboarded with the same scrutiny, not rubber-stamped.

The net effect is that a single wrong endpoint is no longer one breach away, and the cost of the average authorization mistake has dropped from "incident" to "failing test." For a service still hand-rolling role checks, consolidating onto an IAM authority is among the highest-leverage reductions in attack surface available — provided you then treat that authority as the critical asset it has become.

Sources & further reading

Top comments (0)