Production AI Infrastructure

Migrating an Existing App to Heimdall + OpenFGA, One Route at a Time

Part 2: a strangler-fig adoption path for moving a live app to a Zero-Trust gateway and OpenFGA, starting with a single route in shadow mode and expanding without a big-bang rewrite.

15 min read Updated Jun 19, 2026

TL;DR

  • The hard part of the Heimdall + OpenFGA pattern is not the greenfield diagram — it is moving a live app onto it without a scary big-bang cut-over.
  • The safe way is the strangler fig pattern (Martin Fowler): wrap the old system with a facade (your gateway), then migrate one route at a time until the legacy auth is “strangled.”
  • The single most important migration technique is shadow mode: run OpenFGA alongside your existing checks, compare the answers, log the discrepancies, and only enforce once you trust the results.
  • A clean Heimdall trick: shadow with a contextualizer (observe, never blocks), then flip to an authorizer (enforces, fails closed) for the same check.
  • Backfill your existing permissions into OpenFGA as relationship tuples with the fga tuple write --file CLI, and keep them in sync with dual-write during the coexistence window.
  • Make every phase reversible: per-route feature toggles, fail-open during shadow, fail-closed only after validation.
  • This is Part 2. Part 1 builds the architecture from scratch: The Zero-Trust Auth Stack Architects Nod At: Heimdall + OpenFGA. For the OpenFGA modeling details, see OpenFGA for granular permissions.

What You Will Learn Here

  • Why you should never “big-bang” an authorization migration, and what to do instead
  • How the strangler-fig pattern maps onto a gateway + PDP rollout
  • A concrete adoption ladder you can follow phase by phase
  • How to run OpenFGA in shadow mode at the app and at the gateway
  • How to backfill existing permissions into OpenFGA as tuples
  • How to keep legacy and OpenFGA in sync during coexistence (dual-write + reconciliation)
  • How to make every step reversible with per-route toggles
  • The migration pitfalls that bite teams, and a checklist to avoid them

Where This Fits

In Part 1 we built the convergent pattern on a blank canvas:

Client → Gateway (forwardAuth) → Heimdall (PEP + token broker) → OpenFGA (PDP)

                                       └─ mints internal JWT → thin upstream service

That is easy when there is no app yet. The reality is usually different: you have a running service with authentication wired up, permission checks scattered through the code, real users, and zero appetite for a weekend where everything might break.

This article is about getting that app onto the pattern — safely, reversibly, and without asking anyone to trust a flag-day rewrite.

The Principle: Strangler Fig for Authorization

Martin Fowler’s strangler fig pattern is named after a vine that grows around a tree and slowly replaces it. Applied to software, you avoid the high-risk “rewrite everything, then cut over” approach by building the new system around the edges of the old one and migrating functionality gradually until the legacy can be retired.

The pattern has three phases:

  • Transform — build the new capability (gateway + PDP) alongside the old.
  • Coexist — both run in parallel; a facade routes requests. You can pause or reverse at any time.
  • Eliminate — once everything is migrated and validated, retire the legacy path.

Authorization is an ideal candidate for this, because the gateway is a natural facade and authorization decisions are easy to run in parallel and compare. The new system can shadow the old one and prove itself on real traffic before it is ever in charge.

        BEFORE                         DURING (coexist)                AFTER
   ┌───────────────┐            ┌──────────────────────┐         ┌───────────────┐
   │  App with      │           │  Gateway (facade)     │         │  Gateway      │
   │  inline auth   │           │   ├─ migrated routes ─┼─► PDP   │   └─ all routes├─► PDP
   │  checks        │  ───────► │   └─ legacy routes ───┼─► app   │               │
   └───────────────┘           └──────────────────────┘         └───────────────┘
                                   one route moves at a time         legacy auth gone

Starting Point: The App You Actually Have

Be honest about the baseline. A typical service looks like this:

Client ──[login token]──► App
                            │  validateSession()
                            │  if (user.role !== "admin" && doc.ownerId !== user.id) → 403
                            │  ...business logic...

                          Database

Two things are tangled together inside the app:

  1. Authentication — “who is this?” (session/JWT validation)
  2. Authorization — “can they do this?” (the scattered if checks)

