../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. Out of habit he taps his keycard on 202 — wrong door — and the light blinks green. He’s standing in a stranger’s room, a startled bear blinking from the bed.

Whoops.

The hotel knew exactly who Bandit was: a real, paying guest with a genuine keycard. He passed authentication. What door 202 never asked is the second question — this is 201’s card; is he allowed in here? He wasn’t. It opened anyway.

That’s an authorization bug — the same one that lets one merchant read another’s orders. Broken authorization is the most common serious flaw in multi-tenant SaaS and #1 on the OWASP Top 10. This is the reference for getting it right in your Shopify app.

Authentication vs Authorization

Two words used interchangeably that mean 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 (their merchant’s) but not #2002 (a different store’s).

Authentication is the front door; authorization is every door inside. A valid login says nothing about whether this user should see this record. Get authn wrong and strangers get in; get authz wrong and everyone already inside can read everyone else’s data — quieter, more common, and usually worse.

In a Shopify app: prove who’s asking

Every policy below takes a user. It’s only trustworthy if you prove it — never accept a user id from the client.

Session tokens. Embedded apps can’t use cookies, so App Bridge sends a short-lived session token (a JWT) on every request. Verify it server-side — signature (your app secret), exp, nbf, aud (your API key), dest (the shop) — and its sub claim is the user’s ID. That’s your proof of which user on which shop is calling, and it replaces CSRF tokens.

Access mode. When you exchange that token for an Admin API access token, you pick 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

Use online access (via token exchange) when authorization depends on who’s acting — its associated_user carries id, email, and account_owner. Now the policy user is real:

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

Caveat: the online token proves identity and account_owner — not fine-grained per-staff permissions. Use Shopify to know who; keep your own role model for what they may do.

Why this is sharper with AI

You increasingly ship code you didn’t write line by line — an agent scaffolds a route, a refactor moves a query, and an ownership check quietly vanishes in a rewrite nobody read closely. You can’t 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.

Your authorization tests are the invariant; the code is the variable.

The practical steps

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

Don’t scatter if (user.isAdmin) across the codebase. Centralize who can do what to which resource in a library built for it, modelling three things: tenants (the merchant — every resource belongs to one), resources (Order, Customer, Payout…), and roles (owner, staff, read_only).

Every access runs two gates: (a) the role permits the action, and (b) the resource belongs to the caller’s merchant. Miss (b) 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
    )

Same shape everywhere — a role check and a merchant-ownership check, in one place instead of sprinkled through your handlers. (Node: CASL. Ruby: Pundit. PHP: Laravel policies or spatie/laravel-permission. Python: Casbin — Oso’s OSS library is deprecated.)

2. Enforce on the server — always

Client-side checks are not security. A hidden button, a disabled field, a React route guard — all UX. Anyone can open DevTools, replay the request from curl or a proxy, or edit your bundle. Put the gate where the caller can’t reach it: on 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, that’s not authorization — it’s a suggestion.

3. Test authorization deterministically

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). Return 404 for cross-tenant reads so you don’t even confirm the record exists.

Make it non-optional. Require a *.authorization.test.ts beside every server module, enforced in CI:

// 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, and “I forgot the authz check” fails the build instead of shipping.

4. Use an LLM to hunt for policy gaps

AI’s non-determinism also makes it a tireless auditor. An LLM can enumerate your resources × roles × actions and flag the combinations with no rule — or test — behind them: the read_only that somehow reaches a DELETE, the new endpoint with no ownership check. We’ve got a dedicated guide coming on wiring an LLM (and an AI security harness) into your pipeline. Treat it as a reviewer that never gets bored: 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