1. Courses
  2. System Design
  3. Running Software in Production
  4. Stop 2

40 minIntermediate

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

(AuthN) answers "who are you?" and fails with 401 Unauthorized. (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. 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 , 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?

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 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 : a JWT that lives about 15 minutes, sent on every call in the Authorization: Bearer … header.
  • A : a long random string, valid for days or weeks, stored in the database (as a hash), and sent only to /auth/refresh to get a new pair.

With , 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?

App API Postgres 1. email + password 2. verify argon2 3. store hash(RT1) 4. access + RT1 access lives 15 min 5. API call + access 6. 401 expired 7. refresh with RT1 8. mark RT1 used 9. new access + RT2 Later, RT1 is used again Someone kept a copy. The API revokes RT1, RT2 and every token of this login. All devices must log in again.
Login gives a 15-minute access token and refresh token RT1. When an API call gets a 401, the app sends RT1 to /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.

and 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. 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?

The bug that bites most: IDOR

An (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.

Store manager, site 27 token: siteIds = [27] Bug: load by id only GET /orders/1001 her order, OK GET /orders/1002 she edits the URL WHERE id = 1002 200: site 31's order and customer Fix: scope every query GET /orders/1002 WHERE id = 1002 AND site_id IN (27) 0 rows: 404 Not Found 404, not 403: do not confirm it exists
The token is valid in both cases. With the bug, the query uses only the id from the URL, so any logged-in user can read any order by guessing ids. With the fix, the user's allowed sites are part of the query, so another site's order is not found.

Three habits prevent IDOR:

  • Scope in the query, not after it. Put the user's allowed sites in the WHERE clause 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?

As a second safety net, Postgres can enforce the same rule itself with (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 . 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 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:

RiskWhat it looks likeMain defence
Injection'1 OR 1=1' pasted into a search box reaches SQLParameters only: Prisma queries, $queryRaw tagged templates (see SQL you'll actually use); never string-built SQL
"Import image from URL" is given http://10.0.1.5:5432 or a cloud metadata addressAllow-list of hosts; block private and link-local addresses; no redirects
MisconfigurationDebug 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 assignmentBody includes "role":"HQ_ADMIN" and it is savedDTOs with whitelist: true; never pass the raw body to update
Supply chainA hijacked npm package steals environment variablesLock file, npm ci, dependency alerts, few dependencies

HTTPS everywhere, including between your own servers when they cross a network you do not control. 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.

  1. 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.

    SQL
    CREATE 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 Role enum with @@map("role"), and models User (@@map("app_user")), UserSite with @@id([userId, siteId]), and RefreshToken with familyId String @db.Uuid and tokenHash String @unique.

  2. 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. needsRehash lets 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 };
      }
    }
  3. 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. dummyHash is 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 };
      }
  4. 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() } });
      }
    }
  5. Verify the access token with passport-jwt

    Install with npm i @nestjs/passport passport passport-jwt @nestjs/jwt and npm 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) };
      }
    }
  6. Add @Public(), @Roles() and two global guards

    Making 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 {}
  7. 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}
  8. 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_ids is 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.

    SQL
    ALTER 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;
    Output
     id | 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 $executeRaw sends 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) }));
  9. Add security headers, CORS and strict validation

    helmet() sets about a dozen safe response headers, including Strict-Transport-Security (browsers use HTTPS only) and X-Content-Type-Options: nosniff. whitelist drops 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 .env file 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.

JavaScript exerciseRuns in your browser with tests
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);
}
Ctrl + Enter runs
Show a solution
JavaScript
// 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));
}
Pass the exercise to finish this task.

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.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Orders user 7 may see. This version shows everyone's orders.
SELECT id, site_id, total_cents
FROM "order"
ORDER BY id;
Ctrl + Enter runs
Show a solution
SQL
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;
Pass the exercise to finish this task.

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?

Why does the design use a 15-minute access token plus a rotating refresh token, instead of one JWT valid for 30 days?

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?

Flashcards

Open my review

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, whitelist validation, helmet, named CORS origins and an audit log for sensitive actions.

Sources

