When the Authorization Check and the Action Look at Different Objects: CVE-2026-15630 in Casdoor

Louis Sanchez July 20, 2026 8 min read

Most authorization bugs are a missing check. Something forgot to ask "are you allowed to do this?" and the answer slipped through. The one we found in Casdoor is more interesting than that, because the check is not missing. It runs. It says yes for a legitimate reason. And then the code does something completely different from what it just approved.

Casdoor is an open-source identity and access management platform — the single sign-on backend a lot of multi-tenant SaaS products run on, and a drop-in SSO provider for internal apps. A single Casdoor instance often holds the accounts, permissions, and federation certificates for many separate customer organizations at once. That makes it exactly the kind of system where a cross-tenant flaw is not a bug in one feature; it is a bug in the thing every tenant trusts to keep them apart.

The short version

CVE-2026-15630 is a cross-tenant authorization bypass (CWE-863/CWE-639) in Casdoor. Its authorization filter decides whether to allow a request by looking at the ?id= in the URL. The controllers behind the filter ignore ?id= and act on the owner and name in the JSON body instead. So an attacker sends one request that points ?id= at something they own and names a victim in another organization in the body. The filter approves; the controller acts on the victim. From a routine org-admin account, that is enough to delete another tenant's users, plant a backdoor admin, and escalate to global admin of the whole instance. CVSS 9.6. There is no patch.

No fix available

This is being published through CERT/CC coordination (VU#889462) because the vendor did not respond during coordination and there is still no patched release. The flaw is live in the current version. If you self-host Casdoor, the mitigations near the end of this post are what you have to work with right now.

Two identifiers, one request

Casdoor puts a global authorization filter in front of its API. For any state-changing request, that filter resolves which object you are acting on from the ?id= query parameter in the URL. This is a deliberate design choice, and the code says so in a comment: keying on the URL identifier is meant to stop an attacker from spoofing ownership by injecting a different owner value into the request body. It is a reasonable instinct. The URL is the thing the filter trusts.

The problem is that the controllers behind the filter never got the memo. Look at DeleteUser in controllers/user.go: it does not read ?id= at all. It unmarshals the JSON body, takes the owner and name fields, and calls object.DeleteUser on whatever that names. The object layer underneath does no caller-authorization check of its own. Same story in AddUser, in controllers/permission.go, and across roughly 25 add- and delete- endpoints for users, permissions, invitations, groups, and certificates.

So the filter authorizes one object and the controller acts on another. The authorization decision and the action are looking at two different things in the same request. That gap is the entire vulnerability, and because it lives in the shared request pipeline rather than in one handler, it is systemic.

What the exploit actually looks like

The prerequisite is modest: an authenticated account that is an administrator of any single organization on the instance — IsAdmin = true. Not global admin. Just an ordinary org-admin, the kind of account that on a self-signup instance is not hard to get.

The request carries two identities on purpose:

POST /api/delete-user?id=org-attacker/attacker-admin
Cookie: <attacker session>
Content-Type: application/json

{"owner":"org-victim","name":"victim-admin"}

The ?id= points at a user the attacker legitimately owns, so the filter approves. The body names a user in a different organization, so that is who gets deleted. The server returns 200 OK.

From there it chains into a full tenant takeover with a handful of requests. Delete the victim organization's users, including its administrators, until it is empty. Create a new user in the victim organization with isAdmin: true — a backdoor admin the attacker can log in as. Plant a Casbin permission rule in Casdoor's built-in global-admin organization granting the attacker every action on every resource, which is effective control of the entire deployment. And delete the victim's signing certificates to break their SAML or OIDC single sign-on. No race condition, no user interaction, no cleverness beyond sending the requests. We reproduced the whole chain end to end against a stock deployment with an org-admin account that had no relationship to the victim tenant.

That is why this scores 9.6 with Scope Changed: a compromise scoped to the attacker's own organization reaches straight into resources owned by every other organization on the instance.

For the full technical breakdown — the filter code, the controller code, the CVSS rationale, and the complete endpoint list — see the advisory.
Read the advisory

The comment that explains the whole bug

The thing I keep coming back to is that comment in the authorization filter. Someone thought carefully about body-spoofing. They understood that letting the request body decide what you are acting on is dangerous, and they wrote the filter to key on the URL specifically to prevent it. The reasoning is sound. The filter does its job.

What broke is that the rest of the codebase never adopted the same contract. The filter treats ?id= as authoritative; the controllers treat the body as authoritative; nobody reconciled the two. This is the kind of flaw that no amount of scanning finds, because every individual piece looks correct in isolation. The filter is a correct filter. The controller is a correct controller. The vulnerability only exists in the space between them — in the assumption each one makes about the other that turns out not to hold.

You find bugs like this by tracing a single request all the way through and asking, at each hop, "is the object being checked here the same object that gets acted on down there?" When the answer is no, you have a desync. That is a question a tool cannot ask for you, because it requires knowing what the two layers were each supposed to mean.

What operators can do right now

There is no Casdoor setting that turns this off. The flaw is in how the request is handled, and no configuration makes the filter and the controllers agree. So the honest guidance is about shrinking the attack surface and watching for the signature, not closing the hole.

The strongest thing you can do is tighten who holds org-admin. Exploitation needs one authenticated IsAdmin = true account in any organization, so cut org-admins down to what you actually need, kill any self-signup or flow that hands out admin automatically, and put MFA on the admins you keep. Until this is patched, treat every org-admin account as if it can reach into other tenants, because it can.

If you run a reverse proxy or WAF, you can add a tripwire on the /api/add-* and /api/delete-* endpoints that flags any request where the body's owner disagrees with the organization in ?id= — that mismatch is the exploit, plainly. A few cautions: global admins in the built-in organization legitimately write across tenants, so exempt them or you will break them; Casdoor's own admin UI does not always send ?id= the way a naive rule expects, so run it in log-only mode against real admin traffic first; and cover the whole add/delete family, because a rule that only guards delete-user leaves two dozen other endpoints open. Treat it as detection more than prevention.

Detection is cheap and worth doing even where blocking is too risky. Alert on the ?id=-versus-body mismatch, on new Casbin rules in the built-in organization (especially any Allow rule with * resources or actions), on a user in one organization creating admins or deleting users in another, and on any sudden drop in a tenant's user count.

The disclosure

The broader lesson

Authorization is not one decision; it is a chain of them, and every hop in the chain makes an assumption about the hop before it. This bug is what happens when two hops disagree about something as basic as which object the request is even for. The check was not weak. It was answering a different question than the one the action asked.

When you review access control, it is not enough to confirm that a check exists. You have to confirm that the thing being checked is the thing being done. Those are not the same claim, and the distance between them is where findings like this live.

For the full technical detail — the filter and controller code, the CVSS vector rationale, the complete affected-endpoint list, and the mitigations — see the full advisory.

Authorization gaps hide between the layers

The most dangerous access-control flaws are not missing checks — they are checks that authorize one thing while the code does another. Those do not show up in scans. They show up when someone traces a request end to end and asks whether the object authorized is the object acted on. That is the same question we ask when testing web applications and APIs for clients across the Charlotte, NC area and nationwide.

Get a Quote