The migration peels these apart and moves them outward: authentication and decision to the gateway/PDP, leaving the app to do business logic only. We do it one route at a time.

The Adoption Ladder

This mirrors the step-by-step adoption path from the OpenFGA modeling article, but for the whole stack. Climb one rung at a time; you can stop and live on any rung for a while.

Phase 0  Inventory: build the route → permission map
Phase 1  Put the gateway in front, pass-through only (no enforcement)
Phase 2  One route: authenticate at the edge, mint internal JWT, keep app checks
Phase 3  Shadow the decision with OpenFGA (compare, log, do NOT enforce)
Phase 4  Enforce that one route's decision at the gateway (fail closed)
Phase 5  Make the service thin: delete the now-redundant inline check
Phase 6  Expand route by route, service by service
Phase 7  Backfill tuples + add model governance
Phase 8  Decommission legacy auth

Phase 0 — Inventory

You cannot migrate what you have not written down. Produce a route-to-permission map. This is boring and it is the most valuable artifact of the whole project.

RouteMethodToday’s check (in code)Target relationTarget object
/document/:idGETrole==admin OR ownerreaderdocument:{id}
/document/:idDELETErole==admin OR ownerownerdocument:{id}
/documentsGETtenant filterreader (list)document

This table later becomes your Heimdall rules and your OpenFGA model — and it doubles as the spec for shadow-mode comparisons.

Phase 1 — Gateway in Front, Pass-Through

Put the gateway (Traefik, Caddy, NGINX, Envoy) in front of the app and route everything straight through. No enforcement, no decisions. You are just inserting the facade.

Client ──► Gateway ──► App (still does all its own auth)

For the gateway-native version, Heimdall can run with a default rule that simply allows and does nothing — the anonymous authenticator plus a noop finalizer:

# heimdall: pass-through default rule (Phase 1)
default_rule:
  execute:
    - authenticator: anon      # creates a principal, allows everything
    - finalizer: noop          # changes nothing

Goal of this phase: prove the facade is stable, watch latency, confirm nothing breaks. You have changed the topology without changing behavior.

Phase 2 — One Route: Authenticate at the Edge, Mint the Token

Pick one low-risk route (a GET is ideal). For just that route, move authentication to the gateway: validate the login token and mint the internal JWT. Keep the app’s own authorization check in place. Now two things validate identity; that redundancy is intentional and safe.

Client ──[login JWT]──► Heimdall
                          │ jwt_auth: validate login token
                          │ create_jwt: mint internal JWT

                        App (still runs its own if-checks)
# rules: one route, authn + token only (Phase 2)
- id: doc_read
  match:
    routes:
      - path: /document/:id
    methods: [GET]
  forward_to:
    host: upstream:8081
  execute:
    - authenticator: jwt_auth
    - finalizer: create_jwt

The app keeps working exactly as before. You have just proven the token-minting handshake on a real route.

Phase 3 — Shadow the Decision (the critical rung)

Now bring in OpenFGA, but do not let it block anything yet. This is the technique OpenFGA’s own adoption guidance recommends: run both systems, with the existing system as the source of truth, call OpenFGA in parallel, and log every discrepancy.

There are two clean ways to shadow.

Option A — shadow inside the app (simplest first move). Your existing check stays authoritative; you add an async OpenFGA call and compare.

async function canRead(user: User, docId: string): Promise<boolean> {
  // Legacy decision — still authoritative
  const legacy = user.role === "admin" || (await isOwner(user.id, docId));

  // Shadow decision — observe only
  fga
    .check({ user: `user:${user.id}`, relation: "reader", object: `document:${docId}` })
    .then(({ allowed }) => {
      if (allowed !== legacy) {
        logger.warn("fga_shadow_mismatch", {
          route: "GET /document/:id",
          userId: user.id,
          docId,
          legacy,
          fga: allowed,
        });
      }
    })
    .catch((err) => logger.error("fga_shadow_error", { err }));

  return legacy; // legacy still decides
}

Option B — shadow at the gateway with a contextualizer. This is a neat Heimdall property: a contextualizer calls OpenFGA’s /check and enriches the request, but unlike an authorizer it does not fail the pipeline when the answer is false. So you can record the decision (in the minted JWT or logs) while still letting the request through. When you are ready to enforce, you switch that same call from a contextualizer to an authorizer.

