はじめに
Webアプリケーションでユーザーの状態を保持するとき、セッション以外の選択肢として広く使われるのがCookieです。PHPでCookieを送信するための標準関数が setcookie() です。
「ただCookieを送るだけ」と思われがちですが、有効期限・パス・ドメイン・Secure・HttpOnly・SameSiteの各オプションを正しく設定しないと、XSS・CSRF・中間者攻撃のリスクに直結します。PHP 7.3以降は配列形式でオプションを指定できるようになり、SameSite 属性も設定可能になりました。本記事では基本から実践的なセキュリティ設定まで体系的に解説します。
関数の概要
| 項目 | 内容 |
|---|
| 関数名 | setcookie() |
| 所属 | PHP ネットワーク関数 |
| 導入バージョン | PHP 4以降(配列形式は PHP 7.3以降) |
| PHP 8.x | 対応済み |
構文
配列形式(PHP 7.3以降・推奨)
setcookie(string $name, string $value = "", array $options = []): bool
引数形式(後方互換)
setcookie(
string $name,
string $value = "",
int $expires = 0,
string $path = "",
string $domain = "",
bool $secure = false,
bool $httponly = false
): bool
戻り値
- 成功時:
true
- 失敗時:
false(ヘッダーが既に送信済みの場合など)
⚠️ 注意:setcookie() はHTTPヘッダーを送信するため、出力の前(echo や HTML の前)に呼び出す必要があります。
オプション一覧(配列形式)
setcookie('name', 'value', [
'expires' => time() + 3600, // 有効期限(Unixタイムスタンプ)
'path' => '/', // 有効なパス
'domain' => '.example.com', // 有効なドメイン
'secure' => true, // HTTPS のみで送信
'httponly' => true, // JavaScript からアクセス不可
'samesite' => 'Lax', // 'Strict' / 'Lax' / 'None'
]);
| オプション | 型 | デフォルト | 説明 |
|---|
expires | int | 0 | 有効期限(Unixタイムスタンプ)。0でブラウザ終了まで |
path | string | "" | Cookieが有効なURLパス |
domain | string | "" | Cookieが有効なドメイン |
secure | bool | false | true でHTTPSのみ送信 |
httponly | bool | false | true でJavaScriptからアクセス不可 |
samesite | string | "" | クロスサイトリクエストでの送信制御 |
SameSite の使い分け
| 値 | 動作 | 推奨シーン |
|---|
Strict | 同一サイトのリクエストのみ送信 | セキュリティ最優先(SNS・管理画面など) |
Lax | 安全なHTTPメソッドの外部遷移は許可 | 一般的なWebアプリの推奨値 |
None | クロスサイトでも常に送信(Secure 必須) | 埋め込みウィジェット・外部サービス連携 |
setcookie() と setrawcookie() の違い
| 観点 | setcookie() | setrawcookie() |
|---|
| 値のエンコード | 自動で urlencode() | 一切エンコードしない |
| 適した値 | 一般テキスト・数値 | JWT / Base64 / 独自フォーマット |
基本的な使い方
<?php
// 最もシンプルな使い方(セッションCookie)
setcookie('username', 'alice');
// 有効期限付き(1時間)
setcookie('lang', 'ja', time() + 3600);
// 配列形式(推奨・PHP 7.3以降)
setcookie('user_id', '42', [
'expires' => time() + 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
// Cookieの削除(有効期限を過去に設定)
setcookie('username', '', time() - 3600);
// Cookieの読み取り
$username = $_COOKIE['username'] ?? null;
実践的なクラスベースの使用例
例1:セキュア Cookie 発行クラス
<?php
/**
* セキュリティ設定を一括管理する Cookie 発行クラス
* OWASP 推奨設定を標準として適用し、
* 安全な Cookie 操作を提供する
*/
class SecureCookieJar
{
private array $defaults;
public function __construct(array $overrides = [])
{
$this->defaults = array_merge([
'expires' => 0,
'path' => '/',
'domain' => '',
'secure' => $this->isHttps(),
'httponly' => true,
'samesite' => 'Lax',
], $overrides);
}
/**
* Cookie をセットする
*/
public function set(string $name, string $value, array $options = []): bool
{
$this->validateName($name);
$opts = array_merge($this->defaults, $options);
$result = setcookie($name, $value, $opts);
if (!$result) {
throw new \RuntimeException(
"Cookie '{$name}' の送信に失敗しました(ヘッダー送信済みの可能性)"
);
}
echo "Cookie セット: {$name} (secure={$opts['secure']}, httponly={$opts['httponly']}, samesite={$opts['samesite']})\n";
return true;
}
/**
* 有効期限付きで Cookie をセットする
*/
public function setWithTtl(string $name, string $value, int $ttl, array $options = []): bool
{
return $this->set($name, $value, array_merge($options, [
'expires' => time() + $ttl,
]));
}
/**
* Cookie を削除する(有効期限を過去に設定してブラウザに削除を指示)
*/
public function delete(string $name, array $options = []): bool
{
$opts = array_merge($this->defaults, $options, [
'expires' => time() - 86400,
]);
return setcookie($name, '', $opts);
}
/**
* Cookie が存在するか確認する
*/
public function has(string $name): bool
{
return isset($_COOKIE[$name]);
}
/**
* Cookie の値を取得する
*/
public function get(string $name, ?string $default = null): ?string
{
return $_COOKIE[$name] ?? $default;
}
private function validateName(string $name): void
{
if (empty($name)) {
throw new \InvalidArgumentException('Cookie 名を空にすることはできません');
}
// Cookie 名に使えない文字のチェック
if (preg_match('/[=,; \t\r\n\013\014]/', $name)) {
throw new \InvalidArgumentException("Cookie 名に使用できない文字が含まれています: {$name}");
}
}
private function isHttps(): bool
{
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['SERVER_PORT'] ?? 80) == 443;
}
}
$jar = new SecureCookieJar(['domain' => '.example.com']);
// $jar->setWithTtl('lang', 'ja', 365 * 86400);
// $jar->set('session_hint', 'active');
// $jar->delete('old_cookie');
例2:「ログイン状態を保持する」クラス
<?php
/**
* "ログイン状態を保持する" チェックボックスに対応する
* 永続ログイン Cookie 管理クラス
* セキュアなトークンを生成してDBと照合する方式
*/
class RememberMeManager
{
private const COOKIE_NAME = 'remember_token';
private const TTL_DAYS = 30;
public function __construct(
private readonly \PDO $pdo,
private readonly bool $secure = true,
private readonly string $domain = ''
) {}
/**
* ログイン時にトークンを生成して Cookie にセットし DB に保存する
*/
public function issue(int $userId): string
{
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + self::TTL_DAYS * 86400);
// DB にハッシュを保存(平文は保存しない)
$stmt = $this->pdo->prepare(
'INSERT INTO remember_tokens (user_id, token_hash, expires_at)
VALUES (:uid, :hash, :exp)
ON DUPLICATE KEY UPDATE token_hash = :hash, expires_at = :exp'
);
$stmt->execute([':uid' => $userId, ':hash' => $tokenHash, ':exp' => $expiresAt]);
// Cookie にはハッシュ前のトークンをセット
setcookie(self::COOKIE_NAME, $token, [
'expires' => time() + self::TTL_DAYS * 86400,
'path' => '/',
'domain' => $this->domain,
'secure' => $this->secure,
'httponly' => true,
'samesite' => 'Lax',
]);
echo "ログイン維持 Cookie 発行: ユーザー {$userId}({$expiresAt} まで有効)\n";
return $token;
}
/**
* Cookie のトークンを検証してユーザーIDを返す
*/
public function verify(): ?int
{
$token = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($token === null) {
return null;
}
$tokenHash = hash('sha256', $token);
$stmt = $this->pdo->prepare(
'SELECT user_id FROM remember_tokens
WHERE token_hash = :hash AND expires_at > NOW()'
);
$stmt->execute([':hash' => $tokenHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
echo "トークン検証失敗(期限切れまたは無効)\n";
$this->revoke();
return null;
}
echo "トークン検証成功: ユーザー {$row['user_id']}\n";
return (int) $row['user_id'];
}
/**
* Cookie とDBのトークンを削除する(ログアウト)
*/
public function revoke(): void
{
$token = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($token !== null) {
$hash = hash('sha256', $token);
$stmt = $this->pdo->prepare('DELETE FROM remember_tokens WHERE token_hash = :hash');
$stmt->execute([':hash' => $hash]);
}
setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 86400,
'path' => '/',
'domain' => $this->domain,
'secure' => $this->secure,
'httponly' => true,
'samesite' => 'Lax',
]);
echo "ログイン維持 Cookie を削除しました\n";
}
}
/*
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$manager = new RememberMeManager($pdo, secure: true, domain: '.example.com');
$manager->issue(userId: 42);
*/
例3:ユーザー設定・テーマ保存クラス
<?php
/**
* ログイン不要のユーザー設定(テーマ・言語・表示件数など)を
* Cookie に保存・読み込みするクラス
* ページをまたいで設定を維持する
*/
class UserPreferenceCookie
{
private const COOKIE_NAME = 'ui_prefs';
private const TTL = 365 * 86400; // 1年
private array $defaults = [
'theme' => 'light',
'lang' => 'ja',
'per_page' => 20,
'font_size' => 'medium',
'sidebar' => 'open',
];
private array $allowedValues = [
'theme' => ['light', 'dark', 'auto'],
'lang' => ['ja', 'en', 'zh'],
'per_page' => [10, 20, 50, 100],
'font_size' => ['small', 'medium', 'large'],
'sidebar' => ['open', 'closed'],
];
/**
* Cookie から設定を読み込む
*/
public function load(): array
{
$raw = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($raw === null) {
return $this->defaults;
}
$decoded = json_decode(base64_decode($raw), true);
if (!is_array($decoded)) {
return $this->defaults;
}
// バリデーション:許可された値のみを採用
$validated = $this->defaults;
foreach ($this->allowedValues as $key => $allowed) {
if (isset($decoded[$key]) && in_array($decoded[$key], $allowed, true)) {
$validated[$key] = $decoded[$key];
}
}
return $validated;
}
/**
* 設定を Cookie に保存する
*/
public function save(array $prefs): bool
{
// バリデーション
$validated = [];
foreach ($this->allowedValues as $key => $allowed) {
$value = $prefs[$key] ?? $this->defaults[$key];
$validated[$key] = in_array($value, $allowed, true) ? $value : $this->defaults[$key];
}
$encoded = base64_encode(json_encode($validated));
$result = setcookie(self::COOKIE_NAME, $encoded, [
'expires' => time() + self::TTL,
'path' => '/',
'secure' => false,
'httponly' => false, // JS からも読めるようにする(UIテーマの即時反映)
'samesite' => 'Lax',
]);
echo "設定保存: theme={$validated['theme']}, lang={$validated['lang']}\n";
return $result;
}
/**
* 個別設定を更新する
*/
public function update(string $key, mixed $value): bool
{
$current = $this->load();
$current[$key] = $value;
return $this->save($current);
}
/**
* 設定をデフォルトにリセットする
*/
public function reset(): bool
{
return setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 86400,
'path' => '/',
'samesite' => 'Lax',
]);
}
}
$prefs = new UserPreferenceCookie();
// $prefs->save(['theme' => 'dark', 'lang' => 'en', 'per_page' => 50]);
// $current = $prefs->load();
// print_r($current);
例4:フラッシュメッセージ Cookie クラス
<?php
/**
* セッションを使わずに Cookie でフラッシュメッセージを実装するクラス
* リダイレクト後に1回だけ表示するメッセージを Cookie で渡す
* (セッション利用不可環境・API レスポンス後のリダイレクト向け)
*/
class FlashCookieMessage
{
private const COOKIE_NAME = 'flash_msg';
private const MAX_MSGS = 5;
/**
* フラッシュメッセージを Cookie にセットする(リダイレクト前に呼ぶ)
*/
public function set(string $type, string $message): bool
{
$allowed = ['success', 'error', 'warning', 'info'];
if (!in_array($type, $allowed, true)) {
throw new \InvalidArgumentException("無効なメッセージタイプ: {$type}");
}
// 既存のメッセージがあれば追記
$existing = $this->decode($_COOKIE[self::COOKIE_NAME] ?? '');
$existing[] = ['type' => $type, 'message' => $message, 'time' => time()];
// 最大件数を超えたら古いものを削除
if (count($existing) > self::MAX_MSGS) {
$existing = array_slice($existing, -self::MAX_MSGS);
}
$result = setcookie(self::COOKIE_NAME, $this->encode($existing), [
'expires' => time() + 300, // 5分間有効(リダイレクト後に読む想定)
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
echo "フラッシュメッセージ設定: [{$type}] {$message}\n";
return $result;
}
/**
* フラッシュメッセージを取得して Cookie を削除する(リダイレクト後に呼ぶ)
*/
public function consume(): array
{
$raw = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($raw === null) {
return [];
}
$messages = $this->decode($raw);
// 読んだら Cookie を削除
setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 86400,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
echo "フラッシュメッセージ取得: " . count($messages) . " 件\n";
return $messages;
}
/**
* メッセージを種別で絞り込んで取得する
*/
public function consumeByType(string $type): array
{
return array_filter(
$this->consume(),
fn($m) => $m['type'] === $type
);
}
private function encode(array $data): string
{
return base64_encode(json_encode($data));
}
private function decode(string $encoded): array
{
if (empty($encoded)) return [];
$decoded = json_decode(base64_decode($encoded), true);
return is_array($decoded) ? $decoded : [];
}
}
$flash = new FlashCookieMessage();
// $flash->set('success', '保存しました');
// $flash->set('warning', 'セッションが間もなく切れます');
// $messages = $flash->consume();
// foreach ($messages as $msg) {
// echo "[{$msg['type']}] {$msg['message']}\n";
// }
例5:A/B テスト Cookie 管理クラス
<?php
/**
* A/B テストのグループ割り当てを Cookie で管理するクラス
* 一度割り当てたグループを Cookie で記憶し一貫した体験を提供する
*/
class AbTestCookieManager
{
private const COOKIE_NAME = 'ab_groups';
private const TTL = 90 * 86400; // 90日間
public function __construct(
private readonly bool $secure = false
) {}
/**
* テスト名とバリアントの重みを指定してグループを割り当てる
* 既に割り当て済みなら同じグループを返す
*
* @param string $testName テスト名(例: 'checkout_v2')
* @param array $variants バリアント名 => 重み(例: ['control' => 70, 'variant_a' => 30])
*/
public function assign(string $testName, array $variants): string
{
$groups = $this->loadGroups();
// 既に割り当て済みなら同じグループを返す
if (isset($groups[$testName])) {
echo "既存グループ: {$testName} = {$groups[$testName]}\n";
return $groups[$testName];
}
// 重み付きランダム割り当て
$assigned = $this->weightedRandom($variants);
$groups[$testName] = $assigned;
$this->saveGroups($groups);
echo "新規割り当て: {$testName} = {$assigned}\n";
return $assigned;
}
/**
* 特定テストのグループを取得する
*/
public function getGroup(string $testName): ?string
{
return $this->loadGroups()[$testName] ?? null;
}
/**
* 特定のテストの割り当てをリセットする
*/
public function reset(string $testName): void
{
$groups = $this->loadGroups();
unset($groups[$testName]);
$this->saveGroups($groups);
echo "リセット: {$testName}\n";
}
private function loadGroups(): array
{
$raw = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($raw === null) return [];
$decoded = json_decode(base64_decode($raw), true);
return is_array($decoded) ? $decoded : [];
}
private function saveGroups(array $groups): void
{
setcookie(self::COOKIE_NAME, base64_encode(json_encode($groups)), [
'expires' => time() + self::TTL,
'path' => '/',
'secure' => $this->secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
private function weightedRandom(array $variants): string
{
$total = array_sum($variants);
$rand = mt_rand(1, $total);
$cumulative = 0;
foreach ($variants as $name => $weight) {
$cumulative += $weight;
if ($rand <= $cumulative) {
return (string) $name;
}
}
return array_key_first($variants);
}
}
$ab = new AbTestCookieManager(secure: false);
// $group = $ab->assign('checkout_flow', ['control' => 60, 'variant_a' => 40]);
// echo "チェックアウトテスト: {$group}\n";
例6:Cookie セキュリティ監査クラス
<?php
/**
* アプリケーション内で使われている Cookie のセキュリティを
* 一括監査するクラス
* デプロイ前チェック・セキュリティレビューに使用する
*/
class CookieSecurityAuditor
{
private array $registry = [];
/**
* 監査対象の Cookie を登録する
*/
public function register(
string $name,
array $options,
string $description = ''
): void {
$this->registry[] = compact('name', 'options', 'description');
}
/**
* 全Cookie のセキュリティを監査してレポートを出力する
*/
public function audit(): array
{
$report = [];
foreach ($this->registry as $entry) {
$findings = $this->checkCookie($entry['name'], $entry['options']);
$report[] = [
'name' => $entry['name'],
'description' => $entry['description'],
'findings' => $findings,
'score' => $this->score($findings),
];
}
return $report;
}
private function checkCookie(string $name, array $opts): array
{
$findings = [];
if (empty($opts['httponly'])) {
$findings[] = ['level' => 'HIGH', 'msg' => 'httponly が false(XSS でCookie盗難リスク)'];
}
if (empty($opts['secure'])) {
$findings[] = ['level' => 'HIGH', 'msg' => 'secure が false(HTTP で平文送信される)'];
}
$ss = strtolower($opts['samesite'] ?? '');
if ($ss === '') {
$findings[] = ['level' => 'MEDIUM', 'msg' => 'samesite 未設定(CSRF リスク)'];
} elseif ($ss === 'none' && empty($opts['secure'])) {
$findings[] = ['level' => 'HIGH', 'msg' => 'samesite=None なのに secure=false(Cookie が送信されない)'];
}
if (($opts['expires'] ?? 0) > time() + 365 * 86400) {
$findings[] = ['level' => 'LOW', 'msg' => '有効期限が1年超(長すぎる可能性)'];
}
if (($opts['path'] ?? '/') === '/') {
// path=/ は通常問題ないが確認
}
return $findings;
}
private function score(array $findings): string
{
$high = count(array_filter($findings, fn($f) => $f['level'] === 'HIGH'));
$medium = count(array_filter($findings, fn($f) => $f['level'] === 'MEDIUM'));
if ($high > 0) return "❌ 要対応(HIGH: {$high}件)";
if ($medium > 0) return "⚠️ 注意あり";
return "✅ 問題なし";
}
public function printReport(): void
{
$report = $this->audit();
echo "=== Cookie セキュリティ監査レポート ===\n\n";
foreach ($report as $entry) {
echo "[{$entry['name']}] {$entry['description']}\n";
echo " 評価: {$entry['score']}\n";
foreach ($entry['findings'] as $f) {
echo " [{$f['level']}] {$f['msg']}\n";
}
echo "\n";
}
}
}
$auditor = new CookieSecurityAuditor();
$auditor->register('auth_token', [
'expires' => time() + 3600,
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
], '認証トークン');
$auditor->register('ui_prefs', [
'expires' => time() + 365 * 86400,
'secure' => false,
'httponly' => false,
'samesite' => 'Lax',
], 'UIテーマ設定');
$auditor->register('legacy_cookie', [
'expires' => time() + 86400,
'secure' => false,
'httponly' => false,
], 'レガシーCookie(要改修)');
$auditor->printReport();
/*
出力例:
=== Cookie セキュリティ監査レポート ===
[auth_token] 認証トークン
評価: ✅ 問題なし
[ui_prefs] UIテーマ設定
評価: ⚠️ 注意あり
[HIGH] secure が false(HTTP で平文送信される)
[legacy_cookie] レガシーCookie(要改修)
評価: ❌ 要対応(HIGH: 2件)
[HIGH] httponly が false(XSS でCookie盗難リスク)
[HIGH] secure が false(HTTP で平文送信される)
[MEDIUM] samesite 未設定(CSRF リスク)
*/
Cookie の読み取り・更新・削除の基本パターン
<?php
// ---- 読み取り ----
$value = $_COOKIE['name'] ?? null; // 未設定なら null
$value = $_COOKIE['name'] ?? 'default'; // デフォルト値付き
// ---- 上書き(同じ名前で再送)----
setcookie('name', 'new_value', [
'expires' => time() + 3600,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
// ---- 削除 ----
setcookie('name', '', [
'expires' => time() - 86400, // 過去の日時
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
// または値を空文字にして expires を過去にするだけでもよい
関連関数との比較
| 関数 | 役割 |
|---|
setcookie() | Cookie を送信する(値を自動 urlencode) |
setrawcookie() | Cookie を送信する(値をエンコードしない) |
session_set_cookie_params() | セッションCookieのパラメータを設定する |
header('Set-Cookie: ...') | 生のCookieヘッダーを手動で送信する(非推奨) |
よくある落とし穴
<?php
// ❌ NG:出力の後に setcookie() を呼ぶと headers already sent エラー
echo "Hello";
setcookie('name', 'value'); // Warning: Cannot modify header information
// ✅ OK:出力の前に呼ぶ
setcookie('name', 'value');
echo "Hello";
<?php
// ❌ よくある誤解:setcookie() した後すぐ $_COOKIE で読める
setcookie('key', 'value');
echo $_COOKIE['key']; // 未定義!(次のリクエストから有効)
// ✅ 同一リクエスト内で読みたければ $_COOKIE にも手動セット
setcookie('key', 'value', ['httponly' => true, 'samesite' => 'Lax']);
$_COOKIE['key'] = 'value'; // 同一リクエスト内での読み取り用
<?php
// ❌ NG:Cookie の削除時に path/domain を送信時と一致させないと削除されない
setcookie('name', 'value', ['path' => '/app', 'domain' => '.example.com']);
// ... 後から削除しようとして
setcookie('name', '', ['expires' => time() - 86400]); // path と domain が違うので別のCookieとして扱われる
// ✅ 送信時と同じ path・domain を指定して削除する
setcookie('name', '', [
'expires' => time() - 86400,
'path' => '/app', // 送信時と同じ
'domain' => '.example.com', // 送信時と同じ
]);
<?php
// ❌ NG:SameSite=None なのに secure=false(ブラウザが Cookie を拒否する)
setcookie('embed_token', 'value', [
'samesite' => 'None',
'secure' => false, // ← これだと主要ブラウザで無効になる
]);
// ✅ SameSite=None は必ず secure=true とセット
setcookie('embed_token', 'value', [
'samesite' => 'None',
'secure' => true,
]);
まとめ
| パラメータ | 本番推奨値 | 理由 |
|---|
expires | time() + N または 0 | 必要な期間のみ有効に |
path | '/' | アプリ全体で有効 |
domain | '.example.com' | サブドメイン共有が必要な場合のみ |
secure | true | HTTPS のみで送信(必須) |
httponly | true | JS からの盗難防止(XSS対策)(必須) |
samesite | 'Lax' または 'Strict' | CSRF対策(必須) |
setcookie() は1行で書けるシンプルな関数ですが、secure / httponly / samesite の3つを正しく設定するだけでXSS・CSRF・中間者攻撃への耐性が大きく向上します。PHP 7.3以降の配列形式を使い、SameSite を含む全オプションを明示的に設定する習慣をつけることが、セキュアなCookie管理の第一歩です。
参考リンク