APIs, Auth & Multi-Tenancy
If Data Modeling was “how the data is shaped,” this page is “how the data is reached — and reached safely.” We’ll learn the language the browser and the server use to talk (the API), then the two locked doors every request must pass through (authentication and authorization), and finally the one idea that makes Assure PAT special and slightly dangerous: multi-tenancy — many separate customers living inside one app and one database, and what stops customer A from ever seeing customer B’s cards.
⏱ ~20 min · 🟡 Intermediate · Prerequisites: Foundations, Layered Architecture, Data Modeling
1. What an API actually is
An API (Application Programming Interface) is a contract: a published list of things one program will do for another, and exactly how to ask.
The menu analogy
An API is a restaurant menu. The menu lists what you can order (“Margherita pizza”) and what you’ll get back (“a pizza”). You don’t walk into the kitchen and fry your own egg — you point at the menu and the kitchen does the work behind a clean wall. The API is that menu: it hides the kitchen (the database, the crypto, the business rules) and gives you a small, safe list of things you’re allowed to ask for.
The Assure backend’s menu lives at URLs like /api/v2/cards and /api/v2/profiles. The React dashboard and the Android app are both “customers” reading the same menu.
REST: resources + verbs
Assure uses a style called REST. The whole idea in two halves:
- Resources are nouns. Things you can name and point at: a card, a profile, a card-request. Each gets a URL, e.g.
/api/v2/card-requests/42. - HTTP verbs say what to do to the noun. The verb is not part of the URL — it rides along with the request.
| Verb | Means | Plain words | Assure example |
|---|---|---|---|
| GET | read | ”show me this” | GET /api/v2/card-requests/42 — fetch request #42 |
| POST | create | ”make a new one” | POST /api/v2/profiles — create a profile |
| PUT | replace | ”swap the whole thing for this” | PUT /api/v2/card-requests/42/selected-profiles — replace the entire set of selected profiles |
| PATCH | update | ”change just these fields” | PATCH /api/v2/profiles/9 — rename one profile |
| DELETE | remove | ”delete this” | DELETE /api/v2/cards/7 — remove card #7 |
Read it aloud the way the server hears it: “GET the card-request whose id is 42.” Same noun, different verb = different action.
GET /card-requests/42reads it;DELETE /card-requests/42destroys it. The URL didn’t change — the verb did all the work.
The killer mental move: PUT replaces, PATCH edits. If you PUT the selected-profiles for a request, you’re saying “forget whatever was there — here is the complete new list.” If you PATCH, you’re saying “keep everything, just change these few fields.” Confusing the two is a classic bug (a PUT that was meant to be a PATCH silently wipes data).
2. Status codes, idempotency & versioning
HTTP status codes — the three-digit verdict
Every response comes back stamped with a 3-digit number. You only need to know the families, then a handful of specifics:
| Family | Meaning | Memory hook |
|---|---|---|
| 2xx | Success | ”It worked.” 200 OK, 201 Created, 204 No Content |
| 4xx | You messed up | ”Your request was wrong.” |
| 5xx | Server messed up | ”My code broke, not your fault.” |
The 4xx ones you must know cold:
| Code | Name | What it really means |
|---|---|---|
400 | Bad Request | ”Your data is malformed — missing a field, wrong type.” |
401 | Unauthenticated | ”I don’t know who you are. Log in.” |
403 | Forbidden | ”I know who you are, but you’re not allowed to do this.” |
404 | Not Found | ”That noun doesn’t exist (or you can’t see it).” |
500 | Internal Server Error | ”My code threw an exception.” |
401 vs 403 — the distinction people fumble in interviews
401 = “who are you?” (no valid login). 403 = “I know you, you just can’t do that.” A logged-in Mobile User who tries to delete an org’s crypto keys gets a 403 — perfectly authenticated, simply not authorized. Hold this distinction; it returns in section 4.
Idempotency — “safe to retry?”
A request is idempotent if doing it twice has the same effect as doing it once. This matters because networks are flaky — the client often retries.
- GET — idempotent (reading twice changes nothing). ✅ safe to retry
- PUT / DELETE — idempotent (replacing with the same data, or deleting an already-deleted thing, lands you in the same final state). ✅ safe to retry
- POST — not idempotent. Two
POST /profiles= two profiles. ⚠️ retrying can create duplicates
This is why “create” uses POST and “replace” uses PUT. The verb advertises whether a nervous client can safely hit it again.
API versioning — why /api/v2 exists
Every Assure URL starts with /api/v2. That v2 is a version. Once real apps depend on your menu, you can’t just rename a dish — you’d break every customer mid-meal. So you publish a new menu (/v2) alongside the old one (/v1), migrate clients over, then retire /v1. Versioning lets the API evolve without breaking the apps already using it. (Assure’s code review even flagged leftover v1/v2 duplicate routes — the cost of a migration not yet finished.)
3. Never trust the client — request validation
The first law of backend security
Everything the client sends is a lie until proven otherwise. A browser can be tampered with; a malicious user can craft any request by hand. The server must re-check every incoming field itself. “The frontend already validates it” is never enough.
Assure validates incoming requests with zod schemas run by a validateRequest middleware before the request ever reaches the controller. A schema is a tiny spec of what valid input looks like:
// Shape the request MUST match, or it never reaches the controller
const createProfileSchema = z.object({
profile_name: z.string().min(1).max(120),
brand: z.enum(["discover", "diners"]),
issuer_id: z.number().int().positive(),
});
router.post("/profiles", validateRequest(createProfileSchema), createProfile);
If profile_name is missing or issuer_id is the string "oops", the middleware rejects it with a 400 and the controller never runs. The controller gets to assume the data is already clean — which keeps the business logic simple and safe.
4. AuthN vs AuthZ — two different doors
Two words that sound alike and do completely different jobs. Senior engineers keep them crisp:
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question | ”Who are you?" | "Are you allowed to do this?” |
| Analogy | Showing your passport at the gate | The gate agent checking your ticket says business class |
| Fails with | 401 Unauthenticated | 403 Forbidden |
| In Assure | Verify the JWT (section 5) | Check the role (section 6) |
Order matters: AuthN first, AuthZ second. First the system figures out who you are; only then can it ask whether that person may do the thing. You can’t check a ticket before you know whose ticket it is.
5. JWT — how the server knows who you are without remembering you
When you log into Assure, the server hands your browser a JWT (JSON Web Token — say “jot”). On every later request, the browser sends it back in a header:
Authorization: Bearer eyJhbGciOiJ...<the token>...
A JWT is three Base64 chunks joined by dots — header.payload.signature:
flowchart LR
H["HEADER<br/>algorithm + type"] --> P["PAYLOAD<br/>user id, org, role, expiry"] --> S["SIGNATURE<br/>server's secret seal"]
- Header — which signing algorithm was used.
- Payload — the claims: who you are and what you carry. In Assure this includes your user id, your org, your role, and an expiry time. (The payload is only encoded, not encrypted — anyone can read it, so it never holds secrets like passwords.)
- Signature — the server takes
header + payload, runs it through a secret key only the server knows, and produces a tamper-proof seal.
The magic trick: verifying without a database lookup
Here’s why JWTs are everywhere. To check a token, the server recomputes the signature from the header+payload using its secret key and compares it to the signature in the token. If they match, the token is genuine and untampered — because only someone with the secret could have produced that seal.
Why this enables stateless / serverless backends
The server doesn’t store a session anywhere. It doesn’t need a “logged-in users” table to look you up. Everything needed to trust you is inside the token you carried, and the secret key proves it’s real. That means any server instance — or a brand-new serverless function spun up a millisecond ago — can verify you with zero shared memory. That’s what “stateless” means, and it’s why JWTs scale so well. The flip side: you can’t easily “un-issue” a token before it expires, which is exactly why tokens carry a short expiry.
Assure’s three token types — mutually exclusive
Assure runs three separate auth worlds, each with its own middleware. A token from one world is meaningless in another:
| Context | Token | Verified by | Stored in |
|---|---|---|---|
| Org users (web dashboard) | bhtoken | verifyToken | browser localStorage |
| Mobile users (Android app) | mobile token | verifyMobileUserToken | app storage |
| Global admins (system panel) | systemToken | verifySystemToken | browser localStorage |
- Org and system tokens are mutually exclusive — logging in as one clears the other, so you can’t be a customer and an Ayris admin in the same tab.
- Web sessions enforce a 45-minute inactivity timeout.
- OTP verification is optional, controlled per-user by the
is_bypass_otpflag. - A user who belongs to multiple orgs is sent to an org picker (
/select-org) after login, and the token is then re-issued scoped to the chosen org — so the org claim in the payload is always exactly one org.
Real-world caveat from Assure’s own code review
Assure stores the web token in
localStorage, which the security review flagged (localStorage is readable by any script on the page, so it’s vulnerable to XSS). It’s a known trade-off, not a best-in-class choice — worth knowing so you don’t repeat it as gospel.
6. RBAC — Role-Based Access Control (the AuthZ engine)
Once the JWT proves who you are, Assure asks what you’re allowed to do using RBAC: every user has a role, and roles map to allowed actions. There are six org roles:
| Role | ID | What they do |
|---|---|---|
| SME / Admin | 1 | Full org access — users, cards, profiles, issues, audit trails |
| Approver | 2 | Creates & approves test card requests |
| SME (Partner) | 3 | Partner management; read-only on cards/requests |
| Manager | 4 | Essentially full org access (like Role 1) |
| Mobile User | 5 | Mobile app only — taps cards, reports issues |
| Profile Editor | 6 | Creates/edits profiles, manages test cases |
The enforcement point is a middleware called checkRoleAccess, backed by roughly 186 role→action mappings. Before a controller runs, it asks: “does this role have this action?” If not — 403.
flowchart TD
A([Request arrives at a protected route]) --> B{Valid token?<br/>signature + not expired}
B -->|No| E["Return 401 (unauthenticated)"]
B -->|Yes| C{Token belongs<br/>to this org?}
C -->|No| F["Return 403 (forbidden)"]
C -->|Yes| D{Role allowed<br/>this action?}
D -->|No| F
D -->|Yes| G[Run the controller<br/>tenant-scoped query]
G --> H([Return 2xx response])
Read top to bottom: a request only reaches the real work (
G) after passing three gates — is your token real? (AuthN), is it for this org? (tenancy), does your role permit this? (AuthZ). Miss the first → 401. Miss either of the others → 403.
You’ve now met the API verbs, the status codes, both auth doors, and the role engine. Before the multi-tenancy payoff, prove the distinctions stuck — these are the exact ones that trip people up in interviews.
7. Multi-tenancy — the signature Assure pattern
This is the idea that defines Assure PAT. A tenant is a customer organization. Multi-tenancy means many separate customer orgs share one running app and one database — yet each must feel like they have the system entirely to themselves, never seeing another org’s data.
Think of an apartment building: one building, one set of plumbing, but every flat has its own locked door. Multi-tenancy is the locked door. Get the lock wrong and tenant A walks into tenant B’s living room.
The three isolation strategies
There are three classic ways to keep tenants apart, trading isolation against cost:
flowchart TB
subgraph A["1 · Separate DATABASE per tenant"]
A1[(Org A DB)]
A2[(Org B DB)]
end
subgraph B["2 · Shared DB, separate SCHEMA per tenant"]
B1["schema_orgA"]
B2["schema_orgB"]
end
subgraph C["3 · Shared DB, shared SCHEMA + tenant column"]
C1["one cards table<br/>each row tagged org_id"]
end
| Strategy | Isolation | Cost / complexity | Used by |
|---|---|---|---|
| Separate database | Strongest (physically apart) | Highest (a DB per customer) | banks, heavy-compliance apps |
| Separate schema | Strong | Medium | mid-size SaaS |
| Shared schema + tenant column | Logical only (code enforces it) | Lowest, scales to many tenants | Assure PAT ✅ |
Assure uses strategy 3: one shared schema, and every table carries an org_id column pointing at the organizations table. Org A’s cards and Org B’s cards live in the same cards table — told apart only by their org_id. Cheapest and most scalable, but isolation is now the code’s responsibility, not the database’s. One forgotten WHERE org_id = ? and the wall comes down.
Shared-schema isolation, illustrated
erDiagram
ORGANIZATIONS ||--o{ CARDS : "owns (via org_id)"
ORGANIZATIONS ||--o{ PROFILES : "owns (via org_id)"
ORGANIZATIONS ||--o{ USERS : "owns (via org_id)"
ORGANIZATIONS {
int id PK
}
CARDS {
int id PK
int org_id FK
string status
}
One row per org in
ORGANIZATIONS; every other table fans out from it throughorg_id. The org is the root of every ownership chain.
The scopedQuery helper — making the wall automatic
To stop developers from forgetting the org_id filter, Assure funnels queries through a helper, scopedQuery(tableName, orgId), which builds the query with the tenant filter already applied:
// Instead of a raw query you might forget to scope...
const rows = await scopedQuery("cards", req.orgId).where("status", "active");
// → always emits: WHERE org_id = <req.orgId> AND status = 'active'
The orgId comes straight from the verified JWT (section 5), so a user can only ever scope to their own org. This is the locked door, expressed in code.
sequenceDiagram
participant B as Browser
participant M as Auth middleware
participant R as checkRoleAccess
participant DB as MySQL
B->>M: GET /api/v2/cards (Bearer token)
M->>M: verify JWT signature + expiry
M->>M: extract user id, org_id, role
M->>R: pass request with org + role
R->>R: role allowed this action?
R->>DB: scopedQuery("cards", orgId)
Note over R,DB: SELECT ... WHERE org_id = <orgId>
DB-->>R: only this org's rows
R-->>B: 200 { cards: [...] }
Every protected request walks this path: verify (who) → extract the org → check the role → scope the query to that org → respond. The
org_idis never trusted from the request body — it’s pulled from the signed token.
The danger — when a table has no org_id
Strategy 3’s weakness is brutal: if a table is missing the org_id column, it physically cannot be tenant-scoped. There’s nothing to filter on, so a query against it returns every org’s rows mixed together.
A real cross-tenant leak risk in Assure
Some tables — notably
login_logsandmobile_debug_logs— currently lack anorg_idcolumn. That means any feature reading them (e.g. a login-history or debug view) cannot safely filter by tenant: it would expose other organizations’ log entries. This is a genuine cross-tenant data leak waiting to happen — the apartment door with no lock. The fix is to addorg_idto those tables and route every read throughscopedQuery.
The one rule to carry forever
In a shared-schema multi-tenant system, every single query must be tenant-scoped. No exceptions, no “it’s just a log table.” A query without a tenant filter is a security bug, even if it never throws an error — because the leak is silent. Make scoping the default (via a helper like
scopedQuery) so the unsafe path is the one you have to go out of your way to write.
8. Putting it together
Every authenticated Assure request is the same story, and you can now narrate it end to end:
- The browser sends its request with a Bearer JWT (
Authorizationheader). - AuthN — middleware verifies the signature and expiry. Bad token → 401.
- It extracts
user id,org_id, androlefrom the token’s payload — no database lookup needed (stateless). - AuthZ —
checkRoleAccesschecks the role against ~186 mappings. Not allowed → 403. - The request is also validated (zod /
validateRequest) — malformed → 400. - The controller runs the query through
scopedQuery(table, orgId), so it can only ever touch this org’s rows. - A 2xx response carries back exactly — and only — this tenant’s data.
Three gates (real token, right org, allowed role), one validated body, one tenant-scoped query. Master this and you can reason about the security of almost any web backend.
Check Yourself
Cover the answers. If you can’t retrieve it, you haven’t learned it yet — re-read the section, then try again.
What is the difference between PUT and PATCH, and what's the classic bug when you confuse them?
PUT replaces the whole resource — “forget what was there, here’s the complete new version.” PATCH edits just the fields you send — “keep everything, change these few.” The classic bug: a PUT that was meant to be a PATCH silently wipes every field you didn’t include.
A request comes back 401. What does that tell you, and how is it different from 403?
401 = “who are you?” — there’s no valid login/token at all. 403 = “I know who you are, you just can’t do that.” — authenticated fine, but not authorized for this action. AuthN failure vs AuthZ failure.
Why is POST not idempotent, and why does that make GET, PUT, and DELETE safer for a flaky client to retry?
POST creates, so two identical POSTs make two things (duplicates). GET reads (no change), PUT replaces with the same data (same final state), DELETE removes an already-removed thing (still gone) — all idempotent, so a client that didn’t hear the response can safely hit them again.
What are the three parts of a JWT, and how does the server verify one without a database lookup?
header.payload.signature. To verify, the server recomputes the signature from header+payload using its secret key and compares it to the token’s signature. If they match, the token is genuine and untampered — because only the secret-holder could produce that seal. No session table, no lookup. That’s “stateless.”
Name the three multi-tenancy isolation strategies, from strongest isolation to cheapest. Which does Assure use?
- Separate database per tenant — strongest, most expensive. 2. Shared DB, separate schema per tenant — strong, medium cost. 3. Shared schema + tenant column (
org_idon every row) — logical isolation only, cheapest, scales to many tenants. Assure uses strategy 3.
In a shared-schema system, where does org_id come from when scoping a query — and why does the source matter so much?
It comes from the verified JWT payload, never from the request body. If you trusted an org_id the client sent, anyone could read any tenant’s data by changing one field. Pulling it from the signed token means a user can only ever scope to their own org.
Why is a table missing an org_id column a cross-tenant data leak waiting to happen?
With no org_id column there’s nothing to filter on, so a query returns every org’s rows mixed together. In Assure, login_logs and mobile_debug_logs currently lack org_id — any feature reading them would expose other organizations’ entries. The apartment door with no lock.
What three gates does a protected Assure request pass before it touches the database, and which status code does each failure return?
- Valid token? (AuthN) → fail = 401. 2. Token for this org? (tenancy) → fail = 403. 3. Role allowed this action? (AuthZ) → fail = 403. Pass all three and the controller runs a
scopedQueryreturning only this tenant’s rows.
Still Unclear?
Paste any of these into Claude to go deeper on the exact spot that’s fuzzy:
- “Walk me through decoding a real JWT by hand — show me the three Base64 parts, what’s in a typical payload, and demonstrate why I can read the payload but can’t forge the signature without the secret.”
- “Give me a worked example of a cross-tenant data leak: show a controller that forgot the
org_idfilter, the SQL it runs, and exactly what data the wrong tenant would see. Then show the fixed version using a scoped-query helper.” - “Quiz me on 401 vs 403 vs 400 vs 404 with 8 tricky real-world request scenarios, one at a time, and tell me why I’m wrong when I miss one.”
Why AI Can’t Do This For You
AI can generate a login endpoint and a role check in seconds — but it cannot decide which of the three isolation strategies your product actually needs, nor will it notice that one new table quietly shipped without an org_id column. Those are judgment calls grounded in your real data, your compliance bar, and your cost ceiling. The silent cross-tenant leak — the query that never throws an error but exposes another customer’s rows — is invisible to a tool that doesn’t hold your whole system in its head. Knowing that every query must be tenant-scoped, and feeling the danger of the one that isn’t, is exactly the instinct that separates an engineer from a prompt.
Next up — Request Lifecycle — where we take a single real Assure click and trace it through every layer you’ve now learned: the API verb, the auth gates, the tenant scoping, the controller, the service, and the row in MySQL — then back out. This is where all four pages so far click into one picture. 👉
Module done? Add it to today’s tracker