128: 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:
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:
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.
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:
credentials submitted
↓
lookup account
↓
verify password
↓
check account status
↓
rotate/create authenticated session
↓
return cookie/token
↓
audit login
Avoid revealing whether an email exists:
"email not found"
"wrong password"
Those responses make account enumeration easier when enumeration is a concern. Use a generic message:
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:
random session id in secure cookie
↓
server session store
↓
user id + metadata
Cookie:
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:
{
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:
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:
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:
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.
Cookie versus localStorage
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:
admin
manager
cashier
viewer
Permissions:
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:
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:
user.role
user.branchId
resource.ownerId
resource.branchId
resource.status
request.operation
time/environment
Example:
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:
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:
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:
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:
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:
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:
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:
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:
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
- Implement scrypt password record and verification.
- Design session table/store and rotation.
- Compare server session versus JWT access token.
- Write JWT verification checklist.
- Build permission middleware plus resource-specific ABAC check.
- Add tenant scope to repository query.
- Draw OAuth Authorization Code + PKCE.
- Design API key record with hash/scope.
- Verify a webhook HMAC over raw bytes.
- 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.
