Skip to content

Security (Auth & Authorization)

Who you are versus what you may do, storing passwords correctly, sessions versus tokens, and the bug everyone ships.

Intermediate17 minUpdated 27 September 2026

Two words that sound alike, are constantly confused, and protect against completely different failures.

Authentication answers who are you? Proving identity — a password, a passkey, a code from your phone.

Authorization answers what are you allowed to do? Deciding permissions for an already-identified user.

The reason to keep them straight is that a system can get one perfectly right and still be wide open. Flawless login means nothing if any logged-in user can read anyone else's data by changing a number in the URL.

Storing passwords

The rule is absolute: never store a password you can read.

Not encrypted — encryption is reversible, so anyone who obtains the key obtains every password. Not hashed with a general-purpose function either, because SHA-256 is designed to be fast, and fast is exactly wrong here. A modern GPU computes billions of SHA-256 hashes per second, so a stolen table of them is cracked at leisure.

Use a slow, salted, purpose-built password hash: bcrypt, scrypt, or Argon2.

import bcrypt from 'bcrypt';

// Registration — the cost factor makes each hash deliberately slow
const hash = await bcrypt.hash(password, 12);

// Login — compares in constant time
const ok = await bcrypt.compare(submitted, storedHash);

Two properties do the work:

Deliberate slowness. A cost factor of 12 takes roughly a quarter of a second. A user logging in never notices; an attacker trying billions of guesses now needs centuries.

Salting. A random salt is generated per password and stored alongside the hash. Identical passwords therefore produce different hashes, which defeats precomputed rainbow tables and stops an attacker cracking two accounts for the price of one. Bcrypt does this automatically — you do not manage salts yourself.

Sessions or tokens

Once someone proves who they are, the server must remember it across requests. Two approaches, with a genuine trade-off.

Server-side sessions. The server stores session state and gives the client an opaque id in a cookie. Every request looks it up.

JWTs (JSON Web Tokens). The server signs a token containing the user's identity and hands it over. The signature proves authenticity, so no lookup is needed.

SessionsJWTs
Server stateRequired (store or cache)None needed
RevocationImmediate — delete the sessionHard — valid until it expires
ScalingNeeds shared session storageAny server can verify
SizeSmall idLarger, sent on every request

The decisive difference is revocation. Sessions can be destroyed instantly when a user logs out, changes their password, or is fired. A JWT remains valid until it expires, because verification is self-contained — there is nothing to delete.

The usual mitigation is short-lived access tokens (say fifteen minutes) with a longer refresh token that is stored server-side and can be revoked. That reclaims revocation, at the cost of the state you were trying to avoid.

Whichever you choose, set cookies correctly:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

HttpOnly stops JavaScript reading it, which contains the damage from an XSS bug. Secure refuses to send it over plain HTTP. SameSite blocks the cookie from being attached to cross-site requests, defeating most CSRF.

The bug almost everyone ships

Conceptual diagramnoyesnoyesnoyesRequest names an objectAuthenticated?401May THIS user access THIS object?404, not 403Permitted for this action?Serve
Diagram, as text
  • Request names an object leads to Authenticated?
  • Authenticated? leads to 401 (no)
  • Authenticated? leads to May THIS user access THIS object? (yes)
  • May THIS user access THIS object? leads to 404, not 403 (no)
  • May THIS user access THIS object? leads to Permitted for this action? (yes)
  • Permitted for this action? leads to 404, not 403 (no)
  • Permitted for this action? leads to Serve (yes)

Here is the single most common serious vulnerability in application code:

app.get('/api/orders/:id', requireLogin, async (req, res) => {
  const order = await Order.findById(req.params.id);
  res.json(order);
});

requireLogin checks authentication, so this feels protected. It is not. Any logged-in user can read any order by changing the id.

This is broken object-level authorization, also called IDOR. Authentication passed. Authorization was never performed.

The fix is to scope every query by the requester:

app.get('/api/orders/:id', requireLogin, async (req, res) => {
  const order = await Order.findOne({ id: req.params.id, userId: req.user.id });
  if (!order) return res.status(404).end();   // 404, not 403
  res.json(order);
});

Two details matter. Filter in the query, not with an if after fetching — the version that fetches first leaks data through timing and is easy to forget. And return 404 rather than 403 for resources belonging to others, because 403 confirms the record exists, which is itself information.

Ask of every endpoint: not "is this user logged in?" but "is this user allowed to touch this specific row?"

Modelling permissions

Role-based access control (RBAC) assigns roles, and roles carry permissions.

const permissions = {
  admin:  ['orders:read', 'orders:write', 'users:manage'],
  seller: ['orders:read', 'orders:write'],
  viewer: ['orders:read'],
};

Simple, auditable, and sufficient for most systems. Check permissions rather than roles in your code — can(user, 'orders:write') survives the day a new role is added, while user.role === 'admin' does not.

Attribute-based access control (ABAC) decides from attributes of the user, the resource and the context: "a seller may edit an order if they own it and it is not yet shipped." More expressive, and needed once rules depend on the relationship between user and resource — which, as the IDOR example shows, is most of the time.

Defence in depth

