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.
Three separate questions
Authentication
Who is making this request?
Authorization
Is this principal allowed to perform this operation on this resource?
Session management
How do we maintain authenticated state across requests?
Do not collapse these into “JWT authentication.”
Password storage
Never store:
plaintext password reversible encrypted password plain SHA-256(password)
Use a password hashing function designed to be slow and salted.
Common modern choices:
Argon2id scrypt bcrypt
Choice depends on security guidance, platform support, parameters, and library maintenance.
Node's built-in crypto includes scrypt.
Scrypt example
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)
);
}
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.
Password verification timing
Use library functions designed to avoid timing leaks.
Do not compare secret hashes with naive string logic when a cryptographic comparison is required.
Login flow
credentials submitted ↓ lookup account ↓ verify password ↓ check account status ↓ rotate/create authenticated session ↓ return cookie/token ↓ audit login
Avoid revealing whether email exists:
"email not found" "wrong password"
when enumeration is a concern.
Use a generic message:
Invalid credentials
while logging safe internal reason.
Server-side sessions
Model:
random session id in secure cookie ↓ server session store ↓ user id + metadata
Cookie:
Set-Cookie: session=<random>; HttpOnly; Secure; SameSite=Lax; Path=/
Server store:
{ sessionId, userId, createdAt, expiresAt, lastSeenAt, authLevel }
Session ID must be unpredictable.
Session rotation
Rotate session ID:
- after login;
- after privilege elevation;
- after suspicious events.
This reduces session fixation risk.
Session expiration
Use:
- idle expiry;
- absolute expiry;
- revocation;
- device/session management.
Do not rely only on cookie expiry if server session remains valid forever.
Logout
Server-side session:
delete/revoke session clear cookie
JWT access token logout is different because signed tokens may remain valid until expiry unless revocation/introspection/session architecture exists.
JWT structure
JWT commonly:
header.payload.signature
Payload is encoded, not encrypted by default.
Anyone holding token can often decode claims.
Do not put secrets inside JWT payload.
JWT verification
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.
Algorithm confusion
Use a library that enforces expected algorithms.
Do not accept whatever algorithm token header requests.
Key type/algorithm policy belongs to server configuration.
JWT access token lifetime
Short-lived access tokens reduce damage if stolen.
Refresh tokens/session records can maintain longer login.
But token architecture becomes significantly more complex:
- rotation;
- replay detection;
- revocation;
- storage;
- multiple devices.
Do not use refresh tokens just because tutorials do.
Cookie versus localStorage
For browser authentication, HttpOnly secure cookies can reduce token exposure to injected JavaScript, but introduce CSRF considerations.
localStorage tokens are accessible to page JavaScript and therefore to XSS.
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.
Authorization model
RBAC
Role-Based Access Control:
admin manager cashier viewer
Permissions:
task:create task:read task:update task:delete
Map roles to permissions.
Avoid route code:
if (user.role === 'admin') ...
everywhere.
Centralize permission checks.
ABAC
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 is more precise than a single role string.
Resource-scoped authorization
Danger:
const task = await tasks.findById(req.params.id);
if (!task) return 404;
// update
If tenant/user scope missing, attacker can enumerate another tenant's ID.
Safer repository:
const task = await tasks.findOne({
id: taskId,
tenantId: auth.tenantId,
});
Then permission checks.
Authorization should influence query scope where possible.
404 versus 403
For resources an attacker should not learn exist, some APIs intentionally return 404 for unauthorized cross-tenant IDs.
Within known resource context, 403 can be appropriate.
Define policy consistently.
Authentication middleware
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 full password/token in locals.
Authorization middleware
Generic:
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 scoped resource in service/repository.
Do not reduce all authorization to route-level role middleware.
OAuth 2.0
OAuth is primarily an authorization framework for delegated access.
Roles:
resource owner client authorization server resource server
Flows vary by client type.
For modern browser/mobile authorization, Authorization Code + PKCE is common.
Do not implement OAuth protocol from scratch.
Use established identity provider/library.
OpenID Connect
OIDC adds identity/authentication layer on OAuth 2.0.
ID token communicates authentication claims to client.
Access token is for resource server.
Do not send ID token as generic API authorization token unless provider/API specifically defines that use.
External identity provider flow
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
State and nonce defend against classes of attacks.
Use framework/provider SDK.
Account linking
Same email from two identity providers does not automatically mean same trusted account.
Define verified-email/account-linking policy.
Account takeover bugs happen when identity linking is too permissive.
MFA
Multi-factor can add:
- TOTP;
- WebAuthn/passkeys;
- recovery codes.
SMS OTP has different security properties.
Sensitive actions may require step-up authentication.
Do not treat “logged in” as equal assurance for every operation.
Passkeys/WebAuthn
Modern passwordless authentication uses public-key credentials.
Server stores public credential data, not private key.
Implementation is protocol-heavy; use audited WebAuthn libraries/identity providers.
Know conceptually because password-only auth is not the only model.
Service-to-service auth
Options:
- 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.
Rotate credentials.
API keys
Good for machine/application identification when designed properly.
Store hashed API key server-side where feasible.
Show secret once.
Prefix can identify key record.
Rate-limit and scope permissions.
Do not log full API key.
Webhook authentication
Webhook receiver should verify authenticity:
HMAC signature timestamp raw request bytes replay window secret rotation
Important: signature often covers exact raw body.
If JSON parser modifies body representation before verification, signature verification can fail or become incorrect.
Design webhook route parsing separately.
Replay protection
For signed requests:
- timestamp;
- nonce/id;
- idempotency key;
- short acceptance window.
Signature alone does not prevent replay of identical valid request.
Authorization test matrix
For DELETE /tasks/:id:
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
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.
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.
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.
