Access to fetch at 'http://localhost:3001/api/items' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
別のドメインに置いた API を fetch で呼ぶと、ブラウザのコンソールにこの赤い文字が出て止まる。curl(ターミナルから HTTP リクエストを送るコマンド)で叩けば普通に返ってくるのに、ブラウザからだけ読めない。止めているのはブラウザです。
この記事では、その様子を手元で再現します。fetch する側のページをポート 3000、Hono の API サーバーをポート 3001 で動かし、ブラウザのコンソールとサーバーのログを並べて見ながら、止められる理由と、許可を出す手順を確かめていきます。
環境を用意する
用意するのは Node.js だけです。現行の LTS である Node 24 を使ってください。この記事は Node v24.20.0 で確認しました(22.18 以降でも動きますが、.ts をそのまま実行する機能が安定扱いになったのは 24.12 からです)。npm は Node に同梱されているので、別に入れるものはありません(pnpm を使っている方は、以下の npm install を pnpm add に読み替えてください)。
作るものはこれです。
ブラウザが開いているのは 3000 のページ。そのページの JS が 3001 へ fetch する。この2つは別のオリジン。
まず、作業用のディレクトリを作って移動します。名前は何でも構いません。
mkdir cors-handson
cd cors-handson
次に、このディレクトリを Node のプロジェクトにします。npm init は package.json というファイルを作るコマンドで、-y を付けると質問をすべて既定値で済ませます。
npm init -y
package.json は、このプロジェクトの名前や、使うライブラリの一覧を書いておく台帳です。生成直後はこうなっています。
{
"name": "cors-handson",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
ここに1行足します。これから書くファイルは import 文を使う ES モジュール形式なので、そのことを Node に伝えておきます。位置はどこでも構いません。
{
"name": "cors-handson",
"version": "1.0.0",
"type": "module",
書き忘れても Node は警告を出しながら動いてくれますが、後で入れる TypeScript のチェックがここで止まる(error TS1295)ので、先に済ませておきます。ちなみに pnpm の pnpm init はこの行を自動で書きます。npm で進める人だけが踏む一段です。
ライブラリを入れます。Hono 本体と、Hono を Node で動かすためのアダプタです。
npm install hono @hono/node-server
# added 2 packages, and audited 3 packages in 293ms
node_modules/ にライブラリが入り、package.json の dependencies に2つが書き足されます。package-lock.json というファイルも一緒にできます。入ったライブラリのバージョンを控えておくためのもので、そのまま置いておいてください。
TypeScript を入れる
ファイルは TypeScript(.ts)で書きます。Node 24 は .ts ファイルをそのまま実行できるので、JavaScript への変換は要りません。ただし Node がやっているのは型の記述を読み飛ばすことだけで、型が合っているかは見ていません。型チェックは TypeScript 本体の仕事なので、開発用に入れます。
Node は型の記述を読み飛ばして動かすだけ。型が合っているかは tsc --noEmit が見る。
npm install -D typescript @types/node
# added 4 packages, and audited 7 packages in 361ms
npx tsc --version
# Version 7.0.2
-D は開発中だけ使うものという印で、package.json では devDependencies に分かれます。@types/node は Node の組み込み機能の型定義です。@hono/node-server の型が node:http などを参照しているので、これがないと tsc が Cannot find name 'node:http' で止まります。npx は、いま入れたパッケージに含まれるコマンドを呼び出すための接頭辞です。
執筆時点の最新は TypeScript 7.0 です。2026年7月に公開されたこの版から、コンパイラの中身が従来の JavaScript 製から Go 製に置き換わり、フルビルドで 8〜12 倍速くなったと発表されています。使い方は変わらず、npm install -D typescript で入れて npx tsc で呼ぶだけです。今回の2ファイルではチェックが一瞬で終わるので速さの恩恵は感じませんが、これから TypeScript を始める人が古い版を選ぶ理由はもうありません。
設定ファイル tsconfig.json を作ります。Node が .ts を直接実行するときの制約に合わせた内容で、Node の公式ドキュメントが勧めている設定に strict と types を足したものです。
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"types": ["node"]
}
}
大事なのは2つです。noEmit は「JavaScript を書き出さず、チェックだけする」。実行は Node が .ts のまま引き受けるので、変換後のファイルは要りません。erasableSyntaxOnly は「Node が読み飛ばせる記法だけを許す」。enum のように実行時のコードを生む TypeScript 独自の構文は、型の記述を消すだけでは動きません。起動時に ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript enum is not supported in strip-only mode が出て止まります。この設定があれば、書いた時点で tsc が止めてくれます。
ページと API の2ファイルを書く
1つめは fetch する側。ポート 3000 で、ボタンが3つあるだけのページを返します。ボタンを押すと http://localhost:3001/api/items に fetch を送り、結果をコンソールに出します。
// web.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) =>
c.html(`<!doctype html>
<meta charset="utf-8">
<title>CORS hands-on</title>
<button id="get">GET /api/items</button>
<button id="post">POST /api/items(JSON)</button>
<button id="cred">GET /api/items(credentials: include)</button>
<script type="module">
const api = 'http://localhost:3001/api/items'
const run = async (label, init) => {
console.log('[' + label + '] 送信')
try {
const res = await fetch(api, init)
console.log('[' + label + '] status', res.status)
console.log('[' + label + '] body', await res.json())
} catch (e) {
console.log('[' + label + '] 失敗', e)
}
}
document.getElementById('get').onclick = () => run('GET')
document.getElementById('post').onclick = () =>
run('POST', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'ぶどう' }) })
document.getElementById('cred').onclick = () => run('CRED', { credentials: 'include' })
</script>`)
)
serve({ fetch: app.fetch, port: 3000 })
console.log('web: http://localhost:3000')
2つめは API サーバー。ポート 3001 で、GET と POST を1つずつ受けます。logger() は Hono に付属するミドルウェアで、受けたリクエストと返したステータスをターミナルに出してくれます。今日はこのログが主役の1人です。まだ CORS の設定は入れません。
// api.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
const app = new Hono()
app.use(logger())
app.get('/api/items', (c) => c.json({ items: ['りんご', 'みかん'] }))
app.post('/api/items', async (c) => {
const body = await c.req.json()
return c.json({ received: body })
})
serve({ fetch: app.fetch, port: 3001 })
console.log('api: http://localhost:3001')
ここまでで、ディレクトリの中はこうなっています(tree -L 1 の出力です。tree が入っていなければ ls でも同じ顔ぶれが見えます)。
cors-handson/
├── node_modules/
├── api.ts
├── package-lock.json
├── package.json
├── tsconfig.json
└── web.ts
自分で書いたのは web.ts・api.ts・tsconfig.json の3つで、残りは npm が作ったものです。node_modules/ の中には Hono や TypeScript の本体が入っています。
書けたら型チェックを通します。
npx tsc --noEmit
何も表示されなければ通っています。試しに api.ts に const port: number = '3001' という行を足してみると、node api.ts は文句を言わずに動き、npx tsc --noEmit は error TS2322: Type 'string' is not assignable to type 'number'. で止めます。Node は型を見ていない、というのはこういうことです。確かめたら、その行は消してください。
2つのサーバーを起動する
ターミナルを2つ開いて、それぞれ起動します。
node web.ts
# web: http://localhost:3000
node api.ts
# api: http://localhost:3001
ブラウザで http://localhost:3000 を開き、開発者ツールのコンソールを表示しておきます。
CORS エラーを再現する
GET のボタンを押します。コンソールにはこう出ます。
[GET] 送信
Access to fetch at 'http://localhost:3001/api/items' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[GET] 失敗 TypeError: Failed to fetch
ここで api.ts を動かしているターミナルを見てください。
<-- GET /api/items
--> GET /api/items 200 0ms
サーバーはリクエストを受け取り、200 で正常に返しています。サーバーは何も間違えていません。レスポンスがブラウザに届いたあと、ブラウザが「これは JS に渡さない」と判断して捨てたのです。JS に届くのは TypeError: Failed to fetch だけで、理由はコンソールにしか出ません。
Access-Control-Allow-Origin がない応答は、ブラウザが JS に渡さない。サーバー側の処理は実行されている。
許可を出すのはサーバー。それを見て通すのがブラウザ。
なぜ捨てたのか。ブラウザは、ページのオリジンと fetch の宛先のオリジンを比べています。オリジンとは、URL のうちスキーム・ホスト・ポートの3つをまとめた呼び名です。
| URL | スキーム | ホスト | ポート |
|---|---|---|---|
http://localhost:3000/(ページ) |
http | localhost | 3000 |
http://localhost:3001/api/items(API) |
http | localhost | 3001 |
http://127.0.0.1:3000/ |
http | 127.0.0.1 | 3000 |
ポートが書かれていない URL では、https なら 443、http なら 80 が省略されています。3つすべてが同じなら同じオリジン、ひとつでも違えば別のオリジンです。今回はページが 3000、API が 3001 で、ポートだけが違う。それだけで別のオリジンになります。
ひとつでも違えば別のオリジンで、そこへの fetch が CORS の対象。ポートの違いも、localhost と 127.0.0.1 の違いも、別扱い。
ブラウザは、別のオリジンからのレスポンスを、既定では JS に読ませません。ログインしたままの銀行サイトがあるとして、別タブで開いた見知らぬページの JS が銀行の API を呼び、そのレスポンス(残高や取引履歴)を読めてしまったら困る。ブラウザは利用者の代わりに、この読み取りを止めています。
ただし、それでは自社のフロントエンドから自社の API も呼べません。そこで、サーバーがレスポンスヘッダーで「このオリジンからなら読ませてよい」と表明し、ブラウザがそれを見て通す。この取り決めが CORS(Cross-Origin Resource Sharing)です。
cors ミドルウェアで許可を出す
api.ts に2行足します。Hono に付属する cors ミドルウェアを、ルート定義より前に置きます。ミドルウェアとは、ルート(URL ごとの処理)の手前に挟んで共通の処理を済ませる部品のことです。
import { cors } from 'hono/cors'
// app.use(logger()) の後、ルート定義の前に
app.use('/api/*', cors({ origin: 'http://localhost:3000' }))
api.ts を Ctrl+C で止めて node api.ts で起動し直し、もう一度 GET のボタンを押します。
[GET] 送信
[GET] status 200
[GET] body {items: Array(2)}
通りました。何が変わったのかは curl で見えます。-i はレスポンスヘッダーも表示する指定、-H はリクエストにヘッダーを足す指定です。
curl -i -H "Origin: http://localhost:3000" http://localhost:3001/api/items
# HTTP/1.1 200 OK
# access-control-allow-origin: http://localhost:3000
# content-type: application/json
# vary: Origin
# content-length: 35
#
# {"items":["りんご","みかん"]}
レスポンスに access-control-allow-origin: http://localhost:3000 が付きました。ブラウザは fetch を送るとき、ページのオリジンを Origin ヘッダーとして自動で添えます。サーバーはそれを見て、許可するオリジンなら Access-Control-Allow-Origin に同じ値を書いて返す。ブラウザは自分のオリジンとこのヘッダーの値を比べ、一致すればレスポンスを JS に渡します。
ここまでを3行にまとめます。
- 止めるのはブラウザ。サーバーではない
- 許可を出すのはサーバー。レスポンスヘッダー
Access-Control-Allow-Originで表明する - リクエスト自体はサーバーに届いている。止められているのは、レスポンスを JS が読むこと
エラーを見てサーバー側のコードを疑う前に、この3行を思い出してください。
POST の前に OPTIONS が飛ぶ(プリフライト)
次は POST のボタンです。Content-Type: application/json で { name: 'ぶどう' } を送ります。設定はさっきのままで構いません。
[POST] 送信
[POST] status 200
[POST] body {received: Object}
成功しましたが、サーバー側のログを見ると、押したのは1回なのに2往復あります。
<-- OPTIONS /api/items
--> OPTIONS /api/items 204 1ms
<-- POST /api/items
--> POST /api/items 200 1ms
先に OPTIONS メソッドのリクエストが飛び、204 を受け取ってから、本番の POST が飛んでいます。この OPTIONS がプリフライト(事前確認)です。開発者ツールの Network タブにも、OPTIONS と POST の2行が並びます。
本番のリクエストを送る前に、このメソッドとヘッダーを送ってよいかをブラウザが確かめる。
二往復になるぶん遅い。Access-Control-Max-Age で結果を覚えさせられる。
GET のときは飛びませんでした。ブラウザがいきなり本番を送ってよいのは、次の3つをすべて満たすときだけです。
- メソッドが GET・HEAD・POST のいずれか
- 付けるヘッダーが
AcceptAccept-LanguageContent-LanguageContent-Typeなど、ブラウザが安全とみなす範囲に収まっている Content-Typeを付けるなら、application/x-www-form-urlencodedmultipart/form-datatext/plainのいずれか
この範囲を単純リクエストと呼びます。HTML のフォーム送信でできることと、ほぼ同じ範囲です。今回は Content-Type: application/json が3つめの条件を外れたので、プリフライトが入りました。Authorization ヘッダー付きの GET、PUT や DELETE も同じです。実務の API 呼び出しは、ほとんどこちらです。
プリフライトの中身は curl で再現できます。ブラウザが送っているのは、こういうリクエストです。
curl -i -X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type" \
http://localhost:3001/api/items
# HTTP/1.1 204 No Content
# access-control-allow-headers: content-type
# access-control-allow-methods: GET,HEAD,PUT,POST,DELETE,PATCH,QUERY
# access-control-allow-origin: http://localhost:3000
# vary: Origin, Access-Control-Request-Headers
「このオリジンから、このメソッドとこのヘッダーで送ってよいか」を聞き、サーバーは中身を処理せず、許可だけを返している。Hono の cors はこの OPTIONS へのレスポンスも引き受けていて、204 と一緒に、許すメソッドの一覧(allowMethods の既定値)と、許すヘッダーを返します。allowHeaders を省略したときに返るのは、いまの出力のように、ブラウザが聞いてきた Access-Control-Request-Headers の値そのものです。手軽ですが、聞かれたヘッダーは何でも許す動きなので、本番では使うヘッダーを明示しておくほうが締まります。
app.use(
'/api/*',
cors({
origin: 'http://localhost:3000',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
})
)
maxAge は、プリフライトの結果をブラウザに何秒覚えさせるかです。省略時のブラウザの既定は 5 秒で、API を呼ぶたびにほぼ二往復になります。ただし上限があり、Chromium は 7200 秒(2時間)、Firefox は 86400 秒(24時間)を超える値を切り詰めます。1日分を書いても、Chrome では2時間です。
localhost と 127.0.0.1 は別のオリジン
設定はそのままで、ブラウザのアドレスを http://127.0.0.1:3000 に変えてページを開き直し、GET のボタンを押します。
[GET] 送信
Access to fetch at 'http://localhost:3001/api/items' from origin 'http://127.0.0.1:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
[GET] 失敗 TypeError: Failed to fetch
止まりました。サーバーのログは相変わらず --> GET /api/items 200 です。localhost と 127.0.0.1 は同じマシンを指しますが、ブラウザは名前解決をせず、URL に書かれたホスト名そのものを比べます。書き方が違えば別のオリジンです。Hono は許可していないオリジンに対して Access-Control-Allow-Origin を付けないので、ブラウザから見ると「ヘッダーがない」という、最初と同じエラーになります。
curl -i -H "Origin: http://127.0.0.1:3000" http://localhost:3001/api/items
# HTTP/1.1 200 OK
# content-type: application/json
# vary: Origin
# content-length: 35
#
# {"items":["りんご","みかん"]}
両方から呼びたいなら、配列で渡します。
app.use(
'/api/*',
cors({
origin: ['http://localhost:3000', 'http://127.0.0.1:3000'],
})
)
Hono は配列のどれかと一致したときだけ、その値を返します。本番のフロントエンドのオリジンと開発中の localhost を並べておく、という使い方が多いはずです。
Cookie を送るときの条件
最後のボタンは、credentials: 'include' 付きの GET です(コンソールのラベルは [CRED])。fetch は既定で別オリジンには Cookie を送らないので、ログイン状態を Cookie で持っている API を呼ぶときは、この指定が要ります。
先に、うまくいかない例から見ます。api.ts の設定を、オリジンを指定しない cors() に変えて起動し直し、3つめのボタンを押します。
app.use('/api/*', cors())
[CRED] 送信
Access to fetch at 'http://localhost:3001/api/items' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
Failed to load resource: net::ERR_FAILED
[CRED] 失敗 TypeError: Failed to fetch
cors() の origin の既定値は *(誰にでも読ませてよい)で、レスポンスには access-control-allow-origin: * が付いています。誰にでも読ませてよい公開 API ならこれで足りますが、Cookie 付きのリクエストに対して * は使えません。エラー文言のとおり、ブラウザがレスポンスを捨てます。
通る形はこうです。オリジンを明示し、credentials: true を足します。
app.use('/api/*', cors({ origin: 'http://localhost:3000', credentials: true }))
[CRED] 送信
[CRED] status 200
[CRED] body {items: Array(2)}
curl -i -H "Origin: http://localhost:3000" http://localhost:3001/api/items
# HTTP/1.1 200 OK
# access-control-allow-credentials: true
# access-control-allow-origin: http://localhost:3000
# content-type: application/json
# vary: Origin
サーバーは Access-Control-Allow-Credentials: true で「Cookie 付きでも読ませてよい」と表明し、オリジンも * から名指しに変わっています。ブラウザ側の credentials: 'include' と、この2つのヘッダーが揃って初めて、Cookie 付きのレスポンスが JS に渡ります。
誰でもよい、と、Cookie 付き、は両立しない。ブラウザが応答を捨てる。
ブラウザ側の include と、サーバー側の2つのヘッダーが揃って通る。
ここまでで、ハンズオンは終わりです。2つのターミナルを Ctrl+C で止めてください。
注意点
CORS エラーでもリクエストは届いている
ハンズオンのあいだ、サーバーのログにエラーは一度も出ませんでした。GET と POST は 200、プリフライトの OPTIONS は 204 で、全部が正常なレスポンスです。ブラウザが止めたのはレスポンスの読み取りだけで、サーバー側の処理は実行されています。フォーム形式の POST なら、レコードは作られている。CORS エラーが出たから何も起きていない、とは限りません。利用者のブラウザを踏み台にして本人の意図しないリクエストを送らせる攻撃(CSRF)を防ぐには、CORS とは別の対策が要ります。
JS から読めるレスポンスヘッダーは既定で 7 つ
CORS を通ったレスポンスでも、JS の res.headers.get() で読めるヘッダーは既定で Cache-Control Content-Language Content-Length Content-Type Expires Last-Modified Pragma の 7 つだけです。ページネーション用の X-Total-Count のような独自ヘッダーを読ませたいなら、exposeHeaders: ['X-Total-Count'] で名指しします。
エラーの理由は JS から見えない
コンソールに出ていた長い英文は、ブラウザが書いたものです。JS の catch に渡るのは TypeError: Failed to fetch だけで、理由は入っていません。困ったときはコンソールと、開発者ツールの Network タブを見ます。Network タブで止まったリクエストの行(プリフライトがあれば OPTIONS の行)を選び、レスポンスヘッダーに Access-Control-Allow- で始まるものが揃っているかを見るのが最短です。
ミドルウェアはルート定義より前に置く
app.use('/api/*', cors()) をルート定義の後に書くと、先にマッチしたルートがレスポンスを返して終わり、ミドルウェアが通りません。手元で試すと、GET のレスポンスから access-control-allow-origin が消えます。紛らわしいのは、プリフライトの OPTIONS です。ルートが処理しないので cors まで届き、204 で正常に返るため、Network タブでは OPTIONS だけが成功して本番が止まる、という形で現れます。Hono の公式ドキュメントも、cors はルートより前に置くよう明記しています。設定したはずなのにヘッダーが付かないときは、まず順番を疑ってください。
Vary: Origin は自動で付く
curl の出力に、毎回 vary: Origin が並んでいました。Origin ヘッダーを付けずに叩いたときにも付いています。オリジンごとに Access-Control-Allow-Origin の値を変えて返すとき、サーバーの手前に CDN などのキャッシュがあると、あるオリジン向けのレスポンスが別のオリジンにも配られてしまう。レスポンスの中身が Origin ごとに変わることをキャッシュへ伝えるのが Vary: Origin で、Hono は origin が * 以外のとき自動で付けます。自前でヘッダーを書く場合は、忘れやすい一行です。
CORS はサーバーを守らない
ハンズオンで打った GET の curl は、CORS の設定がどうであれ、すべて 200 で中身まで返ってきました。許可ヘッダーを見て止めるのはブラウザだけで、curl も、サーバー間の通信も、CORS を一切見ません。origin を絞ったからといって、他所から API を叩かれなくなるわけではない。アクセスを制限したいなら、それは認証(相手が誰かを確かめる)と認可(何をしてよいかを決める)の仕事で、CORS の出番ではありません。逆に、公開データの API に * を付けるのも危険ではありません。ブラウザに「読ませてよい」と伝えているだけです。
CORS はサーバーの鍵ではなく、ブラウザの自衛策です。許可を出すのはサーバー、止めるのはブラウザ。この分担の名前が CORS です。
