After three lessons, Escena Viva knows who is making each request. req.user arrives verified — with its id and its role — whether by session or by access token, and buyTickets no longer accepts a userId from the client. Authentication is solved.

Now comes the other half, the one that decides what each person may do. And it is worth saying bluntly: authorization is where real APIs break. The top two entries of the OWASP API Security Top 10 are authorization failures, not authentication failures. A flawless login is worth nothing if GET /api/orders/ord-042 hands Marc an order belonging to Lucía.

Contents

  1. Authorization models
  2. The three Escena Viva roles
  3. The permission matrix
  4. The requireRole middleware
  5. 401 versus 403
  6. What RBAC does not cover: resource ownership
  7. Filter in the query, do not compare afterwards
  8. Authorization is never done on the client
  9. When the token's role is stale
  10. Permissions finer than roles
  11. Centralizing the policy
  12. Auditing and privilege escalation
  13. Common mistakes, exercises and conclusion

  1. Authorization models

Model How it decides Example in Escena Viva Drawback
ACL Permissions per resource and per subject "Lucía may read ord-042" Ungovernable as it grows: N users × M resources
RBAC Permissions per role; users hold roles "Organizers publish events" Cannot express "only my events"
ABAC Rules over user, resource and context attributes "Edits events at their venue if they are drafts" Hard to reason about and to test
ReBAC Graph of relationships "You can see the order if you are its buyer" Requires infrastructure (Zanzibar-style)

Why RBAC fits Escena Viva: there are three crisp roles matching real business functions, the set of actions is bounded, and the permissions can be explained in one sentence to a non-technical person. RBAC is the correct default for 90% of applications, with an honest caveat that dominates the second half of this lesson: it answers "can this role perform this action?", not "can it on this particular resource?". We will solve that second question by adding an ownership check — a touch of ABAC/ReBAC on an RBAC base, which is what almost every real system does.

  1. The three Escena Viva roles

They have been modeled since lesson 07-02: const ROLES = Object.freeze(['attendee', 'organizer', 'administrator']);

Role Who they are What they do
attendee Lucía, Marc Catalog, purchasing, and sees their orders and their tickets
organizer The people running Teatro Almendra, Sala Bóveda and Auditorio Ribera Creates, publishes and finishes events at their venue; reads their sales
administrator Platform team Manages users and roles, cancels tickets, sees everything

A warning about hierarchy: it is tempting to code "administrator ⊇ organizer ⊇ attendee". Careful: an administrator should not be able to buy tickets on behalf of an attendee, because that is not a higher permission but a different action. Escena Viva treats roles as explicit sets of permissions, with inheritance only where the matrix says so. One more piece of data we will need: the organizer has an assignedVenue ('Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'); the role says what kind of things they can do, and the venue says on which ones.

  1. The permission matrix

This table is not decorative documentation: it is the specification for the code that follows.

Action Route Anonymous Attendee Organizer Administrator
View catalog and detail GET /api/events, /:id Yes Yes Yes Yes
Buy tickets POST /api/purchases No Yes Yes Yes
View my orders GET /api/orders No Yes (own) Yes (own) Yes (own)
View someone else's order GET /api/orders/:id No No No Yes
Create, publish or finish an event POST /api/events, PATCH /:id/publish No No Yes (their venue) Yes
View a venue's sales GET /api/venues/:venue/sales No No Yes (their venue) Yes
Cancel or use a ticket PATCH /api/tickets/:code/... No No Yes (their venue) Yes
Manage users and roles PATCH /api/users/:id/role No No No Yes

Read it carefully, because it contains the whole lesson. Some rows the role settles on its own: "manage users" is yes or no depending on the role. Some rows the role is not enough for: "publish event" says Yes (their venue), and that parenthesis is resource ownership, which no requireRole covers. And "view my orders" is the most deceptive of all: every role can, but the result set is different for each user; it is not a route permission, it is a data filter.

  1. The requireRole middleware

