Backend in Depth

Authentication and Authorization on the Server

Password hashing, session stores, checking JWTs properly, role-based vs attribute-based access control, and checking ownership on every resource.

Lesson 0618 min read#auth#security#rbac

On this page

  1. Words used in this lesson
  2. Authentication
  3. Authorization
  4. Defence in depth
  5. Common mistakes
  6. Interview questions
  7. Key takeaways
  8. What's next

In one line: authentication proves who you are. Authorization decides what you may do. Most real breaches come from the second one: a logged-in user touches data that is not theirs. Check ownership on every resource, in the query, on the server.

Authentication (often shortened to authn) is the process of checking that a user really is who they claim to be, for example with a password. Authorization (authz) is the process of checking whether a known user is allowed to do a given action on a given thing. Everyday comparison: at an office, the badge scanner at the door authenticates you. Which rooms the badge opens is authorization. Difference in one line: authentication answers "who are you?", authorization answers "what may you do?".

The browser-side lessons covered where credentials live (Browser Contract, Lessons 02-03). This lesson is the backend half. It covers how to store passwords, how to issue and check sessions or tokens, and how to design permissions so nobody can bypass them.

Words used in this lesson

  • A session is the server's memory of a logged-in user across many requests. The browser keeps only a session ID (a random key) in a cookie, and the server looks up the rest.
  • A token is a string that proves who you are or what you may do. Whoever holds it can use it.
  • A JWT (JSON Web Token) is a token made of three parts: a header, a set of claims (facts such as the user ID), and a signature that proves nobody changed it. Claims used here: exp (expiry time), iss (issuer, who created the token) and aud (audience, who it is meant for).
  • An access token is a short-lived token sent with each API call. A refresh token is a longer-lived token used only to get a new access token. Rotating a refresh token means giving a new one each time it is used and cancelling the old one.
  • A cookie is a small piece of data the browser stores and sends back to the same server on each request. Its flags: Secure (sent only over HTTPS), HttpOnly (JavaScript cannot read it), SameSite (limits sending it on requests that come from other sites), and the __Host- name prefix (the browser accepts the cookie only if it is Secure, has no Domain, and uses Path=/).
  • Entropy is a measure of how unpredictable a value is, counted in bits. A value with 64 bits of entropy has 2^64 possible values.
  • A cryptographically secure random generator is a source of random numbers that an attacker cannot predict. In Node.js, use crypto.randomBytes or crypto.randomUUID, never Math.random.
  • MFA (multi-factor authentication) asks for two or more kinds of proof, such as a password plus a code from a phone. TOTP is the 6-digit code that changes every 30 seconds in an authenticator app. WebAuthn is a browser standard for logging in with a device key, and a passkey is a WebAuthn login that replaces the password.
  • A CAPTCHA is a test that tries to tell humans from programs.
  • Brute force means trying many guesses until one works.
  • A tenant is one customer (or organisation) in an app that serves many customers.
  • A role is a named set of permissions, such as admin. A permission is the right to do one action.
  • Least privilege means giving each person, program or key only the rights it needs, and no more.
  • A policy function is a small function that answers "may this user do this?" with true or false.
  • Row-level security (RLS) is a PostgreSQL feature that lets the database itself hide rows a user may not see.
  • An identity provider is a service (such as Google) that logs users in and tells your app who they are.

Authentication

Storing passwords

