Skip to content
Go back

HTTP and CORS Basics

If you work on the backend, you deal with HTTP every day, yet few people can answer “why is HTTP designed this way?”. This note starts from a few core principles and uses them to explain messages, methods, status codes and caching — because most of these follow directly from those principles. CORS comes last: it is not part of HTTP, but a browser rule built on top of it.

Core principles of HTTP

HTTP (Hypertext Transfer Protocol) is the protocol clients and servers use to exchange data on the web. The whole protocol revolves around the six principles below.

Client and server

HTTP has only two roles: the client sends a request, and the server processes it and returns a result.

A client is not just a browser. A mobile app, curl, Postman, or one backend service calling another — all of these are clients. The key point to remember: the server does not know, and does not need to know, who is calling. It relies only on what is inside the request.

Request and response

Every exchange in HTTP follows one pattern: the client sends one request, and the server returns exactly one response.

This pattern has two consequences worth knowing:

  • The client is always the one that sends the request. The server only sends data as the response to a request.
  • Every request gets a response. Even when processing fails, the server still returns a response that reports the error.

So what if the server wants to push data to the client on its own — a real-time notification, for example? Plain HTTP cannot do that, and that is where WebSockets and similar techniques come in. They are covered in the WebSockets note.

Statelessness

HTTP is stateless: the server remembers nothing about earlier requests. Each request must carry everything the server needs to process it.

This may sound like a limitation, but it is one of the most important design decisions in HTTP. Look at its two consequences:

  • Any instance can handle any request. Because the server keeps no state between requests, you can run ten identical instances behind a load balancer, and a request can land on any of them. This is the foundation of horizontal scaling.
  • The client must resend its context with every request. For example, the server does not “remember” that you logged in. On every request, the client sends a cookie or a token to prove who it is. The authentication note goes deeper into this.

Note: stateless does not mean the system stores nothing. The database still stores users and orders as usual. Stateless only means that the protocol itself keeps no state between requests.

Self-descriptive messages

This principle follows directly from statelessness. Since the server remembers nothing, each message must say everything about itself. Besides the data, a message carries a description of that data: is it JSON or HTML, how many bytes long, what language, can it be cached. A message like this is called self-descriptive.

All of that descriptive information lives in headers — we will come back to them when we look at the structure of a message.

Intermediaries

In practice, a request rarely goes straight from the client to the server. On the way, it usually passes through a few intermediaries, such as a proxy, a load balancer, an API gateway or a CDN.

The diagram below shows the path of one call: the request goes from the client, through the intermediaries, to the server; the response travels back along the same path.

flowchart LR
    C["Client<br/>browser, app, service"] -->|request| I["Intermediaries<br/>proxy, load balancer, CDN"]
    I -->|request| S["Server"]
    S -.->|response| I
    I -.->|response| C
    classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
    classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
    class C,S main
    class I infra

Here is an interesting question: these intermediaries know nothing about your application’s logic, so how do they do the right thing? The answer is the self-descriptive principle. Everything they need is already in the message. A CDN that sees Cache-Control: max-age=600 on a response knows it may cache it for 10 minutes, without asking the server. A load balancer reads the Host header and knows which service should get the request.

For this to work, every party on the path must understand methods, status codes and headers in the same way. That is why HTTP standardizes their meaning in RFCs.

Extensibility

HTTP dates from the early 1990s, yet new headers and new status codes are still being added today. What is remarkable is that these additions do not break software that was already running. In other words, HTTP was designed from the start to be extensible and backward compatible.

The trick is simple: software that meets a header it does not understand just ignores it instead of failing. Thanks to this rule, CORS — a mechanism that came much later than HTTP — could be added with just a few new headers, without changing the protocol.

How the principles fit together

Looking at all six principles together, you can see they do not stand alone — they depend on each other:

PrincipleIn simple termsWhat it makes possible
Client–serverTwo clear rolesOne server can serve many kinds of clients
Request–responseEach request gets exactly one responseSimple, predictable exchanges
StatelessEach request carries all its informationScaling by adding more instances
Self-descriptiveA message describes its own contentCaches and proxies work without asking the app
IntermediariesEveryone reads methods, codes, headers the same wayLoad balancers, CDNs and gateways in between
ExtensibleUnknown parts are ignoredNew features without breaking old ones

