FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·128·14 MIN READ

128: Authentication and Authorization in Node — Passwords, Sessions, JWTs, OAuth/OIDC, RBAC, and ABAC

TOPICS COVERED: Authentication and Authorization in Node — Passwords, Sessions, JWTs, OAuth/OIDC, RBAC, and ABAC

Learning objectives

You will learn to:

  • distinguish authentication, session management, and authorization;
  • hash passwords safely;
  • understand server-side sessions;
  • understand JWT access tokens without treating them as magic;
  • verify token claims correctly;
  • understand refresh-token/session rotation concepts;
  • understand OAuth 2.0 and OpenID Connect at a practical level;
  • design RBAC and ABAC checks;
  • prevent IDOR/BOLA with resource-scoped authorization;
  • understand service-to-service authentication;
  • design logout/revocation behavior;
  • avoid putting trust in client claims that the server can derive.

These objectives describe three related but separate jobs: establishing who is calling, keeping that identity available across requests, and deciding what that identity may do. Most authentication bugs come from treating those jobs as one feature with one token.

Three separate questions

Authentication

Who is making this request?

Authentication establishes a principal. A password check, a passkey assertion, or a verified identity-provider response can answer this question. It does not, by itself, say what the principal is allowed to access.

Authorization

Is this principal allowed to perform this operation on this resource?

Authorization is the decision made after, or while, the server evaluates a request. It normally depends on more than a user ID: the operation, tenant, resource ownership, resource state, and the principal's permissions may all matter.

Session management

How do we maintain authenticated state across requests?

Session management connects separate HTTP requests to an authenticated principal. The implementation might use a server-side session identified by a cookie, or short-lived tokens with a different lifecycle. That choice affects logout, revocation, rotation, storage, and the damage caused by theft.

Do not collapse these into “JWT authentication.” JWT is a token format, not a complete authentication, session, or authorization design.

Password storage

The database must not become a list of credentials that can be recovered if it is exposed. Never store:

text
plaintext password
reversible encrypted password
plain SHA-256(password)

Use a password hashing function designed to be slow and salted. A salt makes identical passwords produce different stored values; deliberate computational cost makes large-scale guessing more expensive. Fast general-purpose hashes such as plain SHA-256 are not suitable for password storage.

Common modern choices:

text
Argon2id
scrypt
bcrypt

The right choice depends on current security guidance, platform support, chosen parameters, and whether the library is actively maintained. Node's built-in crypto module includes scrypt, so a separate password-hashing package is not required for this particular algorithm.

Scrypt example

Here is a small record format using Node's callback-based scrypt API through promisify. The salt and derived key are stored as base64 strings so they can be persisted in a text-oriented database field. The salt is not secret; the password and derived key are.

js
import {
  randomBytes,
  scrypt as scryptCallback,
  timingSafeEqual,
} from 'node:crypto';

import { promisify } from 'node:util';

const scrypt = promisify(scryptCallback);

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const derived = await scrypt(password, salt, 64);

  return {
    algorithm: 'scrypt',
    salt: salt.toString('base64'),
    hash: Buffer.from(derived).toString('base64'),
  };
}

export async function verifyPassword(password, record) {
  const salt = Buffer.from(record.salt, 'base64');
  const expected = Buffer.from(record.hash, 'base64');

  const actual = Buffer.from(
    await scrypt(password, salt, expected.length),
  );

  return (
    actual.length === expected.length &&
    timingSafeEqual(actual, expected)
  );
}

The verification path derives a value using the stored salt and the stored hash length, then compares equal-length buffers with timingSafeEqual. In production, the record also needs a version or parameter representation so that the verifier knows which settings produced it and can migrate old records safely.

Production password-storage design also needs:

  • cost parameters;
  • migration when parameters/algorithm change;
  • maximum input length policy;
  • account lockout/rate limiting;
  • breach/password policy as product requires.

Do not copy parameters blindly; follow current security guidance. Parameters that are sensible on one deployment may exhaust CPU or memory on another, and the chosen settings should be tested on the actual service infrastructure.

Password verification timing

Use library functions designed to avoid timing leaks. The comparison step is not the only concern, but naïve comparisons can expose information through differences in how long they take.

Do not compare secret hashes with naïve string logic when a cryptographic comparison is required. Also keep the outward login response generic when account enumeration is a concern; a timing-resistant comparison does not, by itself, make the entire login endpoint indistinguishable.

Login flow

The login path is a sequence of checks and state changes, not just a password comparison:

text
credentials submitted
↓
lookup account
↓
verify password
↓
check account status
↓
rotate/create authenticated session
↓
return cookie/token
↓
audit login

Avoid revealing whether an email exists:

text
"email not found"
"wrong password"

Those responses make account enumeration easier when enumeration is a concern. Use a generic message:

text
Invalid credentials

