# Gating Access with Bifrost: Nine Calls, Three Refusals, One Trap

> Source: <https://dev.to/earlgreyhot1701d/gating-access-with-bifrost-nine-calls-three-refusals-one-trap-46h4>
> Published: 2026-09-09 01:44:34+00:00

I work in the California courts. Courtroom operations, county level.

Part of that job is deciding who can see what in our case management system. Not in the abstract. Which permissions a job carries, who gets assigned to it, and what happens to that assignment when someone moves.

A courtroom clerk, a court reporter, and an interpreter can all be working the same case and need to see different parts of it. Some of what is in that file is public record. Some of it is not, and the line between them does not follow the file. It runs through it.

So when the team behind [Bifrost](https://www.getmaxim.ai/bifrost), an open source AI gateway from Maxim AI, reached out to me about a sponsored piece on scoping permissions and data access control at their gateway, I did not read it as a security topic. I read it as the thing I do on Tuesdays.

Bifrost is [on GitHub](https://github.com/maximhq/bifrost/), Apache 2.0, and you run it yourself. So I did. I built three roles, scoped each one to a different set of models, and then spent an afternoon trying to get past my own rules.

It held. Every call I expected to be refused was refused, at the gateway, before it reached the provider, and the refusal landed in the log with the role name attached. The scoping works.

What follows is how I know that, which took longer than getting it working. Plus one screen in the setup where I nearly fooled myself.

Here is the scoping I work with, next to the scoping I built for this test.

| Role | What the job needs in the building | Scope I gave it at the gateway | Daily limit | 
|---|---|---|---|
| interpreter | Calendar. Names, parties, dates, hearing types. | routine model only | $0.05 | 
| reporter | All of that, plus minute orders with notes, decisions on motions, in camera hearings. | routine, standard | $1.00 | 
| clerk | A great deal, because the clerk is auditing the case live while the proceeding happens. | routine, standard, restricted | $5.00 | 

The clerk row is the one that decides the rest. A clerk is not senior to an interpreter. The access runs the other way from what an org chart would suggest, because it follows what the function requires in the moment. If a clerk cannot see something, court stops.

Mapping permissions to titles instead of functions is where I have watched this go sideways. Nobody gets handed the keys to everything, and nobody gets permissions granted one at a time either. You define the function, attach what the function requires, and put people in it. When someone changes jobs you move them into the other role, and their access changes with them. Nobody goes back through that person's account granting and revoking one permission at a time.

That has a name in software. Role-based access control, RBAC, and underneath it the rule that decides how much any role gets: [least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html). Grant the minimum access the job requires and nothing beyond it. In AWS it is an IAM policy. In my building it is which staff member can open which parts of a case.

I had been doing both for years before I knew either had a name.

Courts are an odd place to practice this, because most of what we hold is public by design. That inverts the usual setup, where everything starts closed and you open what you must. Ours starts open, and California says so directly. [Rule 2.550(c)](https://courts.ca.gov/cms/rules/index/two/rule2_550): "Unless confidentiality is required by law, court records are presumed to be open."

Which makes the exceptions the job. And the rule governing those exceptions will sound familiar to anyone who has written an access policy. When a court seals something, [Rule 2.550(e)(1)](https://courts.ca.gov/cms/rules/index/two/rule2_550) requires the order to be narrowly tailored, to seal only the portions that need protection, and to leave every other portion in the public file. Not the case. The portions.

Some categories run the other direction and start closed. A [juvenile case file is confidential](https://courts.ca.gov/cms/rules/index/five/rule5_551) by default.

Narrowly tailored. Only what requires protection. Leave the rest open. That is least privilege in a different vocabulary, sitting in the Rules of Court.

In a simple AI application, your code may talk directly to a provider like OpenAI or Anthropic. Each one has its own keys, its own limits, its own way of failing.

Bifrost sits in the middle. Your app talks to Bifrost, Bifrost talks to the providers. One address, many providers behind it. It is written in Go, and the [docs are here](https://docs.getbifrost.ai/overview).

Because everything passes through it, the gateway is the natural place to decide who is allowed to do what. That is the part I wanted to poke at.

The failure it is built to prevent is unglamorous. One API key pasted into a group chat, shared by fourteen people, nobody sure who spent what, and no way to cut off one person without breaking it for everyone. I have watched the same shape play out with a shared login.

Bifrost calls the thing you hand out a [virtual key](https://docs.getbifrost.ai/features/governance/virtual-keys). It is an identity carrying its own permissions and its own spending limit, and it is not a provider credential. Your real OpenAI key stays inside the gateway. Nobody downstream ever holds it.

That structure is what gives least privilege something to work with. If everyone shares the one real key, there is no privilege to be least about.

One thing to sort out before going further, because I tripped on it. Bifrost has two different access layers with similar names, and only one of them is what I tested.

| Layer | Scopes | Where it lives | 
|---|---|---|
| Virtual keys, under Governance | What an application or consumer can reach. Models, providers, budgets, rate limits. | Open source | 
| The feature Bifrost calls [RBAC](https://docs.getbifrost.ai/enterprise/rbac) | What a human administrator can do inside Bifrost itself. View logs, create providers, reveal keys. | [Enterprise](https://docs.getbifrost.ai/enterprise/rbac) | 

The first row is this article. I was asked to write about RBAC, and the layer I could run on my own machine is governance, which is role-based in the everyday sense without being the feature carrying that label. The difference matters if you read the brief and go looking for a roles screen that is not in your build.

I gave the three models access tiers rather than price labels, because in a least privilege setup what matters about a resource is how far down the list of people it should reach. Routine is the one anybody doing the work can use. Restricted is the one you need a reason for. They also get more expensive going down, which is why the spending limits move with them.

The plan was nine calls. Every role against every model. Six should go through, three should get turned away. The three refusals are what I was there for. Until something gets denied, the scoping is a claim I have not tested.

The repo is [Heimdall](https://github.com/earlgreyhot1701D/heimdall), named for the watchman who stands at the rainbow bridge and decides who crosses. Bifrost is the bridge. Somebody has to be checking.

*Figure 1. The expected access matrix. Each role made one request to each model. Bifrost allowed six requests and refused three at the gateway, before any provider call.*

I did not write the code for this test, which is the usual arrangement in my builds. It works when I can read what comes back. It works badly when the thing I am supposed to evaluate is a wall of shell script scrolling past at speed.

So before any of this ran, Claude wrote the plan and Kiro built to it, with one requirement on top. Every result had to reach me in plain English.

Every script prints what it expects to happen before it runs. Then what happened. Then whether those two agree, in a line that says MATCH or MISMATCH. I do not have to know what correct looks like. The harness tells me what it predicted, and I read three lines and know whether something is off. The rule I put in the spec was that a mismatch stops everything. Nobody quietly fixes it.

Two files carry the evidence, and the split between them is the whole trust mechanism. `raw_output.log` holds every request and response verbatim, never edited and never trimmed. `RUN_REPORT.md` is the plain English version, and every claim in it cites a line in the log. One file I can read, one file I can check it against.

We also built it in phases, and only the last one cost money. Phase A built the entire harness with no keys and no container running, and every script had a dry-run mode that printed the request it would send and executed nothing. Phase B added the key and made three direct calls to prove the models were reachable. Phase C was the live run.

Phase A caught three things before a key existed.

**The self-check was writing to the evidence file.** The logging helpers were writing unconditionally, so a dry run that is supposed to touch nothing was quietly appending to `raw_output.log`. That file is the proof behind every claim in this article. If it had slipped through, the file that proves the results would itself have been written to by something other than a real call.

**A check was keyed to a string a rename broke.** One check counts the nine matrix calls by matching a printed line that said `badge=`. When we renamed badge to role, the line became `role=` and the matcher would have counted zero calls. A check that reads zero and reports PASS is worse than no check, because it looks like coverage and provides none.

**A guard fired that was not a problem.** `git check-ignore .env` came back "fatal: not a git repository," and my first read was alarm about my key. It was not that. There was no repo yet, so nothing could commit anything. The report explained the difference instead of stamping FAIL and moving on, and that distinction is the whole reason for the plain-English rule. "The guard fired" and "there is a problem" are not the same event, and only the explanation tells them apart.

Every one of those was the harness catching itself rather than anything about Bifrost. Before I had tested the subject at all, the tooling had found three ways my own evidence could have been quietly wrong.

There is a fourth version of the same failure and it kept recurring. The port collision, the tier rename, the `badge=` matcher. Each time a check had a copy of a value written into it, and the value moved. The fix was the same shape every time: key the check to the source of truth, not to a copy.

I caught the agents on two more things, and they were the same kind of thing. Claude handed me phase three instructions while phase two was still running, which would have had me starting Docker before knowing my key worked. And the spec told me to keep a notes file but never told the build agent to prompt me for entries, so the surprises would have gone unrecorded until I sat down to write and could not remember them.

Neither is a story about a model making things up. Both are about sequence and intent, which is the part I hold. The agents did what they were asked. Nobody asked for the note prompt, and nobody told the planner to wait.

Here is the screen I said nearly fooled me. It happened while I was creating the first role, before any of the above was built.

I gave the interpreter role a five cent daily budget and thought I was finished. Then I looked at the row underneath.

**Access & rate limits: All keys · All models · No rate limits.**

The key I had just built to reach one model could reach all three.

There are two separate controls sitting one above the other, and only one of them is scoping.

| Control | What it does | What it does not do | Default | 
|---|---|---|---|
| Model budgets | Caps what a model may spend | Say anything about which models are reachable | No cap | 
| Access & rate limits | Decides which models the key can reach | Cap spending | **All models** | 

The default in that second row is the finding. Access starts open, and the control that reads like scoping is the one above it.

Count what is on that one form: model budgets, provider budget, a virtual key budget, rate limits in two places, allowed models, blocked models. Six ways to constrain a key, in three sections, and only one of them controls access. I said "lord" out loud.

Here is what would have happened if I had missed it. All nine calls come back allowed. The expected table shows six mismatches. The obvious next move is to go hunting for a bug in the harness, which is correct, while the real problem sits in a config screen I already closed.

It is a naming and placement choice rather than a broken feature. It is also the difference between a key you believe is restricted and one that is.

The fix is one field. In **Access & rate limits**, remove the "All Models" chip and add the models the role should reach. The summary line then reads `1 models` for interpreter, `2 models` for reporter, `3 models` for clerk, and that line is the fastest way to confirm the scope took.

Nine calls, run in WSL against the container.

| Role | routine | standard | restricted | 
|---|---|---|---|
| interpreter | allowed | refused | refused | 
| reporter | allowed | allowed | refused | 
| clerk | allowed | allowed | allowed | 

Six allowed, three refused, matching the expected table declared before the run. The three refused cells are the result. Everything above the diagonal is just the gateway working normally.

Every refusal was HTTP 403 with this body, verbatim:

```
{"type":"model_blocked","is_bifrost_error":false,"status_code":403,
"error":{"message":"Model 'gpt-5.6-terra' is not allowed for this virtual key"},
"extra_fields":{"routing_info":{},"provider":"openai",
"original_model_requested":"gpt-5.6-terra","resolved_model_used":"gpt-5.6-terra",
"request_type":"chat_completion"}}
```

Six for six and three for three on the first attempt, from a harness an agent built to a plan another agent wrote. I have learned the hard way that sometimes I assume what I am building is green, and it is green, but it is not the behavior I expected. Passing and correct are different things.

Four things already protected that result. The expectations were declared before the run and came from my own config decisions, so the script could not retrofit them. The denial text says "not allowed for this virtual key," and OpenAI has no concept of a virtual key, so that string could not have come from the provider. Every claim in the report cites a log line I can open myself. And the negative control was still ahead of me.

None of those distinguishes a harness reporting what happened from one reporting what it expected. Only one thing does.

So I broke it on purpose.

I hand-added the standard model to the interpreter role in the Bifrost UI that was running locally, and left the script's expected table alone, still predicting DENY. Then reran.

| Run | interpreter reaching for the standard model | Line in the log | 
|---|---|---|
| 1 | 403 refused | 294 | 
| 2, after I opened the scope by hand | 200 allowed | 649 | 
| 3, after I closed it again | 403 refused | 1021 | 

On run 2, the harness reported MISMATCH on that one call and named it. It caught me changing the rules behind its back. If it had been reading from its own expected table instead of from what came back, it would have said MATCH and I would never have known.

I was apprehensive watching it. If it had not tested as expected, it would have been me back to the drawing board. Really back to the drawing board.

All three states sit side by side in the append-only log, and the expected table still reads DENY for that cell.

Then the negative control, which is the only test in the run whose answer I knew in advance. A negative control is a check that is supposed to fail. If it succeeds, something is wrong with your setup rather than with the thing you are testing.

I took a call that had just returned 200, stopped the Bifrost container, and ran the exact same call:

```
curl: (7) Failed to connect to localhost port 8090 after 0 ms: Could not connect to server
```

Restarted the container. Same call. 200 again.

Alongside that: the client only ever held a `sk-bf-` value, which cannot authenticate to OpenAI, yet allowed calls returned real completions. Responses carried `Server: fasthttp` and a family of `X-Bifrost-` headers no provider sends. And the refusals used gateway vocabulary the provider has no concept of.

Five independent things would have had to lie consistently. That is not proof. It is the same standard I would apply to a report from a person, which is the standard I use at work.

Two things I noticed in the detail view of a denied call that I would have missed from the terminal.

Latency 0ms, tokens blank, cost blank. The refused call never left the building. Bifrost turned it back before it reached OpenAI, so it produced no tokens and cost nothing. The boundary holds before the spend happens rather than after. For anyone running this on a budget that matters, and in civic tech it always matters.

The second is smaller and I want to say it plainly. The error log and the dashboard were easier for me to use than Vercel or the AWS console. I use both. In the Bifrost console I found what I needed without hunting, the request detail view put the failure reason in front of me in red, and I did not have to already know the name of the thing I was looking for. That is not a small feature for someone who does not live in these tools.

It is not a performance question, and it is not one I would have thought to ask before this job.

**Does the log record the times someone tried something they were not allowed to do, or only the times it worked?**

The way I think about least privilege, it has two halves. Enforcement, which is the gateway saying no. And evidence, which is being able to show later that it said no, to whom, and when. Without the second half I have a policy rather than a control. I can assert the boundary held. I cannot produce it.

That distinction is not academic where I work. Somebody reaching for a sealed juvenile file and being stopped is not a non-event. It is the event. The stop is the thing we have to produce months later for a person who was not in the room. "Our system would not have allowed that" does not hold up. The record of the refusal does.

The systems I have worked in log outcomes more reliably than they log attempts. A door that locks, versus a door that locks and tells you who rattled the handle.

**First finding** The dedicated [Audit Logs](https://docs.getbifrost.ai/enterprise/audit-logs) feature is enterprise only. It is not in the open source build.

The feature Bifrost calls RBAC sits on that tier too, as the earlier table shows. What an open source operator gets instead is the governance layer, which scopes access per key and did everything this test needed. The capability is there. The labels point somewhere else.

So the question changes. What does an open source operator have instead? The answer is the traffic log, and whether a refusal lands there is now the thing worth knowing.

It does. Eight denials across three runs, all present, filtered by Status = Error. And the entry carries the reason, not just the failure:

```
Model 'gpt-6-astra' is not allowed for this virtual key
```

Status ERROR, HTTP 403, timestamped to the second, with a request ID. A log that said only "failed" would have been the disappointing answer, and this is not that.

The first view does not say who was refused. It names the model and the reason, then says "this virtual key" without telling you which one. I had a paragraph half written about that being the gap, because in my world a refusal you cannot attribute is not evidence.

Then I expanded More details, and there it was.

**VIRTUAL KEY:** reporter

A link, at that, straight to the role's configuration. So the record holds all four things I needed. Who was refused, what they asked for, why, and when.

The record also exports. One click, a full JSON file, and it carries more than the screen:

```
"virtual_key_name": "reporter",
"virtual_key_id": "d7afb265-1db1-4c8c-b4fc-db61cef460a2",
"error_details": { "type": "model_blocked", "status_code": 403 },
"plugin.governance": 141.562
```

That last line is the permission check itself, timed, itemized separately from everything else the gateway did. The refusal took 142 microseconds and never reached OpenAI.

The export also has fields I never used. `user_id`, `team_id`, `customer_id`, `business_unit_id`, all null in mine because I set up three roles and nothing above them. The identity model goes wider than what I exercised.

So the gap I was preparing to write about turned out to be a smaller note about placement. The record is complete and producible. "Who" is the first question I ask about a refusal, and it sits one click in.

The answer to what I came in with is yes, with an asterisk about where it lives. Denied access is recorded, the reason is recorded, who was refused is recorded, and the whole thing exports as structured data. What is paywalled is the dedicated audit view, not the underlying record.

Stated plainly so the scope is clear.

**Budget enforcement.** I set a five cent cap and ran two hundred calls at it. The cap never engaged, because two hundred one-word calls cost about $0.0016. That is my test design being wrong by a factor of thirty, not the product failing to enforce. I cannot tell you whether the cap fires.

There is a real observation left in the wreckage of that test. A spend cap is meaningless unless you already know your per-call cost, and the place where you type the number does not tell you what a call costs. I picked five cents because it sounded small. Next to a "hello" it is enormous.

Also untested: rate limits, teams and customers, single sign-on, failover between providers, more than one provider at all, and anything at volume. Whether the record holds up as an audit trail under retention rules, or against someone trying to alter it, I did not test and cannot say.

One person, one laptop, one afternoon. The whole run cost a penny.

I came in with a court question about whether a refusal gets recorded. I am leaving with a smaller and more general one.

Gating access matters for the people using an application, not only for the institution running it. Least privilege is not a security posture you adopt when you get big enough. I think it is hygiene, and it is cheap. Three roles took me under an hour of clicking, and a refused call costs nothing at all.

The part I would tell someone starting this: set the access scope, not just the budget, and then try to get past your own rules before you believe them. A boundary you have not tested is a claim.

I would like to hear if your experience or your thinking differs. The [repo](https://github.com/earlgreyhot1701D/heimdall) has the config, the scripts, the raw log, and the exported record, so you can run it yourself and come to your own read.

Quick context if you are new here. I work in the California courts, running court operations for the county. I started building with AI in July 2025 and I have been learning in public ever since. I do not write the code. I direct, the agents generate, I validate and decide. I build the [Clew Suite](https://earlgreyhot1701d.github.io/Clew-Labs/), a set of civic tech tools for making complex systems easier to inspect. That is the lens I am writing from.

Researched with AI assistance from public documentation in September 2026, and from a run I did myself. Every claim about Bifrost links to a primary source or to a file in the repo. These tools move fast, so check the source before you quote me.

**How this piece was made.** This is a sponsored piece. The Bifrost team suggested the topic and compensated me for the write-up. I ran the test myself, on my own machine, with my own API key, and every result here traces to a log file in the repo. The links to Bifrost's site and docs are documentation links, not affiliate links, and I earn nothing if you click them.

AI Assisted. Human Approved. Powered by NLP.