Where HTTP sits in the network

A common misunderstanding: HTTP does not move data across the network by itself. It only defines what a message means. Getting each byte from one machine to another is the job of the layers below it.

LayerResponsible forExamples
ApplicationThe meaning of messagesHTTP
SecurityEncrypting data, verifying the serverTLS
TransportDelivering data completely and in orderTCP, QUIC
NetworkRouting packets between machinesIP

This separation is what lets the meaning of HTTP be defined once, in RFC 9110. HTTP/1.1, HTTP/2 and HTTP/3 share the same methods, status codes and headers; they only differ in how they carry messages over the network. The end of this note compares the three versions.

The structure of an HTTP message

Back to the self-descriptive principle: for a message to describe itself, it needs a fixed structure that every party can read. Every request and every response has four parts:

  1. Start line: the first line, saying what the request is or how it turned out.
  2. Headers: information that describes the message.
  3. An empty line: signals that the headers are over.
  4. Body: the main data, which may be absent.

The request

POST /orders HTTP/1.1
Host: api.shop.test
Content-Type: application/json
Accept: application/json
Content-Length: 25

{"sku": "A-42", "qty": 1}

The start line of a request is called the request line. It has three parts:

PartIn the exampleMeaning
MethodPOSTWhat the client wants to do
Target/ordersWhich resource to do it on
VersionHTTP/1.1Which format the rest of the message follows

Notice the Host header: it tells the server which domain the request is for. HTTP/1.1 requires it, because one server often runs many domains on the same IP address.

The response

HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/9001
Content-Length: 33

{"id": 9001, "status": "pending"}

The start line of a response is called the status line: the version, a three-digit status code and a short reason phrase. Your code should act on the status code; the reason phrase is only there to make logs easier for humans to read.

Headers

Headers are name–value pairs, and their names are not case-sensitive. There are many headers, but grouped by purpose there are only a few kinds:

GroupAnswers the questionExamples
Request informationWho is calling, and calling what?Host, User-Agent, Authorization
Content negotiationWhich forms of data can the client accept?Accept, Accept-Language, Accept-Encoding
RepresentationWhat format is the body, and how long is it?Content-Type, Content-Length, Content-Encoding
CachingCan it be cached, and for how long?Cache-Control, ETag, If-None-Match, Vary
Response informationWhat else does the server want to tell you?Location, Retry-After, Allow

The body and its length

A question that sounds simple: how does the receiver know where the body ends? HTTP/1.1 has two ways:

  • Content-Length: states how many bytes the body has. The receiver reads exactly that many bytes and stops.
  • Transfer-Encoding: chunked: sends the body in chunks, each with its own size, ending with an empty chunk. This is used when the server starts sending before it knows the total size — for example, when streaming a large export.

With HTTP/2 and HTTP/3, messages are split into frames by the protocol itself, so these headers are not needed to find the end of the body.

Content negotiation

The same resource can be returned in several forms: JSON or XML, English or Vietnamese, compressed or not. Each of these forms is a representation. Content negotiation is how the client and the server agree on which one to send.

It works in a very intuitive way: the client says which forms it accepts, and the server picks one and states its choice in the response.

The client sendsThe server answers with
Accept: application/jsonContent-Type: application/json
Accept-Language: vi, en;q=0.8Content-Language: vi
Accept-Encoding: gzip, brContent-Encoding: br

The q value is a priority from 0 to 1. In the example above, the client prefers Vietnamese, and English is fine as a second choice.

Methods

The first word of the request line is the method. The method tells the server what the client wants to do with the resource.

Two properties: safe and idempotent

When you learn about methods, do not just memorize their names. What matters more are two properties the HTTP standard defines for them:

  • Safe: the method only reads. It does not ask the server to change any data.
  • Idempotent: calling it once or calling it many times leaves the server in the same final state.

It is easy to see that a safe method is always idempotent: if it changes nothing, calling it again changes nothing either.

