Authentication & Authorization in Node.js đŸĒĒ

1. Introduction 👋

Authentication and authorization sit at the heart of nearly every application that handles user data. This tutorial covers the strategies, standards, and libraries used to implement both securely in Node.js — from session cookies and JWTs to OAuth, MFA, and role-based access control.

Warning

Getting auth mostly right is not the same as getting it right. Small mistakes here can lead to full account takeovers.

2. Authentication vs Authorization 🔀

These two terms are often confused but answer fundamentally different questions.

ConceptQuestion It AnswersExample
AuthenticationWho are you?Logging in with email & password
AuthorizationWhat are you allowed to do?Only admins can delete users

Information

Authentication always happens first — you can't authorize an action for an identity you haven't verified yet.

3. Authentication Strategies 🧭

Node.js applications commonly implement one or more of these strategies, often combined for different use cases.

  • Session-based — server stores session state, client holds a session ID cookie.
  • Token-based (JWT) — stateless, self-contained tokens verified without a server-side lookup.
  • OAuth 2.0 / OIDC — delegated authentication via a trusted third-party provider.
  • API keys — long-lived credentials for machine-to-machine communication.

4. Session-Based Authentication đŸĒ

In session-based auth, the server creates a session record and gives the client a cookie referencing it.

src/auth/session.ts

import session from "express-session";

app.use(session({
  secret: process.env.SESSION_SECRET as string,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, maxAge: 1000 * 60 * 30 },
}));

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  req.session.userId = user.id;
  res.json({ success: true });
});

Tip

Sessions require server-side storage (e.g. Redis) to scale across multiple instances — plain in-memory sessions don't survive restarts or work across replicas.

5. Token-Based Authentication đŸŽŸī¸

Token-based auth issues a self-contained credential the client sends with every request, removing the need for server-side session lookups.

Client Login
Server issues Token
Client stores Token
Client sends Token on each request
Server verifies Token (no DB lookup needed)

Note

Token-based auth scales well horizontally since it's stateless, but tokens are harder to revoke instantly compared to sessions.

6. JWT Authentication đŸŽĢ

JSON Web Tokens encode claims in a signed, compact format that can be verified without a database round-trip.

src/auth/jwt.ts

import jwt from "jsonwebtoken";

function issueToken(userId: string): string {
  return jwt.sign({ sub: userId }, process.env.JWT_SECRET as string, {
    algorithm: "HS256",
    expiresIn: "15m",
  });
}

function verifyToken(token: string) {
  return jwt.verify(token, process.env.JWT_SECRET as string, { algorithms: ["HS256"] });
}

Danger

Always whitelist the expected algorithms during verification — accepting whatever algorithm the token claims opens the door to signature-bypass attacks.

7. OAuth 2.0 🔑

OAuth 2.0 is an authorization framework that lets users grant limited access to their data on one service to another, without sharing credentials.

src/auth/oauth.ts

import { Router } from "express";
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";

passport.use(new GoogleStrategy(
  {
    clientID: process.env.GOOGLE_CLIENT_ID as string,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    callbackURL: "/auth/google/callback",
  },
  (accessToken, refreshToken, profile, done) => done(null, profile)
));

const router = Router();
router.get("/auth/google", passport.authenticate("google", { scope: ["profile", "email"] }));
router.get("/auth/google/callback", passport.authenticate("google"), (req, res) => res.redirect("/dashboard"));

Reference

See oauth.net for the full specification and flow diagrams.

8. OpenID Connect (OIDC) đŸĒĒ

OIDC is a thin identity layer built on top of OAuth 2.0, adding a standardized ID Token that carries verified user identity claims.

  • OAuth 2.0 handles authorization (access to resources).
  • OIDC adds authentication (verified identity) via the id_token.
  • Common providers: Google, Microsoft Entra ID, Auth0, Okta.

Important

If you need to know who the user is (not just what they can access), you need OIDC — plain OAuth 2.0 alone doesn't guarantee identity.

9. API Keys đŸ—ī¸

API keys are simple, long-lived credentials typically used for server-to-server or third-party integrations rather than end-user login.

src/middleware/apiKey.ts

function requireApiKey(req: Request, res: Response, next: NextFunction) {
  const key = req.header("x-api-key");
  if (!key || !isValidApiKey(key)) {
    return res.status(401).json({ error: "Invalid API key" });
  }
  next();
}

Caution

API keys grant broad access and rarely expire — store them hashed, allow easy revocation, and never embed them in client-side code.

10. Password Hashing 🔨

Passwords must always be hashed with a slow, adaptive algorithm before storage — never in plain text, and never with a fast general-purpose hash.

AlgorithmNotes
bcryptBattle-tested, widely supported default.
argon2Modern, memory-hard, recommended for new projects.

11. bcrypt 🧂

bcrypt is a widely adopted, slow hashing algorithm with a built-in salt, making it resistant to rainbow-table attacks.

