Technology Sep 06, 2026 · 6 min read

Here's my app's access control, the passwords, and a curl command. Go break it

I moved my app's authorization below the application code. Here is the live demo, the passwords, and the curl commands to try to break it. Every B2B app I built before this one put authorization in the application code. A decorator here, an if user.role == 'admin' there, a query that remembers to a...

DE
DEV Community
by Srikanth reddy kasa
Here's my app's access control, the passwords, and a curl command. Go break it

I moved my app's authorization below the application code. Here is the live demo, the passwords, and the curl commands to try to break it.

Every B2B app I built before this one put authorization in the application code. A decorator here, an if user.role == 'admin' there, a query that remembers to add WHERE tenant_id = ?. It works, right up until one endpoint forgets, and then it is a data leak with a CVE number.

The thing that bothers me is that this is re-litigated in every codebase. The rule "policyholders must never see the fraud score" is a business fact. It ends up encoded across a serializer, three endpoints and a React component, and nothing structurally prevents the fourth endpoint from getting it wrong.

So I built the other version: the rules are a declaration, and the server applies them in front of the database, before application code runs.

That is an easy claim to make and a cheap one to fake. So here is a live app, the passwords, and the commands to check.

The app

Sentinel is an insurance claims system — policies, claims intake, adjuster review with fraud scoring, documents, and separate portals for policyholders and staff.

It runs two insurers on one deployment: Northwind Mutual and Cascade Assurance. Four logins, all with the password Password123!:

Tenant Role Email
Northwind Mutual Policyholder member@sentinel.insure
Northwind Mutual Claims team claims@sentinel.insure
Cascade Assurance Policyholder member@cascade.insure
Cascade Assurance Claims team claims@cascade.insure

The data is fictional and gets reset. Please don't put anything real in it.

There are two separate things to try to break: fields (can a policyholder get the fraud score?) and tenants (can Northwind get Cascade's book of business?).

Test 1: the field the client never receives

Adjusters see fraud_score and internal_notes on every claim. Policyholders must never see either. Log in as the policyholder and read the claims:

API=https://api.supero.dev

login() {
  curl -sS "$API/api/v1/auth/login" -H 'Content-Type: application/json' \
    -d "{\"domain_name\":\"supero-apps\",\"project\":\"sentinel\",
         \"email\":\"$1\",\"password\":\"Password123!\"}"
}

TOKEN=$(login member@sentinel.insure | jq -r .auth.access_token)

curl -sS "$API/api/v1/crud/supero-apps/sentinel:claim" \
  -H "Authorization: Bearer $TOKEN" \
| jq '{claims: .result_count,
       fraud_score:    [.results[] | has("fraud_score")]    | any,
       internal_notes: [.results[] | has("internal_notes")] | any}'
{ "claims": 9, "fraud_score": false, "internal_notes": false }

Now change one word — member to claims — and run it again:

{ "claims": 9, "fraud_score": true, "internal_notes": true }

Same endpoint, same query, no filter parameter. The fields are not in the JSON for the policyholder. Not display: none, not dropped by the frontend — never sent. Open devtools on the live demo and they are not in the network tab either.

Test 2: the tenant boundary

Both claims-team accounts are tenant_admin — the most privileged role in the app. Read claims as each of them:

for EMAIL in claims@sentinel.insure claims@cascade.insure; do
  TOKEN=$(login "$EMAIL" | jq -r .auth.access_token)
  echo -n "$EMAIL -> "
  curl -sS "$API/api/v1/crud/supero-apps/sentinel:claim" \
    -H "Authorization: Bearer $TOKEN" \
  | jq -c '[.results[].claim_number] | sort | .[0:3]'
done
claims@sentinel.insure -> ["CLM-202047","CLM-203980","CLM-204120"]
claims@cascade.insure  -> ["CLM-CA-88214","CLM-CA-88301","CLM-CA-88355"]

Northwind numbers its claims CLM-…, Cascade uses CLM-CA-… — deliberately, so a leak would be obvious on sight rather than needing a UUID comparison. Neither admin can reach the other's rows. There is no tenant_id in the request for anyone to tamper with; the tenant is bound to the session at login.

Where the rules come from

The login response includes the policy the server issued for that session. The client does not choose it, and cannot alter it — it is shown to the client so the UI knows what to render:

login member@sentinel.insure | jq '.policy.entities["sentinel:claim"]'
{
  "entity": "sentinel:claim",
  "can_create": true,
  "can_read": true,
  "can_update": true,
  "can_delete": false,
  "filter_field": "owner_username",     // row scope
  "filter_match": "$user.name",
  "hidden_fields": ["fraud_score", "internal_notes"],   // field scope
  "readonly_fields": ["status", "state", "created_by", "tenant_uuid", ...]
}

default_access for this role is none; every entity the role can touch is listed explicitly. The claims-team policy is the same document without the filter_field and hidden_fields lines.

The part I find genuinely useful: look at the schema that defines a claim and note what is not there. fraud_score is an ordinary integer attribute. Nothing in the data model marks it as secret. The sensitivity is declared separately, in the access policy, and applied by the server. Which means the answer to "who can see the fraud score?" is one file, not a grep across the codebase.

The honest part

If you go read the app's own source, you will find role checks in it:

c.isAdmin ? h('button', { onClick:  }, '🛡 Claims console') : null,

I am not going to pretend those don't exist, because you would find them in a minute and then disbelieve the rest. They decide what the UI draws. They are not what protects the data, and that is the whole point: if I deleted every one of them and shipped the claims console to policyholders, the console would render with the fraud score column empty, because the server still won't send the field. The UI check is a convenience. The enforcement is underneath it.

That is the difference I was after. In the version I have written five times before, that isAdmin check was the security boundary.

What this does not show

Being straight about the edges, because you'd find them anyway:

  • No field-level encryption here. The platform supports encrypted fields; this app doesn't use them, so don't read the above as evidence about encryption at rest.
  • The demo logins are shared and public, and they can write. The data is fictional and reset periodically.
  • This is one app's configuration, not an audit. I have shown you the two axes I claimed. I have not shown you that every endpoint in the platform is correct, and you should not take a blog post as proof of that.
  • The UI code is hand-written. The schemas, the API, the access enforcement and the admin console are generated from declarations; the custom screens in this app are a file I wrote by hand. I have seen "N bytes in, M bytes out" claims made about codebases like this one and I'm not going to make one.

Try it

The live demo is at sentinel.supero.live — sign in as a policyholder and as the claims team on the same claim and watch the payload change.

If you'd rather read the schemas and the access policy than run curl, the app is on GitHub under MIT: github.com/supero-platform/supero-apps (apps/insurance/sentinel).

I'm curious where people land on this. The objection I expect — and half agree with — is that moving authorization into a declarative layer trades one failure mode for another: you can't grep for it, and a misconfigured policy is as bad as a missing if. If you've run something like this in production, I'd like to hear which way that went.

DE
Source

This article was originally published by DEV Community and written by Srikanth reddy kasa.

Read original article on DEV Community
Back to Discover

Reading List