The common methods

MethodUsed toSafeIdempotent
GETRead a resourceyesyes
HEADLike GET, but headers onlyyesyes
OPTIONSAsk the server what it supportsyesyes
POSTCreate a new resource or run an actionnono
PUTReplace the whole resource at a specific URLnoyes
PATCHChange part of a resourcenonot guaranteed
DELETEDelete a resourcenoyes

PATCH is defined in RFC 5789; the other methods are in RFC 9110.

Note: idempotent is about the end state on the server, not about the response. Call DELETE twice and you may get 204 the first time and 404 the second — different responses, but the same end state on the server: the resource is gone.

Why these properties matter

These properties are not just theory. They tell everyone on the path which requests may be sent again automatically.

  • With a safe method, a browser can prefetch a page and a cache can store the response, without any risk of changing data.
  • With an idempotent method, the client can retry freely. Imagine the connection drops before the response arrives: the client cannot tell whether the server processed the request. If the method is idempotent, it can simply send it again.
  • POST has neither property. Retrying a POST may create a second record. To make retries safe, we use an idempotency key: the client sends a unique value with the request, and the server uses it to recognize a repeated request. The REST API design note covers this in detail.

Status codes

Every request gets a response, and the status code is the quickest way to know the outcome. It is a three-digit number; the first digit tells you which class it belongs to.

The five classes

ClassMeaningWhat the client should do
1xxInformationalKeep waiting
2xxSuccessNothing more
3xxRedirectionCall a different address
4xxClient errorFix the request before sending it again
5xxServer errorWait for the server to recover; a retry may work

Of the five classes, the line between 4xx and 5xx is the one you most need to understand, because it is a line of responsibility. A 4xx means the problem is in the request — sending it again unchanged will not help. A 5xx means the problem is on the server — trying again later may succeed. Retry logic and monitoring both depend on this line. Return a 500 by mistake for a request with bad data, and you may page your whole on-call team for nothing.

Common status codes

CodeNameMeaning
200OKSuccess, with a body
201CreatedA new resource was created; its URL is in Location
202AcceptedThe request was received and will be processed later
204No ContentSuccess, no body
301Moved PermanentlyThe resource has moved to a new URL for good
302FoundThe resource is temporarily at another URL
304Not ModifiedThe client’s cached copy is still usable
307Temporary RedirectLike 302, but the method and body must stay the same
308Permanent RedirectLike 301, but the method and body must stay the same
400Bad RequestThe request is malformed
401UnauthorizedNot authenticated
403ForbiddenAuthenticated, but not allowed
404Not FoundThe resource was not found
405Method Not AllowedThe resource does not support this method
409ConflictThe request conflicts with the resource’s current state
415Unsupported Media TypeThe server does not accept this body format
422Unprocessable ContentThe format is valid, but the data is not
429Too Many RequestsToo many requests; the client is rate limited
500Internal Server ErrorThe server hit an unexpected error
502Bad GatewayA proxy got an invalid response from the server behind it
503Service UnavailableThe server is temporarily unable to serve requests
504Gateway TimeoutA proxy waited too long for the server behind it

Note: this is where many people get confused. Despite its name, 401 Unauthorized actually means not authenticated — the server does not know who you are yet. 403 means the server knows who you are, but you are not allowed to do this.

Caching

Now you can clearly see why the self-descriptive and intermediary principles matter: they are what give HTTP a built-in caching mechanism. A cache stores a response and reuses it for later requests, so the server does not have to process them again from scratch.

To cache correctly, two questions must be answered. First, how long can this response be reused? Second, once it expires, has the data actually changed? HTTP answers them with two matching mechanisms: freshness and validation.

Freshness

The server says how long a response can be reused through the Cache-Control header.

DirectiveMeaning
max-age=600Can be reused for 600 seconds
no-cacheMay be stored, but must be checked with the server before each use
no-storeMust not be stored
privateOnly the user’s browser may store it, not a CDN
publicShared caches such as a CDN may store it too