The rules:

  1. Never store passwords (or anything that can be turned back into the password). Store a slow, salted, one-way hash. A hash is a fixed-size scrambled value made from the input by a function. You cannot turn it back into the password, but the same input always gives the same hash, so you can compare. A salt is random extra data added to each password before hashing. It makes sure two users with the same password get different hashes. Encryption is different: it can be reversed with a key, so it is the wrong tool for passwords.
  2. Use a password hashing function, not a general hash. Fast hashes (SHA-256, MD5) can be guessed by brute force at billions of tries per second on a GPU.
  3. Recommended algorithms (from OWASP's Password Storage guidance, roughly from best to less good): Argon2id, then scrypt, then bcrypt (cost factor of at least 10, and remember its 72-byte input limit), or PBKDF2 where rules require it (for example FIPS-140 compliance). Argon2id is a password hashing algorithm that is deliberately slow and memory-hard: it needs a lot of memory for each guess, which makes attacks on GPUs expensive. A cost factor is a setting that makes the hash slower when you raise it.
  4. A unique random salt for each password is built into these libraries. It is stored inside the final hash string. A pepper is an optional extra layer. It is a secret key kept outside the database, for example in a secrets manager.
  5. Choose settings that cost real time (tens to hundreds of milliseconds). Raise them as hardware gets faster. Store the settings inside the hash, so you can rehash on login when they change.
  6. Compare in constant time. This means the comparison always takes the same time, however many characters match, so attackers learn nothing from timing. The libraries do this for you.
JavaScript
import argon2 from "argon2";export const hashPassword = (plain: string) =>  argon2.hash(plain, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }); // OWASP's minimum-profile style settingsexport async function verifyPassword(hash: string, plain: string) {  return argon2.verify(hash, plain); // constant-time compare inside}

Hashing is slow on purpose (it uses CPU and memory). So use the asynchronous APIs, so you do not block the event loop (Lesson 10). Also rate-limit login attempts.

Login endpoint hygiene

  • Use one generic failure message. Say "Invalid email or password" for both an unknown email and a wrong password. Then you do not show which accounts exist. This attack is called user enumeration (finding out which accounts exist by comparing the answers). Make the timing equal too: hash a dummy password when the user is not found.
  • Rate limit (allow only a set number of attempts in a time window), and lock out step by step (for each account and each IP address). This slows credential stuffing (trying stolen email and password pairs from other sites). After repeated failures, add a CAPTCHA or MFA check.
  • Support MFA (see the words above: TOTP apps, WebAuthn/passkeys). Passkeys remove the password, which is the weak link.
  • Create a new session ID on login. This stops session fixation: an attacker plants a session ID they know before the victim logs in.
  • Password reset: use a long random token that works once. Store it hashed. Give it a short life (for example 15–60 minutes). Invalidate it after use. Send it only to the registered email. Give a generic answer ("If that account exists, we sent an email").
  • Check passwords against lists of known leaked passwords. Prefer length over complex rules. Do not force odd composition rules or regular password changes.

Sessions or tokens

Browser Contract Lesson 02 covers this in depth. The server-side basics are:

  • Session IDs must come from a cryptographically secure random generator with plenty of entropy. OWASP asks for at least 64 bits. In practice, use 128 bits or more (crypto.randomBytes(32)).
  • Store sessions in Redis (a fast in-memory data store) or the database with an expiry. Use an idle timeout (time with no activity) and an absolute timeout (maximum total life).
  • Cookie flags: __Host- prefix, Secure, HttpOnly, SameSite.
  • For JWTs: check the signature with a fixed algorithm that you choose (do not let the token pick it). Check exp, iss and aud. Never use decode as if it were verify: decode only reads the content, and verify also checks the signature. Keep access tokens short-lived and rotate refresh tokens.
  • Logout destroys the session or revokes the refresh token on the server.
JavaScript
// Express middleware: authentication only, "who is this?" (async throws reach the error handler in Express 5)// Assumes cookie-parser fills req.cookies, and that `sessions` and `UnauthorizedError` exist in your app.export const requireAuth: RequestHandler = async (req, _res, next) => {  const sid = req.cookies["__Host-sid"];  const session = sid ? await sessions.get(sid) : null;          // Redis lookup  if (!session) throw new UnauthorizedError();                    // 401 → "log in"  req.user = { id: session.userId, role: session.role };  next();};

Authorization

Authentication says "this is user 42." Authorization asks "may user 42 do this action on that resource?" You must check it on the server, for every request, using the current data.

Broken access control

OWASP puts Broken Access Control at the top of its Top 10 list of web application risks. (OWASP is a non-profit group that publishes security guidance.) Its API Security list puts Broken Object Level Authorization (BOLA) first. This means the API accepts an object ID but does not check that the caller may access that object. It is also called IDOR (insecure direct object reference).

The classic scenario: a user changes /orders/123 to /orders/124 and sees someone else's order. What went wrong? The server checked who the user was, but never checked whether they may access that specific object. UUIDs make guessing harder, but they do not fix this. Only authorization checks fix it.

