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 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 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 (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 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 |
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
ifchecks - 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.tscovering 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 — why this is the #1 web risk
- CASL — isomorphic authorization for Node/JS
- Pundit — policy objects for Ruby
- Laravel Authorization — gates & policies for PHP
- Casbin — one authorization library across Python, PHP, Ruby, Node, Go…
- Shopify session tokens — proving the current user + shop on every embedded-app request
- Shopify online access tokens — the
associated_userthat tells you who’s acting - Token exchange — turn a verified session token into an access token