Episode transcriptOne number changed in a URL · about 13 minutes · tap any line to play from there
  1. NadiaImran, guess how most real data breaches happen. Not in films. In real companies.
  2. ImranSome clever hacker breaking the encryption?
  3. NadiaAlmost never. Someone changes a number in a URL and sees another store's orders. Or finds a database password sitting in a git repository. Most breaches aren't clever at all.
  4. ImranThat's more worrying. Because that could be us.
  5. NadiaSo today is the short list of controls that stop most of them. And it starts with the ID card every store staff member wears.
  6. NadiaThe guard at the staff door checks that the card is real and not expired. That's authentication, proving who you are. Inside, the card opens some doors and not others.
  7. ImranA cashier's card opens the sales floor but not the cash room.
  8. NadiaAnd the store manager's card opens the cash room of this store, not the one across the road. That's authorization, deciding what you may do.
  9. NadiaMost software break-ins happen at the second step. The guard checked the card at the front door, but inside, nobody checks which room you walk into.
  10. ImranSo the API checks the token is valid, and then hands order one thousand and two to whoever asks.
  11. NadiaExactly that. Authentication answers who are you, and fails with four oh one. Authorization answers may you do this, to this record, and fails with four oh three, or four oh four if you don't want to reveal the record exists.
  12. ImranAnd authorization is just roles, right? Cashier, store manager, zonal manager.
  13. NadiaThat's the first layer, and teams often build only that one. Role checks say, only store managers and above may approve a GRN reversal. One check per route.
  14. NadiaThe second layer is record checks. This zonal manager may see orders only for the fourteen stores in her zone. The answer depends on the user and the record, so it must be in every query.
  15. ImranAnd that second layer is where the bugs are.
  16. NadiaThe most common serious one. OWASP, the group behind the best-known list of web risks, put broken access control at number one in both its twenty twenty-one and twenty twenty-five lists.
  17. ImranBefore the bug, how does the app prove who it is on every call?
  18. NadiaHTTP forgets you after each request, so the client sends proof every time. One design is a server session: the login lives in Redis or Postgres, and the browser holds a random session ID in a cookie. Logout deletes it instantly.
  19. NadiaThe other is a JWT, a JSON web token. A small signed object with the user ID, role and expiry. Any API copy can check the signature with a key, with no lookup.
  20. ImranSo we give the app a JWT valid for thirty days, and it never has to log in again.
  21. NadiaThat's the tempting design, and the catch is serious. A JWT is valid until it expires. Fire someone one minute after issuing it, and a stolen thirty-day token works for thirty days.
  22. ImranSo what's the standard answer?
  23. NadiaTwo tokens. A short access token, a JWT that lives about fifteen minutes and goes with every call. And a refresh token, a long random string valid for days or weeks, stored in the database as a hash, used only to get a new pair.
  24. NadiaIf you can see the screen, the first diagram shows this as a conversation between the app, the API and Postgres. Login checks the password and returns an access token and refresh token one. Fifteen minutes later, an API call gets four oh one, expired.
  25. ImranSo the app sends refresh token one, and gets a new pair with refresh token two.
  26. NadiaAnd token one is marked used. That's rotation. Now the bottom of the diagram. Later, token one shows up again.
  27. ImranWhich means someone kept a copy.
  28. NadiaSo the server revokes the whole chain, token one, token two, everything from that login, and every device must sign in again. And locking out a staff member who leaves is simple: delete their refresh tokens, and within fifteen minutes their access is gone.
  29. ImranWhere does the app keep these tokens?
  30. NadiaOn the web, never in local storage, where any injected script can read them. Put the refresh token in a cookie marked HttpOnly, Secure and SameSite, and keep the access token in memory.
  31. NadiaOn mobile, use the phone's secure storage, the iOS Keychain or Android Keystore, through a library like expo-secure-store. Never AsyncStorage, because it's a plain file.
  32. ImranAnd our handscanners are shared between staff.
  33. NadiaThen you need 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.
  34. ImranThe lesson also covers OAuth. Sign in with Google, that kind of thing.
  35. NadiaIn plain words: the app opens the login server's page, the user signs in there, so your app never sees the password. The login server sends back a one-time code, and the app trades it for tokens, plus a secret it made up at the start.
  36. NadiaThat made-up secret is PKCE, proof key for code exchange, and a stolen code is useless without it. If the company already has single sign-on for staff, use it through a maintained library instead of writing your own login.
  37. ImranNow the bug. The one with the URL.
  38. NadiaIt's called IDOR, insecure direct object reference. The user is logged in, the token is fine, and the API loads the record by the ID in the URL and returns it, without asking whether this user may see it.
  39. NadiaThe second diagram shows it. A store manager for site twenty-seven opens order one thousand and one. Hers, fine. Then she edits the URL to one thousand and two. The buggy query asks only for ID one thousand and two, and returns site thirty-one's order, with the customer details.
  40. ImranAnd with the fix?
  41. NadiaThe query says ID one thousand and two, and site in twenty-seven. Zero rows. Four oh four, not found. Which doesn't even confirm the order exists.
  42. ImranCouldn't we just switch to random UUIDs? Then nobody can guess the next order.
  43. NadiaThat's the trap. Random IDs help a little, but they leak through logs, shared links and screenshots. They don't fix IDOR. The fix is three habits.
  44. NadiaScope in the query, not after it. Put the user's allowed sites in the where clause of every read, update and delete. Use one scoping function per resource, so the rule lives in one place. And never trust IDs from the client for ownership. The user ID comes from the token.
  45. ImranAnd if a developer still forgets the filter on endpoint number fifty-one?
  46. NadiaThat's what Postgres row-level security is for, a second safety net. A table policy says, only rows whose site is in a setting called app site IDs. The API sets that at the start of each transaction. Forget the filter, and you get zero rows instead of the whole chain.
  47. ImranAny catch?
  48. NadiaEvery query must run in a transaction that sets the value, and debugging why do I see no rows gets harder. Many teams use it only on the most sensitive tables.
  49. NadiaAnd one subtle point. The helper uses set config with a true flag, instead of a plain set command. Why? Because connections are pooled and shared between users.
  50. ImranSo a plain set would stay on the connection, and the next user who borrows it inherits site twenty-seven.
  51. NadiaExactly. The true flag makes it end with the transaction, so it can never leak to the next user.
  52. ImranNow passwords. I assume we encrypt them.
  53. NadiaNever encrypt them. Anything you can decrypt, an attacker with your key can decrypt too. Store a slow, salted hash. Argon2id is the first choice today, bcrypt an acceptable older one.
  54. NadiaSlow on purpose, about fifty to two hundred and fifty milliseconds per hash. Never a fast hash like SHA two fifty-six or MD5 for passwords, because a graphics card can test billions per second.
  55. ImranAnd secrets, like the database password?
  56. NadiaFrom environment variables, filled by a secret store. Never in git, not even a private repository, because git history keeps them forever. Rotate them when someone with access leaves.
  57. NadiaThe lesson's short list has a few more. Injection, which parameters prevent. Mass assignment, where a body sneaks in role equals HQ admin. And CORS, which only tells browsers which websites may call you. Curl and mobile apps ignore it, so it isn't access control.
  58. ImranAnd for sensitive actions, like price changes or GRN reversals, an audit log.
  59. NadiaAn append-only table: who, what, which record, old and new values, when, and the request ID. The API may insert into it but never update or delete. When something goes wrong, it's the table everyone asks for.
  60. ImranLet me picture the lesson's Mangrove example, our zonal manager with fourteen stores.
  61. NadiaA user site table maps her to her sites, and login puts those site IDs in her access token. Every order, GRN and stock query adds her sites through one helper, and the sales and GRN tables have a row-level policy too.
  62. ImranAnd when she moves zone?
  63. NadiaHR updates her sites, her refresh tokens are revoked, and her new token carries the new sites within fifteen minutes.
  64. NadiaNow the classic mistake. The app hides the approve reversal button for cashiers, so the team assumes cashiers can't approve.
  65. ImranBut anyone with a valid token can call that endpoint with curl. Hiding a button is user experience, not security.
  66. NadiaExactly. The Build-it, in words. Create the tables: users with a role, a user site table, and a refresh token table that stores only the token's hash and a family ID for each login.
  67. NadiaHash passwords with argon2id at OWASP's minimum settings. At login, if the email doesn't exist, still verify against a dummy hash, so both failures take the same time and nobody learns which emails have accounts.
  68. ImranThen refresh with rotation.
  69. NadiaWith a thirty-second grace period, because on a weak store network the response can get lost and the app retries with the same token. Outside the grace period, reuse means theft, and the family is revoked.
  70. NadiaThen a global JWT guard, so every new endpoint is protected unless marked public, and a roles guard after it. Then the order service's scope helper. In the test, order one thousand and one came back, one thousand and two gave four oh four, and no token gave four oh one.
  71. NadiaLast, the row-level policy, with the API connecting as a role that doesn't own the tables. Then helmet for safe headers, named CORS origins, and strict validation that drops unknown fields.
  72. ImranMy review questions for every new endpoint. Who may call it, and which records may each of them see?
  73. NadiaAnd where exactly is the site filter for this query? Show me the line.
  74. ImranAnd I can practise exactly that line in the page.
  75. NadiaTwo ways. In JavaScript you fix a find order function that has the bug, so another site's order comes back as not found. Then in SQL you write the scoped query yourself, with the user's sites in the where clause.
  76. ImranIf I change the ID in the URL to another store's, what do I get back? And how fast can we lock out someone who leaves?
  77. NadiaHere's one to think about. A store manager for site twenty-seven asks for an order from site thirty-one, with a valid token. Should the API answer four oh one? Take a second.
  78. ImranNo. She is logged in, so four oh one is wrong. Four oh four, because her scoped query finds nothing.
  79. NadiaRight. Some designs use four oh three, which is acceptable. Returning the order is the IDOR bug.
  80. ImranMy takeaways. One: authentication is the guard at the door, authorization is which rooms the card opens. Two: short access tokens, rotating refresh tokens, revoke the family on reuse. Three: the user's sites go in every query, with row-level security as the safety net.
  81. NadiaAnd one addition: argon2id for passwords, and secrets never in git. Next time, how code gets from a laptop to the stores safely, and how to undo a bad release in one command.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Threat model checklist

STRIDE questions for one feature as data, with tests that every threat has an owner and a mitigation.

Open in the Security Lab
Next stopShipping safely: containers, CI/CD and releases
Was this lesson helpful?
Questions and discussion

Loading the discussion…