Note: the name no-cache is misleading. It does not mean “do not cache”; it means “you may store it, but check with the server before using it”. To forbid storing completely, you need no-store.

Validation

When a cached copy has expired (it is stale), the cache does not have to download everything again. It only needs to ask the server one question: “has this changed?”

To make that question possible, the server gives each version of a response an ETag — a short code that stands for exactly that content. Next time, the cache sends this code back in the If-None-Match header. If the data has not changed, the server just returns 304 Not Modified with no body, and the cache keeps using its copy. For a large response, this saves a lot of bandwidth.

Responses that vary

There is one detail that is easy to miss. Because of content negotiation, the same URL can return different representations. A cache must never give the Vietnamese version to someone who asked for English. The Vary header solves this: it tells the cache which request headers the response depends on, for example Vary: Accept-Language. The cache then stores a separate copy for each value of that header.

Server-side caching is a big topic of its own and is covered in the caching note.

HTTP versions

As mentioned earlier, the meaning of HTTP is the same in every version. What changes from version to version is how messages travel over the network — specifically, how several requests share one connection.

HTTP/1.1

HTTP/1.1 sends messages as text over TCP. A connection can be reused for many requests, but it handles only one request at a time. The next request has to wait until the previous response has fully arrived.

This problem is called head-of-line blocking: a single slow response makes every request queued behind it wait as well. Browsers work around it by opening several connections to the same server in parallel — a workaround rather than a real fix.

HTTP/2

HTTP/2 solves this by splitting messages into binary frames. Frames from many requests are interleaved on the same connection, and each request belongs to its own stream. As a result, many requests run in parallel over a single connection. HTTP/2 also compresses headers to reduce the amount of data sent.

However, head-of-line blocking has not completely disappeared — it has only moved down a layer. All streams still share one TCP connection, and TCP requires data to arrive in order. If one packet is lost, every stream has to wait until that packet is sent again.

HTTP/3

HTTP/3 fixes the remaining problem by dropping TCP and moving to QUIC — a transport protocol built on UDP. QUIC handles each stream independently, so a lost packet only slows down the stream it belongs to. QUIC also combines the connection handshake with the TLS handshake, so a new connection is ready sooner.

Comparison

HTTP/1.1HTTP/2HTTP/3
TransportTCPTCPQUIC over UDP
FormatTextBinary framesBinary frames
Requests on one connectionOne after anotherIn parallelIn parallel
Head-of-line blockingAt the HTTP levelAt the TCP levelOnly within one stream
SpecificationRFC 9112RFC 9113RFC 9114

Origins and the same-origin policy

Everything we have covered so far is true for any HTTP client. HTTP itself has no rule about who may read a response. A browser, however, needs such a rule. The reason is that a browser runs code from many different websites in one place, and it holds your login cookies for all of those websites. To understand this rule, we first need to know what an origin is.

Origin

The origin of a URL is made of three parts: scheme, host and port (RFC 6454). Two URLs have the same origin only when all three parts are the same.

URLSame origin as https://shop.test?Reason
https://shop.test/cartyesThe path is not part of the origin
http://shop.testnoDifferent scheme
https://api.shop.testnoDifferent host
https://shop.test:8443noDifferent port

One consequence every developer has run into: a frontend on localhost:5173 and an API on localhost:8080 are two different origins, even on the same machine.

The same-origin policy

The same-origin policy is a browser rule: JavaScript on a page may send a request to another origin, but may not read the response unless that other origin allows it.

Why block reading? The answer is cookies. When page A sends a request to origin B, the browser automatically attaches your cookies for B. If JavaScript on page A could read the response, any website could read your personal data on every other website where you are logged in — your email, your bank account, everything.

Sending requests to another origin, on the other hand, has always been allowed, because the web needs it to work: <img> tags, forms and scripts loaded from a CDN are all cross-origin requests. The only thing blocked is reading the response.

CORS

The same-origin policy protects users very well, but it is also very strict. In real projects, a frontend on one origin often needs to read data from an API on another. CORS was created to open an exception for these cases — in a controlled way.