// src/middleware/require-role.js
'use strict';
const { AuthenticationError, AuthorizationError } = require('../errors.js');
const { ROLES } = require('../models/user.js');
// Factory: it takes the allowed roles and returns the middleware.
function requireRole(...allowedRoles) {
  // Checked at STARTUP, not on every request: a misspelled role ('organizers')
  // would make the route silently deny everybody.
  const invalid = allowedRoles.find((role) => !ROLES.includes(role));
  if (invalid) { throw new Error(`Unknown role in requireRole: ${invalid}`); }
  return function checkRole(req, res, next) {
    // 401: we do not know who they are. This is an AUTHENTICATION failure.
    if (!req.user) { return next(new AuthenticationError('You must sign in')); }
    // 403: we know who they are, and this is not for them. This is AUTHORIZATION.
    if (!allowedRoles.includes(req.user.role)) {
      // We do not return the current role to the client, so as not to hint at
      // the permission structure: that goes into the log.
      return next(new AuthorizationError('You do not have permission for this operation',
        { required: allowedRoles }));
    }
    return next();
  };
}
module.exports = { requireRole };
// Applied to the real routes, in src/routes/events.js:
router.get('/', optionalAuthenticate, listEvents); // public: enriched when there is a session
router.get('/:id', optionalAuthenticate, getEvent);
// The order IS the policy: authenticate -> role -> load resource -> ownership.
router.post('/', authenticate, requireRole('organizer', 'administrator'),
  validate(createEventSchema, VALID_SOURCES.BODY), createEvent);
router.patch('/:id/publish', authenticate, requireRole('organizer', 'administrator'),
  loadEvent, requireVenueOwnership, publishEvent);

Look at the chain on PATCH /:id/publish: authenticate (who are you?) → requireRole (does your role allow publishing?) → loadEvent (which resource is it?) → requireVenueOwnership (is it yours?). Each link answers one question and none of them answers another's.

  1. 401 versus 403

401 Unauthorized 403 Forbidden
Real meaning Not authenticated (an unfortunate name in the specification) Authenticated, no permission
Cause No credential, expired or invalid Insufficient role or someone else's resource
What the client should do Sign in or refresh: retrying may change the outcome Do not retry: it will be the same; show "you do not have permission"

Confusing them has a very concrete cost: if you return 401 to an attendee trying to publish an event, the front-end sends them to the login page, they sign in correctly, try again and fail again, in a loop the user does not understand; and the other way round, a 403 for an expired token stops the client from realizing all it had to do was refresh. With the STATUS_BY_CODE table extended in 08-02 (NOT_AUTHENTICATED: 401, NO_PERMISSION: 403), the response comes out in the usual format: { "error": { "code": "NO_PERMISSION", "message": "You do not have permission for this operation", "status": 403, "details": { "required": ["organizer", "administrator"] } } }.

A nuance about information leakage. Sometimes even a 403 says too much: it confirms that the resource exists. If an attendee tries GET /api/orders/ord-042 and gets a 403, they have learned that this order exists; with a 404, they learn nothing. The practical Escena Viva rule: 403 when the resource is public or its existence is no secret (an event in the catalog); 404 when the very existence is sensitive information (orders, tickets, users).

  1. What RBAC does not cover: resource ownership

Go back to the matrix. The organizer of Sala Bóveda has the organizer role, so requireRole('organizer') waves them through to PATCH /api/events/evt-001/publish… and evt-001 is the Concierto de Otoño at Teatro Almendra. Correct role, someone else's resource.

This is the insecure direct object reference (IDOR), or in OWASP language, broken object level authorization: first place on their list and, by a wide margin, the most common flaw in real APIs. It is so common because the code looks reasonable:

// BAD: the role is correct and the resource belongs to somebody else
router.patch('/:id/publish', authenticate, requireRole('organizer'), async (req, res) => {
  const event = await Event.findById(req.params.id);
  event.status = 'published';
  await event.save();
  res.json(event);
});

The ownership middleware, which runs after loading the resource:

// src/middleware/require-ownership.js
'use strict';
const { ResourceNotFound } = require('../errors.js');
const { eventRepository } = require('../repositories/index.js');
// 1) Load the resource. Without it, nothing can be decided.
async function loadEvent(req, res, next) {
  try {
    const event = await eventRepository.getEventById(req.params.id);
    if (!event) { throw new ResourceNotFound(`Event ${req.params.id} does not exist`); }
    req.resource = event; // domain object, not the ODM document (M7)
    next();
  } catch (error) { next(error); }
}
// 2) Compare the resource with the user. Here, and not before. An administrator
// walks through the check, but it gets recorded (section 12).
function requireVenueOwnership(req, res, next) {
  if (req.user.role === 'administrator') { return next(); }
  if (req.resource.venue !== req.user.assignedVenue) {
    // 404, not 403: we do not confirm that another venue's event exists.
    return next(new ResourceNotFound(`Event ${req.params.id} does not exist`));
  }
  return next();
}
module.exports = { loadEvent, requireVenueOwnership };

