Security essentials
Most real breaches are not clever. Someone changes a number in a URL and sees another store's orders, or finds a database password in a git repository. This lesson covers the few controls that stop most of them: how users log in, how the API decides what each user may see, how passwords and secrets are stored, and the headers and limits around it all.
By the end you can
- Tell authentication from authorization, and say where each check lives in NestJS
- Design login with short access tokens and rotating refresh tokens, for web and mobile
- Spot an IDOR bug and fix it by scoping every query to what the user may see
- Hash passwords with argon2id and keep secrets out of code
- Add a Postgres row-level security policy as a second safety net
The everyday picture
Every staff member at a store wears an ID card. The guard at the staff door checks the card is real and not expired: that is authentication, proving who you are. Inside, the card opens some doors and not others. A cashier's card opens the sales floor but not the cash room. The store manager's card opens the cash room of this store, not of the store across the road. That is authorization, deciding what you may do.
Most break-ins in software happen at the second step. The guard checked the card at the front door, but inside, nobody checks which room you walk into. The API confirmed the token was valid, then returned order 1002 to whoever asked for it.
Authentication and authorization
Authentication (AuthN) answers "who are you?" and fails with 401 Unauthorized. Authorization (AuthZ) answers "may you do this, to this record?" and fails with 403 Forbidden, or 404 Not Found when you do not want to reveal that the record exists.
Authorization has two layers, and teams often build only the first:
- Role checks. RBAC gives each user a role, and each role allows actions. "Only store managers and above may approve a GRN reversal." This is one check per route.
- Record checks. "This zonal manager may see orders only for the 14 stores in her zone." The answer depends on the user and the record. This is ABAC, or row-level access. It must be in every query that reads or changes data.
The second layer is where the most common serious bug lives. OWASP, the group that publishes the best-known list of web risks, has put broken access control at number one in both its 2021 and 2025 lists.
Quick check
A cashier with a valid token calls the "approve GRN reversal" endpoint, which needs the store manager role. What should the API answer?
The cashier is authenticated, so 401 is wrong: the API knows exactly who is calling. The role does not allow this action, so it is an authorization failure, 403. A valid token proves identity, not permission.
Sessions, JWTs and refresh tokens
HTTP forgets you after every request (see What happens when you open an app or website), so after login the client must send proof with each call. There are two common designs.
A server session stores the login in Redis or Postgres and gives the browser a random session id in a cookie. Each request looks the session up. Logging out deletes it, instantly. The cost is one lookup per request, which is small with Redis (see Scaling up vs scaling out).
A JWT is a signed JSON object, such as {"sub":"1","role":"ZONAL_MANAGER","exp":…}. Any API copy can verify the signature with a key, with no lookup. The catch: a JWT is valid until it expires, even if you fire the employee one minute after issuing it. The standard answer is two tokens:
- An access token: a JWT that lives about 15 minutes, sent on every call in the
Authorization: Bearer …header. - A refresh token: a long random string, valid for days or weeks, stored in the database (as a hash), and sent only to
/auth/refreshto get a new pair.
With rotation, every refresh returns a new refresh token and marks the old one used. If an old one ever comes back, someone has a copy: the server revokes the whole chain, and every device in that login must sign in again. Revoking a user is fast, too: delete their refresh tokens, and within 15 minutes all their access tokens have expired.
Quick check
A staff member leaves at 10:00, and you revoke all their refresh tokens at once. Access tokens live 15 minutes. When can they no longer call the API?
Any API copy accepts a signed access token until it expires, so one issued at 9:59 still works until about 10:14. After that the app needs a refresh, and the revoked refresh token is refused. Instant lockout needs a server-side check, such as a session lookup or a deny list.
/auth/refresh and receives a new pair with RT2; RT1 is marked used. If RT1 shows up again later, the server treats it as stolen and revokes the whole token family.Where to keep tokens, and OAuth in plain words
Web app: do not keep tokens in localStorage, where any injected script can read them. Put the refresh token (or a session id) in a cookie with three flags. HttpOnly means JavaScript cannot read it. Secure means it travels over HTTPS only. SameSite=Lax or Strict means other sites' forms cannot send it, which blocks most cross-site request forgery. Keep the access token in memory only. Mobile app: there are no cookies to lean on. Store the refresh token in the device's secure storage: iOS Keychain or Android Keystore, through a React Native library such as react-native-keychain or expo-secure-store. Never use AsyncStorage for tokens, because it is a plain file.
A shared handscanner used by several staff needs one more thing: a quick "switch user" that logs the previous person out and revokes their refresh token, so a cashier never works under the manager's login.
OAuth 2.0 and OIDC matter when login happens somewhere else, such as "Sign in with Google" or a company login server like Keycloak or Microsoft Entra ID. The flow for apps is the authorization code flow with PKCE. In plain words: the app opens the login server's page in a browser. The user signs in there, so your app never sees the password. The login server redirects back with a one-time code. The app trades that code, plus a secret it made up at the start, for tokens. PKCE is that made-up secret: a stolen code is useless without it. If your company already has single sign-on for staff, use it through a maintained library instead of writing your own password login.
Quick check
Where should the Store app, a React Native app, keep its refresh token?
Secure storage is encrypted by the operating system and survives restarts. AsyncStorage is a plain file that other code or a rooted phone can read. Putting the refresh token inside a JWT would hand it to every API call and every log that captures headers.
The bug that bites most: IDOR
An IDOR (insecure direct object reference) is the classic broken access control bug. The user is logged in. The API checks the token. Then it loads the record by the id in the URL and returns it, without asking whether this user may see this record.
Three habits prevent IDOR:
- Scope in the query, not after it. Put the user's allowed sites in the
WHEREclause of every read, update and delete. Loading first and checking afterwards is easy to forget on one endpoint out of fifty. - One scoping function per resource. All order queries call the same
scope(user)helper, so the rule lives in one place and code review can check it. - Never trust ids from the client for ownership. If the body says
"siteId": 31, check that 31 is in the user's sites. Take the user id from the token, never from the request.
Random UUIDs make ids harder to guess, which helps a little. They do not fix IDOR: ids leak through logs, shared links and screenshots.
Quick check
An endpoint loads order by id only, and the team switches order ids to random UUIDs. Is the IDOR bug fixed?
The bug is the missing ownership check, not the shape of the id. A UUID copied from a shared link or a screenshot still opens someone else's order. A valid token only proves who is calling, which was already true before the fix.
As a second safety net, Postgres can enforce the same rule itself with row-level security (RLS). You create a policy such as "only rows whose site_id is in app.site_ids", and the API sets app.site_ids at the start of each transaction. A developer who forgets the WHERE clause then gets zero rows instead of everything. RLS has costs: every query must run inside a transaction that sets the value, and debugging "why do I see no rows?" is harder. Many teams use RLS only on the most sensitive tables.
Think first: why does the RLS helper use set_config with true, instead of a plain SET?
The API shares a small pool of database connections across all users (see Running Postgres in production). A plain SET app.site_ids = '27' stays on the connection after the request ends. The next request that borrows that connection, maybe from a zonal manager of a different zone, would inherit site 27. set_config('app.site_ids', '27', true) is like SET LOCAL: the value ends with the transaction, so it can never leak to the next user. This also works with PgBouncer in transaction mode.
Passwords, secrets and the other usual suspects
Passwords. Never store them, and never encrypt them, since anything you can decrypt, an attacker with your key can decrypt too. Store a slow, salted hash. argon2id is the current first choice; bcrypt is an acceptable older choice. Both are slow on purpose: about 50 to 250 ms per hash on your server, and a salt per password. Never use a fast hash like SHA-256 or MD5 for passwords, because a graphics card can test billions per second. Limit login attempts per account and per IP (see Rate limiting and protecting your system), and give the same error, after the same delay, for "no such email" and "wrong password".
Secrets. Database passwords, JWT signing keys and payment API keys come from environment variables filled by a secret store: your cloud's secret manager, HashiCorp Vault, or at minimum a root-only file on the server. Never commit them to git, not even in a private repository; git history keeps them forever. Use a different secret per environment, and rotate them when a person with access leaves. Tools such as gitleaks can scan commits in CI (see the next lesson, Shipping safely).
The rest of the short list:
| Risk | What it looks like | Main defence |
|---|---|---|
| Injection | '1 OR 1=1' pasted into a search box reaches SQL | Parameters only: Prisma queries, $queryRaw tagged templates (see SQL you'll actually use); never string-built SQL |
| SSRF | "Import image from URL" is given http://10.0.1.5:5432 or a cloud metadata address | Allow-list of hosts; block private and link-local addresses; no redirects |
| Misconfiguration | Debug mode on, stack traces in responses, admin panel or database open to the internet (see Networking you actually need) | Safe defaults, helmet, a checklist per environment |
| Mass assignment | Body includes "role":"HQ_ADMIN" and it is saved | DTOs with whitelist: true; never pass the raw body to update |
| Supply chain | A hijacked npm package steals environment variables | Lock file, npm ci, dependency alerts, few dependencies |
HTTPS everywhere, including between your own servers when they cross a network you do not control. CORS only tells browsers which other websites may call your API with JavaScript. It is not access control: curl and mobile apps ignore it completely. Allow your own web origins by name, and never combine * with credentials.
Audit logs. For sensitive actions, such as price changes, GRN reversals, refunds, role changes and data exports, write a row to an append-only audit_log table: who, what, which record, old and new values, when, from which IP and request id. The API's database role gets INSERT on it but not UPDATE or DELETE. When something goes wrong, this is the table everyone asks for.
Build it
Build login with argon2id and rotating refresh tokens, a global JWT guard with @Roles(), order queries scoped to the user's sites, a Postgres RLS policy, and safe HTTP headers. The steps need a NestJS project and Postgres on your machine. The scoping rule from step 7 is also an exercise you can run in the browser, in JavaScript and in SQL, in the Practice section below.
-
Create the tables
The refresh token itself is never stored, only its SHA-256 hash. A fast hash is fine here because the token is 32 random bytes, not a human password, so it cannot be guessed.
SQLCREATE TYPE role AS ENUM ('CASHIER', 'STORE_MANAGER', 'ZONAL_MANAGER', 'HQ_ADMIN'); CREATE TABLE app_user ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, password_hash text NOT NULL, -- '$argon2id$v=19$m=19456,t=2,p=1$...' role role NOT NULL, is_active boolean NOT NULL DEFAULT true ); CREATE TABLE user_site ( -- which stores each user may see user_id bigint NOT NULL REFERENCES app_user (id) ON DELETE CASCADE, site_id bigint NOT NULL REFERENCES site (id), PRIMARY KEY (user_id, site_id) ); CREATE TABLE refresh_token ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES app_user (id) ON DELETE CASCADE, family_id uuid NOT NULL, -- all tokens from one login token_hash text NOT NULL UNIQUE, -- sha256(token), never the token expires_at timestamptz NOT NULL, used_at timestamptz, -- set when it is rotated revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX refresh_token_family_id_idx ON refresh_token (family_id);In Prisma, map them the same way as in Modeling data in tables: a
Roleenum with@@map("role"), and modelsUser(@@map("app_user")),UserSitewith@@id([userId, siteId]), andRefreshTokenwithfamilyId String @db.UuidandtokenHash String @unique. -
Hash passwords with argon2id
Install with
npm i argon2. The library's defaults are already argon2id with 64 MiB of memory. The settings below are OWASP's stated minimum, which suits small servers.needsRehashlets you raise the cost later: old hashes are upgraded the next time each user logs in.TypeScript// src/auth/password.service.ts import { Injectable } from '@nestjs/common'; import * as argon2 from 'argon2'; // OWASP minimum for argon2id: 19 MiB memory, 2 passes, 1 thread. // Raise memoryCost until one hash takes roughly 50-250 ms on your server. const OPTIONS: argon2.HashOptions = { type: argon2.argon2id, memoryCost: 19_456, timeCost: 2, parallelism: 1 }; @Injectable() export class PasswordService { hash(password: string): Promise<string> { return argon2.hash(password, OPTIONS); // random salt, stored inside the string } async verify(stored: string, password: string): Promise<{ ok: boolean; newHash?: string }> { const ok = await argon2.verify(stored, password); if (ok && argon2.needsRehash(stored, OPTIONS)) { return { ok, newHash: await this.hash(password) }; // upgrade an older, weaker hash } return { ok }; } } -
Issue the tokens at login
The access token carries the user id, role and site ids, so most requests need no database lookup for authorization. Each login starts a new token family.
dummyHashis the hash of a random string, made once at startup. Verifying against it when the email does not exist makes both failures take the same time, so an attacker cannot learn which emails have accounts.TypeScript// src/auth/auth.service.ts (part 1) import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); const REFRESH_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days const GRACE_MS = 30_000; // app retried after a lost response @Injectable() export class AuthService { private readonly dummyHash: Promise<string>; constructor(private prisma: PrismaService, private jwt: JwtService, private passwords: PasswordService) { this.dummyHash = passwords.hash(randomUUID()); // for "no such email" } async login(email: string, password: string) { const user = await this.prisma.user.findUnique({ where: { email } }); const stored = user?.passwordHash ?? (await this.dummyHash); const { ok, newHash } = await this.passwords.verify(stored, password); if (!user || !user.isActive || !ok) throw new UnauthorizedException('Wrong email or password'); if (newHash) await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } }); return this.issueTokens(user.id, randomUUID()); // a new login starts a new family } private async issueTokens(userId: bigint, familyId: string) { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, include: { sites: true } }); const accessToken = await this.jwt.signAsync({ sub: user.id.toString(), role: user.role, siteIds: user.sites.map((s) => s.siteId.toString()), }); const refreshToken = randomBytes(32).toString('base64url'); // random, not a JWT await this.prisma.refreshToken.create({ data: { userId, familyId, tokenHash: sha256(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TTL_MS) }, }); return { accessToken, refreshToken, expiresIn: 900 }; } -
Rotate on refresh, and detect reuse
The short grace period handles a common mobile case: the app sent the refresh, the response was lost on a weak store network, and the app retried with the same token. Outside the grace period, reuse means theft.
TypeScript// src/auth/auth.service.ts (part 2, inside AuthService) async refresh(refreshToken: string) { const row = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } }); if (!row || row.revokedAt || row.expiresAt < new Date()) throw new UnauthorizedException(); if (row.usedAt) { if (Date.now() - row.usedAt.getTime() < GRACE_MS) return this.issueTokens(row.userId, row.familyId); await this.revokeFamily(row.familyId); // an old token came back: probably stolen throw new UnauthorizedException('Session ended, please log in again'); } // Claim it atomically, so two parallel refreshes cannot both rotate it const claimed = await this.prisma.refreshToken.updateMany({ where: { id: row.id, usedAt: null }, data: { usedAt: new Date() }, }); if (claimed.count === 0) throw new UnauthorizedException(); return this.issueTokens(row.userId, row.familyId); } async logout(refreshToken: string) { const row = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } }); if (row) await this.revokeFamily(row.familyId); } private revokeFamily(familyId: string) { return this.prisma.refreshToken.updateMany({ where: { familyId, revokedAt: null }, data: { revokedAt: new Date() } }); } } -
Verify the access token with passport-jwt
Install with
npm i @nestjs/passport passport passport-jwt @nestjs/jwtandnpm i -D @types/passport-jwt. Pin the algorithm, issuer and audience, so a token made for another system, or with a different algorithm, is refused.TypeScript// src/auth/jwt.strategy.ts import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import type { Role } from '@prisma/client'; import { ExtractJwt, Strategy } from 'passport-jwt'; export interface AuthUser { userId: bigint; role: Role; siteIds: bigint[] } // becomes req.user interface AccessPayload { sub: string; role: Role; siteIds: string[] } @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Authorization: Bearer ... secretOrKey: process.env.JWT_SECRET!, // 32+ random bytes algorithms: ['HS256'], issuer: 'ops-api', audience: 'ops-app', }); // exp is checked for you } // Runs only after the signature and expiry are valid validate(p: AccessPayload): AuthUser { return { userId: BigInt(p.sub), role: p.role, siteIds: p.siteIds.map(BigInt) }; } } -
Add
@Public(),@Roles()and two global guardsMaking the JWT guard global means a new endpoint is protected by default. You must opt out with
@Public(), which is much safer than remembering to opt in. Guards run in the order they are registered: login first (401), then role (403).TypeScript// src/auth/guards.ts import { CanActivate, createParamDecorator, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import type { Role } from '@prisma/client'; export const Public = () => SetMetadata('isPublic', true); export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); export const CurrentUser = createParamDecorator( (_d: unknown, ctx: ExecutionContext): AuthUser => ctx.switchToHttp().getRequest().user); @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { constructor(private readonly reflector: Reflector) { super(); } canActivate(ctx: ExecutionContext) { const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [ctx.getHandler(), ctx.getClass()]); return isPublic ? true : super.canActivate(ctx); } } @Injectable() export class RolesGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(ctx: ExecutionContext): boolean { const needed = this.reflector.getAllAndOverride<Role[]>('roles', [ctx.getHandler(), ctx.getClass()]); if (!needed?.length) return true; // logged in is enough const user: AuthUser | undefined = ctx.switchToHttp().getRequest().user; return !!user && needed.includes(user.role); } }TypeScript// src/auth/auth.module.ts @Module({ imports: [ PassportModule, JwtModule.register({ secret: process.env.JWT_SECRET, signOptions: { expiresIn: '15m', algorithm: 'HS256', issuer: 'ops-api', audience: 'ops-app' }, }), ], controllers: [AuthController], // marked @Public(); add a login rate limit providers: [ PrismaService, AuthService, PasswordService, JwtStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard }, // 1. every route needs a valid token { provide: APP_GUARD, useClass: RolesGuard }, // 2. then the @Roles() check ], }) export class AuthModule {} -
Scope every order query to the user's sites
The scoping rule lives in one private method. Every read goes through it, so a new endpoint cannot forget.
TypeScript// src/orders/orders.service.ts @Injectable() export class OrdersService { constructor(private readonly prisma: PrismaService) {} private scope(user: AuthUser): Prisma.OrderWhereInput { if (user.role === 'HQ_ADMIN') return {}; // head office sees all sites return { siteId: { in: user.siteIds } }; // everyone else: own sites only } async findOne(user: AuthUser, id: bigint) { const order = await this.prisma.order.findFirst({ where: { AND: [{ id }, this.scope(user)] } }); if (!order) throw new NotFoundException(); // 404: do not confirm it exists return order; } list(user: AuthUser, siteId?: bigint) { return this.prisma.order.findMany({ where: { AND: [this.scope(user), siteId ? { siteId } : {}] }, // ?siteId=31 cannot widen it orderBy: { id: 'desc' }, take: 50, }); } } // src/orders/orders.controller.ts @Get(':id') @Roles('STORE_MANAGER', 'ZONAL_MANAGER', 'HQ_ADMIN') findOne(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) { return this.orders.findOne(user, BigInt(id)); // convert bigint fields to strings for JSON }Output$ curl -s localhost:3000/v1/orders/1001 -H "Authorization: Bearer $TOKEN" # site 27 {"id":"1001","siteId":"27","status":"PAID","total":"5400"} $ curl -s localhost:3000/v1/orders/1002 -H "Authorization: Bearer $TOKEN" # site 31 {"message":"Not Found","statusCode":404} $ curl -s localhost:3000/v1/orders/1001 {"message":"Unauthorized","statusCode":401} -
Add a row-level security policy as a safety net
RLS does not apply to the table's owner or to superusers, so the API must connect as a separate role that does not own the tables (the app role from Running Postgres in production). When
app.site_idsis not set,current_setting(…, true)returns NULL or an empty string, the comparison is never true, and the query sees zero rows. The default is "deny", which is what you want.SQLALTER TABLE "order" ENABLE ROW LEVEL SECURITY; -- The API role sees and writes only rows for the sites set in this transaction CREATE POLICY order_site_scope ON "order" USING (site_id = ANY (string_to_array(NULLIF(current_setting('app.site_ids', true), ''), ',')::bigint[])) WITH CHECK (site_id = ANY (string_to_array(NULLIF(current_setting('app.site_ids', true), ''), ',')::bigint[])); -- A read-only reporting role may read every row CREATE POLICY order_reporting_all ON "order" FOR SELECT TO reporting_ro USING (true);SQL-- Try it as the API role BEGIN; SELECT set_config('app.site_ids', '27,44', true); -- true: only for this transaction SELECT id, site_id FROM "order"; -- rows for 27 and 44 only INSERT INTO "order" (site_id, status, total) VALUES (31, 'PAID', 100); COMMIT;Outputid | site_id ----+--------- 1 | 27 3 | 27 4 | 44 (3 rows) ERROR: new row violates row-level security policy for table "order"In Prisma, set the value inside an interactive transaction. The tagged-template
$executeRawsends the site list as a parameter, so it is safe from injection.TypeScript// src/prisma/with-site-scope.ts import { PrismaClient } from '@prisma/client'; type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$extends'>; export function withSiteScope<T>(prisma: PrismaClient, user: AuthUser, fn: (tx: Tx) => Promise<T>) { return prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.site_ids', ${user.siteIds.join(',')}, true)`; return fn(tx); }); } // usage: the WHERE is still there; RLS catches the day someone forgets it const sales = await withSiteScope(this.prisma, user, (tx) => tx.order.findMany({ where: this.scope(user) })); -
Add security headers, CORS and strict validation
helmet()sets about a dozen safe response headers, includingStrict-Transport-Security(browsers use HTTPS only) andX-Content-Type-Options: nosniff.whitelistdrops unknown body fields, which blocks mass assignment.TypeScript// src/main.ts import helmet from 'helmet'; const app = await NestFactory.create(AppModule); app.use(helmet()); app.enableCors({ origin: ['https://ops.example.com'], methods: ['GET', 'POST', 'PATCH', 'DELETE'] }); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));Output$ curl -sI localhost:3000/v1/orders/1001 -H "Authorization: Bearer $TOKEN" Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Content-Security-Policy: default-src 'self';base-uri 'self';...
Bring it to your team
Ask these for every new endpoint and every new screen that shows data.
- Who may call this endpoint, and which records may each of them see or change?
- Where exactly is the site or owner filter for this query? Show me the line.
- If I change the id in the URL to another store's id, what do I get back?
- How long does an access token live, and how fast can we lock out a staff member who leaves?
- Where is the refresh token stored on the handscanner, and what happens when another person picks it up?
- Which secrets does this feature need, where do they live, and who can read them?
Red flags
findUnique({ where: { id } })with an id from the URL and no owner or site check.- Access tokens that never expire, or refresh tokens kept in AsyncStorage or localStorage.
- "Only admins see that button, so it is safe."
- Passwords hashed with SHA-256 or MD5, or stored encrypted.
- A
.envfile with production secrets in the repository.
Practice
Fix an IDOR in an access check
The starter has the IDOR bug from this lesson. Fix both functions. A user looks like { userId: 5, role: 'STORE_MANAGER', siteIds: [27, 44] }, and each order has id and siteId. A user may see an order when their role is 'HQ_ADMIN' or the order's site is in their siteIds. findOrder(user, orders, orderId) returns the order, or null when it does not exist or the user may not see it (the API turns null into a 404). listOrders(user, orders, requestedSiteId) returns the orders the user may see, narrowed to requestedSiteId when one is given; a requested site must never widen what the user can see.
function findOrder(user, orders, orderId) {
// The IDOR bug: loads by id only, whoever is asking.
return orders.find((o) => o.id === orderId) ?? null;
}
function listOrders(user, orders, requestedSiteId) {
return orders.filter((o) => requestedSiteId === undefined || o.siteId === requestedSiteId);
}
const orders = [
{ id: 1001, siteId: 27 }, { id: 1002, siteId: 31 }, { id: 1003, siteId: 44 }, { id: 1004, siteId: 27 },
];
const ama = { userId: 5, role: 'STORE_MANAGER', siteIds: [27, 44] };
const hq = { userId: 1, role: 'HQ_ADMIN', siteIds: [] };
const ids = (list) => list.map((o) => o.id);
test('a manager can open an order from her own site', () => {
expect(findOrder(ama, orders, 1001)).toEqual({ id: 1001, siteId: 27 });
});
test("another site's order is not found (404, not the order)", () => {
expect(findOrder(ama, orders, 1002)).toBe(null);
});
test('head office can open any order, and unknown ids are not found', () => {
expect(findOrder(hq, orders, 1002).id).toBe(1002);
expect(findOrder(hq, orders, 9999)).toBe(null);
});
test('the list shows only her sites', () => {
expect(ids(listOrders(ama, orders))).toEqual([1001, 1003, 1004]);
});
test('?siteId=31 cannot widen what she sees', () => {
expect(listOrders(ama, orders, 31)).toEqual([]);
expect(ids(listOrders(ama, orders, 44))).toEqual([1003]);
});
test('head office can filter by any site', () => {
expect(ids(listOrders(hq, orders, 31))).toEqual([1002]);
});
Show a solution
// One rule, used by every query: the scoping helper
function canSee(user, order) {
return user.role === 'HQ_ADMIN' || user.siteIds.includes(order.siteId);
}
function findOrder(user, orders, orderId) {
return orders.find((o) => o.id === orderId && canSee(user, o)) ?? null;
}
function listOrders(user, orders, requestedSiteId) {
return orders.filter((o) =>
canSee(user, o) && (requestedSiteId === undefined || o.siteId === requestedSiteId));
}Scope the query in SQL
Now write the same rule where it matters most: in the query. The user_site table says which sites each user may see. User 7 is logged in (in the real API this id comes from the token, never from the request). Return id, site_id and total_cents of every order user 7 may see, ordered by id.
CREATE TABLE "order" (id int PRIMARY KEY, site_id int NOT NULL, status text NOT NULL, total_cents int NOT NULL); CREATE TABLE user_site (user_id int NOT NULL, site_id int NOT NULL, PRIMARY KEY (user_id, site_id)); INSERT INTO "order" VALUES (1001, 27, 'PAID', 5400), (1002, 31, 'PAID', 12900), (1003, 44, 'PENDING', 2300), (1004, 27, 'PENDING', 8800), (1005, 52, 'PAID', 4100); INSERT INTO user_site VALUES (7, 27), (7, 44), (8, 31), (9, 52);
-- Orders user 7 may see. This version shows everyone's orders.
SELECT id, site_id, total_cents
FROM "order"
ORDER BY id;
[{"id": 1001, "site_id": 27, "total_cents": 5400}, {"id": 1003, "site_id": 44, "total_cents": 2300}, {"id": 1004, "site_id": 27, "total_cents": 8800}]
Show a solution
SELECT o.id, o.site_id, o.total_cents
FROM "order" o
WHERE o.site_id IN (SELECT site_id FROM user_site WHERE user_id = 7) -- the scope
ORDER BY o.id;Check yourself
A store manager for site 27 calls GET /orders/1002, an order from site 31, with a valid token. What should a correct API return?
She is authenticated, so 401 is wrong. The query is scoped to her sites, so the order is not found, and 404 does not even confirm it exists. A 403 is also acceptable in some designs; returning the order is the IDOR bug.
Why does the design use a 15-minute access token plus a rotating refresh token, instead of one JWT valid for 30 days?
Any API copy accepts a JWT until its exp, so a stolen 30-day token works for 30 days. A 15-minute token limits that window, and the refresh token lives in the database, where you can revoke it and notice when an old one is replayed.
The RLS helper uses set_config('app.site_ids', …, true) inside a transaction. What goes wrong with a plain SET app.site_ids = … at the start of each request?
A session-level SET lasts until the connection closes or the value is changed, and pooled connections are shared between users. The true flag makes it last only until the transaction ends, like SET LOCAL, so nothing leaks between requests.
Flashcards
Key takeaways
- Authentication proves who the caller is (401). Authorization decides what they may do to which record (403 or 404).
- Use short access tokens and rotating refresh tokens stored as hashes; revoke the whole family on reuse.
- Prevent IDOR by putting the user's sites in every query, through one scoping helper per resource. RLS is the safety net.
- Hash passwords with argon2id; keep secrets in a secret store, never in git.
- Protect by default: a global guard,
whitelistvalidation,helmet, named CORS origins and an audit log for sensitive actions.
Sources
- OWASP Top 10, OWASP Foundation
- OWASP API Security Top 10, 2023 edition, OWASP Foundation
- Insecure direct object reference prevention cheat sheet, OWASP
- Password storage cheat sheet, OWASP
- Row security policies, PostgreSQL documentation
- RFC 7636: Proof Key for Code Exchange (PKCE), IETF, 2015