【IT】CSRではAPIキーが漏れる → SSRプロキシで解決(SvelteKit + Vercel想定)
皆さま
こんにちは
アプリを作成していたときにふと気づいたことがあり、
記事にました。
1. 背景
Capacitor + SvelteKit でお天気アプリを作成した際、
`.env` に API_KEY を置いても、ブラウザの開発者ツールの Network で確認できてしまいます。
<例>

CSR(Client Side Rendering)では、ビルド時にAPI_KEYがJSに埋め込まれるため、
.env にしても秘匿にはなりません。
これは特定のフレームワークの問題ではなく、CSRという仕組みの性質となります。もちろんReactでも同様なことが起こります。
今回は、 SSR(サーバ)にキーを置く:CSR → 自前SSR → 外部API のプロキシ構成にて対応したい思います。
また、合わせて簡単なセキュリティ対策も行います。
本文は SvelteKit(SSR)をVercelに載せてAPIプロキシ化するための実装メモとなります。
2. CSR / SSR / SSG の違い
CSR (Client Side Rendering)
ブラウザが「空HTML + JS」を受け取り、画面を描画する方式。
API呼び出しもクライアント側で行うため、通信内容はすべて露出する。SSR (Server Side Rendering)
サーバがリクエストごとにHTMLやデータを生成して返す方式。
API呼び出しはサーバ側で行うため、API_KEYはクライアントに漏れない。SSG (Static Site Generation)
ビルド時に静的HTMLを生成し、配信する方式。
表示は速いが、データ更新には再ビルドが必要。
3. 解決策:SSRでAPIプロキシを挟む
お天気アプリとして以下の構成を考えてみました。
構成イメージ:
[CSRアプリ (Capacitor)]
|
| ① fetch /api/weather?q=Tokyo
v
[SSRサーバ (SvelteKit)]
|
| ② fetch https://www.example.com/...&key=API_KEY
v
[Weather API]
|
| ③ JSON response
v
[SSRサーバ]
|
| ④ 必要項目のみ返却
v
[CSRアプリ]
4. 実装例(SvelteKit SSR 側)
`.env` にAPIキーを保存(ロカールで動作確認のきに使用)
プロジェクトディレクトリ直下にAPI_KEYを保存します。
WEATHER_API_URL=https://www.example.com/xxx/yyyyyy.json (API指定のURL)
WEATHER_API_KEY=xxxxx(契約のキー)/src/routes/api/weather/+server.ts:
<作成のポイント>
APIキーは サーバ側のみ(`$env/static/private` 経由)
CORSを厳格化(Originホワイトリスト + Vercelプレビュー *.vercel.app 任意許可)
レート制限/短期キャッシュ/レスポンス縮小/HTTPS強制 など最小ハードニング
import type { RequestHandler } from './$types';
import { WEATHER_API_KEY, WEATHER_API_URL } from '$env/static/private';
/* =========================
許可する Origin 一覧
========================= */
// 固定で許可するもの(あなたの環境に合わせて書き換え)
const FIXED_ALLOWED_ORIGINS = new Set<string>([
// 1) 本番の独自ドメイン(将来持つ場合の例)
'https://example.com',
// 実際のVercelのURLへ変更してください。
// 2) Vercel 本番
'https://your-app.vercel.app',
// 3) Capacitor アプリ内 WebView(iOS/Android)
'http://localhost',
// ★Capacitor WebView の既定Origin
'capacitor://localhost'
]);
/** Vercel プレビュー URL (ブランチごと *.vercel.app) も許可するか? */
const ALLOW_VERCEL_PREVIEW = true;
/* =========================
簡易キャッシュ & レート制限
========================= */
type CacheEntry = { body: any; expireAt: number };
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 60_000; // 60 秒
const rateBuckets = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 30; // 1 分あたり
const WINDOW_MS = 60_000;
const now = () => Date.now();
/* =========================
CORS 判定
========================= */
function isAllowedOrigin(origin: string) {
try {
const u = new URL(origin);
// ★ 開発時: localhost/127.0.0.1 はポート任意で許可
if (
u.protocol === 'http:' &&
(u.hostname === 'localhost' || u.hostname === '127.0.0.1')
) {
return true; // 例: http://localhost:5173, http://127.0.0.1:5173
}
// 固定リスト完全一致
if (FIXED_ALLOWED_ORIGINS.has(origin)) return true;
// Vercel プレビュー (任意のブランチURLを許可)
if (ALLOW_VERCEL_PREVIEW && u.protocol === 'https:' && u.hostname.endsWith('.vercel.app')) {
return true;
}
return false;
} catch {
return false;
}
}
function buildCorsHeaders(origin: string | null) {
const headers: Record<string, string> = {
'access-control-allow-methods': 'GET, OPTIONS',
'access-control-allow-headers': 'content-type, authorization',
'access-control-max-age': '600',
'vary': 'Origin' // CDN キャッシュ分離
};
// 許可していないときは allow-origin を付けない(空文字は返さない)
if (origin && isAllowedOrigin(origin)) {
headers['access-control-allow-origin'] = origin;
}
return headers;
}
/* =========================
レスポンス縮小(必要項目のみ)
========================= */
function shrink(json: any) {
return {
location: {
name: json?.location?.name,
country: json?.location?.country
},
current: {
temp_c: json?.current?.temp_c,
condition: {
text: json?.current?.condition?.text,
// //cdn... を https で補完
icon: json?.current?.condition?.icon
? `https:${json.current.condition.icon}`
: null
}
}
};
}
function ensureHttps(u: string) {
try {
const url = new URL(u.startsWith('//') ? `https:${u}` : u);
url.protocol = 'https:';
return url.toString();
} catch {
return `https://${u.replace(/^https?:\/\//, '')}`;
}
}
function checkRateLimit(ip: string) {
const t = now();
const b = rateBuckets.get(ip);
if (!b || t > b.resetAt) {
rateBuckets.set(ip, { count: 1, resetAt: t + WINDOW_MS });
return { ok: true, remaining: RATE_LIMIT - 1, resetAt: t + WINDOW_MS };
}
if (b.count >= RATE_LIMIT) return { ok: false, remaining: 0, resetAt: b.resetAt };
b.count++;
return { ok: true, remaining: RATE_LIMIT - b.count, resetAt: b.resetAt };
}
async function safePeek(res: Response) {
try { return await res.text(); } catch { return '<no-body>'; }
}
/* =========================
CORS: プリフライト
========================= */
export const OPTIONS: RequestHandler = async ({ request }) => {
const origin = request.headers.get('origin');
return new Response(null, { status: 204, headers: buildCorsHeaders(origin) });
};
/* =========================
GET: 本体
========================= */
export const GET: RequestHandler = async (event) => {
const { url, request, getClientAddress, fetch: skFetch } = event;
const origin = request.headers.get('origin');
// HTTPS 強制(プロキシ配下想定)
const xfProto = request.headers.get('x-forwarded-proto');
if (xfProto === 'http') {
const httpsURL = new URL(url);
httpsURL.protocol = 'https:';
return Response.redirect(httpsURL.toString(), 301);
}
const baseHeaders = {
'content-type': 'application/json; charset=utf-8',
...buildCorsHeaders(origin)
};
// レート制限
const ip = getClientAddress();
const rate = checkRateLimit(ip);
if (!rate.ok) {
return new Response(JSON.stringify({ error: 'Too Many Requests' }), {
status: 429,
headers: { ...baseHeaders, 'retry-after': Math.ceil((rate.resetAt - now()) / 1000).toString() }
});
}
// city パラメータ
const city = url.searchParams.get('city');
if (!city) {
return new Response(JSON.stringify({ error: 'Missing city' }), { status: 400, headers: baseHeaders });
}
// キャッシュ確認(60秒)
const cacheKey = `weather:${city.toLowerCase()}`;
const hit = cache.get(cacheKey);
if (hit && hit.expireAt > now()) {
return new Response(JSON.stringify(hit.body), {
status: 200,
headers: { ...baseHeaders, 'cache-control': 'public, max-age=30', 'x-cache': 'HIT' }
});
}
// 外部 API 呼び出し(サーバ側でAPIキー秘匿)
const endpoint =
`${ensureHttps(WEATHER_API_URL)}?key=${encodeURIComponent(WEATHER_API_KEY)}&q=${encodeURIComponent(city)}&aqi=no`;
const upstream = await skFetch(endpoint);
if (!upstream.ok) {
console.error('Weather upstream error', upstream.status, await safePeek(upstream));
return new Response(JSON.stringify({ error: 'Upstream error' }), { status: 502, headers: baseHeaders });
}
const raw = await upstream.json();
const body = shrink(raw);
// キャッシュ格納
cache.set(cacheKey, { body, expireAt: now() + CACHE_TTL_MS });
return new Response(JSON.stringify(body), {
status: 200,
headers: { ...baseHeaders, 'cache-control': 'public, max-age=30', 'x-cache': 'MISS' }
});
};Vercel設定(環境変数)
プロジェクト → Settings → Environment Variables に設定。
WEATHER_API_URL=https://www.example.com/xxx/yyyyyy.json (API指定のURL)
WEATHER_API_KEY=xxxxx(契約のキー)読み取りは $env/static/private。ビルド時にサーバ側に埋め込まれ、クライアントへは配布されない。
5. CORS運用(ポイント)
許可リスト固定:
https://your-app.vercel.app(本番)
http://localhost(Capacitor WebView)
https://example.com(将来の独自ドメイン例)
capacitor://localhost(Capacitor既定Origin)
Vercelプレビュー許可:ALLOW_VERCEL_PREVIEW = true で *.vercel.app をHTTPS限定で一括許可
未許可Origin:access-control-allow-origin を 付与しない(空文字は返さない)
CDN混線防止:Vary: Origin を必ず付与
6. セキュリティ/運用メモ
APIキーをフロントに出さない:$env/static/private を使い、+server.ts内で消費
レート制限:ここではメモリ実装(1分30req/IP)。水平スケール時は Upstash/Redis 等へ移行
レスポンス縮小:必要項目のみ返す(露出面/転送料削減)
短期キャッシュ:60秒のメモリキャッシュ。多インスタンス時は Redis or CDN に置換
HTTPS強制:x-forwarded-proto: http を検知して 301 リダイレクト
CapacitorのOrigin:配布後も http://localhost or capacitor://localhost(固定で扱える)
ログ:console.error はVercelのLogsで可視化(漏洩防止のため外部APIの生エラーは出し過ぎない)
7. 実装例(SvelteKit CSR 側)
クライアント側(Capacitorアプリ):
ディプロしたVercel上のURLからAPIを参照するように環境変数を変更
/src/routes/+page.svelte:
<script lang="ts">
・
import axios from "axios";
・
・
// 実際のVercelのURLへ変更してください。
const WEATER_API = "https://your-app.vercel.app/api/weather?city=";
・
・
// 天気を取得する関数
const getWeather = (city: string) => {
・
・
// APIリクエスト
axios
// .get(`${API_URL}?key=${API_KEY}&q=${city}&aqi=no`)
.get(`${WEATER_API}${city}`) →変更
・
・
};
・
・
</script>8. 付録:CSR / SSR / SSG(最短メモ)
CSR:ブラウザで描画/APIもブラウザ → 鍵は漏れる
SSR:サーバで描画/APIはサーバ → 鍵はサーバ内
SSG:ビルド時生成/動的データは再ビルド or 別API → 鍵の扱いはSSR側で
9 .まとめ(運用指針)
フロント(CSR)だけで外部APIを直接叩かない
SSR(またはサーバレス関数)を噛ませて鍵を隠す
CORS・レート制限・キャッシュで最低限のハードニングを入れる
では
また!