Why the check comes after loading: ownership is an attribute of the resource (event.venue, order.userId), and it cannot be checked without having the resource in hand. Any attempt to deduce it from the identifier — encoding the venue in the id, for instance — is a fragile patch.

  1. Filter in the query, do not compare afterwards

There is a technique even better than comparing after loading: do not load what is not yours.

// ACCEPTABLE: load and compare
const order = await Order.findById(id);
if (String(order.userId) !== req.user.id) { throw new AuthorizationError(); }
// BETTER: filter inside the query. It does not distinguish "does not exist" from "not yours".
const order = await Order.findOne({ _id: id, userId: req.user.id });
if (!order) { throw new ResourceNotFound(); }

The second form has four advantages, and they are solid: the check is impossible to forget (there is no if to delete during a refactor; if the filter is not there, there is no result); existence does not leak ("does not exist" and "not yours" give the same answer); someone else's data never enters the process, so it cannot end up in a log or a response by accident; and it is more efficient, because the index does the work in the database. That is why the filter is pushed down into the Module 7 repository, where it becomes part of the operation's signature:

// src/repositories/orders.js  (extended in module 8)
// The userId is MANDATORY in the signature: it is impossible to call these
// functions "by accident" without restricting to the owner.
async function listUserOrders(userId, { page = 1, perPage = 20 } = {}) {
  const documents = await Order.find({ userId }).sort({ createdAt: -1 })
    .skip((page - 1) * perPage).limit(Math.min(perPage, 100)).lean(); // hard cap (08-06)
  return documents.map(toDomain);
}
async function getUserOrder(orderId, userId) {
  const document = await Order.findOne({ _id: orderId, userId }).lean();
  return document ? toDomain(document) : null;
}
module.exports = { createOrder, listUserOrders, getUserOrder };
// And the controller ends up so simple it is hard to get wrong:
async function getOrder(req, res, next) {
  try {
    const order = await getUserOrder(req.params.id, req.user.id);
    if (!order) { throw new ResourceNotFound('Order not found'); } // 404 in both cases
    res.json({ order });
  } catch (error) { next(error); }
}

Golden rule: if the userId restricting the query comes from req.user, the authorization is correct by construction. If it comes from req.params or req.body, you have an IDOR.

  1. Authorization is never done on the client

Hiding a button is not a permission. The Escena Viva front-end may hide "Publish event" from attendees, and it should for usability reasons, but that only prevents accidental clicks. Anyone can fire off curl -X PATCH https://api.escenaviva.test/api/events/evt-001/publish -H "Authorization: Bearer <attendee token>". The browser is the user's territory: JavaScript variables can be edited, the HTML can be modified and the router can be ignored. The only security boundary is the server.

Corollaries: do not trust hidden fields (an <input type="hidden" name="userId"> is editable); do not trust client-side validation, which is usability, while the real one is zod at the edge (M6); and do not return data the user cannot see hoping the front-end will hide it — if the response carries it, it has leaked. This is OWASP's excessive data exposure, which we cover in 08-06.

  1. When the token's role is stale

In 08-04 we saw that the JWT's role is a snapshot taken at issue time. If an administrator demotes the organizer of Sala Bóveda to attendee, their access token keeps saying organizer for up to 15 minutes.

Risk Strategy When
Low Trust the token's role Reads and reversible actions: listing my orders
Medium Trust the token; the change propagates on refresh (/auth/refresh re-reads the user) Creating a draft event
High Re-read the user from the database Cancelling tickets, changing roles, viewing sales
// src/middleware/require-fresh-role.js
// For critical operations: the role is re-read from the database, not from the
// token. It costs one query; in exchange, a revoked role stops working NOW.
function requireFreshRole(...allowedRoles) {
  return async function check(req, res, next) {
    try {
      if (!req.user) { throw new AuthenticationError('You must sign in'); }
      const current = await User.findById(req.user.id).select('role assignedVenue').lean();
      if (!current || !allowedRoles.includes(current.role)) {
        throw new AuthorizationError('You do not have permission for this operation');
      }
      req.user = { ...req.user, role: current.role, venue: current.assignedVenue };
      next();
    } catch (error) { next(error); }
  };
}

