DEV Community

Cover image for 401 vs 403 in your Node API logs: what each pattern means
ApiTraffic
ApiTraffic

Posted on Originally published at apitraffic.io

401 vs 403 in your Node API logs: what each pattern means

A 401 and a 403 sit next to each other in the log, both amber and both "auth stuff". They answer different questions, though, and the pattern each one makes over an hour tells you whether you're looking at an attack, a scraper or your own deploy.

What each status means

RFC 9110 section 15.5.2 defines 401 Unauthorized as a request that "lacks valid authentication credentials for the target resource." The server sending it must include a WWW-Authenticate header with at least one challenge. The challenge in that header tells the client how to authenticate.

Section 15.5.4 defines 403 Forbidden: the server understood the request and refuses it. If credentials came with the request, they aren't enough, and the client should not retry with the same ones. The server knows who you are and the answer is still no.

So 401 is about identity and 403 is about permission. If your API keeps them apart, the four patterns below are easy to pick out of a log.

Four patterns worth reading

A burst of 401s from one IP, then a 200 on the same account

Dozens of failed logins in a few seconds followed by one success is a password or key that worked. The 401s alone are noise you see every day. The 200 after them is the line to alert on. We covered the wider version of this in spotting credential stuffing.

A slow trickle of 403s from one user across sequential IDs

/invoices/1042, /invoices/1043, /invoices/1044, a few seconds apart. The user is authenticated and walking objects they don't own. This is the probing side of broken object level authorization, the top item on the OWASP API Security Top 10 2023. If your ownership check works, you see 403s. If it doesn't, you see 200s, and that's worse.

401s jumping at the minute a deploy finished

Every client failing at once, from IPs you recognize, is almost never an attack. Look for a broken token check, a rotated signing secret that only half the servers picked up, or clock skew that makes fresh tokens look expired. Line the spike up against your deploy markers before anyone opens a security ticket.

A 403 on a resource the caller shouldn't know exists

A 403 on /admin/billing-export confirms the route is real. RFC 9110 lets a server that wants to hide a forbidden resource answer 404 instead. Use that for anything whose existence is itself information: other tenants' records, internal routes, unreleased features.

Returning the right one

Express: a middleware that sends 401 with the challenge header when the token is missing or invalid.

// auth.js
function logDenial(req, status, reason) {
  console.log(JSON.stringify({
    msg: 'auth_denied',
    status,
    reason,
    method: req.method,
    path: req.originalUrl.split('?')[0],
    userId: req.user?.id ?? null,
    ip: req.ip,
    at: new Date().toISOString(),
  }));
}

function requireAuth(verifyToken) {
  return (req, res, next) => {
    const [scheme, token] = (req.get('authorization') || '').split(' ');
    const user = scheme === 'Bearer' && token ? verifyToken(token) : null;
    if (!user) {
      logDenial(req, 401, token ? 'invalid_token' : 'missing_token');
      res.set('WWW-Authenticate', 'Bearer realm="api"');
      return res.status(401).json({ error: 'unauthorized' });
    }
    req.user = user;
    next();
  };
}

module.exports = { requireAuth, logDenial };
Enter fullscreen mode Exit fullscreen mode

The ownership check runs after the record is loaded. A caller who isn't allowed to know the invoice exists gets the same 404 as a missing one, and the reason code keeps the two apart in your logs:

app.get('/invoices/:id', requireAuth(verifyToken), async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  if (!invoice || invoice.accountId !== req.user.accountId) {
    logDenial(req, 404, invoice ? 'not_owner' : 'not_found');
    return res.status(404).json({ error: 'not_found' });
  }
  res.json(invoice);
});

app.post('/admin/refunds', requireAuth(verifyToken), (req, res) => {
  if (req.user.role !== 'admin') {
    logDenial(req, 403, 'missing_role');
    return res.status(403).json({ error: 'forbidden' });
  }
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Fastify: the same 401 logic as a preHandler hook, logging through Fastify's built-in pino logger.

fastify.decorateRequest('user', null);

fastify.addHook('preHandler', async (request, reply) => {
  const [scheme, token] = (request.headers.authorization || '').split(' ');
  const user = scheme === 'Bearer' && token ? verifyToken(token) : null;
  if (!user) {
    request.log.warn({
      msg: 'auth_denied',
      status: 401,
      reason: token ? 'invalid_token' : 'missing_token',
      path: request.url.split('?')[0],
      ip: request.ip,
    });
    reply.code(401).header('WWW-Authenticate', 'Bearer realm="api"').send({ error: 'unauthorized' });
    return reply;
  }
  request.user = user;
});
Enter fullscreen mode Exit fullscreen mode

What to log on every denial

One line per 401, 403 or ownership 404, with the same fields every time:

  • route or path, with the real IDs, so sequential walks show up
  • user id or API key id (never the token itself; see redacting sensitive data from logs)
  • client IP
  • a reason code: missing_token, invalid_token, expired_token, not_owner, missing_role

The reason code is what separates the patterns. A wall of expired_token after a deploy points at clocks or secrets. A wall of not_owner from one user points at a person.

Five searches to run against last week's logs

  1. 401s grouped by IP, top 20. Then check whether any of those IPs got a 200 on a login route within a minute of their last 401.
  2. not_owner denials grouped by user id. Anyone with more than a handful across different IDs gets a closer look.
  3. 401s per minute, plotted next to your deploy times.
  4. Routes that return 403 to callers who shouldn't know the route exists. Decide whether each one should be a 404.
  5. Routes that never returned a single 401, 403 or not_owner all week. Either nobody tried, or the check isn't there.

With JSON-lines logs like the ones above, the first search is one line of jq:

jq -r 'select(.msg=="auth_denied" and .status==401) | .ip' app.log | sort | uniq -c | sort -rn | head -20
Enter fullscreen mode Exit fullscreen mode

See what's hitting your API: apitraffic.io

Top comments (0)