No single control should be all that stands between an attacker and your data.

  • Validate every input at the boundary. Covered in the DTO lesson — it is a security control, not only a correctness one.
  • Use parameterised queries always. String-concatenated SQL is how injection happens, and an ORM does not save you if you drop to raw SQL.
  • Escape output by context. React escapes by default; dangerouslySetInnerHTML opts out of that protection.
  • Rate limit authentication endpoints. Covered separately — it is what makes credential stuffing impractical.
  • Least privilege everywhere. Your application's database user rarely needs DROP TABLE.
  • Log security events — failed logins, permission denials, password changes — without logging the credentials themselves.

Check your understanding

Common mistakes

Authorization checks in the UI only. Hiding a button is a usability choice. The API is the security boundary, and anyone can call it directly.

Leaking existence through error messages. "No account with that email" tells an attacker which addresses are registered. Return the same message for unknown user and wrong password.

Long-lived tokens with no revocation. A thirty-day JWT is a thirty-day window for anyone who steals it.

Secrets in source control. Covered in configuration management — git history is permanent, so a leaked key must be rotated.

Rolling your own session ids. Math.random() is not cryptographically secure and its output is predictable. Use crypto.randomUUID() or your framework's session handling.

Practice

Design authorisation for a document product where a document can be owned by a user, shared with named users, shared with a team, or public. Say where the check happens and how you would stop it being forgotten.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

A rule that must be remembered on every endpoint will eventually be forgotten on one.

Show a strong answer

Model the question as a single function, not as a condition repeated per endpoint: canAccess(user, document, action) → allow | deny, resolving in a defined order — owner, then explicit share, then team membership, then public, then deny.

Where the check happens: as close to the data as possible. The strongest version is that the query itself cannot return rows the user may not see — the access predicate is part of the data-fetching layer rather than a separate check the caller must remember. Then a new endpoint that forgets to authorise still returns nothing, because the query never selected it.

How to stop it being forgotten — this is the actual problem, since the rule is easy and remembering it everywhere is not:

  • Deny by default. The base query returns nothing unless a permission predicate is applied. Forgetting the check yields an empty result rather than everyone's documents. The failure mode becomes visible and harmless instead of silent and severe.
  • Make the unsafe path hard to reach. If the only way to load a document is a repository method that requires a user argument, there is no convenient way to write the vulnerable version.
  • Test it as a matter of routine. For each endpoint, a test that a non-owner receives 404. Make it a required part of adding an endpoint, so the gap fails CI rather than review.
  • Return 404, not 403, for objects the user may not see. A 403 confirms the object exists, which leaks information an attacker can enumerate.

What not to rely on: unguessable identifiers. They reduce casual enumeration and are not an access control — IDs leak through logs, referrers, screenshots and support tickets.

A colleague proposes storing the session token in localStorage 'because cookies are complicated'. Give the trade-offs honestly and make a recommendation.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Consider which attack each option is vulnerable to, and which you can actually mitigate.

Show a strong answer

localStorage: readable by any JavaScript on the page. If a cross-site scripting flaw exists anywhere — including in a third-party script, an analytics tag or a dependency — the token can be read and exfiltrated. It is not sent automatically, so cross-site request forgery is not a concern.

A cookie with HttpOnly, Secure and SameSite: unreadable by JavaScript, so XSS cannot steal it directly. It is sent automatically with requests to your domain, which is what creates CSRF exposure — mitigated by SameSite=Lax or Strict, which modern browsers apply, plus a token for state-changing requests where needed.

The asymmetry that decides it: with localStorage, an XSS flaw means full account takeover — the attacker takes the token and uses it from their own machine, indefinitely. With an HttpOnly cookie, an XSS flaw is still serious, but the attacker must act through the victim's browser while the session lives; they cannot walk away with the credential.

CSRF has a complete, well-understood defence that is on by default. XSS does not — it is a whole class of bugs across your code and every dependency, and you will eventually have one.

Recommendation: the cookie, with HttpOnly, Secure, SameSite=Lax, a short-lived access token and a refresh token in a separate cookie scoped to the refresh endpoint only. "Cookies are complicated" is true and the complication is where the protection lives.

One honest exception: a pure API consumed by a native mobile app, where there is no browser and therefore no CSRF and no DOM to inject into. There, a token in secure device storage is reasonable.

Key takeaways

  • Authentication is who you are; authorization is what you may do. Perfect login still leaves you exposed without per-resource checks.
  • Store passwords with a slow, salted hash — bcrypt, scrypt or Argon2. Fast hashes like SHA-256 are the wrong tool.
  • Sessions revoke instantly; JWTs cannot. Prefer sessions by default; keep tokens short-lived if you need statelessness.
  • Set cookies HttpOnly, Secure, SameSite to contain XSS and defeat most CSRF.
  • Broken object-level authorization is the most commonly shipped serious bug. Scope every query by the requester, and return 404 rather than 403.
  • Check permissions, not roles, so new roles do not require rewriting checks.
  • Apply defence in depth: validation, parameterised queries, contextual escaping, rate limiting and least privilege.

Further reading

  • OWASP, Top 10 Web Application Security Risks (2021) — broken access control is number one.
  • OWASP, Cheat Sheet Series — password storage, session management and authorisation.
  • RFC 9106, Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications.
  • Niels Provos and David Mazières, "A Future-Adaptable Password Scheme" (USENIX 1999) — the case for tunable cost.
  • RFC 6265bis, Cookies: HTTP State Management Mechanism — SameSite, HttpOnly and Secure.

Check yourself

3 questions in this lesson. Answer each once to complete it.

No problem practices this lesson yet. Browse the practice problems

Next lessonTesting Strategies
Lessons in this track