Skip to main content

API

I. API Basics

1. Why a Separate BE + FE?

  • Modern pattern: host BE (Express + DB) and FE (React) separately on different servers
  • They talk to each other via JSON — not HTML like traditional server-rendered apps
  • One backend can serve multiple frontends — a website, a mobile app, a desktop app all hitting the same API
  • This separation is sometimes called the Jamstack pattern
  • In Express, switching from serving HTML to serving JSON is as simple as using res.json() instead of res.render()

2. REST

REST = Representational State Transfer — a widely adopted convention for naming and organizing API endpoints

Without a convention, API routes can become inconsistent and hard to read:

  • /api/getAllPostComments — what verb does this imply? What if I want to create one?
  • /api/savePostInDatabase — this names the action not the resource

REST fixes this by being resource-based — you name endpoints after the thing you're working with, and use HTTP verbs to express what you're doing to it.

HTTP Verbs and What They Mean

VerbActionExample
GETReadGET /posts — fetch all posts
POSTCreatePOST /posts — create a new post
PUTUpdatePUT /posts/:id — replace a post entirely
PATCHUpdatePATCH /posts/:id — update part of a post
DELETEDeleteDELETE /posts/:id — delete a post

REST URL Structure

REST APIs typically expose two URL shapes per resource — one for the whole collection, one for a single item:

GET  /posts           → all posts
GET /posts/:postId → one specific post
POST /posts → create a new post
PUT /posts/:postId → update a specific post
DELETE /posts/:postId → delete a specific post

You can also nest resources to express relationships:

GET /posts/:postId/comments            → all comments on a specific post
GET /posts/:postId/comments/:commentId → one specific comment on that post

The URL itself tells you what you're working with at every level. Each segment narrows it down further.

Why Follow REST?

  • Makes your API predictable — other developers can guess your endpoints
  • Easier to maintain as the app grows
  • Aligns with how HTTP was designed to work
  • Industry standard — most APIs you've consumed (Giphy, weather APIs, etc.) follow it

3. CORS — Cross-Origin Resource Sharing

The Same Origin Policy

Browsers enforce a security rule: a web page can only make requests to the same origin (same domain + port + protocol) that served it. This prevents a malicious site from silently making requests to another site using your cookies.

For example, if your frontend is hosted at https://myapp.com and it tries to fetch from https://api.myapp.com, the browser will block that request by default — different subdomain = different origin.

Why This Matters for APIs

When you separate your FE and BE (the modern pattern above), they almost always live on different domains:

  • Frontend: https://myapp.com or http://localhost:5173
  • Backend: https://api.myapp.com or http://localhost:3000

Without explicitly allowing it, the browser will block all requests from the FE to the BE.

Enabling CORS in Express

Install the CORS middleware package:

npm install cors

Allow all origins (fine for development):

const cors = require("cors");
app.use(cors());

Allow only your specific frontend in production:

app.use(cors({
origin: "https://myapp.com", // only requests from this domain are allowed
}));

Allow multiple specific origins:

app.use(cors({
origin: ["https://myapp.com", "https://admin.myapp.com"],
}));

CORS is enforced by the browser — it does not protect your API from server-to-server requests or tools like Postman. It only prevents unauthorized browser-based requests.

CORS on a Single Route

You can also apply CORS selectively to individual routes instead of the whole app:

const cors = require("cors");

// Only this route allows cross-origin requests
app.get("/public-data", cors(), (req, res) => {
res.json({ message: "Anyone can access this" });
});

// This route does not have CORS — only same-origin requests allowed
app.get("/private-data", (req, res) => {
res.json({ secret: "restricted" });
});

II. API Security

1. Session-based vs Token-based Auth

There are two main strategies for keeping a user "logged in" across requests.

a. Session-based (traditional)

  1. User logs in with username + password
  2. Server creates a session and stores it in a database
  3. Server sends back a cookie with the session ID
  4. Browser automatically includes that cookie on every subsequent request
  5. Server looks up the session ID in the database to verify the user

b. Token-based (modern API approach)

  1. User logs in with username + password
  2. Server creates a signed token and sends it back
  3. Client stores the token (in memory or localStorage)
  4. Client manually includes the token in the Authorization header of every subsequent request
  5. Server verifies the token's signature — no database lookup needed

Token auth is preferred for APIs because:

  • FE and BE on different domains make cookies complicated (cookie scope is tied to domain)
  • Tokens are stateless — the server doesn't need to store anything to verify them
  • Works for any type of client — browser, mobile app, desktop app, other servers
  • Tokens can be set to expire after a certain time for added security

2. JWT — JSON Web Token

A JWT is the most common type of token used for API auth. Structure: three base64-encoded parts joined by dots:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.abc123signature
↑ ↑ ↑
header payload signature
(algorithm) (your data, e.g. (proves it hasn't
userId, role) been tampered with)
  • The header specifies the signing algorithm
  • The payload contains claims — data you embed in the token (user ID, role, expiry)
  • The signature is created by hashing header + payload with a secret key only the server knows

When the server receives a token, it re-hashes the header + payload with its secret key and checks if the signatures match. If they do, the token is valid and untampered. If someone modified the payload, the signature won't match — the server rejects it.

JWTs are not encrypted by default — the payload is just base64 encoded, which anyone can decode. Never put sensitive information (passwords, credit cards) in a JWT payload. They are signed (tamper-proof) but not secret.

Sending a token in a request:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.abc123

The Bearer prefix is just a convention — it signals that what follows is a bearer token.

Token expiry:

const token = jwt.sign(
{ userId: user.id, role: user.role }, // payload
process.env.SECRET_KEY, // secret
{ expiresIn: "7d" } // expires in 7 days
);

Once expired, the token is rejected and the user must log in again — this limits the damage if a token is stolen.

3. Auth Middleware Pattern

The standard pattern is to create a middleware function that:

  1. Reads the token from the Authorization header
  2. Verifies it
  3. Attaches the decoded user data to req for downstream use
  4. Calls next() to pass to the controller — or returns a 401 if invalid
const jwt = require("jsonwebtoken");

function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}

const token = authHeader.split(" ")[1]; // extract token after "Bearer "

try {
const decoded = jwt.verify(token, process.env.SECRET_KEY);
req.user = decoded; // attach decoded payload to req for controllers to use
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}

Apply it to any route that requires authentication:

// Public route — no auth needed
router.get("/posts", getAllPosts);

// Protected route — must be logged in
router.post("/posts", requireAuth, createPost);
router.delete("/posts/:id", requireAuth, deletePost);

Inside the controller, the user is available on req.user:

async function createPost(req, res) {
const { userId } = req.user; // set by requireAuth middleware
const { title, body } = req.body;

const post = await db.createPost({ title, body, authorId: userId });
res.status(201).json({ success: true, data: post });
}

4. Layered Auth — Roles and Tiers

In real apps, not all authenticated users have the same permissions. You can chain multiple middleware functions to enforce different tiers:

function requireAuth(req, res, next) {
// verify JWT, attach req.user
}

function requireAdmin(req, res, next) {
if (req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
next();
}

// Anyone logged in can access this
router.get("/profile", requireAuth, getProfile);

// Only admins can access this
router.delete("/users/:id", requireAuth, requireAdmin, deleteUser);
  • 401 Unauthorized — no valid token (not logged in)
  • 403 Forbidden — valid token but insufficient permissions (logged in, but wrong role)

5. Signing and Verifying Tokens — Full Flow

const jwt = require("jsonwebtoken");

// On login — create and send the token
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);

if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
return res.status(401).json({ error: "Invalid credentials" });
}

const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.SECRET_KEY,
{ expiresIn: "7d" }
);

res.json({ token }); // client stores this and sends it with future requests
});

// On a protected request — verify the token
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "Unauthorized" });

try {
req.user = jwt.verify(token, process.env.SECRET_KEY);
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}