第6章 【守り②】使いすぎを防ぐ — 認証ユーザーごとの上限
📖 この章のゴール:なぜ公開時にログイン+使用量制限が要るかを自分の言葉で説明でき、ユーザーごとの上限(回数/トークン/1日)をサーバー側で実装できるようになる。 ← 目次・はじめにへもどる
📱 ChatGPTではこう見える
ChatGPTをたくさん使っていると、ときどきこんな表示に出会います。
- 「いまは混み合っています。しばらくしてからお試しください」
- 「この期間の上限に達しました。○時間後にまた使えます」
これは故障ではありません。わざと止めているのです。なぜ、わざわざ止めるのでしょう? その理由が分かると、自分のクローンにも同じ仕組みが必要だと腹落ちします。
第1章の「たくさん使うと、ときどき制限がかかる」の正体が、この章のテーマです。
🤔 なぜ上限が要る?やらないとどうなる(🟢 基礎)
LLMは 従量課金(じゅうりょうかきん)=使った分だけお金がかかる仕組みです。電気やガスと同じで、たくさん使えば請求も増えます。そして料金は、第2章で見たとおり 鍵の持ち主(あなた)に請求されます。
手元(localhost)で自分だけが使っているうちは、これで問題ありません。使いすぎても、困るのは自分だけだからです。
問題は 公開して「自分以外」の人が使えるようになった瞬間に起きます。
- 誰かが(あるいは自動プログラム=bot(ボット)が)休みなく無限に叩いたらどうなる?
- そのたびにあなたのサーバーがLLMを呼び、あなたの財布に請求が積み上がります。
- 一晩で数万円・数十万円——という事故が、実際に起きています。
だから公開アプリには、次の2段構えが要ります。
① ログイン(認証)で「誰が使っているか」を確定する → ② その人ごとに使った量を数えて、上限を超えたら止める
これが第2章で予告した「ローカルでは不要/公開して初めて要る」の回収です。背骨②の 「財布(使用量)を守る」 に当たります。
⚠️ 認証 ≠ 認可(にんか)——ここを混同しないこと。
- 認証(Authentication)=「あなたは誰か」を確かめること(=ログイン)。
- 認可(Authorization)=「その人に、何をどれだけ許すか」を決めること(=上限・権限)。
「ログイン済み=無制限に使ってOK」ではありません。ログインで誰かが分かったうえで、「1日◯回まで」という許可の線を引くのが認可です。両方そろって初めて財布が守れます。
🔧 応用:レート制限とクォータは別物
- レート制限(Rate Limit)=短時間の連打を防ぐ(例:1分に20回まで)。一気に叩かれる事故への備え。
- クォータ(Quota)=長い期間の総量に上限(例:1日100回まで)。じわじわ使われる事故への備え。
この章では分かりやすい 「1日の上限(クォータ)」 を作ります。連打対策のレート制限も大事ですが、詳しくは付録Gにまとめます。
🛠 こう作る — ログイン必須+1日の上限(🟢 基礎)
作戦は3ステップです。① ログイン中の人だけ /api/chat を使えるようにし、② その人が今日どれだけ使ったかを数え、③ 上限を超えたら止める。
ステップ1:ログインしている人だけ通す(認証)
// auth.ts — リクエストに付いてきたトークンから「誰か」を確定する
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function requireUser(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
const { data, error } = await supabase.auth.getUser(token);
if (error || !data.user) {
return res.status(401).json({ error: "ログインが必要です" });
}
req.userId = data.user.id;
next();
}
1行ずつ読むと:
import { createClient } ...:Supabase(保存とログインを担当する道具)を読み込む。createClient(process.env.SUPABASE_URL!, ...):自分のSupabaseに接続する。URLや鍵は 第2章と同じく.envから読む。async function requireUser(req, res, next):本番の処理の前に挟む見張り役(これを「ミドルウェア」と呼びます)。next()を呼ぶと次へ進みます。req.headers.authorization?.replace("Bearer ", ""):ブラウザが付けてきたログインの証明書(トークン)を取り出す。Googleログイン後にSupabaseが発行したものです。await supabase.auth.getUser(token):そのトークンが本物か、Supabaseに確認してもらう。if (error || !data.user):本物でなければ——res.status(401).json(...):HTTP 401(=「あなたが誰か分からない」)を返して門前払い。req.userId = data.user.id:本物なら、この人のIDを後ろの処理へ渡す。next():チェック通過。本番の/api/chatへ進ませる。
💡 トークン(証明書)は、ブラウザが
fetchのときにheaders: { Authorization: "Bearer <トークン>" }の形で付けます。Googleログインの詳しい設定は 付録B(第1弾と共通)。
ステップ2:使った量を数える台帳(usageテーブル)
「誰が・いつ・何回・何トークン使ったか」を覚える表を、Supabase(Postgres)に作ります。
-- usage: ユーザーごと・日付ごとの使用量
create table usage (
user_id uuid not null,
day date not null,
count int not null default 0,
tokens int not null default 0,
primary key (user_id, day)
);
1行ずつ読むと:
create table usage (...):usage(使用量)という名前の表を作る。user_id uuid not null:誰の記録か(ステップ1のreq.userIdが入る)。day date not null:いつの日の記録か(1日ごとに1行)。count int not null default 0:その日の呼び出し回数。最初は0。tokens int not null default 0:その日の使ったトークン量(トークン=料金の単位。第8章・付録Dで詳しく)。primary key (user_id, day):「人+日」で1行と決める(同じ人の同じ日は1行にまとまる)。
ステップ3:上限チェック→加算(認可)
/api/chat のいちばん最初で今日の使用量を見て、上限オーバーなら止めます。通れば処理し、最後に使った分を足します。
const DAILY_LIMIT = 100; // 1日の上限回数(マジックナンバーにせず名前を付ける)
app.post("/api/chat", requireUser, async (req, res) => {
const today = new Date().toISOString().slice(0, 10); // "2026-06-29"
const { data: usage } = await supabase
.from("usage").select("count")
.eq("user_id", req.userId).eq("day", today).maybeSingle();
if ((usage?.count ?? 0) >= DAILY_LIMIT) {
return res.status(429).json({ error: "本日の上限に達しました。明日また使えます" });
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: req.body.message }],
});
await supabase.rpc("increment_usage", {
p_user_id: req.userId,
p_day: today,
p_tokens: completion.usage?.total_tokens ?? 0,
});
res.json({ reply: completion.choices[0].message.content });
});
1行ずつ読むと:
const DAILY_LIMIT = 100:上限回数を名前付きの定数にする(コードの途中に直接100と書かない)。app.post("/api/chat", requireUser, ...):窓口にステップ1の見張り役を挟む。これだけで「ログイン必須」が成立。new Date().toISOString().slice(0, 10):今日の日付を"2026-06-29"の形で取り出す(時刻は捨てて日付だけ)。supabase.from("usage").select("count"):usage表から回数だけを読む。.eq("user_id", req.userId).eq("day", today):この人の・今日の行に絞り込む。.maybeSingle():1行(まだ無ければ無し)として受け取る。if ((usage?.count ?? 0) >= DAILY_LIMIT):今日の回数が上限以上なら——(記録がまだ無ければ0として扱う)。res.status(429).json(...):HTTP 429(=「使いすぎ」)を返して止める。LLMはまだ呼んでいないので、ここで止めればお金はかからない。openai.chat.completions.create({ ... }):通った人だけ、LLMに代理で頼む(第2章と同じ)。await supabase.rpc("increment_usage", { ... }):処理が済んだら、今日の回数を+1し、使ったトークンを足す(後述の関数を呼ぶ)。p_tokens: completion.usage?.total_tokens ?? 0:OpenAIが教えてくれる実際に使ったトークン数を渡す。res.json({ reply: ... }):返事の文章だけをブラウザへ返す。
💡
increment_usageは「無ければ作り、あれば足す」を1回でやるDB側の小さな関数(UPSERT=update or insert)。countを+1、tokensを+渡された値するだけです。中身のSQLは付録Gに置きます。数える処理はDB側で1回にまとめるのがコツ(同時アクセスでも数え間違えにくい)。
✅ 正解のしるし:ブラウザの送信ボタンをいくら連打しても、サーバーが上限で429を返して止める。フロントを細工して回避しようとしても、サーバーで止まっているので無駄——これが守れていれば合格です。
⚠️ ハマりどころ
- フロントのボタンを無効化(disabled)しただけ=無意味。 ボタンの状態はブラウザ側の都合。サーバーを直接叩けば素通りします。第2章と同じ鉄則——止めるのは必ずサーバー側。フロントの無効化は「親切な見た目」にすぎません。
- 匿名(ログイン無し)でも使える窓口を残す → 数えようにも「誰か」が無いので、上限がかけられません。公開するなら匿名利用は絞る(IP単位でごく少回数のみ)か、不可にする。
- 数え漏れ/数えすぎ。 LLM呼び出しが失敗したのに加算すると不公平、逆に成功したのに加算し忘れるとすり抜けます。「成功した分だけ確実に足す」を意識する(失敗時の扱いを決めておく)。
- 🔧 上限チェックを処理の後ろに置く → 先にLLMを呼んでしまい、お金が出てから気づくことに。チェックは必ず窓口の最初で。
🤖 AIに頼むなら(Vibe codingのコツ)
🗣 プロンプト例: 「TypeScript + Express の
/api/chatに、Supabaseの認証でログイン必須を付けて。さらに 1日の上限回数(DAILY_LIMIT)をサーバー側でチェックして、超えていたら HTTP 429 を返して。使用量はusage(user_id, day, count, tokens)テーブルに、成功時だけ加算して。フロントの無効化に頼らないで」
出てきたコードの確認ポイント:
- 止めているのはサーバー側か(フロントのボタン無効化だけに頼っていないか)
/api/chatがログイン必須か(トークン未検証で素通りしないか)- 上限超で 429 を返しているか(しかもLLMを呼ぶ前に止めているか)
- 加算は 成功時だけか(失敗時に二重・過小カウントにならないか)
- ログに会話本文や鍵などの秘密(PII)を残していないか
📝 ことばメモ
- 従量課金:使った分だけ料金がかかる仕組み。LLMはこれ
- 認証(Authentication):「あなたは誰か」を確かめること(=ログイン)
- 認可(Authorization):「その人に何をどれだけ許すか」を決めること(=上限・権限)。認証とは別物
- クォータ(Quota):一定期間の総量の上限(例:1日100回)
- レート制限(Rate Limit):短時間の連打への上限(例:1分20回)。詳しくは付録G
- 429(Too Many Requests):「使いすぎ/多すぎ」を表すHTTPの返事
- PII:個人を特定できる情報。ログに残さない
➡️ 次の章へ
財布を守れるようになりました。次の 第7章 では、会話の「順番」に注目します。ChatGPTで返事の途中に止めたり、エラーが出たりしたあと、続きを送ると話がかみ合わなくなる——あの現象の正体です。user(あなた)と assistant(AI)の発言が交互に並ぶべき、という記憶のルール(背骨①)を守る話に入ります。