Enforce ownership in the query

Put the check in the data access code, so it is impossible to forget:

JavaScript
// ❌ Fetches by ID alone. Any logged-in user can read any order.const order = await prisma.order.findUnique({ where: { id } });// ✅ Scope by owner: someone else's order simply "doesn't exist" for this userconst order = await prisma.order.findFirst({ where: { id, userId: req.user.id } });if (!order) throw new NotFoundError("Order"); // 404, not 403, so existence isn't revealed

The same rule applies to updates and deletes:

JavaScript
const { count } = await prisma.post.deleteMany({ where: { id, authorId: user.id } });if (count === 0) throw new NotFoundError("Post");

In multi-tenant systems (one app that serves many customers), always include tenantId in every query. Or enforce it with a Prisma extension (code that adds a filter to every query) or PostgreSQL row-level security. Then a forgotten filter cannot leak data between customers.

Also check function-level and property-level authorization:

  • Function level: admin-only endpoints must check the role. Do not rely on them being "hidden". (Mass assignment, where a client sets fields it should not, is covered in Lesson 03.)
  • Property level: a user may update their displayName but not their role. Filter response fields, so users cannot see other people's private attributes.

Authorization models

ModelIdeaGood forWatch out for
RBAC (role-based)Users have roles. Roles have permissions (admin, editor, viewer)Most apps; simple and easy to audit"Role explosion" (too many roles); cannot say "only their own" without extra checks
ABAC (attribute-based)Decisions use facts about the user, resource, action and context (dept == resource.dept && time in office hours)Fine-grained rules that depend on contextHarder to understand and test
ReBAC (relationship-based)Access comes from relationships (owner, member of team, shared with)Sharing models like Drive or GitHubNeeds a graph or a dedicated engine
Ownership checks"Is resource.ownerId === user.id?"Almost every endpointEasy to forget unless you have a pattern

Most backends use RBAC plus ownership checks, kept in policy functions:

JavaScript
// A single place that answers "may this user do this?"export const can = {  readOrder:   (u: User, o: Order) => u.role === "admin" || o.userId === u.id,  cancelOrder: (u: User, o: Order) => (u.role === "admin" || o.userId === u.id) && o.status === "pending",  refundOrder: (u: User)           => u.role === "admin" || u.role === "support",};export async function cancelOrder(user: User, orderId: string) {  const order = await orders.getById(orderId);  if (!order || !can.readOrder(user, order)) throw new NotFoundError("Order"); // don't reveal existence  if (!can.cancelOrder(user, order)) throw new ForbiddenError("Order cannot be cancelled");  // ... cancel the order}

Policies in one module are easy to test and audit. Default deny means that if no rule says yes, the answer is no.

Don't trust the client for authorization data

Roles, user IDs, tenant IDs and prices must come from the session or the database. They must not come from request bodies, headers or hidden form fields. You can trust a JWT's claims only after you have verified the token. Old claims are a risk with long-lived tokens. For example, an admin who was demoted may still have admin rights in an old token.