src/auth/bcrypt.ts

import bcrypt from "bcrypt";

async function hashPassword(plain: string): Promise<string> {
  return bcrypt.hash(plain, 12);
}

async function verifyPassword(plain: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}

Tip

A cost factor of 12 is a reasonable default in 2026 — increase it over time as hardware gets faster.

12. argon2 🏆

argon2 won the Password Hashing Competition and is considered the strongest general-purpose password hashing algorithm available today.

src/auth/argon2.ts

import argon2 from "argon2";

async function hashPassword(plain: string): Promise<string> {
  return argon2.hash(plain, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2 });
}

async function verifyPassword(hash: string, plain: string): Promise<boolean> {
  return argon2.verify(hash, plain);
}

Best Practice

Use the argon2id variant — it combines resistance to both side-channel and GPU-based brute-force attacks.

13. Refresh Tokens â™ģī¸

Refresh tokens are long-lived credentials used to obtain new access tokens without requiring the user to log in again.

src/auth/refresh.ts

async function refreshAccessToken(refreshToken: string): Promise<string> {
  const stored = await findRefreshToken(refreshToken);
  if (!stored || stored.revoked || stored.expiresAt < new Date()) {
    throw new Error("Invalid refresh token");
  }
  return issueAccessToken(stored.userId);
}

Caution

Store refresh tokens hashed in the database and rotate them on each use (refresh token rotation) to limit the damage if one is stolen.

14. Access Tokens đŸŽĢ

Access tokens are short-lived credentials sent with each API request to prove the caller's identity and permissions.

src/auth/access-token.ts

function issueAccessToken(userId: string): string {
  return jwt.sign({ sub: userId, type: "access" }, process.env.JWT_SECRET as string, {
    expiresIn: "15m",
  });
}

Note

Keep access tokens short-lived (minutes, not days) — this limits the exposure window if one is compromised.

15. Cookie-Based Authentication đŸĒ

Storing tokens in HttpOnly cookies protects them from being read by client-side JavaScript, mitigating XSS-based theft.

src/auth/cookie.ts

res.cookie("accessToken", token, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  maxAge: 15 * 60 * 1000,
});

Best Practice

Prefer HttpOnly cookies over localStorage for storing tokens — localStorage is fully accessible to any script on the page.

16. Multi-Factor Authentication (MFA) 📱

MFA adds a second verification factor — something the user has or is — on top of a password, significantly reducing account takeover risk.

src/auth/totp.ts

import { authenticator } from "otplib";

const secret = authenticator.generateSecret();
const token = authenticator.generate(secret);
const isValid = authenticator.verify({ token: userInput, secret });
  • TOTP (authenticator apps) — most common, works offline.
  • SMS/Email codes — convenient but more vulnerable to interception.
  • WebAuthn / Passkeys — phishing-resistant, hardware-backed.

17. Social Login 🌐

Social login lets users authenticate via an existing account (Google, GitHub, etc.) rather than creating a new password.

src/auth/github-strategy.ts

import { Strategy as GitHubStrategy } from "passport-github2";

passport.use(new GitHubStrategy(
  {
    clientID: process.env.GITHUB_CLIENT_ID as string,
    clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    callbackURL: "/auth/github/callback",
  },
  async (accessToken, refreshToken, profile, done) => {
    const user = await findOrCreateUserFromGitHub(profile);
    done(null, user);
  }
));

Tip

Always link social accounts to a unique, verified email to prevent account confusion when a user has multiple login methods.

18. Role-Based Access Control (RBAC) 🎭

RBAC assigns users one or more roles, each granting a predefined set of permissions.

src/middleware/rbac.ts

type Role = "admin" | "editor" | "viewer";