while logging the safe internal reason. Logs must not contain the submitted password or a full token. Account status checks can include disabled, locked, unverified, or otherwise restricted states, depending on the product's policy.

Server-side sessions

With a server-side session, the browser carries only an unpredictable identifier. The useful model is:

text
random session id in secure cookie
↓
server session store
↓
user id + metadata

Cookie:

text
Set-Cookie:
session=<random>;
HttpOnly;
Secure;
SameSite=Lax;
Path=/

HttpOnly prevents ordinary page JavaScript from reading the cookie. Secure restricts it to HTTPS, and SameSite provides a degree of cross-site request protection. These flags do not remove the need to understand CSRF and deployment details.

Server store:

js
{
  sessionId,
  userId,
  createdAt,
  expiresAt,
  lastSeenAt,
  authLevel
}

The store can hold more metadata, such as tenant, device, or revocation information. Session ID must be unpredictable. Treat it as a bearer credential: anyone who obtains a valid ID may be able to act as that session until it expires or is revoked.

Session rotation

Rotate session ID:

  • after login;
  • after privilege elevation;
  • after suspicious events.

This reduces session fixation risk. In practice, rotation means issuing a new identifier and invalidating or safely transferring the old session state rather than allowing an attacker-controlled pre-login identifier to become the authenticated session.

Session expiration

Use:

  • idle expiry;
  • absolute expiry;
  • revocation;
  • device/session management.

Idle expiry limits a session that has stopped being used; absolute expiry limits its total lifetime. Revocation handles events such as logout, password reset, or suspected theft. Device/session management gives the user or support team a way to inspect and invalidate individual sessions.

Do not rely only on cookie expiry if the server session remains valid forever. A browser can stop sending an expired cookie while the server still retains an active record that could be used if the identifier is recovered.

Logout

For a server-side session, logout normally has two parts:

text
delete/revoke session
clear cookie

Clearing the cookie handles the current browser. Deleting or revoking the record handles reuse of the identifier elsewhere. JWT access-token logout is different because signed tokens may remain valid until expiry unless the design includes revocation, introspection, or a server-side session architecture.

JWT structure

JWT commonly has three dot-separated parts:

text
header.payload.signature

Payload is encoded, not encrypted by default. Anyone holding the token can often decode its claims, so do not put secrets inside JWT payload. Encoding makes a representation transportable; it does not provide confidentiality.

JWT verification

Verification must establish more than that the token has a plausible shape. The server must verify:

  • signature;
  • allowed algorithm;
  • issuer (iss);
  • audience (aud);
  • expiration (exp);
  • not-before (nbf) when used;
  • token type/purpose;
  • key rotation/kid policy.

Do not simply:

js
decode(token)

and trust the result. Decode is not verify. Decoding can be useful for inspection or diagnostics, but it does not prove who issued the claims, that they were not changed, or that they are intended for this API.

Algorithm confusion

Use a library that enforces expected algorithms. Do not accept whatever algorithm the token header requests. The header is untrusted input, so it cannot be allowed to choose the verification policy.

Key type/algorithm policy belongs to server configuration. The configuration also needs a deliberate key rotation and kid policy so the verifier can select an approved active key without accepting arbitrary key material.

JWT access token lifetime

Short-lived access tokens reduce damage if stolen. Refresh tokens or server-side session records can maintain a longer login without making every access token long-lived.

But token architecture becomes significantly more complex:

  • rotation;
  • replay detection;
  • revocation;
  • storage;
  • multiple devices.

For example, a refresh token that is reused after rotation may indicate theft, but detecting that requires server-side state and a defined response. Do not use refresh tokens just because tutorials do. Choose them when the client and threat model require their trade-offs, and design their lifecycle before shipping them.

For browser authentication, HttpOnly secure cookies can reduce token exposure to injected JavaScript, but they introduce CSRF considerations because the browser sends them automatically in qualifying requests.

localStorage tokens are accessible to page JavaScript and therefore to XSS. They avoid automatic cookie sending, but any script that can execute in the page's origin may read them.

There is no universal one-line answer; choose based on app architecture and threat model. Do not put long-lived sensitive tokens in insecure storage casually. The choice must be paired with appropriate CSRF, XSS, origin, and token-lifetime controls.

Authorization model

Authentication gives the server a principal. Authorization then needs a policy that can be applied consistently rather than a scattering of ad hoc checks across routes.

RBAC

Role-Based Access Control:

text
admin
manager
cashier
viewer

Permissions:

text
task:create
task:read
task:update
task:delete

Map roles to permissions. A role is a convenient grouping for administration; a permission names the operation more directly.

Avoid route code:

js
if (user.role === 'admin') ...

everywhere. Centralize permission checks. That keeps policy changes from requiring a search through every route and makes it easier to test whether a permission is actually required.