It is a conscious trade: one query per request in exchange for immediate consistency; apply it where a 15-minute delay would do unacceptable damage, not everywhere.

  1. Permissions finer than roles

There comes a point where roles fall short: Escena Viva wants certain organizers to be able to create events but not publish them without review, and with pure roles the way out is to invent junior_organizer, from which it is one step to having fifteen roles. The alternative is permissions named after actions, with roles as sets of permissions:

// src/authorization/permissions.js
'use strict';
const PERMISSIONS = Object.freeze({
  EVENT_CREATE: 'event:create', EVENT_PUBLISH: 'event:publish',
  EVENT_FINISH: 'event:finish', ORDER_READ_OWN: 'order:read:own',
  ORDER_READ_ANY: 'order:read:any', TICKET_CANCEL: 'ticket:cancel',
  TICKET_USE: 'ticket:use', USER_MANAGE: 'user:manage', SALES_READ: 'sales:read',
});
// The role stops being the permission: it becomes a SHORTCUT for a set of them.
const PERMISSIONS_BY_ROLE = Object.freeze({
  attendee: [PERMISSIONS.ORDER_READ_OWN],
  organizer: [PERMISSIONS.EVENT_CREATE, PERMISSIONS.EVENT_PUBLISH, PERMISSIONS.EVENT_FINISH,
    PERMISSIONS.TICKET_CANCEL, PERMISSIONS.TICKET_USE, PERMISSIONS.SALES_READ, PERMISSIONS.ORDER_READ_OWN],
  administrator: Object.values(PERMISSIONS),
});
module.exports = { PERMISSIONS, PERMISSIONS_BY_ROLE };

When the jump is worth it: when you start creating roles whose only difference is one action, when the business asks for exceptions ("this organizer yes, that one no"), or when you need to grant temporary permissions. As long as three roles describe reality well, adding permissions is complexity without benefit. Escena Viva sets up the structure but keeps the three roles.

  1. Centralizing the policy

The antipattern: if (req.user.role === 'administrator' || ...) scattered across twelve controllers. When a rule changes you will have to find them all, and the one you miss will be a hole.

// src/authorization/policy.js
'use strict';
const { PERMISSIONS, PERMISSIONS_BY_ROLE } = require('./permissions.js');
// PURE functions: no req, no database, no Express. That is why they can be
// tested on their own and exhaustively (module 9).
const hasPermission = (user, permission) =>
  Boolean(user?.role) && (PERMISSIONS_BY_ROLE[user.role] || []).includes(permission);
// Ownership rule: the event's venue must be the one assigned to the organizer.
const canManageEvent = (user, event) => hasPermission(user, PERMISSIONS.EVENT_PUBLISH)
  && (user.role === 'administrator' || event.venue === user.assignedVenue);
const canViewOrder = (user, order) =>
  hasPermission(user, PERMISSIONS.ORDER_READ_ANY)
  || String(order.userId) === String(user.id);
// Nobody changes their own role, not even an administrator: this prevents both
// escalation and accidental self-lockout.
const canChangeRole = (user, target) => hasPermission(user, PERMISSIONS.USER_MANAGE)
  && String(user.id) !== String(target.id);
module.exports = { hasPermission, canManageEvent, canViewOrder, canChangeRole };

The advantages are concrete: it tests itself — no server, no database; in Module 9 we will write a case table walking the entire matrix from section 3; it reads like the specification, because canManageEvent(user, event) maps straight onto a row of the table; it changes in one place; and it can be audited by reading one file instead of twelve controllers. The middleware become the thinnest of wrappers:

// Usage: router.patch('/:id/publish', authenticate, loadEvent,
//          requirePolicy(canManageEvent), publishEvent);
function requirePolicy(check) {
  return function apply(req, res, next) {
    if (!req.user) { return next(new AuthenticationError('You must sign in')); }
    if (!check(req.user, req.resource)) {
      return next(new AuthorizationError('You do not have permission for this operation'));
    }
    return next();
  };
}

  1. Auditing and privilege escalation