# Phase 3: observe with a contextualizer (does NOT block)
- id: doc_read
  match:
    routes:
      - path: /document/:id
    methods: [GET]
  forward_to:
    host: upstream:8081
  execute:
    - authenticator: jwt_auth
    - contextualizer: openfga_check_shadow   # logs/embeds the decision, never blocks
      config:
        values:
          store_id: <store_id>
          model_id: <model_id>
          relation: reader
          object: document:{{ .Request.URL.Captures.id }}
    - finalizer: create_jwt
Client ──► Heimdall ──┬─ jwt_auth (enforced)
                      ├─ openfga shadow check (observe only) ──► OpenFGA
                      └─ mint JWT ──► App (legacy check still authoritative)

                                   log mismatch if app ≠ OpenFGA

Stay on this rung until the mismatch rate is effectively zero. Every mismatch is a bug in your model, your tuples, or your understanding of the legacy rule — and you want to find those while OpenFGA is harmless.

Shadow mode is the cheapest insurance in the entire migration. The discrepancy log is your test suite against production traffic.

Phase 4 — Enforce at the Gateway (Fail Closed)

When the route’s shadow results match, promote the check from contextualizer to authorizer. Now OpenFGA’s decision is enforced at the edge, and the rule fails closed: if OpenFGA denies (or is unreachable), the request is rejected before it ever reaches the app.

# Phase 4: enforce with an authorizer (fails closed)
  execute:
    - authenticator: jwt_auth
    - authorizer: openfga_check        # was a contextualizer in Phase 3
      config:
        values:
          store_id: <store_id>
          model_id: <model_id>
          relation: reader
          object: document:{{ .Request.URL.Captures.id }}
    - finalizer: create_jwt

The app still has its inline check at this point. That is fine — defense in depth for one more phase. Wrap the enforcement in a per-route toggle so you can revert to shadow instantly if something looks wrong.

Phase 5 — Make the Service Thin

Only now, with the gateway proven to enforce correctly, delete the redundant inline check from the app for that route. The service goes from this:

app.get("/document/:id", async (req, res) => {
  const user = await requireSession(req);
  if (user.role !== "admin" && !(await isOwner(user.id, req.params.id))) {
    return res.sendStatus(403);            // ← delete this block
  }
  res.json(await getDocument(req.params.id));
});

to this:

// Gateway already authorized + minted an internal JWT.
app.get("/document/:id", verifyInternalJwt, async (req, res) => {
  res.json(await getDocument(req.params.id));
});

verifyInternalJwt just validates Heimdall’s signature/issuer/expiry (see the downstream-verify section in Part 1). The route is now genuinely thin: no roles, no ownership logic, no authorization branches.

Phase 6 — Expand, Route by Route

Repeat Phases 2–5 for the next route, then the next, prioritizing by risk and value. The strangler fig grows: more routes behind the gateway, fewer inline checks in the app. You get value continuously, and you can pause whenever a higher priority shows up.