ABAC

Role checks alone cannot express many real rules. Attribute-Based Access Control evaluates attributes:

text
user.role
user.branchId
resource.ownerId
resource.branchId
resource.status
request.operation
time/environment

Example:

js
function canUpdateTask(actor, task) {
  if (!actor.permissions.has('task:update')) {
    return false;
  }

  if (actor.tenantId !== task.tenantId) {
    return false;
  }

  if (
    actor.branchId !== task.branchId &&
    !actor.permissions.has('task:update:any-branch')
  ) {
    return false;
  }

  return true;
}

This policy first checks the operation permission, then tenant isolation, then the branch constraint and its explicit exception. That is more precise than a single role string, and the same kind of policy can incorporate ownership, resource status, operation, or environment when those attributes are part of the product rules.

Resource-scoped authorization

A route can authenticate a user correctly and still expose another tenant's data. The danger is a lookup that trusts an attacker-controlled identifier:

js
const task = await tasks.findById(req.params.id);

if (!task) return 404;

// update

If tenant/user scope is missing, an attacker can enumerate another tenant's ID and reach the update path. Checking authorization after an unrestricted lookup may also reveal whether the resource exists.

Safer repository:

js
const task = await tasks.findOne({
  id: taskId,
  tenantId: auth.tenantId,
});

Then permission checks. Authorization should influence query scope where possible. The repository should make the safe scope part of the data-access operation, while service-level rules can evaluate ownership, status, and other resource attributes after the scoped record is loaded.

404 versus 403

For resources an attacker should not learn exist, some APIs intentionally return 404 for unauthorized cross-tenant IDs. That makes a foreign identifier look like a missing resource.

Within a known resource context, 403 can be appropriate: the server knows the resource and the caller, but the caller is not allowed to perform the operation. Define the policy consistently so clients and tests can rely on it; do not let individual handlers choose responses accidentally.

Authentication middleware

Authentication middleware should establish a small, trusted request context and leave detailed resource authorization to the appropriate service layer:

js
async function authenticate(req, res, next) {
  try {
    const session = await sessionService.fromRequest(req);

    if (!session) {
      return res.status(401).json({
        error: {
          code: 'UNAUTHENTICATED',
          message: 'Authentication required.',
        },
      });
    }

    res.locals.auth = {
      userId: session.userId,
      tenantId: session.tenantId,
      permissions: session.permissions,
    };

    next();
  } catch (error) {
    next(error);
  }
}

Do not store the full password or token in locals. Keep only the derived identity and authorization data the rest of the request needs. Error handling should also avoid turning token or session details into response or log content.

Authorization middleware

Generic permission middleware can handle operation-level checks:

js
function requirePermission(permission) {
  return (req, res, next) => {
    const auth = res.locals.auth;

    if (!auth.permissions.has(permission)) {
      return res.status(403).json(...);
    }

    next();
  };
}

But resource-specific rules often belong after loading a scoped resource in the service or repository. The middleware can answer “may this principal attempt task updates?”; the service still needs to answer “may this principal update this particular task?”

Do not reduce all authorization to route-level role middleware. That approach misses tenant boundaries, ownership, resource state, and operation-specific exceptions.

OAuth 2.0

OAuth is primarily an authorization framework for delegated access. It lets a client obtain access to a resource on behalf of a resource owner; it is not, by itself, an identity protocol.

Roles:

text
resource owner
client
authorization server
resource server

Flows vary by client type. For modern browser/mobile authorization, Authorization Code + PKCE is common. PKCE helps protect the authorization-code exchange when a client cannot safely keep a client secret.

Do not implement OAuth protocol from scratch. Use an established identity provider and library, and follow the provider's current guidance for redirect URIs, state, code exchange, token handling, and client type.

OpenID Connect

OIDC adds an identity/authentication layer on OAuth 2.0. An ID token communicates authentication claims to the client. An access token is for the resource server.

Do not send an ID token as a generic API authorization token unless the provider or API specifically defines that use. The two tokens have different audiences and purposes, so accepting the wrong one can create an audience-validation and trust-boundary bug.

External identity provider flow

The practical shape of an external login is:

text
user redirects to IdP
↓
IdP authenticates
↓
authorization code returned
↓
server/client exchanges code securely
↓
verify issuer/audience/state/nonce/PKCE
↓
create local session/account link

The checks are not optional decoration. State and nonce defend against classes of request-forgery and replay/substitution attacks, while issuer and audience checks ensure that accepted claims came from the expected provider and are intended for this application. Use the framework/provider SDK rather than assembling protocol messages yourself.

Account linking

The same email from two identity providers does not automatically mean the same trusted account. Email verification rules, provider trust, and an explicit linking action all affect the decision.

Define a verified-email/account-linking policy. Account-takeover bugs happen when identity linking is too permissive, especially when an unverified or merely matching email is treated as proof of control of an existing account.