Auditing. Every sensitive decision leaves a trail: who, what, when, on what and with which outcome.

// src/services/audit.js -> action: 'event:publish' | 'user:change-role';
// outcome: 'allowed' | 'denied'. Structured (JSON), not free text, so it can be
// queried and alerted on; requestId comes from module 6 and correlates everything.
function recordAction({ actor, action, resource, outcome, requestId }) {
  console.log(JSON.stringify({ type: 'audit', timestamp: new Date().toISOString(),
    requestId, actorId: actor.id, actorRole: actor.role, action, resource, outcome }));
}

What deserves an audit entry: role changes, ticket cancellations, publishing and finishing events, administrator access to other people's data, and denied attempts (an attendee probing ten administrator routes is a signal). Credentials and tokens are never logged. Structured logging and shipping it to a centralized system are developed in Module 11.

Privilege escalation. The classic flaw fits in one innocent line: User.create(req.body) with a body containing "role":"administrator". That is mass assignment, and that is why the 08-02 registration zod schema carries .strict() and the controller builds the object field by field with role: 'attendee' set by the server. The role is never an input of registration.

// src/controllers/users.js
async function changeRole(req, res, next) {
  try {
    const { role } = req.validatedData.body; // zod: enum(ROLES), .strict()
    const target = await User.findById(req.params.id);
    if (!target) { throw new ResourceNotFound('User not found'); }
    // The policy decides; the controller just obeys.
    if (!canChangeRole(req.user, target)) {
      throw new AuthorizationError('You do not have permission for this operation');
    }
    const previousRole = target.role;
    target.role = role;
    await target.save();
    // A role change revokes the sessions: it takes effect now, without waiting
    // for the access token to expire (08-04).
    await revokeAllRefreshTokens(target._id);
    recordAction({ actor: req.user, action: 'user:change-role',
      resource: String(target._id), outcome: 'allowed', requestId: req.requestId });
    res.json({ user: { id: target._id, role: target.role, previousRole } });
  } catch (error) { next(error); }
}

Common Mistakes and Tips

  • Checking the role and forgetting ownership. It is textbook IDOR, the single most frequent authorization failure there is. And its mirror image: checking ownership before loading the resource, which is impossible because ownership is an attribute of the resource.
  • Returning 403 where 404 was called for, confirming the existence of private resources; or trusting the JWT's role for critical actions, when it may be up to 15 minutes stale.
  • Assuming administrator implies every permission "by logic": write it in the matrix, because what is implicit ends up inconsistent. And authorizing on the client, or writing User.create(req.body) / Object.assign(user, req.body): privilege escalation served on a plate.
  • Tip: write the permission matrix before the code and turn it into Module 9's test case table: if a cell has no test, that cell is an assumption. And make the owner filter live in the repository's signature — security you cannot forget beats security you have to remember — and deny by default: a new route with no explicit policy should be unreachable, not public.

Exercises

Exercise 1: find the flaw

These two endpoints pass a superficial review and each has one authorization flaw. Identify them and rewrite them.

router.get('/api/orders/:id', authenticate, async (req, res, next) => {
  const order = await Order.findById(req.params.id).lean();
  if (!order) { return next(new ResourceNotFound('Order not found')); }
  res.json({ order });
});
router.get('/api/venues/:venue/sales', authenticate, requireRole('organizer'), async (req, res) => {
  res.json({ sales: await calculateSales(req.params.venue) });
});

Exercise 2: implement one row of the matrix

Implement PATCH /api/tickets/:code/use (marking a ticket as used on entry to the venue) respecting the matrix: only the organizer of that venue or an administrator. The ticket must be in the valid status; if it is already used or cancelled, a StateConflict is called for. Write the route with its middleware chain and the policy function.

Exercise 3: a new role

Escena Viva hires box-office staff who can mark tickets as used, but cannot cancel them, create events or view sales. Decide whether you would add a box-office role or a standalone permission, justify the choice, and write the change in permissions.js.

Solutions

Exercise 1. The first endpoint does not check ownership: any authenticated user reads any other user's order given the identifier. That is an IDOR. The second checks the role but not the venue, so the organizer of Sala Bóveda sees Teatro Almendra's sales; on top of that it excludes administrators, who according to the matrix are allowed.