week 1:  GET /document/:id            ✅ enforced + thin
week 2:  DELETE /document/:id         ✅ enforced + thin
week 3:  GET /documents (list)        🔵 shadowing
week 4:  /projects/* ...              ⏳ inventory done

Phase 7 — Backfill Tuples + Governance

For shadow mode to match, OpenFGA needs the facts — your existing permissions, expressed as relationship tuples. Export them from your current source of truth (DB rows, role tables, ACLs) and import with the FGA CLI:

# tuples.json generated from your existing permissions tables
fga tuple write --file tuples.json \
  --store-id <store_id> \
  --model-id <model_id> \
  --max-tuples-per-write 40 \
  --max-parallel-requests 8

A tiny exporter that turns DB rows into tuples:

// Build tuples.json from existing ownership/role data
const tuples = [];
for (const doc of await db.documents.findMany()) {
  tuples.push({ user: `user:${doc.ownerId}`, relation: "owner", object: `document:${doc.id}` });
}
for (const m of await db.orgAdmins.findMany()) {
  tuples.push({ user: `user:${m.userId}`, relation: "admin", object: `organization:${m.orgId}` });
}
await fs.writeFile("tuples.json", JSON.stringify(tuples, null, 2));

Verify with fga tuple read and re-run shadow mode. Governance to put in place around now:

  • Pin the model id. OpenFGA models are immutable; every write creates a new version. Always pass --model-id / authorization_model_id so production is deterministic.
  • Backwards-compatible model changes. To rename or split a relation, add the new relation, copy tuples over, deploy, then remove the old one — never rename in place.
  • One tuple writer. Decide which service is allowed to write tuples so relationships do not drift.

Phase 8 — Decommission Legacy Auth

When every route is enforced at the gateway and thin in the service, remove the legacy authorization code, the dual-write shims, and the old role-check helpers. The fig has strangled the tree. Keep authentication’s issuer (your IdP) — you are retiring scattered authorization, not login.

Coexistence: Keeping Two Systems in Sync

The trickiest window is Phases 3–7, when legacy and OpenFGA both exist. Two practices keep them honest:

Dual-write. Any time a permission changes (a share, a role grant, an ownership transfer), write it to both the legacy store and OpenFGA in the same operation. Otherwise OpenFGA drifts and your shadow mismatches explode.

async function shareDocument(docId: string, targetUserId: string, role: Role) {
  await db.documentShares.create({ docId, targetUserId, role }); // legacy
  await fga.write({
    writes: [{ user: `user:${targetUserId}`, relation: role, object: `document:${docId}` }],
  }); // OpenFGA
}

Periodic reconciliation. Dual-write can still miss edges (failures, out-of-band DB edits, on-prem upgrade/downgrade cycles). Run a scheduled job that re-derives tuples from the source of truth and diffs them against OpenFGA, fixing drift. Treat one-time migration scripts as untrustworthy on their own.

Make Everything Reversible

The whole point of strangler-fig is that you can stop or back out. Bake that in:

  • Per-route toggle. A config flag (shadow | enforce | legacy) per route lets you promote and demote rungs without a deploy.
  • Fail-open while shadowing, fail-closed after. During Phases 1–3 a PDP outage must not break traffic. From Phase 4, a PDP outage should deny — that is the Zero-Trust contract.
  • Keep the inline check one phase longer than you think. Delete it (Phase 5) only after the route has run enforced and clean for a real soak period.

Pitfalls to Avoid

  • Big-bang cut-over. Flipping the whole app to OpenFGA at once throws away the entire safety net. One route at a time.
  • Enforcing before shadowing. If you have not compared decisions on real traffic, you will lock out real users. Shadow first, always.
  • Forgetting dual-write. A backfill without ongoing sync goes stale within hours. Mismatches will look like model bugs but are really drift.
  • Tuple explosion. “The best tuple is the one you don’t have to write.” Derive permissions from the model where possible instead of materializing every grant.
  • Renaming relations in place. Models are immutable and apps may target old versions; always migrate relations backwards-compatibly.
  • Trusting the app’s inline check forever. The endgame is a thin service. If you never delete the legacy checks, you have two authorization systems to maintain, not one.

Migration Checklist

PhaseDone when
0 InventoryEvery route mapped to a target relation + object
1 FacadeGateway in front, pass-through, latency acceptable
2 Authn at edgeOne route validates token + mints internal JWT; app unchanged
3 ShadowOpenFGA called in parallel; mismatch rate ≈ 0 in logs
4 EnforceGateway fails closed for the route; toggle to revert exists
5 ThinInline check deleted; route soaked clean
6 ExpandNext routes climbing the same ladder
7 BackfillTuples imported, model pinned, dual-write + reconciliation live
8 DecommissionLegacy authorization code removed; only the IdP remains

Final Mental Model

A migration is not a moment, it is a gradient:

legacy decides  ──►  both decide (compare)  ──►  OpenFGA decides  ──►  service is thin
   Phase 2            Phase 3 (shadow)            Phase 4 (enforce)     Phase 5

Each route walks that gradient independently, on real traffic, with a toggle back at every step. Nothing about the destination is novel — it is the same convergent stack from Part 1. What makes it survivable is refusing to get there in one jump. Let the new system prove itself in the shadows, route by route, until the old one quietly fades away.

Source List