# Authorization: who's allowed to touch what

> Authentication proves who you are. Authorization decides what you can see and change — and getting it wrong is how one merchant reads another's orders. How to define it, enforce it server-side, and lock it down with deterministic tests.

**Field Guide** · **Updated:** 2026-08-12 · **Tags:** authorization, authentication, access-control, multi-tenancy, session-tokens, testing

_Source: https://verybadpanda.dev/field-guide/authorization_

---

![Bandit the raccoon standing in the open doorway of hotel room 202, holding a keycard labelled 201, while a startled bear sits up in the bed. A speech bubble above Bandit reads 'Whoops.'](https://verybadpanda.dev/assets/authorization-hotel.webp)

*Authenticated guest, wrong room — and the card still worked. That's the bug.*

Bandit checks into the **Insecure Inn**, room **201**. Keycard in paw, he ambles down the hall and taps it on the reader for **202** — force of habit, wrong door — and the light blinks green. The lock clicks. He's standing in someone else's room, a very startled bear blinking at him from the bed.

*Whoops.*

Here's the thing: the hotel knew *exactly* who Bandit was. He's a real, paying guest — the front desk checked his ID, his keycard is genuine. He sailed through **authentication**. What the door on 202 never asked is the *second* question: this is guest 201's card — is he allowed in *this* room? He wasn't. It let him in anyway.

That's an **authorization** bug — and it's the same one that lets one merchant read another merchant's orders.

Most breaches aren't Hollywood. The single most common serious flaw in multi-tenant SaaS — Shopify apps very much included — is **broken authorization**: a logged-in user asks for a resource that isn't theirs, and the app hands it over. It's [#1 on the OWASP Top 10](https://owasp.org/Top10/A01_2021-Broken_Access_Control/). This is the reference for getting it right in your app.

## Authentication vs Authorization

Two words used interchangeably that mean completely different things:

| | The question it answers | Example |
|---|---|---|
| **Authentication** (authn) | *Are you who you say you are?* | A valid session token, an OAuth login, a verified webhook HMAC. |
| **Authorization** (authz) | *Are you allowed to view or modify **this** resource?* | This user may read order #1001 because it belongs to their merchant — but not #2002, which belongs to a different store. |

Authentication is the front door. Authorization is every door inside. **A valid login tells you nothing about whether this user should see this record.** Merchant A's staffer is fully, correctly authenticated — and must still be stopped cold the instant their browser asks for Merchant B's customer list.

Get authn wrong and strangers get in. Get authz wrong and *everyone who's already in* can read everyone else's data. The second is quieter, more common, and usually worse.

## In a Shopify app: prove who's asking, then authorize

Every policy example below takes a `user`. Your authorization is only as trustworthy as *that* object — so don't accept a user id from the client. Prove it with Shopify's own tokens.

**Session tokens — the per-request identity.** Embedded apps can't rely on cookies (browsers block third-party cookies), so App Bridge sends a short-lived [session token](https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens) — a JWT — on every request to your backend. Verify it **server-side**: check the signature with your app's secret, and validate `exp`, `nbf`, `aud` (your API key), and `dest` (the shop). Its `sub` claim is the **ID of the user** who made the request. A verified session token is your proof of *which user on which shop* is calling — and it doubles as CSRF protection.

**Online vs offline access mode — does this token know the user?** When you exchange that session token for an Admin API access token, you choose a mode:

| | Offline (default) | Online |
|---|---|---|
| Tied to | the shop / app | the **logged-in user** |
| Lifespan | long-lived (background) | the user's web session (≤ 24h, then refresh) |
| Knows the user? | no | yes — carries `associated_user` |
| Use for | webhooks, jobs, service-to-service | anything that must respect *this* user's identity |

Request [**online** access](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/online-access-tokens) (via [token exchange](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange)) when authorization depends on *who* is acting. The response includes an `associated_user` — `id`, `email`, and crucially `account_owner` — plus the `associated_user_scope` Shopify granted.

**Feed that proven identity into your policy.** Now the `user` in the examples below is real, not claimed:

```js
// Built from the verified session token / online access token — never from the client.
const user = {
  id: associatedUser.id,
  merchantId: shop,                                        // (b) tenant gate: their store
  role: associatedUser.account_owner ? 'owner' : 'staff', // map Shopify identity -> your roles
};
// ...now run your policy: ability.can('update', subject('Order', order))
```

**One caveat worth stating.** Shopify's online token proves *identity* — who the user is, and whether they're the account owner. It does **not** hand you fine-grained per-staff permissions. Past `account_owner`, deciding that a user is `staff` vs `read_only` is *your app's* job. Use Shopify to know **who**; keep your own role model to decide **what they may do**.

## Why this is sharper in the age of AI

You increasingly ship code you didn't write line by line. An agent scaffolds a route, a refactor moves a query, a "make it faster" prompt rewrites a controller. That code is **non-deterministic** — the same feature built today may not look like it did last week, and an ownership check that was there on Monday can quietly vanish in a Thursday refactor nobody read closely.

You cannot secure that by *reading* the code and trusting it looks right. The only thing that holds is a **deterministic test that fails the build the moment data leaks**:

> Merchant A must never receive Merchant B's data. Assert it. Then no prompt, refactor, or 3 a.m. hotfix can regress it without turning CI red.

Treat your authorization tests as the invariant and the code as the variable. The code will change constantly. The invariant must not.

## The practical steps

### 1. Define roles, resources, and organizations — with a policy library

Don't scatter `if (user.isAdmin)` across 200 files. Centralize *who can do what to which resource* in one place, with a library built for it. Model three things:

- **Organizations / tenants** — the merchant. Every resource belongs to exactly one.
- **Resources** — `Order`, `Customer`, `Product`, `Payout`…
- **Roles** — `owner`, `staff`, `read_only` — what each may do.

Every access runs **two gates**: (a) does the role permit the action, and (b) does the resource belong to the caller's merchant. Miss the second and you've built a cross-tenant leak.

```js
// CASL's default rule factory. Despite the name it's database-agnostic — conditions are
// plain objects matched in memory, so it works the same on Postgres, MySQL, anything.

// Build the caller's abilities from their role, scoped to their merchant.
export function defineAbilitiesFor(user) {
  const { can, build } = new AbilityBuilder(createAbility);
  const ownMerchant = { merchantId: user.merchantId }; // (b) the tenant gate

  if (user.role === 'owner') {
    can('manage', 'all', ownMerchant);                 // every action, own merchant only
  } else if (user.role === 'staff') {
    can(['read', 'update'], ['Order', 'Customer'], ownMerchant);
  } else {
    can('read', ['Order', 'Customer'], ownMerchant);   // read_only
  }
  return build();
}

// On a request — check against the actual record:
const ability = defineAbilitiesFor(currentUser);
if (!ability.can('update', subject('Order', order))) {
  throw new ForbiddenError();
}
```

```ruby
# app/policies/order_policy.rb  (Pundit)
class OrderPolicy < ApplicationPolicy
  def show?
    same_merchant?                                   # (b) tenant gate
  end

  def update?
    same_merchant? && user.role.in?(%w[owner staff]) # (a) role gate + (b) tenant gate
  end

  private

  def same_merchant?
    record.merchant_id == user.merchant_id
  end
end

# In the controller:
def update
  order = Order.find(params[:id])
  authorize order            # raises Pundit::NotAuthorizedError -> 403
  order.update!(order_params)
end
```

```php
// app/Policies/OrderPolicy.php  (Laravel)
class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        return $order->merchant_id === $user->merchant_id;            // (b) tenant gate
    }

    public function update(User $user, Order $order): bool
    {
        return $order->merchant_id === $user->merchant_id            // (b) tenant gate
            && in_array($user->role, ['owner', 'staff'], true);      // (a) role gate
    }
}

// In the controller:
public function update(Request $request, Order $order)
{
    $this->authorize('update', $order); // throws 403 if denied
    $order->update($request->validated());
}
```

```python

# Casbin loads an RBAC model + policy (role -> resource -> action).
enforcer = casbin.Enforcer("model.conf", "policy.csv")

def can_update_order(user, order) -> bool:
    return (
        enforcer.enforce(user.role, "order", "update")   # (a) role gate
        and user.merchant_id == order.merchant_id        # (b) tenant gate
    )
```

The shape is identical in every language: a **role** check *and* a **merchant-ownership** check, defined in one place instead of sprinkled through your handlers. (Node: [CASL](https://casl.js.org). Ruby: [Pundit](https://github.com/varvet/pundit). PHP: [Laravel policies](https://laravel.com/docs/authorization), or `spatie/laravel-permission`. Python: [Casbin](https://casbin.org) — note that Oso's open-source library is now deprecated.)

### 2. Enforce on the server — always

Say it plainly: **client-side checks are not security.** Hiding a button, disabling a field, or a React route guard improves UX and does nothing to stop an attacker. Anyone can open DevTools, replay the request from a proxy or `curl`, or edit your bundle. The request that actually reaches your server is entirely under their control.

Authorization has to live where the caller can't reach it — **in the server, on every request that reads or writes a resource**, after authentication and before the query.

```text
Browser (untrusted)                 Server (the trust boundary)
  hides the "Delete" button   ──►     authorize('delete', order)   ← the real gate
  React route guard           ──►     ...runs no matter what the client did
```

If your only "check" is that the frontend didn't render the button, you don't have authorization — you have a suggestion.

### 3. Test authorization deterministically

This is the invariant from earlier, made real. For every resource, assert the **negative** cases — the ones that leak data when they break:

```ts
// orders.authorization.test.ts
test("a staffer cannot read another merchant's order", async () => {
  const theirOrder = await seedOrder({ merchantId: 'merchant-B' });
  const res = await asUser({ merchantId: 'merchant-A', role: 'staff' })
    .get(`/api/orders/${theirOrder.id}`);
  expect(res.status).toBe(404); // never 200 — and 404 hides that it even exists
});

test('read_only cannot update an order, even in its own merchant', async () => {
  const order = await seedOrder({ merchantId: 'merchant-A' });
  const res = await asUser({ merchantId: 'merchant-A', role: 'read_only' })
    .patch(`/api/orders/${order.id}`, { note: 'nope' });
  expect(res.status).toBe(403);
});
```

Cover both gates: **cross-tenant** (A can't touch B) and **role** (read_only can't write). Prefer `404` over `403` for cross-tenant reads so you don't even confirm the record exists.

**Make it non-optional.** New functionality that touches a resource must arrive with an authorization test. Enforce the convention in CI — require a `*.authorization.test.ts` beside every server module:

```js
// scripts/require-authz-tests.mjs — fail CI if a server module has no authz test

const modules = globSync('src/server/**/*.ts', {
  ignore: ['**/*.test.ts', '**/*.authorization.test.ts'],
});
const missing = modules.filter(
  (m) => globSync(m.replace(/\.ts$/, '.authorization.test.ts')).length === 0,
);

if (missing.length) {
  console.error('Missing *.authorization.test.ts for:\n  ' + missing.join('\n  '));
  process.exit(1);
}
```

Wire it into CI (or a pre-commit hook). Now "I forgot the authz check" fails the build instead of shipping. An ESLint rule or a per-directory convention works too — the point is that the requirement is *enforced*, not *hoped for*.

### 4. Use an LLM to hunt for policy gaps

The same non-determinism that makes AI risky also makes it a tireless auditor. An LLM can enumerate your resources × roles × actions, cross-reference them against your policy definitions and route handlers, and flag the combinations nobody wrote a rule — or a test — for: the `read_only` role that can somehow reach a `DELETE`, the new endpoint with no ownership check.

We're writing a **dedicated guide** on this — wiring an LLM (and an AI security harness) into your pipeline to surface authorization gaps automatically. For now, treat it as a reviewer that never gets bored, not a source of truth: it finds candidates; your deterministic tests confirm them.

## The checklist

- [ ] Authorization lives in one place (a policy library), not scattered `if` checks
- [ ] Every resource access runs two gates: **role** + **merchant/tenant ownership**
- [ ] Enforcement is **server-side**; client checks are UX only
- [ ] Cross-tenant reads return `404`, not the record
- [ ] Every server module has a `*.authorization.test.ts` covering the negative cases
- [ ] CI fails when an authorization test is missing
- [ ] New resources ship with their policy **and** tests in the same PR

## References

- [OWASP A01: Broken Access Control](https://owasp.org/Top10/A01_2021-Broken_Access_Control/) — why this is the #1 web risk
- [CASL](https://casl.js.org) — isomorphic authorization for Node/JS
- [Pundit](https://github.com/varvet/pundit) — policy objects for Ruby
- [Laravel Authorization](https://laravel.com/docs/authorization) — gates & policies for PHP
- [Casbin](https://casbin.org) — one authorization library across Python, PHP, Ruby, Node, Go…
- [Shopify session tokens](https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens) — proving the current user + shop on every embedded-app request
- [Shopify online access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/online-access-tokens) — the `associated_user` that tells you who's acting
- [Token exchange](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange) — turn a verified session token into an access token