function requireRole(...allowed: Role[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!allowed.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

router.delete("/posts/:id", requireRole("admin", "editor"), deletePost);

Information

RBAC works well when permissions map cleanly onto a small, stable set of roles — for more granular needs, see permission-based access control below.

19. Permission-Based Access Control 🔐

Permission-based (or attribute-based) access control checks fine-grained capabilities directly, rather than relying on broad role labels.

src/middleware/permissions.ts

function requirePermission(permission: string) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user.permissions.includes(permission)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

router.post("/reports/export", requirePermission("reports:export"), exportReport);

Best Practice

Permission-based systems scale better than RBAC alone in complex applications, since permissions can be composed independently of role names.

20. Authentication Middleware 🧩

Authentication middleware verifies a token or session before a request reaches protected route handlers.

src/middleware/authenticate.ts

function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
  const token = req.cookies.accessToken;
  if (!token) return res.status(401).json({ error: "Unauthenticated" });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET as string);
    req.user = payload as AuthUser;
    next();
  } catch {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

router.get("/profile", authenticate, getProfile);

21. Session Management đŸ—‚ī¸

Beyond simple login/logout, robust session management includes tracking active sessions and allowing users to revoke them individually.

src/auth/sessions.ts

async function listActiveSessions(userId: string) {
  return db.session.findMany({ where: { userId, revoked: false } });
}

async function revokeSession(sessionId: string) {
  await db.session.update({ where: { id: sessionId }, data: { revoked: true } });
}

Tip

Regenerate the session ID (or issue a new token pair) immediately after login to prevent session fixation attacks.

22. Secure Logout đŸšĒ

Logout must invalidate the session or token server-side — clearing a cookie client-side alone isn't sufficient for token-based auth.

src/auth/logout.ts

router.post("/logout", authenticate, async (req: AuthRequest, res: Response) => {
  await revokeRefreshToken(req.user.refreshTokenId);
  res.clearCookie("accessToken");
  res.clearCookie("refreshToken");
  res.json({ success: true });
});

Warning

With stateless JWTs, true immediate revocation requires a denylist or short expiry — a stolen, unexpired JWT remains valid until it expires on its own.

23. Account Verification âœ‰ī¸

Verifying ownership of an email address (or phone number) prevents fake signups and confirms the user can be reached.

src/auth/verify-email.ts

async function sendVerificationEmail(userId: string, email: string) {
  const token = crypto.randomBytes(32).toString("hex");
  await storeVerificationToken(userId, token, { expiresInMinutes: 60 });
  await sendEmail(email, `Verify your account: https://app.example.com/verify?token=${token}`);
}

Tip

Use cryptographically random, single-use tokens with a short expiry — never sequential IDs or predictable values.

24. Password Reset 🔁

Password reset flows must be designed carefully to avoid leaking whether an email exists in the system.

src/auth/password-reset.ts

router.post("/forgot-password", async (req, res) => {
  const user = await findUserByEmail(req.body.email);
  if (user) {
    const token = crypto.randomBytes(32).toString("hex");
    await storeResetToken(user.id, token, { expiresInMinutes: 15 });
    await sendEmail(user.email, `Reset your password: https://app.example.com/reset?token=${token}`);
  }
  // ✅ Always return the same response, regardless of whether the user exists
  res.json({ message: "If that email exists, a reset link has been sent." });
});

Important

Always respond identically whether or not the email exists — otherwise attackers can enumerate valid accounts.

25. Authentication Best Practices ✅

  • Hash passwords with argon2 or bcrypt — never store or log plain-text passwords.
  • Use short-lived access tokens paired with rotating refresh tokens.
  • Store tokens in HttpOnly, Secure, SameSite cookies.
  • Rate-limit login, signup, and password-reset endpoints.
  • Offer MFA, especially for accounts with elevated privileges.

26. Security Considerations đŸ›Ąī¸

  • Use generic error messages for failed logins to avoid revealing whether the email or password was wrong.
  • Enforce HTTPS everywhere — tokens sent over plain HTTP can be intercepted.
  • Log authentication events (logins, failed attempts, password changes) for auditing.
  • Regularly rotate signing secrets and support key rotation without downtime.

Danger

Never roll your own cryptographic signing or hashing scheme — use established, audited libraries exclusively.

27. Common Mistakes âš ī¸

MistakeConsequence
Storing JWTs in localStorageVulnerable to theft via XSS
Long-lived access tokens with no rotationLarge exposure window if leaked
Revealing "email not found" on loginEnables account enumeration
No rate limiting on login endpointsVulnerable to brute-force & credential stuffing
Trusting client-supplied roles/permissionsPrivilege escalation

28. Frequently Asked Questions ❓

Question

Should I use sessions or JWTs?

Answer

Sessions are simpler to revoke and audit; JWTs scale better statelessly across services. Many apps use short-lived JWTs with a server-side refresh token as a hybrid approach.

Question

Is OAuth the same as authentication?

Answer

Not by itself — OAuth 2.0 is an authorization framework. OpenID Connect adds the identity layer needed for true authentication.

Question

How long should access tokens last?

Answer

Typically 5–15 minutes, paired with a longer-lived, rotating refresh token to re-issue new access tokens.

Question

Is MFA necessary for every application?

Answer

Not always mandatory, but strongly recommended for any account handling sensitive data, payments, or administrative privileges.

29. Summary 📋

Authentication verifies identity, and authorization controls what that identity can do — both must be implemented carefully to keep users and data safe. From password hashing and JWTs to OAuth, MFA, and RBAC, a robust Node.js auth system layers multiple protections together.

  1. Hash passwords with argon2 or bcrypt, never store them in plain text.
  2. Use short-lived access tokens with rotating refresh tokens.
  3. Store credentials in HttpOnly cookies, not localStorage.
  4. Implement RBAC or permission-based checks server-side for every protected action.
  5. Layer in MFA and rate limiting for sensitive endpoints.

Summary

Strong authentication and authorization aren't a one-time feature — they require ongoing vigilance as your application and its threat landscape evolve. 🔐