Contracts and security
Configure CORS deliberately
Allow browser clients from known origins without confusing CORS with authentication or general server security.
A page served from http://localhost:5173 calls fetch('http://localhost:3000/books'). The request reaches the server, the server answers, and the browser throws the answer away. Your script gets a CORS error. This is the browser’s same-origin policy at work: a script may only read responses from its own origin unless the other server says otherwise.
CORS is how the server says otherwise. It adds response headers that tell the browser which origins, methods and headers are allowed. Nothing more. It is a browser rule, so it does nothing against curl, a script, or another server. CORS is not authentication and never a security boundary. The next lesson handles that part.
What an origin is
An origin is the scheme, host and port together. http://localhost:5173 and http://localhost:3000 are different origins. So are https://books.app and http://books.app. A path is not part of it, and neither is a hostname suffix. Allowing “anything ending in .app” lets evil-books.app in.
Add the middleware
Hono ships a cors middleware. Give it the exact origins your frontend uses:
import { cors } from 'hono/cors'
app.use('/books/*', cors({
origin: ['http://localhost:5173'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type']
}))
When the request’s Origin matches, the response gets Access-Control-Allow-Origin: http://localhost:5173. When it doesn’t, the header is missing and the browser blocks the read. The default in most CORS libraries is *, which lets every website on the internet read your API from a page. Set the list on purpose.
The preflight
For anything beyond a simple GET, the browser first asks permission with an OPTIONS request carrying the method and headers it intends to use. The server answers whether the real request may proceed. This is the preflight, and you can send one by hand:
curl -i -X OPTIONS http://localhost:3000/books \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
The answer should be a 204 with these headers:
access-control-allow-origin: http://localhost:5173
access-control-allow-methods: GET,POST,PUT,DELETE
access-control-allow-headers: Content-Type
vary: Origin
Change the Origin to http://localhost:4000 and the allow headers disappear. That’s the rejected case, and you want to see both.
Credentials and caching
If the browser must send cookies or an Authorization header, two things change. You return the exact origin, never *, because browsers refuse credentialed responses with a wildcard. And you set credentials: true in the middleware, which adds Access-Control-Allow-Credentials: true.
The Vary: Origin header in the output above is there for caches. Without it a CDN could store a response with allow-origin: http://localhost:5173 and serve it to a different site. Hono’s middleware adds it when the origin is a list.
One more thing: the preflight carries no credentials, even when the real request will. So a preflight that reaches an auth-protected route must still answer 204. Mount cors before the auth middleware.
Add a development allowlist now and inspect both a plain GET and the preflight above from the browser network panel and from curl.
Lesson completed