MFA

Multi-factor authentication can add:

  • TOTP;
  • WebAuthn/passkeys;
  • recovery codes.

SMS OTP has different security properties and should not be treated as equivalent to every stronger factor. Sensitive actions may require step-up authentication even when a session already exists.

Do not treat “logged in” as equal assurance for every operation. A session's authLevel or a recent factor check may need to be considered when changing credentials, moving money, or performing another high-impact action.

Passkeys/WebAuthn

Modern passwordless authentication uses public-key credentials. The server stores public credential data, not the private key. The private key remains with the authenticator, subject to the platform's credential protections.

Implementation is protocol-heavy; use audited WebAuthn libraries or identity providers. Know the model conceptually because password-only authentication is not the only viable design, even if a library handles the protocol details.

Service-to-service auth

Options include:

  • mTLS;
  • signed service tokens;
  • workload identity/cloud IAM;
  • OAuth client credentials;
  • private network plus identity controls.

Do not share one hard-coded API key across every service forever. Service identity should be scoped so that compromise of one workload does not automatically grant every other capability. Rotate credentials, and make rotation operationally possible rather than relying on a value embedded permanently in source or deployment configuration.

API keys

API keys are useful for machine or application identification when designed properly. They are still bearer secrets, so their lifecycle deserves the same care as other credentials.

Store hashed API keys server-side where feasible. Show the secret once, and retain a prefix that can identify the key record without exposing the secret itself. Rate-limit keys and scope their permissions.

Do not log the full API key. Redact it from request logs, error reports, traces, and debugging output.

Webhook authentication

A webhook receiver should verify authenticity before acting on the event:

text
HMAC signature
timestamp
raw request bytes
replay window
secret rotation

Important: the signature often covers the exact raw body. If a JSON parser modifies the body representation before verification, signature verification can fail or become incorrect. Design webhook route parsing separately so the handler can retain the bytes needed for verification before parsing the payload.

Replay protection

For signed requests, consider:

  • timestamp;
  • nonce/id;
  • idempotency key;
  • short acceptance window.

Signature alone does not prevent replay of an identical valid request. The receiver needs a freshness or uniqueness check and should make processing idempotent where the event can be delivered more than once.

Authorization test matrix

For DELETE /tasks/:id, test both identity and resource scope. A successful authentication check is not enough to prove that the caller may delete this task:

text
no session → 401
valid user no permission → 403
permission wrong tenant → 404/403 policy
permission same tenant → 204
deleted already → idempotent policy
expired session → 401
revoked session → 401

The exact wrong-tenant response depends on the API's 404-versus-403 policy. The already-deleted case also needs an explicit idempotency decision so clients do not receive accidental behavior from the database implementation.

Common mistakes

  • plaintext/fast password hashes;
  • JWT decode without verify;
  • no issuer/audience check;
  • long-lived access token with no revocation story;
  • secret tokens in logs/URLs;
  • client role trusted from request body;
  • role-only authorization;
  • missing tenant/resource scope;
  • OAuth implemented manually;
  • ID token confused with access token;
  • account linking by email without trust checks;
  • API keys without rotation/scope;
  • webhook signature verified after body transformation.

These mistakes cross different layers. Fixing token verification does not fix an unrestricted repository query, and adding a role check does not fix a stolen long-lived session. Review the complete flow from credential input through session/token handling, scoped data access, and audit logging.

Exercises

  1. Implement scrypt password record and verification.
  2. Design session table/store and rotation.
  3. Compare server session versus JWT access token.
  4. Write JWT verification checklist.
  5. Build permission middleware plus resource-specific ABAC check.
  6. Add tenant scope to repository query.
  7. Draw OAuth Authorization Code + PKCE.
  8. Design API key record with hash/scope.
  9. Verify a webhook HMAC over raw bytes.
  10. Write authorization tests for cross-tenant IDs.

Work through the exercises in order when possible. The sequence moves from credential storage to session lifecycle, then to token verification and policy enforcement, and finally to integrations and cross-tenant tests. The exercises are intentionally practical: each one should produce either code, a design, a diagram, or a test matrix that can be inspected.

Mastery checklist

Explain:

  • authn/authz/session;
  • password hashing;
  • session rotation;
  • JWT verification;
  • token storage/revocation;
  • RBAC;
  • ABAC;
  • BOLA/IDOR;
  • OAuth/OIDC;
  • service auth;
  • API keys;
  • webhook signatures/replay.

Being able to name these terms is only the first check. You should also be able to point to the trust boundary, explain what the server verifies, identify the resource scope, and describe how logout or compromise changes the state.

Official references

Reader page: /nodejs/lesson/128/authentication-and-authorization-in-node-passwords-sessions-jwts-oauth-oidc-rbac-and-abac