CORS (Cross-Origin Resource Sharing) is a mechanism, defined in the Fetch Standard, that lets a server tell the browser which origins may read its responses. And here you can see the extensibility principle from the start of this note at work: CORS runs entirely on a few new HTTP headers.

The diagram below summarizes how a browser handles a cross-origin request. The sections that follow explain each step.

flowchart TD
    A["Page calls another origin"] --> B{"Simple request?"}
    B -->|yes| C["Send request with Origin"]
    B -->|no| D["Send OPTIONS preflight"]
    D --> E{"Server allows<br/>method and headers?"}
    E -->|no| F["Stop: real request<br/>is not sent"]
    E -->|yes| C
    C --> G{"Response allows<br/>this origin?"}
    G -->|yes| H["JavaScript can read the response"]
    G -->|no| X["JavaScript cannot<br/>read the response"]
    classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
    classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
    classDef ok fill:#dcfce7,stroke:#166534,color:#14532d
    classDef bad fill:#fee2e2,stroke:#991b1b,color:#7f1d1d
    class B,E,G main
    class A,C,D infra
    class H ok
    class F,X bad

What CORS is and is not

Before going into details, one thing needs to be clear: CORS can only relax the same-origin policy, never make it stricter. If the server sends no CORS headers, the browser applies its default restriction — which is the strictest one.

From this come two points that many developers get wrong:

  • CORS protects users, not servers. It stops an unknown website from reading data the browser fetched with the user’s cookies.
  • Only browsers apply CORS. curl, Postman or another backend service ignore CORS completely. So never treat CORS as a security layer for your API. Protecting the API is the job of authentication and authorization.

Simple requests

Some cross-origin requests could already be sent by HTML forms before CORS existed. Since the web already allowed them, the browser sends these requests directly, without asking first. They are called simple requests.

A request is simple when it meets all three conditions:

  • The method is GET, HEAD or POST.
  • It only uses headers from the safelisted set, such as Accept or Content-Language.
  • If it has a body, the Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain.

For a simple request, the browser adds the Origin header, sends it, and only then checks the CORS headers in the response. Pay attention to this point: if the origin is not allowed, JavaScript cannot read the response — but the server has already received and processed the request.

Preflight requests

The remaining requests can do things an HTML form never could, such as sending JSON or custom headers. For these, the browser is more careful: it asks the server before sending. That asking request is called the preflight.

The preflight is an OPTIONS request that tells the server which method and headers the real request will use. The server answers whether they are allowed. Only if they are does the browser send the real request.

In practice, most API calls need a preflight, because any one of these is enough:

  • The body is JSON (Content-Type: application/json).
  • There is an Authorization header or another custom header.
  • The method is PUT, PATCH or DELETE.
sequenceDiagram
    participant B as Browser (page on app.shop.test)
    participant S as Server (api.shop.test)
    B->>S: OPTIONS /orders/9001<br/>Origin: https://app.shop.test<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: content-type
    S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: https://app.shop.test<br/>Access-Control-Allow-Methods: PUT<br/>Access-Control-Allow-Headers: content-type
    Note over B: The server allowed it
    B->>S: PUT /orders/9001
    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://app.shop.test

If the preflight is refused, the real request is never sent.

Simple requestPreflighted request
WhenRequests an HTML form could sendAll other cases
Calls to the server12 (1 if the preflight result is cached)
If not allowedThe server still processes it; JavaScript cannot read itThe real request is not sent

The CORS headers

HeaderSent byMeaning
OriginBrowserThe origin of the calling page
Access-Control-Request-MethodBrowser, in a preflightThe method of the real request
Access-Control-Request-HeadersBrowser, in a preflightThe non-safelisted headers the real request will use
Access-Control-Allow-OriginServerThe origin allowed to read the response, or * for any
Access-Control-Allow-MethodsServer, in a preflightThe allowed methods
Access-Control-Allow-HeadersServer, in a preflightThe allowed request headers
Access-Control-Allow-CredentialsServerAllows reading the response when cookies are sent
Access-Control-Expose-HeadersServerExtra response headers JavaScript may read
Access-Control-Max-AgeServer, in a preflightHow many seconds the browser may cache the preflight result

