Foundations roadmap

Auth Tokens and CORS

Log in to save this

Saving keeps this in your list across devices. It's a free account — no card.

Most APIs have to answer two questions on every request: who is asking, and are they allowed to do this? Getting those answers wrong is one of the most common ways real apps leak data. This lesson covers how a server knows who you are, the difference between 401 and 403, why the server must never trust the client, and what CORS really does.

Sessions and tokens

HTTP has no memory. Each request arrives on its own, so the client must prove who it is every time. There are two common ways.

With a session, the server stores a record like "session abc123 belongs to user 42" and gives the browser a cookie holding abc123. The browser attaches that cookie to every request to that site automatically.

With a token, such as a JWT (JSON Web Token), the server hands the client a signed string that contains the user's id and an expiry time. The client sends it in a header:

const res = await fetch('https://api.example.com/me', {
  headers: { Authorization: 'Bearer ' + token },
});

The server checks the signature with a secret only it knows. If anyone edited the token, say changing "role": "user" to "role": "admin", the signature no longer matches and the token is rejected. Note that a JWT is signed, not encrypted: anyone holding it can decode and read its contents, so never put passwords or other secrets inside.

function requireUser(req, res, next) {
  const token = (req.get('Authorization') || '').replace('Bearer ', '');
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Not signed in' });
  }
}

401 versus 403

  • 401 Unauthorized really means "unauthenticated": there are no credentials, or they are invalid or expired. The fix is to sign in again.
  • 403 Forbidden means "I know who you are, and you are not allowed". Signing in again changes nothing.

A regular user calling an admin-only route gets a 403. A request with an expired token gets a 401. Getting this right lets the frontend react correctly: send the user to the login page on 401, show "you don't have access" on 403.

Never trust the client

Everything the browser sends can be changed by the user: form fields, hidden inputs, request bodies, even the JavaScript itself. So:

  • Hiding the Delete button from non-admins is a UI choice, not security. The API route must check the role itself.
  • Take the user's identity from the verified token, never from a userId field in the request body.
  • Check ownership on every request for a specific record. Otherwise a user who changes /invoices/1234 to /invoices/1235 sees someone else's invoice.
app.get('/invoices/:id', requireUser, async (req, res) => {
  const invoice = await findInvoice(req.params.id);
  if (!invoice || invoice.userId !== req.user.id) {
    return res.status(404).json({ error: 'Not found' });
  }
  res.json(invoice);
});

Answering 404 here, rather than 403, also avoids confirming that invoice 1235 exists.

What CORS actually protects

Browsers follow the same-origin policy: JavaScript on https://evil.example may send requests to https://api.yourbank.com, but it cannot read the responses. An origin is the scheme, domain and port together, so http://localhost:3000 and http://localhost:4000 are different origins.

CORS is how a server relaxes that rule on purpose. When your frontend at https://app.example.com calls your API, the API answers with Access-Control-Allow-Origin: https://app.example.com and the browser lets your code read the response. For some requests, such as ones with an Authorization header or a JSON body, the browser first sends an OPTIONS "preflight" request to ask permission.

Two consequences surprise people. CORS is enforced by the browser only, so curl, Postman and other servers ignore it completely; it does not protect your API from anyone. What it protects is your users: it stops other websites, running in their browser with their cookies, from reading their data. And a CORS error is fixed on the server's headers, never in the frontend code.

Why "just allow everything" is dangerous

When a CORS error blocks you, it is tempting to allow every origin. Browsers already refuse Access-Control-Allow-Origin: * on requests that include cookies, so people work around it by echoing back whatever Origin header arrives, together with Access-Control-Allow-Credentials: true. That tells the browser any site may read responses made with your users' cookies. A malicious page can then fetch /api/me in the background and read the logged-in visitor's account.

Instead, list the exact origins you trust, such as your production site and http://localhost:3000 for development.

Try it

Sign in to your own project, copy the token from the Network tab, and paste it into a JWT decoder: everything inside is readable. Then call a protected route without the header and confirm you get a 401, and as the wrong user confirm you do not get someone else's data.

Resources

Curated resources for this node are on the way. Use what you already know how to search for, and check back soon.