401 vs 403 vs 404

  • 401 (Unauthorized): the user is not authenticated. (The name is misleading: it means "not authenticated".)
  • 403 (Forbidden): the user is authenticated but not allowed (use it when the user is allowed to know the resource exists).
  • 404: use it when saying that a resource exists would itself leak information (someone else's order).

Defence in depth

"Defence in depth" means using several layers of protection, so if one fails, others still help.

  • Rate limit authentication and sensitive endpoints (Practical Design Problems, Lesson 02).
  • Audit-log (keep a permanent record of) security events: logins, failures, permission changes, data exports. Record user IDs, never secrets.
  • Least privilege everywhere: give database users, cloud roles and API keys only the rights they need.
  • Secrets management: keep keys in a secrets manager and rotate them. Never keep them in the repo.
  • Test authorization directly. For each endpoint, write tests that show a different user, an unauthenticated user and a lower-privileged role are denied.
JavaScript
it("does not let user B read user A's order", async () => {  const order = await createOrderFor(userA);  const res = await request(app).get(`/v1/orders/${order.id}`).set(authFor(userB));  expect(res.status).toBe(404);});

Common mistakes

  1. Checking authentication but not ownership (the IDOR/BOLA bug).
  2. Authorization only in the UI or only in a route guard, and not in the data layer.
  3. Fast hashes (SHA-256) or hashes with no salt for passwords. Also logging passwords by accident.
  4. Different error messages for "no such user" and "wrong password".
  5. Trusting IDs, roles or tenant IDs from the request body.
  6. No new session ID on login, and session IDs that can be guessed.
  7. Old roles inside long-lived tokens.
  8. No default deny. New endpoints stay open until someone remembers to protect them.

Interview questions

How do you store passwords?

I use a slow, salted, memory-hard password hashing algorithm (it needs a lot of memory for each guess, so attacks are costly). I prefer Argon2id (or scrypt, or bcrypt with a good cost). The library makes a unique salt and puts the settings in the output. I check passwords with the library's constant-time compare and use async APIs. I rate-limit logins, and I rehash on login when I upgrade the settings. I never use SHA-256 or reversible encryption.

Where do you enforce "users can only edit their own posts"?

On the server, in the data access path. The update or delete query includes the owner (where: { id, authorId: user.id }). A policy function in the service states the rule. It is not enough to hide the button in the UI, or to rely on a role check on the route. The decision about a single object depends on that specific record.

Scenario: changing /orders/123 to /orders/124 shows another user's order. What failed?

This is broken object-level authorization (IDOR). The endpoint knew who the caller was, but it did not check that this order belongs to them. I fix it by limiting the query to the current user (or tenant) and returning 404 when nothing matches. I add policy functions and regression tests for access across users. I also review other endpoints for the same pattern. Using UUIDs alone is not a fix.

What are the entropy requirements for session IDs?

They must come from a cryptographically secure random generator, and nobody must be able to guess them. OWASP says at least 64 bits of entropy. In practice I use 128 bits or more (for example 32 random bytes). Then brute force is impossible, even with very many active sessions.

RBAC vs ABAC?

RBAC gives permissions through roles. It is simple and easy to audit, but it struggles with "own resources only" and rules that depend on context. ABAC checks facts about the user, the resource, the action and the context. It gives fine-grained rules, but it is more complex. Most apps use RBAC plus clear ownership checks. They move to ABAC or relationship-based models only when the requirements need it.

Why return 404 instead of 403 for someone else's resource?

A 403 confirms that the resource exists. That lets attackers find valid IDs and learn about other tenants' data. A 404 for objects the user should not even know about avoids this leak. I use 403 when the user knows the resource exists but lacks permission for the action.

What is session fixation and how do you prevent it?

An attacker sets or learns a victim's session ID before login. If the app keeps the same ID after login, the attacker now has a logged-in session. To prevent it, I issue a new session ID on login (and when privileges change) and invalidate the old one. I also use strict cookie settings.

How do you avoid user enumeration at login and reset?

I return the same generic message and status for an unknown email and a wrong password. I make response times similar (for example, by checking against a dummy hash). For password reset, I always answer "If that account exists, we've sent instructions." I also rate-limit these endpoints.

Scenario: you add "Sign in with Google" next to email and password. What account-linking risks do you handle?

"Sign in with Google" uses OAuth/OpenID Connect: Google confirms the user's identity to your app. The risk is pre-account-takeover. An attacker registers with a victim's email and a password before the victim uses Google (or the other way around). I link accounts only when the identity provider confirms that the email is verified. I ask the user to prove they control the existing account (password or email link) before linking. I treat unverified emails as separate identities. I never merge accounts automatically on email alone.

Key takeaways

  • Authentication is not authorization. Most severe bugs are missing object-level checks.
  • Hash passwords with Argon2id/scrypt/bcrypt, never with fast hashes. Rate-limit and use generic login errors.
  • Limit every query by owner or tenant. Keep rules in policy functions. Default deny.
  • Take roles and IDs from the session, not from the request. Session IDs need high entropy and must be renewed on login.
  • Test authorization: another user, no user, a lower role.

What's next

Lesson 07: Database Fundamentals for API Engineers. Almost every slow or wrong API comes back to the database. Next: indexes, EXPLAIN ANALYZE, transactions and isolation levels.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series