Credentials

In CORS, credentials are what the browser attaches to a request automatically: cookies, HTTP authentication and client certificates. Be careful with the distinction: a token that your JavaScript puts in the Authorization header does not count as a credential in this sense.

Because credentials open the door to a user’s private data, the rules for them are much stricter:

  • The page must turn them on explicitly, for example fetch(url, { credentials: "include" }).
  • The server must answer with Access-Control-Allow-Credentials: true.
  • Access-Control-Allow-Origin must name one specific origin; * is not allowed.

A question that comes up often is “is it OK to use *?”. The answer depends on how the API identifies users:

The API identifies users withCan it use Access-Control-Allow-Origin: *?
Nothing (public data)Yes
A token in the Authorization headerYes
CookiesNo — name the exact origin

Allowing several origins

Access-Control-Allow-Origin can hold only one origin. So what if you have several frontends? The server keeps a list of allowed origins. For each request, it compares the Origin header with the list and, if it matches, returns that exact origin.

Now the response changes depending on the request’s Origin header. Remember the caching section: a response that varies by a request header must declare Vary: Origin. If you forget it, a CDN may store the response meant for origin A and serve it to origin B by mistake — a bug that is very hard to track down.

Configuring CORS on a server

At this point, the rules above translate directly into code. The example below uses plain Node.js without a framework, so you can see every header.

import { createServer } from "node:http";

const ALLOWED_ORIGINS = new Set([
  "https://app.shop.test",
  "http://localhost:5173",
]);

createServer((req, res) => {
  const origin = req.headers.origin;

  // The answer depends on the Origin header, so caches must keep them apart.
  res.setHeader("Vary", "Origin");

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    // Needed only if the API identifies users with cookies.
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }

  // Preflight: answer before any authentication, without a body.
  if (
    req.method === "OPTIONS" &&
    req.headers["access-control-request-method"]
  ) {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization"
    );
    res.setHeader("Access-Control-Max-Age", "600");
    res.writeHead(204).end();
    return;
  }

  // Authentication and the real request handling go here.
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ ok: true }));
}).listen(8080);

Each part of the code matches a rule we have already covered:

In the codeWhy
Vary: OriginA response that varies by a request header must tell caches
An allowlist with exact matchingWith cookies, Allow-Origin must be one specific origin
No CORS headers for unknown originsWithout the headers, the browser keeps its default restriction
Preflight handled before authenticationThe browser never sends cookies or tokens with a preflight
Access-Control-Max-AgeThe browser does not have to send a preflight for every request

Reading a CORS error

When you hit a CORS error, remember that it is reported by the browser, not by the server. Open the browser’s Network tab and look at the matching request; you will usually see the cause right away:

The Network tab showsCause
The OPTIONS request gets 401 or 403The preflight was blocked by authentication, but it carries no cookie or token
OPTIONS succeeds, but the real request is never sentThe method or a header is missing from Allow-Methods / Allow-Headers
The response has no Access-Control-Allow-OriginThe origin is not allowed, or this route does not return CORS headers
Access-Control-Allow-Origin: * while using cookiesWith cookies, you must name the exact origin

Summary

  • HTTP rests on six principles: client–server, request–response, statelessness, self-descriptive messages, intermediaries and extensibility. The rest of the protocol grows out of them.
  • A message has a start line, headers, an empty line and an optional body. Headers describe the message so that every party on the path can understand it.
  • For methods, the two properties to remember are: safe means read-only, and idempotent means calling it again leaves the server in the same state.
  • The status code tells the client what to do next: 4xx means fix the request, 5xx means the server failed and a retry may work later.
  • Caching relies on freshness (Cache-Control) and validation (ETag, 304). Vary keeps the cache from serving the wrong representation.
  • HTTP/1.1, HTTP/2 and HTTP/3 share the same meaning and only differ in how they carry messages over the network.
  • The same-origin policy is a browser rule: it blocks reading cross-origin responses, not sending requests.
  • CORS lets a server open exceptions for chosen origins. It protects users, not servers, and only browsers apply it.

References

Next Post