@Xeoul
Aegis case study

Writing ·

Running a security service and its policy engine in the browser

The Aegis live demo has no backend. The real FastAPI app and the real Cedar policy engine run in your tab as WebAssembly. Here's how, what had to change, and how CI keeps the browser's decisions identical to the server's.

Why not just host it?

Aegis is a just-in-time access service: you ask for access in plain English, Cedar policies decide, risky access waits for a second person, and grants expire on their own. It's a backend project, and backend projects are hard to show. A hosted demo costs money, sleeps on free tiers, and is one more internet-facing service holding (fake) security data. Every visitor also shares it, so one person's experiments become the next person's starting point.

Running it in the browser removes all of that. There's nothing to pay for or keep awake, nothing for a stranger to attack, and every visitor gets a private sandbox that resets on reload. The catch: it has to be the real code. A JavaScript imitation of the policy engine would demo something I didn't build.

The shape of it

Pyodide is CPython compiled to WebAssembly. The page loads it, installs FastAPI, Pydantic and SQLAlchemy (pure-Python wheels, or Pyodide's own builds), writes the unmodified app/ package into Pyodide's virtual file system, and imports it. SQLite works as-is, in memory.

There's no network in between, so a small bridge turns each API call into an ASGI call. It builds the scope dictionary a web server would, feeds the body through receive, and collects the response from send:

async def call(method, path, token=None, body=None):
    scope = {"type": "http", "method": method, "path": path,
             "headers": [(b"authorization", f"Bearer {token}".encode())], ...}
    await app(scope, receive, send)   # the real FastAPI app
    return json.dumps({"status": status, "body": payload})

Everything above that line is untouched: routing, dependency injection, Pydantic validation, authentication, the audit chain. The page signs in for a token and calls endpoints exactly like any other client, and the "API calls made" panel at the bottom of the demo lists every one.

What a browser can't do

Four things needed a stand-in, and each one is small and explicit in demo/bridge.py:

  • Threads. FastAPI runs plain def endpoints in a thread pool. The browser has no threads, so anyio.to_thread.run_sync is patched to call the function inline.
  • The scheduler. APScheduler needs threads too. The page calls the scheduler's own sweep function on a timer instead, and a demo clock lets you skip an hour ahead to watch grants expire.
  • The LLM. A public page can't hold an API key, so the demo uses Aegis's built-in keyword parser. The parser's output is untrusted either way; the policy engine makes the decision.
  • The policy engine. This was the interesting one.

Cedar, twice

On the server, Aegis uses cedarpy: Cedar's Rust core compiled as a CPython extension. Native extensions don't load in Pyodide unless someone builds them for it, and nobody has built this one. But Cedar ships an official WebAssembly build, @cedar-policy/cedar-wasm, from the same Rust code.

So the page loads that, exposes it as a single JavaScript function, and the bridge installs a small Python module named cedarpy that implements the four functions Aegis's evaluator calls, forwarding each one as JSON:

// demo.js
globalThis.aegisCedar = (fn, arg) => JSON.stringify(cedar[fn](JSON.parse(arg)));

# bridge.py: a stand-in for the native module
def is_authorized(request, policies, entities, schema=None):
    answer = json.loads(js.aegisCedar("isAuthorized", json.dumps({...})))
    ...

The evaluator doesn't know the difference. The policies, the schema and the decisions are the real ones. The build pins cedar-wasm by version and checks the download against the npm registry's SHA-512 integrity hash, and the page's Content Security Policy allows WebAssembly ('wasm-unsafe-eval') without allowing eval.

Proving the two agree

"Same engine" is a claim, so CI checks it. On every push, a smoke test builds the static site, boots it under Node with Pyodide and cedar-wasm exactly as the page does, and walks the main flows: an allowed request, denials with the expected guardrails named, MFA step-up, an approval, prompt-injection flagging, SCIM deprovisioning, and audit tampering being caught.

It also runs Aegis's policy test suite, a file of requests and the outcome each must get, which the server's CI runs against cedarpy. Under WebAssembly it has to pass too.

It caught a real difference. Cedar returns the policies behind a decision as a set, and the native and WebAssembly builds iterate that set in different orders. Nothing was wrong with the decisions, but the reasons shown to the user came out in a different order, and an exact-match assertion failed. The fix was in Aegis, not the test: the evaluator now sorts reasons by where each policy appears in the policy file, so both builds explain a decision the same way.

What it costs

  • About 20 MB on first load (Python, the packages and Cedar), then cached. It boots in roughly 10 to 20 seconds the first time.
  • No persistence. Each tab is its own database and starts over on reload, which is the point for a demo.
  • Some features need a server to be honest about. The live AWS credential broker and the Claude parser only run server-side, and the demo says so instead of faking them.

What I'd reuse

An ASGI app is just an async function, so any FastAPI or Starlette service can run this way with a bridge of a few dozen lines. The work goes into the edges, such as threads, schedulers, native extensions and secrets, and keeping each workaround small and named makes it easy to explain. Most importantly, if you swap a component, have CI prove the swap is faithful. A demo that quietly disagrees with production is worse than no demo.

Try the live demo, read the case study, or browse the demo source.