Skip to main content

Your JSON Schema Passes. Can This User Change That Record?

Updated 6 min readMarkdown
Your JSON Schema Passes. Can This User Change That Record?

The request passes your JSON Schema. Its project_id is a string, its title is short enough, and it contains no unknown properties. The server saves the change—to another customer's project.

Validation established the request's shape. It did not establish the caller's right to change that object.

In a multi-tenant API, treat schema validation, tenant membership, object access and permitted state changes as separate checks. Derive identity from trusted authentication state, authorise the selected tenant, then constrain the database operation to the authorised scope. A well-formed object ID remains untrusted input.

Put the contract on paper first

Imagine a task service where a member can create a task inside a project belonging to their current organisation. The client may choose a title and project, but may not choose an owner, tenant or billing status.

A small request schema might be:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["project_id", "title"],
  "properties": {
    "project_id": {"type": "string", "minLength": 1, "maxLength": 100},
    "title": {"type": "string", "minLength": 1, "maxLength": 200}
  },
  "additionalProperties": false
}

JSON Schema's object reference explains why both required and an additional-property policy matter: describing a property does not make it mandatory, and unknown properties are not rejected by default.

This contract intentionally excludes tenant_id, owner_id and is_admin. It also does not promise that a non-empty string contains visible text; spaces can satisfy minLength. If the product needs trimming or a non-whitespace title, define that normalisation and validation explicitly rather than allowing different clients to guess.

The schema gives the handler a predictable input shape. That is valuable. It simply cannot infer which projects the authenticated caller may use.

A valid identifier is not an access decision

Consider this illustrative handler:

validate(request.body)
project = database.find_project(request.body.project_id)
database.insert_task(project.id, request.body.title)

The missing question is whether this caller may create tasks in that project. The project can exist, its identifier can be unguessable, and the request can still be unauthorised.

OWASP describes this as broken object-level authorisation. Changing an input ID can select an object beyond the caller's scope. UUIDs reduce convenient guessing; they do not turn possession of an identifier into permission.

The relevant test uses two ordinary accounts in different test tenants. Give account A the exact identifier of account B's fixture project. A should still be denied. Hiding the ID would conceal the missing control rather than verify it.

Make tenant selection an authorised step

Some applications keep a current tenant in the server session. Others take a tenant ID in the route or a header because users can switch organisations. Either design can work, but the selection must be checked against the authenticated user's current membership and role.

Do not treat a signed-in user's arbitrary X-Tenant-ID header as trusted context. Signing in proves identity; it does not approve every tenant string the user supplies.

For the task service, the conceptual sequence is:

user = require_authenticated_identity()
tenant = require_current_membership(user, requested_tenant)
input = validate_create_task_body(request.body)
project = find_project_in_tenant(input.project_id, tenant.id)
require_permission(user, "task:create", project)
create_task(tenant.id, project.id, user.id, input.title)

This is framework-neutral pseudocode, not a drop-in authentication implementation. Notice where the output fields come from. The tenant is the approved tenant, the actor is the authenticated user, and the request contributes only the fields it is allowed to set.

OWASP's authorisation guidance recommends denying by default and checking permissions on every request. Centralising those rules helps, provided background jobs, downloads and alternate APIs use them too.

Carry the scope into the database operation

A scoped read can express a useful constraint:

SELECT id
FROM projects
WHERE id = :project_id AND tenant_id = :tenant_id;

Bind both parameters. The tenant value comes from the authorised context. A missing result can be handled using the API's consistent not-found or forbidden policy, without disclosing another tenant's object details.

For updates and deletes, keep the scope in the mutation as well. A separate successful check followed by an unconstrained update can be fragile if ownership or membership changes between operations. Transactions, database constraints and, where appropriate, row-level policies can support the invariant. Their exact guarantees depend on the database and isolation model; adding a transaction label is not proof that every race disappeared.

For this service, a composite relationship between task tenant and project tenant can prevent accidental cross-tenant links at the data layer. It complements request authorisation rather than replacing permission rules such as “viewers cannot create tasks”.

Authorise properties and transitions too

Object access is only one dimension. A member who can edit a task title may not be allowed to transfer ownership or mark an invoice paid.

Avoid passing the whole validated body straight into an ORM update merely because validation succeeded. Use an operation-specific allowlist and derive privileged fields on the server. If two roles accept different fields, make those contracts explicit and test both.

Likewise, an enum such as status: ["draft", "approved"] establishes recognised values. It does not establish who may approve, whether the previous state permits that transition, or whether required review has occurred.

The same distinction applies to formats. JSON Schema's format behaviour depends on the vocabulary and validator configuration. A field marked format: "email" is not proof of mailbox ownership, even when syntax checking is enabled.

Build tests across boundaries

Use a small permission matrix for the task endpoint:

Caller and input Expected outcome
Member A, own tenant, writable project Task created with server-derived tenant and actor
Member A, another tenant's project ID Rejected; no task created
Viewer A, own tenant's project Rejected by operation permission
Former member, previously valid project Rejected using current membership policy
Member A, extra owner_id or tenant_id field Rejected by request contract

Check the database after rejected writes. A forbidden response is insufficient if an earlier side effect already happened.

Preserve IDs accurately as well. If the frontend rounds a large numeric ID, your permission system may receive a different object reference than the user selected. Jitsumi's guide to keeping every JSON ID digit covers that representation boundary. Accurate IDs and proper access checks are both necessary.

The useful review question is precise: for each client-selected object and each proposed change, which server-controlled fact grants permission? A schema can help you understand the request. Your authorisation model must answer whether it may happen.

References

About the author

  • We are a small team of developers with one goal in mind. Pushing low spec PCs to the limit.

No comments yet. Be the first to comment.