../field-guide

field guide #authorization#authentication#access-control#multi-tenancy#session-tokens#testing updated Aug 12, 2026

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.

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.'
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. 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 answersExample
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 — 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 tothe shop / appthe logged-in user
Lifespanlong-lived (background)the user’s web session (≤ 24h, then refresh)
Knows the user?noyes — carries associated_user
Use forwebhooks, jobs, service-to-serviceanything that must respect this user’s identity

Request online access (via token exchange) when authorization depends on who is acting. The response includes an associated_userid, 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:

// 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.
  • ResourcesOrder, Customer, Product, Payout
  • Rolesowner, 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.

// 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.
import { AbilityBuilder, createMongoAbility as createAbility, subject } from '@casl/ability';

// 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();
}
# 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
// 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());
}
import casbin

# 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. Ruby: Pundit. PHP: Laravel policies, or spatie/laravel-permission. Python: Casbin — 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.

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:

// 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:

// scripts/require-authz-tests.mjs — fail CI if a server module has no authz test
import { globSync } from 'glob';

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