Skip to content

13 min read

Schema validation and authorisation

On this page

A tool call arriving at a downstream API passes through several checks, and they answer different questions. Schema validation asks whether the payload has the right fields with the right types. Authentication asks which principal the session is acting as — the identity a request acts under, a user, a service account, an agent's credential, established at the door and never read from the request body. Authorisation asks whether that principal may act on this particular resource. Least privilege asks something narrower still: whether the action is within the scope granted for this task, rather than everything the principal is entitled to in general.

Only the first of those is normally in the path between a model and an API, and it is the one that produces visible results. That combination — a layer that fires constantly and three that may not exist — is how a service ends up believing its tool boundary is defended when the only thing it refuses is malformed input. The request an injection produces is not malformed.

What each layer can and cannot see

It helps to lay the checks out and be exact about which question each one answers, because the failure is a category confusion — treating a layer that checks shape as though it checked authority.

LayerQuestion it answersWhat it cannot see
Schema validationIs the request well-formed and correctly typed?Whose resource it names; whether this caller may touch it
AuthenticationWhich principal is this session?Whether that principal may do this, to this resource
AuthorisationMay this principal act on this resource?Whether the task actually needs this action
Least privilegeIs this within the scope granted for the task?Whether the arguments are well-formed

Read the table down the right-hand column rather than across, and it stops being four independent controls and becomes a chain. What schema validation cannot see is whose resource is named — which is precisely the question authorisation asks two rows down. What authentication cannot see is whether this principal may do this to this resource — which is the row directly beneath it. What authorisation cannot see is whether the task needed the action — which is the row beneath that. Each row's blind spot is the next row's subject, with no gap between them and nothing left over. That is what licenses treating them as an ordered stack rather than as a menu: the layers are not four opinions about safety, they are one question refined four times, and removing any of them leaves a specific, nameable question that no remaining layer asks. It also says exactly what you lose by stopping at the top. You do not lose "some security"; you lose the answer to "whose resource is this", and that is the question the attack is built around.

Each row answers a different question, and no row is a substitute for the one below it. A request can be perfectly formed, from a genuinely authenticated session, and still be an attempt to act on a resource the session's principal does not own — which is the authorisation layer's job to refuse, and which schema validation cannot even perceive. A fuzzer aimed at a tool boundary exercises only the top row. It proves the API rejects malformed input, which is worth knowing and is not the property at risk; the property at risk is enforced two rows down, and a suite of malformed payloads never reaches it, because every payload dies at the top.

The reason schema validation feels like the control is that it is the layer closest to the model, the one that catches the model's most common failure — a garbled argument — and the one that produces the most visible test results. It sits right where the untrusted string arrives, and it does real work. But it is answering "is this a valid refund call," and the injection's request is a valid refund call. Confusing the two is the same category error as the input-sanitiser reasoning from the first chapter of this topic: a control that solves one problem is being asked to cover a different one it was never shaped for.

The confusion is reinforced by where the two kinds of failure show up. A malformed call fails immediately and loudly — a 400, a validation exception, a red line in a test — so the schema layer is constantly, visibly earning its keep, and every one of those rejections quietly builds confidence that the boundary is doing its job. An authorisation failure that is not being checked produces nothing at all: the cross-tenant refund succeeds, returns a 200, and looks exactly like a legitimate refund in the logs, because at the level of shape and type it is one. The layer that is missing is invisible precisely because its absence produces success responses rather than errors. So a team's sense that "the API rejects bad input" is accurate and beside the point; the API rejects malformed input reliably and admits well-formed hostile input silently, and the silence reads as safety.

There is a sharper way to state what is wrong with relying on it, and it is the test to apply to any control you are counting on. Take the two requests the control is supposed to tell apart — the refund the task intended and the refund an injection redirected — and vary the property that distinguishes them, which here is which tenant owns the named account. Then look at what the control outputs. Schema validation outputs valid for both, and it keeps outputting valid however far you move the account id, because the account id is a string of the right type in every case. A control whose output does not change when the property it must discriminate changes is not discriminating at all, and its passing verdict carries no information about that property. It is still a reassuring signal, because it is a real verdict about a real property; it is just a verdict about the shape of the payload, and the shape is constant across the attack.

The request an injection actually produces

Make it concrete with the two shapes that get through. The first is cross-tenant, a tenant being one customer organisation among the many a single deployment serves, each of which is supposed to see only its own data: an injection steers the model to name another tenant's resource. The task was "refund this customer's last order," the session belongs to tenant A, and the injected instruction in a retrieved support note says to issue a credit to acct_7731, which is tenant B's account. The tool call is immaculate:

json
{
  "tool": "refund",
  "arguments": { "account_id": "acct_7731", "amount": 5000, "currency": "USD" }
}

Types correct, fields present, amount in range. Schema passes it. The only defence that stops it is an authorisation check that knows this session is scoped to tenant A and that acct_7731 is not tenant A's, and that check has to run on the server, keyed to the authenticated session, with no input from the model about which tenant it is.

The second shape is excessive scope. The task needs to read one order to summarise it, but the injection produces a call that asks for far more than the task requires — a list of all orders, or a token with write scope when the task only reads. The individual call is valid and authorised in the sense that the agent's credentials technically allow it; it is simply wider than the task justifies. This is where least privilege does the work schema and even coarse authorisation miss: the agent should be holding a capability — a credential that carries its own narrow permissions rather than inheriting the caller's full rights — scoped to this task's needs, so that a call asking for more is refused because this task was never granted it, whatever rights the principal holds in general.

python
# Server-side handler — the model's output is data, not authority.
def handle_refund(session, args):
    # 1. Schema already passed: args are well-formed. That is not enough.
    account = args["account_id"]

    # 2. Authorisation: does THIS session own THIS resource?
    if not owns(session.tenant_id, account):
        raise Forbidden(f"{session.tenant_id} may not act on {account}")

    # 3. Least privilege: was refund-write granted for this task?
    if "refund:write" not in session.task_scopes:
        raise Forbidden("task scope does not include refund:write")

    # 4. Task-shaped limits the model cannot argue past.
    if args["amount"] > session.per_task_refund_cap:
        raise Forbidden("amount exceeds per-task cap")

    return issue_refund(account, args["amount"], args["currency"])

Every check after the schema uses session, which is derived from the authenticated caller and not from anything the model emitted. That is the load-bearing property. The model supplies account_id; the server decides whether this session may touch that account. If the ownership check drew its notion of "which tenant" from a field in the model's output, the injection would simply set that field, and you would be back to letting the attacker's text spend the victim's authority — the confused-deputy shape from the injection topic, in which a trusted component is talked into exercising its own authority on the attacker's behalf, reappearing at the downstream API rather than at the model.

The soundness argument is worth writing out as a rule you can apply to a check you have not seen before, because "keep the model out of it" is otherwise a slogan. Every check is a predicate over some set of inputs. The model can choose any byte of args, because args is its output. It cannot choose any byte of session, because session is derived from a credential presented before the request body was read and verified against something the model has no access to. So a predicate whose inputs are drawn only from session and from server-held state — the ownership table, the granted scopes, the configured cap — has no input the model can move, and its outcome is therefore fixed by facts the attacker's text cannot reach. Conversely, the moment a predicate reads even one field of args, the model controls one of that predicate's inputs, and the question becomes whether the model can pick a value that makes it pass, and whether a value that makes it pass is one you are happy with. The per-task cap in the listing reads args["amount"] and is still sound, because the only way the model gets past it is by asking for a small refund, which is the outcome the cap exists to force. The ownership check would not be sound if it read a tenant field out of args, because the way past it would be to name the victim's tenant — the attack itself. Reading model output is not the fault. Letting model output decide the answer in the direction that helps the attacker is. Look at each condition in handle_refund, note where every operand came from, and ask what a free choice of the model-supplied operands buys; that check is fast, mechanical, and catches the whole family.

Testing the property that actually matters

The corrective for a fuzzer is not more malformed payloads; it is a different assertion. The interesting test sends a well-formed, correctly-typed, in-range tool call that names a resource outside the session's tenant, or requests a scope the task did not grant, and asserts that the downstream layer refuses it. The payload is deliberately boring — it is meant to pass the schema — and the whole test is in whether authorisation and least privilege reject it.

python
def test_cross_tenant_refund_is_refused():
    session = make_session(tenant_id="A", task_scopes={"refund:write"})
    # Valid shape, in range — schema will accept it.
    args = {"account_id": "acct_7731",  # belongs to tenant B
            "amount": 5000, "currency": "USD"}
    with pytest.raises(Forbidden):
        handle_refund(session, args)

def test_excessive_scope_is_refused():
    session = make_session(tenant_id="A", task_scopes={"order:read"})
    args = {"account_id": "acct_A_1", "amount": 100, "currency": "USD"}
    with pytest.raises(Forbidden):        # refund:write not in task scope
        handle_refund(session, args)

Both tests must fail before the server-side checks exist, and that failure is the part that carries the information. A test that passes on the day it is written has not demonstrated that the property holds; it has demonstrated that the test does not exercise it, which is the state a fuzzer suite is permanently in with respect to authority. Watching these two go from red to green establishes three things at once: the property was genuinely absent, the code you added is what supplies it, and the assertion is wired to the path the request travels. From then on the tests earn their keep in the other direction — they are the thing that fails when somebody refactors the handler and drops a check, which is the ordinary way an authorisation layer disappears.

Where a schema is still the right control

There is a related trap worth naming so the test suite does not overreach, because it is the neighbouring idea this gets confused with. Schema validation is not useless, and the lesson is not "authorisation replaces it." A malformed payload can still be an attack in its own right — a value that overruns a downstream parser, an integer that overflows, a string that a poorly-written consumer will re-interpret. Those are real, and schema validation is a genuine control against them, at the same top-row altitude it always occupies. The point is narrower: schema validation is necessary and it is not sufficient, and the specific thing it cannot cover is the well-formed request aimed at the wrong resource or asking for too much. You keep the schema and you add the layers beneath it; you do not trade one for the other.

Where the model's output must never sit is in the authorisation decision. If the argument that decides "may this proceed" comes even partly from the model — a tenant id in the payload, a role field the model fills, a "this is an admin action" flag — then an injection sets that argument and the check certifies its own bypass. Policy is enforced outside the model, on state the model cannot write: the authenticated session, the task's granted scopes, the server's record of who owns what. That is the same architectural rule the injection chapters reached for, applied one hop further downstream, at the API the tool call finally hits.

A green run on a fuzzer and a red run on the two tests above tells the true story: the API rejects garbage and admits the cross-tenant request, which is the one an attacker will actually send. Turning both green means the refusals live in the server, keyed to the session, outside the model's influence — and it means the property is now written down somewhere that fails when someone removes it.

Each of the five sinks across this topic now has a control, and each control has a test. What is left is the judgement a real service demands, because you rarely inherit one clean sink — you inherit four at once, all fed by one model response, and you cannot fix them all on Monday. The order you fix them in is not the order of how bad each exploit is. The next chapter is about ranking sinks by who can reach them, answering the manager who points at a year with no incidents, and leaving a rule that catches the sinks you have not built yet.

Insecure output handling downstream — read the current version at https://daviddominguez.dev/learn/en/insecure-output-handling-downstream/schema-validation-and-authorisation

Back to top