router.get('/api/orders/:id', authenticate, async (req, res, next) => {
  try {
    // Filter inside the query: impossible to forget, and it does not
    // distinguish "does not exist" from "not yours".
    const order = await getUserOrder(req.params.id, req.user.id);
    if (!order) { throw new ResourceNotFound('Order not found'); }
    res.json({ order });
  } catch (error) { next(error); }
});
router.get('/api/venues/:venue/sales', authenticate, requireRole('organizer', 'administrator'),
  (req, res, next) => {
    // The requested venue must be the assigned one, unless administrator.
    if (req.user.role !== 'administrator' && req.params.venue !== req.user.assignedVenue) {
      return next(new ResourceNotFound('Venue not found'));
    }
    next();
  },
  async (req, res, next) => {
    try { res.json({ sales: await calculateSales(req.params.venue) }); } catch (e) { next(e); }
  });

An administrator holding ORDER_READ_ANY would need a separate branch, resolved with canViewOrder and recorded in the audit trail (section 12).

Exercise 2. A preliminary note: markTicketUsed must perform the transition atomically in the repository (findOneAndUpdate with { code, status: 'valid' }), or two simultaneous scans at the door of Auditorio Ribera could both go through. It is the same concurrency lesson as Module 7.

// src/authorization/policy.js  (addition)
const canUseTicket = (user, ticket) => hasPermission(user, PERMISSIONS.TICKET_USE)
  && (user.role === 'administrator' || ticket.venue === user.assignedVenue);
// src/routes/tickets.js
router.patch('/:code/use', authenticate, requireRole('organizer', 'administrator'),
  loadTicketByCode,                 // leaves req.resource
  requirePolicy(canUseTicket),      // venue ownership
  async (req, res, next) => {
    try {
      const ticket = req.resource;
      // Transition valid -> used. Any other origin is a conflict.
      if (ticket.status !== 'valid') {
        throw new StateConflict(`The ticket is ${ticket.status}`,
          { currentStatus: ticket.status, expectedTransition: 'valid -> used' });
      }
      recordAction({ actor: req.user, action: 'ticket:use', resource: ticket.code,
        outcome: 'allowed', requestId: req.requestId });
      res.json({ ticket: await markTicketUsed(ticket.code) });
    } catch (error) { next(error); }
  });

Exercise 3. A box-office role, not a standalone permission: it corresponds to a real and stable business function, it is explainable in one sentence, and it is not a one-off exception on top of another role. Individual permissions are justified when exceptions have to be made case by case; here we have a category of people.

const PERMISSIONS_BY_ROLE = Object.freeze({
  attendee: [PERMISSIONS.ORDER_READ_OWN],
  'box-office': [PERMISSIONS.TICKET_USE], // no cancelling, no creating, no sales
  organizer: [/* ...same as before */],
  administrator: Object.values(PERMISSIONS),
});

Changes required: add 'box-office' to ROLES in the User model (with a migration, M7), add the row to the matrix in section 3, and update the policy and its tests. That the change is confined to so few places is the payoff of having centralized the policy.

Conclusion

Escena Viva no longer just knows who is calling: it knows what each caller may do. We compared the authorization models and chose RBAC with our eyes open, wrote the permission matrix as an executable specification, and built requireRole as a factory with the correct distinction between 401 (I do not know who you are) and 403 (I know who you are and this is not for you). Above all, we tackled what RBAC does not cover: resource ownership, checked after loading the resource and, better still, filtered inside the query from the repository, which is how you make IDOR impossible instead of remembering to avoid it. We saw why the client never authorizes, when to re-read the role from the database instead of trusting the token, how to make the jump to fine-grained permissions if needed, and why the policy lives in src/authorization/policy.js as pure functions that test themselves. And we closed off privilege escalation where it is born: nobody assigns themselves their own role.

One lesson remains to finish the module. We have solid authentication and correct authorization, but a defensible API is more than that: rate limits tuned per route, size and pagination caps, protection against injection and mass assignment, security headers chosen with judgment, mandatory HTTPS, secret management and monitoring of security events. In API Security Best Practices we walk the OWASP API Security Top 10 point by point over Escena Viva, and turn everything we have learned into a checklist you can actually use.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved