DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning HTTP, APIs, and Auth As If You Built It Yourself

If you have ever built a frontend that calls a backend, you have run into all three of these. You wrote a fetch. You picked a URL shape. You stuck a token somewhere and prayed. Something returned a 200 with { ok: false } in the body, and you spent the afternoon arguing in the team chat about whether that was correct.

It probably was not.

There is a small set of conventions that the whole web agreed on, decades ago, and most apps half know them. Frontend engineers who fully know them ship cleaner, more debuggable systems and have shorter Slack arguments.

That is the gap this post fills.

What are HTTP, APIs, and auth, really

Think of the web as a polite postal system. Your client (the browser) writes a letter (a request), the server reads it, writes back (a response), and the conversation ends. Each letter has an envelope (headers), a way of being addressed (the URL), and a body (the data). Each reply has a stamp on the front (a status code) that tells you, at a glance, what happened.

  • HTTP is the postal protocol itself. Verbs, headers, status codes, caching rules.
  • APIs are the postal addresses your team agreed on. Which URLs do what, what each one expects in the body, what it sends back.
  • Auth is the picture ID you put in the envelope. Who are you, and are you allowed to ask for this?

A senior frontend engineer treats those three as one fluent topic, not three separate puzzles.

That is the whole vibe.

Let's pretend we are building one

We want to design a small backend a frontend can talk to without surprises, with proper status codes, predictable URLs, and authenticated calls. We will not hand build a server. We will agree on the conventions and use any framework that respects them.

For the running example, we are designing the Mochi Recipes API: list recipes, read one, create one, log in, save a favorite. Small, but it lets us touch every important corner.

Decision 1: HTTP, the verbs and the rules

Every request is one verb plus one URL. The verbs:

  • GET: read. Safe (no side effects). Idempotent (same request, same result).
  • POST: create or trigger an action. Not idempotent.
  • PUT: replace the resource entirely. Idempotent.
  • PATCH: change part of the resource. Idempotent in spirit.
  • DELETE: delete. Idempotent.
  • HEAD: like GET but only the headers come back.
  • OPTIONS: what verbs and headers are allowed here? (Used for CORS preflights.)

Two things every senior should know cold:

  • GET must never modify state. Browsers, proxies, and prefetchers send GETs whenever they like. A GET /unsubscribe?id=42 link will be deleted by Outlook's URL preview. (Yes, this is a real bug that has happened many times.)
  • PUT vs PATCH: PUT /recipes/42 with a partial body replaces the recipe with that partial body and removes the missing fields. PATCH /recipes/42 with the same body changes only what was sent. Pick on purpose.

Decision 2: Status codes that say what happened

The first three letters of the response. Memorize the pattern:

  • 2xx success
    • 200 OK: the default success.
    • 201 Created: after a successful POST that created a thing. Include a Location: header pointing at the new resource.
    • 204 No Content: success but nothing to return. Common after DELETE.
  • 3xx redirection
    • 301 Moved Permanently: the URL changed forever. Browsers cache this.
    • 302 Found, 307 Temporary Redirect: the resource is over there, for now.
    • 304 Not Modified: the cached copy is still good. The body is empty.
  • 4xx the client made a mistake
    • 400 Bad Request: malformed input.
    • 401 Unauthorized: you are not logged in. (Misnamed, it is really "unauthenticated".)
    • 403 Forbidden: you are logged in, but not allowed.
    • 404 Not Found: no such resource.
    • 405 Method Not Allowed: wrong verb for this URL.
    • 409 Conflict: your write would conflict with the current state (e.g. duplicate email).
    • 422 Unprocessable Entity: well formed but invalid (e.g. validation errors).
    • 429 Too Many Requests: slow down. Pair with Retry-After.
  • 5xx the server made a mistake
    • 500 Internal Server Error: something blew up.
    • 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout: upstream issues.

The two anti patterns I see most often on real teams:

  • Returning 200 { ok: false } for errors. This breaks every standard tool. Use the right status code. Tools, logs, monitoring, CDNs, and the browser DevTools all rely on it.
  • Returning 400 for "not found". A missing user is 404, not 400. A malformed JSON body is 400. Tell them apart.

Decision 3: REST URL design

A URL points at a resource (a thing). The verb says what you are doing to it. Resources are nouns, plural, lowercase.

GET    /recipes              -> list
GET    /recipes/42           -> read one
POST   /recipes              -> create
PATCH  /recipes/42           -> update
DELETE /recipes/42           -> delete

GET    /recipes/42/comments  -> list comments on recipe 42
POST   /recipes/42/comments  -> add a comment
Enter fullscreen mode Exit fullscreen mode

A few senior level rules:

  • Nouns, not verbs. POST /createRecipe is wrong. POST /recipes is right.
  • Plural, consistently. GET /recipes, not GET /recipe.
  • Use query parameters for filtering, sorting, paging. GET /recipes?tag=dessert&sort=-createdAt&page=2&limit=20.
  • Use sub paths for relationships. GET /users/42/recipes reads better than GET /recipes?userId=42 for natural ownership.
  • Version the API in the URL or in a header. /v1/recipes or Accept: application/vnd.example.v1+json. Pick one and never argue again.
  • Return JSON in a stable shape. Never change a field type or remove a key without a version bump.

If REST does not fit your needs (lots of nested data, lots of round trips), look at GraphQL or tRPC. Both are excellent in 2026. Neither replaces REST for public APIs.

Decision 4: Headers, the metadata you cannot ignore

Headers are the envelope of every request and response. The ones every senior should recognize:

Content negotiation

Content-Type:    application/json
Accept:          application/json
Accept-Language: en-US,en;q=0.9
Enter fullscreen mode Exit fullscreen mode

Always set Content-Type on requests with a body. Always read it on responses (the body might not be JSON).

Auth

Authorization: Bearer <token>
Cookie:        session=...
Enter fullscreen mode Exit fullscreen mode

We will dig into these in a moment.

Caching

Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag:          "v3-abc123"
Last-Modified: Wed, 09 May 2026 10:00:00 GMT
Enter fullscreen mode Exit fullscreen mode

The single most underused tool in frontend performance is conditional requests. A client with an ETag can ask "is this still fresh?":

GET /recipes/42
If-None-Match: "v3-abc123"
Enter fullscreen mode Exit fullscreen mode

If yes, the server replies 304 Not Modified with no body. Bandwidth saved, page faster.

CORS

Access-Control-Allow-Origin:      https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods:     GET, POST, PATCH, DELETE
Access-Control-Allow-Headers:     Authorization, Content-Type
Enter fullscreen mode Exit fullscreen mode

CORS lives entirely on the server. It tells the browser which origins are allowed to call this API from JavaScript. It does not exist for security against attackers (curl ignores it). It exists to protect users from a malicious page calling another origin from their browser.

The two CORS bugs every frontend developer hits:

  • The preflight OPTIONS request. Before a "non simple" request, the browser sends an OPTIONS to ask permission. Your server must respond with the right headers. Most frameworks have a one-line cors middleware.
  • * and credentials do not mix. If your server returns Access-Control-Allow-Origin: *, the browser will refuse to send cookies. For credentialed requests, set the exact origin.

Rate limiting

X-RateLimit-Limit:     100
X-RateLimit-Remaining: 23
X-RateLimit-Reset:     1715251200
Retry-After:           60
Enter fullscreen mode Exit fullscreen mode

A polite API tells the client when to back off. A polite client respects it.

Decision 5: Errors that the client can actually handle

The body of a 4xx or 5xx response should follow a stable shape. The de facto standard is RFC 7807 / 9457 Problem Details:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type":     "https://example.com/problems/validation",
  "title":    "Invalid input",
  "status":   422,
  "detail":   "Some fields failed validation.",
  "instance": "/recipes",
  "errors": [
    { "field": "title", "code": "too_short" },
    { "field": "prepMinutes", "code": "must_be_positive" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few rules:

  • Use the right status code first. The body explains, the status decides.
  • Include a stable error code per problem. UI code can switch on it without parsing English.
  • Include enough info to render a good error message without leaking internals.
  • Never echo user input back in HTML errors unless you escape it. That is a classic XSS path.

Decision 6: Authentication, the choices that actually exist

Auth is two words pretending to be one:

  • Authentication (authn): who are you?
  • Authorization (authz): what are you allowed to do?

The four mainstream patterns in 2026:

1. Session cookies (the original, still excellent)

The server creates a session, stores it server side (in a database or Redis), and sets a cookie. The browser sends the cookie automatically on every request to the origin. The server looks up the session and knows who you are.

Set-Cookie: session=opaque_id; HttpOnly; Secure; SameSite=Lax; Path=/
Enter fullscreen mode Exit fullscreen mode

Every flag matters:

  • HttpOnly: JavaScript cannot read it. Stops XSS from stealing the token.
  • Secure: only sent over HTTPS.
  • SameSite=Lax (or Strict): only sent for same site requests, blocking most CSRF.
  • Path=/: sent on every path under the origin.

The security defaults are excellent. The state lives on the server where you can revoke it instantly. Best for first party web apps where the API and the frontend share an origin.

The one CSRF caveat: if you use cookies, every state changing endpoint should require either SameSite=Strict, a CSRF token, or a custom header that browsers will not send cross origin without permission.

2. JWT (JSON Web Tokens)

A JWT is a self contained token that is signed by the server. It contains the user id, expiration, and any claims you want, all readable by anyone (it is base64 encoded JSON). Only the signature can be checked by the server.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
Enter fullscreen mode Exit fullscreen mode

Pros: stateless, easy to verify on any service that has the public key, popular in SPAs, mobile, and microservices.

Cons: hard to revoke before expiration (you would need a blocklist, defeating the stateless point). Often misused to store sensitive data in the token (the body is readable by the client).

The 2026 advice:

  • Short lived access token (15 minutes) plus a long lived refresh token. When the access token expires, the client uses the refresh token to get a new one.
  • Store tokens in HttpOnly cookies, not in localStorage. Tokens in localStorage are XSS's lunch.
  • Never use JWTs as session cookies on a single origin web app. Use real session cookies. JWTs shine for cross origin or service to service.

3. OAuth 2.0 / OAuth 2.1

OAuth is not a login method. It is a protocol for your app to act on behalf of a user at another service. "Sign in with Google" is OAuth.

The flow you actually need to know:

  1. Your app sends the user to Google's /authorize URL.
  2. The user logs in to Google and clicks "Allow".
  3. Google redirects back to your app with an authorization code.
  4. Your server exchanges the code for an access token (and a refresh token) at Google's /token endpoint.
  5. Your server uses the access token to call Google's APIs.

OAuth 2.1 (the modern consolidation) makes a few things mandatory:

  • PKCE for all authorization code flows. A short random secret the client generates and proves possession of. Closes a class of attacks.
  • No more Implicit grant. It was insecure in browsers. Use authorization code with PKCE.
  • No more Resource Owner Password grant. Bad pattern, gone.
  • Exact redirect URI matching. No fuzzy patterns.

For most frontend developers, you will not implement OAuth. You will use a library or a service: Auth.js / NextAuth, Clerk, Auth0, WorkOS, Stack Auth. Pick one, follow its setup, get on with your day.

4. API keys

A long opaque string a service gives a developer for server to server access. Useful for partners and CLI tools. Never put one in frontend code, ever. If a key shows up in your client bundle, it is leaked.

Decision 7: Storing tokens in the browser, the only sane way

The single most common security mistake in frontend code is putting an auth token where JavaScript can read it.

// don't
localStorage.setItem("token", token);

// don't
document.cookie = `token=${token}`;
Enter fullscreen mode Exit fullscreen mode

If any third party script (analytics, chat widget, ad SDK) is XSS'd, your token leaks to it.

The right pattern:

  • The server sets an HttpOnly cookie with the session or refresh token.
  • JavaScript never sees it.
  • The browser sends it automatically on calls to your API.
  • For cross origin frontends, set the API on a subdomain and use SameSite=None; Secure cookies, with explicit CORS allowing credentials.

Modern auth libraries default to this. If yours does not, find one that does.

Decision 8: Calling APIs from the frontend without pain

Once the conventions are set, the client side patterns are simple.

fetch, the modern default

async function getRecipes() {
  const res = await fetch("/api/recipes", {
    headers: { Accept: "application/json" },
  });

  if (!res.ok) {
    throw new ApiError(await res.json(), res.status);
  }

  return res.json() as Promise<Recipe[]>;
}
Enter fullscreen mode Exit fullscreen mode

A few habits to internalize:

  • fetch does not throw on 4xx or 5xx. You have to check res.ok yourself.
  • Always cancel in flight requests when the user navigates away or types again. AbortController is the answer:
const ctrl = new AbortController();
fetch("/api/search?q=" + q, { signal: ctrl.signal });
// later: ctrl.abort();
Enter fullscreen mode Exit fullscreen mode
  • Wrap in a typed client. Hand rolling fetch with try/catch in 80 places is painful. Use TanStack Query for caching and retries, or tRPC, or Hey API generated clients from an OpenAPI spec, or ts-rest for typed contracts.
  • Use credentials: "include" when you need cookies on cross origin requests, alongside the right CORS headers.

TanStack Query, the go to data layer

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function useRecipes() {
  return useQuery({
    queryKey: ["recipes"],
    queryFn: () => fetch("/api/recipes").then((r) => r.json()),
    staleTime: 60_000,
  });
}

function useCreateRecipe() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (data: NewRecipe) =>
      fetch("/api/recipes", { method: "POST", body: JSON.stringify(data) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["recipes"] }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Caching, deduping, retries, background refetching, optimistic updates, all included. We have already discussed why server data does not belong in Zustand or Redux.

Decision 9: WebSockets and Server-Sent Events

For real time data (chat, notifications, live dashboards), HTTP request/response is not enough. Two real options:

  • Server-Sent Events (SSE): a one way stream from server to client over HTTP. Simple. Built into the browser via EventSource. Great for live updates that flow one direction.
  • WebSockets: a two way persistent connection. Use when both ends need to push. Heavier, more work to deploy. The browser API is WebSocket. Frameworks like Socket.IO and partykit make it friendlier.

Pick SSE first. It is simpler, plays better with HTTP infrastructure (load balancers, proxies, CDNs), and covers most "live updates" needs.

Decision 10: Senior level moves and pitfalls

A short list that separates "calls APIs" from "designs and consumes APIs well":

  • Always use HTTPS. Even in development, with a self signed cert if you have to. Cookies and tokens get weird without it.
  • Validate every input on the server. The client is not in your control. Use Zod (or your language equivalent) at the boundary.
  • Never trust the client about auth. Authorization happens on the server.
  • Pagination by default. Any list endpoint that can return more than a few hundred rows must paginate. ?page=2&limit=20 or cursor based (?after=cursor).
  • Rate limit even your internal APIs. A bug on the client side that hammers your endpoint will eat your database otherwise.
  • Set up CORS once, in middleware, not per route.
  • Send a Content-Length and a real Content-Type.
  • Use application/json for JSON, not text/json.
  • Use ISO 8601 timestamps with timezone. 2026-05-09T10:00:00Z. No "May 9 2026". No 1715251200000 unless you also tell the consumer it is millis.
  • Money in integer cents. Same lesson as the SQL post.
  • Document with OpenAPI or a similar spec. Future you, your other team, your AI tools, and your client generators all benefit.
  • Treat 5xx as your fault, 4xx as the client's. Logging, alerts, dashboards must respect that line.
  • Rate limit responses include the limit headers. Do not make clients guess.
  • For long jobs, return 202 Accepted with a polling URL, or use a webhook, or stream progress with SSE. Do not hold an HTTP request open for 30 minutes.

A peek under the hood

What really happens when your frontend calls /api/recipes:

  1. The browser builds a request: method, URL, headers, optional body.
  2. If cross origin and "non simple", the browser sends an OPTIONS preflight first.
  3. DNS resolves, TCP and TLS handshake (kept warm if possible).
  4. The request is sent over HTTP/2 or HTTP/3, multiplexed with other requests on the same connection.
  5. The server matches the route, runs middleware (auth, rate limit, validation), runs the handler, builds a response.
  6. The response status, headers, and body stream back.
  7. The browser respects Cache-Control, sets cookies (Set-Cookie), updates its CORS state.
  8. Your fetch resolves. Your app gets the parsed JSON.

That whole loop is what every "an API call" really is. Once you can picture it, debugging gets ten times faster, because you know where to look.

Tiny tips that will save you later

  • Use the right verb and the right status code. Half of all API arguments end here.
  • Set Cache-Control on every response. Even no-cache is better than nothing.
  • Use ETags for read heavy endpoints. Free 304s.
  • Always AbortController in flight requests. Especially in search-as-you-type.
  • Cookies for first party web apps. JWT for cross origin or services.
  • Never put tokens in localStorage.
  • Validate every body server side with Zod.
  • Use TanStack Query (or equivalent) for any non trivial frontend.
  • Page everything. Sort consistently. Paginate with stable cursors when ordering matters.
  • Document your API. The OpenAPI spec pays for itself.
  • Read RFCs once. Especially RFC 7231 (HTTP semantics), RFC 6750 (Bearer), and RFC 9457 (Problem Details).

Wrapping up

So that is the whole story. The web runs on a polite postal protocol where the verbs and the stamps already mean something specific. APIs are agreements about which addresses do what. Auth is the picture ID in your envelope.

We learned to use the right verbs (GET is safe, POST creates, PATCH updates, DELETE deletes), the right status codes (2xx success, 4xx your fault, 5xx mine), the right URL shapes (nouns, plural, sub paths for ownership), the right headers (Content-Type, Accept, Authorization, Cache-Control, ETag, CORS), and the right errors (Problem Details, stable codes, useful messages).

We picked auth on purpose: session cookies for first party web apps, JWT in HttpOnly cookies for cross origin SPAs, OAuth 2.1 for "sign in with X", API keys never in the browser. We aborted in-flight requests, cached on purpose, paginated everything, and treated client validation as cosmetic and server validation as security.

Once that map is in your head, every API conversation becomes shorter. You stop arguing about whether something should return 200 { ok: false } because you already know the answer. You stop chasing CORS ghosts because you can read the preflight in DevTools. You stop leaking tokens because you never put them where JavaScript could see them in the first place.

Happy networking, and may your 2xx always outnumber your 5xx.

Top comments (0)