ログイン状態はどうやって維持されるのか|Webアプリが動くまでを分解する #6
Webアプリにログインすると、別のページへ移動しても、自分の名前や投稿履歴が表示されます。
一度ブラウザを閉じても、再び開いたときにログイン状態が残っていることもあります。
しかし、前回説明したAPIへの通信は、基本的に一回ごとのリクエストです。
GET /api/articles
この一行だけを受け取っても、サーバーは誰から送られてきたリクエストなのか判断できません。
それではWebアプリは、どのようにして「このリクエストを送ってきたのは、さっきログインしたユーザーだ」と判断しているのでしょうか。
「ブラウザがログイン状態を覚えている」と説明されることもありますが、実際にはもう少し複雑です。
ブラウザは、サーバーから渡された“ログイン済みであることを確認するための情報”を保存します。そして次のリクエストから、その情報をサーバーへ送り返します。
サーバーは受け取った情報を検証し、ユーザーを特定します。
今回扱うのは、その仕組みを作るCookie、Session、JWT、そして認証と認可です。
このシリーズでは、Webアプリが動くまでの全体像を7つのパートに分け、各テーマを別の記事として投稿しています。用語だけを簡単に紹介するのではなく、一つずつ実際の通信やコードまで掘り下げていきます。
第6回では、ログインボタンを押してからログアウトするまで、裏側で何が起きているのかを一本につなげます。
HTTPは前のリクエストを覚えていない
HTTPは、ステートレスなプロトコルとして設計されています。
ステートレスとは、簡単に言えば、それぞれのリクエストを独立して理解できるということです。同じ通信接続から送られてきたからといって、サーバーが「前回と同じユーザーだ」と判断してよいわけではありません。RFC 9110でも、HTTPのリクエストはそれぞれ独立して解釈されるものと定義されています。
例えば、次の2つのリクエストが送られたとします。
GET /api/articles/100
GET /api/articles/101
サーバーが受け取れるのがこれだけなら、同じ人が続けて送ったのか、別々の人が送ったのかは分かりません。
ログイン機能を作るには、独立したHTTPリクエスト同士を「同じユーザーから送られたもの」と結びつける仕組みが必要です。
その役割を担うのが、セッション管理です。
認証・セッション管理・認可は別の処理
ログイン機能を理解するときは、次の3つを分けて考える必要があります。
認証
認証、英語ではAuthenticationは、「あなたは誰なのか」を確認する処理です。
メールアドレスとパスワードを使う場合、サーバーは入力された情報を確認し、登録されているユーザー本人かどうかを判断します。
OWASPも認証を、個人やシステムが主張どおりの存在であるかを確認する処理と説明しています。OWASP Authentication Cheat Sheet
セッション管理
一度認証したユーザーを、次のリクエストでも同じユーザーとして認識するための処理です。
ログインのたびにパスワードを送り直すのではなく、通常は一時的なセッションIDやトークンを使います。
認可
認可、英語ではAuthorizationは、「そのユーザーが何をしてよいのか」を判断する処理です。
ログイン済みであっても、すべての操作が許可されるわけではありません。
例えば記事投稿アプリなら、次のような確認が必要です。
ログインしているか
その記事の投稿者本人か
管理者権限を持っているか
記事を編集できる状態か
「誰なのかを確認する」のが認証で、「その人に操作を許可するか」を決めるのが認可です。
ログインボタンを押した瞬間に起きること
メールアドレスとパスワードを使うWebアプリを例にします。
ユーザーがログインフォームを送信すると、ブラウザからサーバーへ次のようなリクエストが送られます。
POST /api/login HTTP/1.1
Host: app.example.com
Content-Type: application/json
{
"email": "user@example.com",
"password": "入力されたパスワード"
}
この通信は必ずHTTPSで保護する必要があります。HTTPSを使わなければ、パスワードやセッション情報が通信経路で読み取られる危険があります。
サーバー側では、主に次の処理が行われます。
メールアドレスからユーザーを検索する
保存されているパスワードハッシュを取得する
入力されたパスワードを検証する
正しければ新しいセッションIDを生成する
セッション情報をサーバー側へ保存する
セッションIDをCookieとしてブラウザへ返す
ここで重要なのは、データベースにパスワードをそのまま保存しないことです。
パスワードは復号する前提の暗号文ではなく、Argon2idなどのパスワード保存用アルゴリズムによってハッシュ化して保存します。ログイン時には「復号」するのではなく、入力されたパスワードが保存済みハッシュと一致するかを専用ライブラリで検証します。OWASP Password Storage Cheat Sheet
認証に成功すると、サーバーは推測困難なランダム値を生成します。
b8Lh2QwF1A9X...省略...sK4mZ
これがセッションIDです。
ユーザーIDをそのままセッションIDとして使ってはいけません。
sessionId=123
このような予測可能な値では、別の数字へ変更するだけで他人のセッションを試せてしまいます。
セッションIDは暗号学的に安全な乱数生成機能で作り、ユーザー情報や権限情報を直接含めないのが基本です。OWASP Session Management Cheat Sheetでも、クライアント側のセッションIDには個人情報や業務上の意味を持たせず、その意味はサーバー側へ保存することが推奨されています。
サーバー側に保存されるセッション
サーバー側には、例えば次のようなレコードが作られます。
{
"sessionIdHash": "セッションIDをハッシュ化した値",
"userId": 42,
"createdAt": "2026-08-05T10:00:00Z",
"lastAccessedAt": "2026-08-05T10:00:00Z",
"expiresAt": "2026-08-05T10:30:00Z"
}
セッションの保存先には、次のようなものが使われます。
アプリケーションサーバーのメモリ
Redisなどのインメモリデータストア
リレーショナルデータベース
セッション管理専用のサービス
小さな開発環境ではメモリ保存でも動きます。
ただし、サーバーを再起動するとセッションが消えます。また、複数台のサーバーを使っている場合、ログインを処理したサーバーとは別のサーバーに次のリクエストが届く可能性があります。
そのため、本番環境では複数のサーバーから共有できるRedisやデータベースへセッションを保存する構成がよく使われます。
Cookieはセッションそのものではない
ここで混同されやすいのが、CookieとSessionです。
Cookieは、ブラウザが保存し、条件に一致するHTTPリクエストへ付けて送るデータです。
Sessionは、複数のリクエストを同じユーザーの操作として扱うための、Webアプリ側の仕組みです。
つまり、CookieとSessionは同じものではありません。
一般的な構成では、次のように役割を分けます。
ブラウザ側
セッションIDをCookieとして保存する
サーバー側
セッションIDに対応するユーザー情報を保存する
ブラウザが持っているのは、ユーザー情報そのものではなく、サーバー側のセッションを探すための鍵です。
Set-Cookieでブラウザへ保存を依頼する
ログインに成功すると、サーバーはレスポンスにSet-Cookieヘッダーを付けます。
HTTP/1.1 204 No Content
Set-Cookie: __Host-session=b8Lh2QwF1A9X...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800
Cache-Control: no-store
Set-Cookieは、サーバーからブラウザへCookieを保存させるレスポンスヘッダーです。保存されたCookieは、その後、条件に一致するリクエストへブラウザが付けて送ります。MDNのSet-Cookieリファレンスに詳しい仕様がまとめられています。
それぞれの属性には意味があります。
HttpOnly
HttpOnly
JavaScriptのdocument.cookieからCookieを読み取れないようにします。
これによって、XSS脆弱性が発生した場合でも、JavaScriptからセッションIDそのものを盗まれる危険を減らせます。
ただし、HttpOnlyを付けても、JavaScriptから実行したfetch()にはCookieが送信されます。
つまり、セッションIDを「読む」ことは防げても、侵入したJavaScriptがユーザーの権限でリクエストを送ることまでは防げません。XSS対策そのものが不要になるわけではありません。
Secure
Secure
CookieをHTTPS通信でのみ送るようにします。
ログイン時だけでなく、ログイン後のすべての通信をHTTPSにする必要があります。途中の一回でもHTTPへセッションIDを送れば、その通信がセッションを盗まれる入口になります。
SameSite
SameSite=Lax
別のサイトから開始されたリクエストへ、Cookieをどの程度送るかを制御します。
主な値は次の3つです。
Strict:同じサイトからのリクエストに限定する
Lax:同じサイトに加え、一部の安全なトップレベル移動でも送る
None:サイトをまたぐリクエストでも送る。ただしSecureが必要
SameSiteは、後で説明するCSRFへの重要な対策になります。
Path
Path=/
どのURLパスへCookieを送るかを指定します。
Path=/なら、サイト内のすべてのパスが対象になります。
ただし、PathはCookieの送信範囲を調整するものであり、強いセキュリティ境界ではありません。別のパスから絶対に読み取れないことを保証する仕組みとして使うべきではありません。
Max-Age
Max-Age=1800
Cookieの有効時間を秒数で指定します。
1800秒なら30分です。値を0以下にすると、ブラウザはCookieを期限切れとして扱います。
__Host-という名前
__Host-session
__Host-から始まるCookieには、ブラウザ側で追加の制約がかかります。
Secureが必要
Path=/が必要
Domainを指定できない
これによって、セッションCookieを発行したホストに強く結びつけられます。
次のリクエストではCookieが自動送信される
ログイン後に、フロントエンドが現在のユーザー情報を取得するとします。
const response = await fetch("/api/me");
const user = await response.json();
同一オリジンへの通常のリクエストであれば、フロントエンドがセッションIDを手動で読み取って追加する必要はありません。
ブラウザが保存済みCookieを確認し、自動的に次のようなヘッダーを付けます。
GET /api/me HTTP/1.1
Host: app.example.com
Cookie: __Host-session=b8Lh2QwF1A9X...
サーバーはCookieからセッションIDを取り出し、保存してあるセッションを検索します。
受け取ったセッションID
↓
セッションを検索
↓
userId: 42
↓
ユーザー42としてリクエストを処理
これが「ログイン状態が維持されている」ように見える仕組みです。
実際には、サーバーがずっと一つの接続を握っているわけではありません。
リクエストのたびにCookieが送られ、リクエストのたびにセッションが検証されています。
バックエンドの認証処理
概念的なコードにすると、認証ミドルウェアは次のようになります。
async function authenticate(req, res, next) {
const sessionId = req.cookies["__Host-session"];
if (!sessionId) {
return res.status(401).json({
error: "authentication_required"
});
}
const session = await sessionStore.findByToken(sessionId);
if (!session || session.expiresAt <= new Date()) {
return res.status(401).json({
error: "invalid_session"
});
}
const user = await userRepository.findById(session.userId);
if (!user || user.status !== "active") {
return res.status(401).json({
error: "invalid_session"
});
}
req.user = user;
req.session = session;
next();
}
実際の実装では、自作の乱数生成やCookie解析へ頼らず、利用しているフレームワークのセッション管理機能や、十分に検証されたライブラリを使います。
このミドルウェアをログイン必須APIの前に置きます。
app.get("/api/me", authenticate, async (req, res) => {
res.json({
id: req.user.id,
name: req.user.name
});
});
セッションが存在しなければ、サーバーはユーザーを特定できません。
セッションが期限切れでも同様です。
ログイン済みでも認可は必要
ログインしていることが確認できても、処理をそのまま実行してはいけません。
例えば、記事の編集APIを考えます。
PATCH /api/articles/100
攻撃者はURLの数字を変更して、別のユーザーの記事を指定するかもしれません。
そのため、サーバー側で記事の所有者を確認します。
app.patch(
"/api/articles/:id",
authenticate,
async (req, res) => {
const article = await articleRepository.findById(req.params.id);
if (!article) {
return res.status(404).json({
error: "article_not_found"
});
}
const isOwner = article.authorId === req.user.id;
const isAdmin = req.user.role === "admin";
if (!isOwner && !isAdmin) {
return res.status(403).json({
error: "permission_denied"
});
}
const updatedArticle =
await articleRepository.update(article.id, req.body);
res.json(updatedArticle);
}
);
フロントエンドで編集ボタンを非表示にしても、認可にはなりません。
ブラウザの表示やJavaScriptはユーザー側で変更できるからです。最終的な認可は、必ずサーバー側で行います。
一般的には、有効な認証情報がない場合は401、認証済みだが操作する権限がない場合は403として区別します。MDNの401 Unauthorizedと403 Forbiddenも、この違いを理解する手がかりになります。
フロントエンドとAPIのオリジンが違う場合
フロントエンドとAPIが同一オリジンなら、Cookieの送信は比較的分かりやすいです。
https://app.example.com
https://app.example.com/api
一方、次のようにホストが分かれている場合は、オリジンが異なります。
フロントエンド
https://app.example.com
API
https://api.example.com
この場合、fetch()では認証情報を含める設定が必要です。
const response = await fetch(
"https://api.example.com/api/me",
{
credentials: "include"
}
);
さらにAPI側でも、許可するフロントエンドのオリジンと認証情報の受け入れを正しく設定する必要があります。
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
認証情報を含む通信でAccess-Control-Allow-Origin: *を使うことはできません。
Fetch APIのcredentialsは、Cookieをリクエストへ含めるかだけでなく、レスポンスのSet-Cookieを受け入れるかにも影響します。同一オリジンが既定値で、クロスオリジンへ送る場合はincludeを指定します。MDN Request.credentials
Cookieが保存されないときは、Cookieだけを見るのではなく、次の条件を一緒に確認する必要があります。
SecureとHTTPS
SameSite
Cookieを発行したホスト
PathとDomain
Fetch APIのcredentials
CORSレスポンスヘッダー
Cookieが自動送信されるからCSRFが起きる
Cookieは便利ですが、「条件に一致すればブラウザが自動的に送る」という性質があります。
この性質を悪用するのが、CSRFです。
例えば、ユーザーが記事投稿アプリへログインしたまま、別の悪意あるサイトを開いたとします。
そのサイトが、記事削除APIへリクエストを送る仕掛けを持っていた場合、ブラウザがセッションCookieを付けてしまう可能性があります。
サーバーから見ると、正しいCookieが付いたリクエストです。
しかし、その操作はユーザー本人が意図したものではありません。
OWASPは、Cookie認証を使うWebアプリでは、状態を変更するリクエストにCSRFトークンを付け、サーバー側で検証することを推奨しています。OWASP CSRF Prevention Cheat Sheet
代表的な対策は次のとおりです。
セッションCookieにSameSite=LaxまたはStrictを設定する
POST、PATCH、DELETEなどにCSRFトークンを要求する
OriginやFetch Metadataヘッダーを検証する
GETでデータを変更しない
フレームワーク標準のCSRF対策を利用する
重要操作ではパスワードなどを再確認する
SameSiteは重要ですが、それだけに頼るのではなく、CSRFトークンなどと組み合わせて考えます。
HttpOnlyならXSS対策は不要なのか
HttpOnlyを付けると、JavaScriptからセッションCookieを直接読めなくなります。
しかし、XSSによって悪意あるJavaScriptがページ内で実行された場合、そのコードは次のようなリクエストを送れる可能性があります。
fetch("/api/articles/100", {
method: "DELETE"
});
Cookieを直接読めなくても、ブラウザは同一オリジンへのリクエストにCookieを付けます。
つまり、HttpOnlyはセッションIDの持ち出しを難しくする対策であって、XSSによる操作をすべて止める対策ではありません。
入力値の適切な処理、出力時のエスケープ、危険なDOM操作の回避、Content Security Policyなど、XSS自体を防ぐ対策が別に必要です。
セッションIDはログイン後に作り直す
ログイン前から匿名ユーザー用のセッションを発行しているWebアプリもあります。
その場合、ログイン成功後も同じセッションIDを使い続けると、セッション固定攻撃の危険が生まれます。
そのため、次のように権限が変化したタイミングでセッションIDを再生成します。
ログインに成功したとき
管理者モードへ切り替えたとき
パスワードを変更したとき
ユーザーの権限が変更されたとき
OWASPも、認証を含む権限レベルの変更後には、以前のセッションIDを破棄して新しいIDを発行するよう推奨しています。OWASP Session ID Life Cycle
Cookieの期限とセッションの期限は別に考える
CookieにMax-Ageを設定しても、それだけで安全な期限管理になるわけではありません。
Cookieの有効期限はブラウザ側の期限です。
セッションの有効期限はサーバー側の期限です。
サーバー側では、少なくとも次の2種類を考えます。
アイドルタイムアウト
最後の操作から一定時間使われなかった場合に、セッションを無効にします。
例えば30分間操作がなければ、再ログインを要求します。
絶対タイムアウト
操作を続けていたとしても、ログインから一定時間が経過したらセッションを無効にします。
例えばログインから8時間経過したら、もう一度ログインを求めます。
期限の判定はサーバー側で行います。
ブラウザから期限切れのセッションIDが送られてきても、サーバー側のexpiresAtを過ぎていれば拒否します。
Cookieの期限だけに頼ると、Cookieの値を書き換えられた場合や、古いセッションIDを直接送られた場合に対応できません。
ログアウトはCookieを消すだけではない
ログアウト処理では、ブラウザのCookieを消すだけでは不十分です。
サーバー側のセッションが残っていれば、コピーされていたセッションIDを使って再びアクセスできる可能性があるからです。
正しい順序は、次のようになります。
サーバー側のセッションを無効化する
ブラウザ側のCookieを期限切れにする
app.post(
"/api/logout",
authenticate,
verifyCsrfToken,
async (req, res) => {
await sessionStore.delete(req.session.id);
res.clearCookie("__Host-session", {
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax"
});
res.status(204).end();
}
);
HTTPレスポンスとしては、次のようなCookieが返されます。
Set-Cookie: __Host-session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0
Cookieを削除するときは、作成時と同じPathやDomainの条件を使う必要があります。
また、「すべての端末からログアウト」という機能を作る場合は、そのユーザーに紐づく全セッションをサーバー側で無効化します。
JWTはSessionの代わりなのか
ログインの話では、JWTという言葉もよく登場します。
JWTはJSON Web Tokenの略で、ユーザーIDや有効期限などのクレームを、コンパクトな形式で表現するための標準です。RFC 7519
よく使われる署名付きJWTは、次の3つの部分で構成されます。
ヘッダー.ペイロード.署名
ペイロードのイメージは次のようになります。
{
"sub": "user_42",
"iss": "https://auth.example.com",
"aud": "article-api",
"exp": 1785927600
}
主なクレームには次のようなものがあります。
sub:誰を表すトークンか
iss:誰が発行したか
aud:どのサービス向けか
exp:いつ失効するか
署名付きJWTのペイロードは、通常、暗号化されているわけではありません。
Base64urlでエンコードされているだけなので、中身を読むことはできます。署名によって確認できるのは、発行後に内容が改ざんされていないことです。
秘密にしたい情報を、そのままペイロードへ入れてはいけません。
サーバーはJWTを受け取ったとき、署名だけでなく、許可するアルゴリズム、発行者、対象サービス、有効期限なども検証する必要があります。RFC 8725: JWT Best Current Practices
「JWT対Cookie」という比較は少しずれている
よく「JWTとCookieのどちらを使うべきか」と比較されます。
しかし、この2つは同じ種類の技術ではありません。
JWTは、トークンの形式です。
Cookieは、ブラウザでデータを保存し、HTTPリクエストへ送る仕組みです。
そのため、JWTをCookieへ保存する構成もあります。
Set-Cookie: access_token=JWTの文字列; HttpOnly; Secure; SameSite=Lax
一方、JWTをAuthorizationヘッダーで送る構成もあります。
Authorization: Bearer JWTの文字列
比較するなら、次の2つの構成を比べる方が正確です。
サーバー側セッション方式
ブラウザはランダムなセッションIDを持ち、ユーザー情報や期限はサーバー側に保存します。
利点は、サーバー側のレコードを削除すればすぐに失効させられることです。
一方で、リクエストごとにセッションストアを確認する必要があり、複数サーバーで共有する仕組みも必要です。
自己完結型トークン方式
JWT内にユーザーIDや有効期限などを持たせ、各APIが署名を検証します。
中央のセッションストアへ毎回問い合わせずに検証できるため、複数サービスで利用しやすい場面があります。
一方で、有効期限前のJWTを即座に無効化するのは簡単ではありません。
ユーザーがログアウトしても、署名と期限が有効なJWTはそのまま検証を通る可能性があります。失効リストや短い有効期限、リフレッシュトークンのローテーションなどを導入すると、結局サーバー側の状態管理が必要になることもあります。
つまり、「JWTなら完全にステートレスになる」とは限りません。
JWTをlocalStorageへ入れれば安全なのか
JWTをlocalStorageへ保存し、JavaScriptで読み出してAuthorizationヘッダーへ付ける構成もあります。
ただし、localStorageは同じオリジンのJavaScriptから読み取れます。
XSSが発生した場合、保存されたトークン自体を持ち出される危険があります。
一方、HttpOnly CookieならJavaScriptから値を読み取れませんが、Cookieが自動送信されるためCSRF対策が必要です。
つまり、保存場所を変えればすべて解決するわけではありません。
JavaScriptから読めるトークンはXSSの影響を受けやすい
自動送信されるCookieはCSRFを考慮する必要がある
どちらの場合もHTTPS、短い有効期限、適切な失効処理が必要
一般的なブラウザ向けWebアプリでは、成熟したフレームワークを利用し、HttpOnly・Secure・適切なSameSiteを付けたCookieでセッションIDを扱う構成が理解しやすく、失効も管理しやすい選択肢です。MDNのSession managementでも、ブラウザ上のセッションID保存にはHttpOnlyを利用できるCookieが推奨されています。
ただし、最終的な構成は、Webアプリだけなのか、モバイルアプリや外部APIもあるのか、複数サービス間で認証情報を共有するのかによって変わります。
ログイン状態が維持されるまでを一本につなげる
ここまでの処理を、もう一度順番に並べます。
1. ログイン情報を送信する
POST /api/login
ブラウザがメールアドレスとパスワードをHTTPSで送ります。
2. サーバーが認証する
サーバーがユーザーを検索し、パスワードハッシュを検証します。
3. セッションを作成する
推測困難なセッションIDを生成し、ユーザーIDや期限をサーバー側へ保存します。
4. Cookieを発行する
Set-Cookie: __Host-session=...; Path=/; HttpOnly; Secure; SameSite=Lax
ブラウザがセッションIDを保存します。
5. 次のリクエストへCookieを付ける
Cookie: __Host-session=...
ブラウザが条件に一致するリクエストへCookieを自動的に付けます。
6. サーバーがセッションを検証する
サーバーはセッションの存在、期限、ユーザーの状態を確認します。
7. 認可を確認する
ログイン済みであることだけでなく、そのユーザーに対象の操作を許可してよいか確認します。
8. ログアウトまたは期限切れで無効化する
サーバー側のセッションを削除し、ブラウザ側のCookieも期限切れにします。
ログイン状態とは、サーバーがユーザーを永久に覚えている状態ではありません。
ブラウザがリクエストごとに証明となる情報を送り、サーバーが毎回それを検証している状態です。
この仕組みがあるからこそ、HTTP自体はステートレスなままでも、Webアプリは「同じユーザーが操作を続けている」ように振る舞えます。
次回はシリーズ最終回です。
これまで扱ってきたURL、DNS、HTTPS、ブラウザ、フロントエンド、バックエンド、API、データベース、セッションをまとめ、URLを入力してからログイン済みの画面が表示されるまでを一本の処理としてつなげます。
