メインコンテンツまでスキップ

HTTP-only Cookie 認証

HTTP-only Cookie を使用した認証は、セッションベースの認証方式の中でも特にセキュリティを重視した実装方法です。

HTTP-only 属性が付与された Cookie は、JavaScript からアクセスできないため、XSS 攻撃に対する耐性が高い Cookie です。

// 通常のCookie (JavaScriptからアクセス可能)
document.cookie = "token=abc123; path=/";
console.log(document.cookie); // "token=abc123" が取得できる

// HTTP-only Cookie (JavaScriptからアクセス不可)
// サーバー側でのみ設定可能
// document.cookieでは取得できない
属性説明推奨値
HttpOnlyJavaScript からのアクセスを防ぐtrue
SecureHTTPS 通信のみで送信true
SameSiteCSRF 攻撃を防ぐStrict または Lax
PathCookie が送信されるパス/
Max-Age / ExpiresCookie の有効期限セッションに応じて設定
DomainCookie が有効なドメイン設定しない or 自ドメイン

認証フロー

基本的なフロー

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 CookieBearer トークン
XSS 耐性高い(JavaScript不可)低い(LocalStorage使用時)
CSRF 耐性低い(対策必須)高い
モバイル対応やや難容易
ステートレス性低い(セッション管理必要)高い
CORS 対応やや複雑単純
マイクロサービスやや不向き向いている
実装の複雑さ中程度中程度

それぞれの使い分け

  • 従来型の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 などの永続化されたセッションストアを使用

参考リンク