HTTP-only Cookie 認証
HTTP-only Cookie を使用した認証は、セッションベースの認証方式の中でも特にセキュリティを重視した実装方法です。
HTTP-only Cookie とは
HTTP-only 属性が付与された Cookie は、JavaScript からアクセスできないため、XSS 攻撃に対する耐性が高い Cookie です。
通常の Cookie との違い
// 通常のCookie (JavaScriptからアクセス可能)
document.cookie = "token=abc123; path=/";
console.log(document.cookie); // "token=abc123" が取得できる
// HTTP-only Cookie (JavaScriptからアクセス不可)
// サーバー側でのみ設定可能
// document.cookieでは取得できない
Cookie の属性
| 属性 | 説明 | 推奨値 |
|---|---|---|
| HttpOnly | JavaScript からのアクセスを防ぐ | true |
| Secure | HTTPS 通信のみで送信 | true |
| SameSite | CSRF 攻撃を防ぐ | Strict または Lax |
| Path | Cookie が送信されるパス | / |
| Max-Age / Expires | Cookie の有効期限 | セッションに応じて設定 |
| Domain | Cookie が有効なドメイン | 設定しない or 自ドメイン |
認証フロー
基本的なフロー
Set-Cookie ヘッダーの例
HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123def456; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
Content-Type: application/json
実装例
サーバー側の実装
Node.js + Express + express-session
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const app = express();
// Redis クライアント設定
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redisClient.connect();
// セッション設定
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScriptからアクセス不可
secure: true, // HTTPS のみ
sameSite: 'strict', // CSRF対策
maxAge: 1000 * 60 * 60 * 24, // 24時間
},
}));
app.use(express.json());
// ログインエンドポイント
app.post('/auth/login', async (req, res) => {
const { username, password } = req.body;
try {
// ユーザー検証(実際はデータベースから取得)
const user = await getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// パスワード検証
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// セッションにユーザー情報を保存
req.session.userId = user.id;
req.session.username = user.username;
res.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// ログアウトエンドポイント
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Failed to logout' });
}
res.clearCookie('connect.sid'); // セッションCookieを削除
res.json({ success: true });
});
});
// 認証ミドルウェア
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// 保護されたエンドポイント
app.get('/api/users/me', requireAuth, (req, res) => {
res.json({
id: req.session.userId,
username: req.session.username,
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
ASP.NET Core
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// Cookie認証の設定
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(24);
options.SlidingExpiration = true;
options.LoginPath = "/auth/login";
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// ログインエンドポイント
app.MapPost("/auth/login", async (HttpContext context, LoginRequest request) =>
{
// ユーザー検証(実装省略)
var user = await AuthenticateUser(request.Username, request.Password);
if (user == null)
{
return Results.Unauthorized();
}
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Email, user.Email),
};
var claimsIdentity = new ClaimsIdentity(claims,
CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Results.Ok(new { success = true, user = new { user.Id, user.Username } });
});
// ログアウトエンドポイント
app.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Ok(new { success = true });
});
// 保護されたエンドポイント
app.MapGet("/api/users/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
username = user.FindFirst(ClaimTypes.Name)?.Value,
});
})
.RequireAuthorization();
app.Run();
record LoginRequest(string Username, string Password);
クライアント側の実装
JavaScript (Fetch API)
// ログイン
async function login(username, password) {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Cookie を送受信するために必須
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
return await response.json();
}
// 認証が必要なAPIを呼び出し
async function fetchUserProfile() {
const response = await fetch('https://api.example.com/api/users/me', {
method: 'GET',
credentials: 'include', // Cookie を自動送信
});
if (!response.ok) {
if (response.status === 401) {
// 認証エラー: ログイン画面へリダイレクト
window.location.href = '/login';
return;
}
throw new Error('Failed to fetch profile');
}
return await response.json();
}
// ログアウト
async function logout() {
const response = await fetch('https://api.example.com/auth/logout', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
window.location.href = '/login';
}
}
React での実装例
import { createContext, useContext, useState, useEffect } from 'react';
interface User {
id: string;
username: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 初期化時にセッションを確認
useEffect(() => {
checkSession();
}, []);
async function checkSession() {
try {
const response = await fetch('/api/users/me', {
credentials: 'include',
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Session check failed:', error);
} finally {
setIsLoading(false);
}
}
async function login(username: string, password: string) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
setUser(data.user);
}
async function logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
});
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
セキュリティ上の考慮事項
CSRF (Cross-Site Request Forgery) 対策
HTTP-only Cookie は CSRF 攻撃に対して脆弱です。以下の対策が必要です:
1. SameSite 属性の使用
cookie: {
sameSite: 'strict', // または 'lax'
// ...
}
- Strict: 同一サイトからのリクエストのみ Cookie を送信
- Lax: GET リクエストなど安全なメソッドでは送信
- None: すべてのリクエストで送信(Secure属性必須)
2. CSRF トークンの使用
const csurf = require('csurf');
// CSRF保護ミドルウェア
const csrfProtection = csurf({ cookie: false }); // セッションストアを使用
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
app.post('/api/important-action', csrfProtection, (req, res) => {
// CSRFトークンが検証される
res.json({ success: true });
});
クライアント側:
// CSRFトークンを取得
const csrfResponse = await fetch('/api/csrf-token', {
credentials: 'include',
});
const { csrfToken } = await csrfResponse.json();
// リクエスト時にトークンを送信
await fetch('/api/important-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
body: JSON.stringify({ /* data */ }),
});
CORS の設定
クライアントとサーバーが異なるドメインの場合、CORS の適切な設定が必要です。
const cors = require('cors');
app.use(cors({
origin: 'https://yourapp.com', // 特定のオリジンのみ許可
credentials: true, // Cookie の送受信を許可
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'X-CSRF-Token'],
}));
セッションの固定化攻撃対策
ログイン成功時にセッションIDを再生成します。
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.username, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// セッションIDを再生成(セッション固定化攻撃を防ぐ)
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
res.json({ success: true, user });
});
});
セッションストアの選択
本番環境ではメモリベースではなく、永続化されたストアを使用します。
- Redis: 高速で推奨
- MongoDB: ドキュメント指向
- PostgreSQL: リレーショナルDB
- Memcached: 分散キャッシュ
// Redis を使用した例
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({
url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD,
});
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:',
ttl: 86400, // 24時間
}),
// ...
}));
Bearer 認証との比較
| 項目 | HTTP-only Cookie | Bearer トークン |
|---|---|---|
| XSS 耐性 | 高い(JavaScript不可) | 低い(LocalStorage使用時) |
| CSRF 耐性 | 低い(対策必須) | 高い |
| モバイル対応 | やや難 | 容易 |
| ステートレス性 | 低い(セッション管理必要) | 高い |
| CORS 対応 | やや複雑 | 単純 |
| マイクロサービス | やや不向き | 向いている |
| 実装の複雑さ | 中程度 | 中程度 |
それぞれの使い分け
HTTP-only Cookie が適している場合
- 従来型のWebアプリケーション(サーバーサイドレンダリング)
- 同一ドメイン内でのアプリケーション
- XSS 対策を最優先する場合
- セッション管理が必要な場合
Bearer トークンが適している場合
- SPA (Single Page Application)
- モバイルアプリ
- マイクロサービスアーキテクチャ
- API ファーストな設計
- クロスドメインでの認証が必要な場合
ハイブリッドアプローチ
両方の利点を組み合わせることも可能です:
// セッションIDをHTTP-only Cookieに保存
// アクセストークンはメモリで管理
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body);
// セッション作成(HTTP-only Cookie)
req.session.userId = user.id;
// 短時間有効なアクセストークンを発行
const accessToken = generateAccessToken(user.id);
res.json({
accessToken, // メモリで管理
expiresIn: 900, // 15分
});
});
まとめ
- HTTP-only Cookie は XSS 攻撃に強い認証方式
- CSRF 対策(SameSite, CSRFトークン)が必須
- セッション管理のためサーバー側の実装が必要
credentials: 'include'でクライアント側の Cookie 送受信を有効化- CORS 設定時は
credentials: trueが必要 - 本番環境では Redis などの永続化されたセッションストアを使用