「Node.js の認証とセキュリティ:JWT、bcrypt、および実用的なセキュリティ対策」
1. 事例:保護されていないAPI
アリスは2週間かけてユーザー管理APIのデプロイを行ったが、その翌日、データベースが削除されていたことに気づいた。彼女のAPIには認証機能がなく、誰でも直接呼び出すことができたからだDELETE /api/users。この教訓を活かし、彼女はJWT認証を実装した。ユーザーはまずログインしてトークンを取得する必要があり、その後、保護されたルートにアクセスするにはリクエストにそのトークンを含めなければならない。同時に、管理者操作用のロールベースのミドルウェアを追加し、トークンを持っているユーザーであっても危険な操作を実行できないようにしました。1週間後、彼女はHelmetを使用してレスポンスヘッダーにセキュリティ対策を施し、APIはついに要塞のように堅固なものとなりました。
2. 基本概念
(1) 認証と認可の違い
- 認証:「あなたが誰であるか」を確認すること。例えば、ユーザー名やパスワードの確認など。
- 認可:「何ができるか」を確認するもので、役割に基づく権限の決定などがこれにあたります
- これら2つはしばしば組み合わせて使用されます。まず本人確認が行われ、その後、操作の承認が行われます。
(2) JWTの原則の概要
- JWT(JSON Web Token)は、3つのBase64エンコードされたセグメントで構成されるステートレストークンです
- サーバーから発行された後は、保存する必要はありません。クライアントがそれを保存し、リクエストに含めます。
- 分散システムに適しており、セッション共有の問題を回避できる
(3) bcrypt によるパスワードハッシュ化
- bcryptは、パスワード専用に設計されたハッシュアルゴリズムであり、ソルトが組み込まれています
cost factorを使用して計算時間を調整し、ブルートフォース攻撃を防ぐことができます。- 同じ平文でも毎回異なるハッシュ値が生成されるため、MD5やSHAよりもはるかに安全です
3. 技術的な詳細
(1) JWTの構造と動作原理
JWTは、.で連結された3つの部分で構成されています:
TEXT
Header.Payload.Signature
| セクション | 内容 | 説明 |
|---|---|---|
| ヘッダー | { "alg": "HS256", "typ": "JWT" } |
アルゴリズムとデータ構造 |
| 積荷 | { "userId": 1, "role": "admin", "exp": ... } |
カスタム申告 + 標準申告 |
| 署名 | HMACSHA256(base64(header) + "." + base64(payload), secret) |
改ざん防止署名 |
ワークフロー:
- ユーザーがログインすると、サーバーが認証情報を確認する
- 認証が成功した場合は、JWTを発行し、それをクライアントに返す
- クライアントはトークンをlocalStorageまたはクッキーに保存する
- それ以降の要求では、
Authorization: Bearer <token>ヘッダーに以下の内容を含めます: - サーバーサイドのミドルウェアが署名と有効期限を確認する
▶ 例:JWTの発行と検証
JAVASCRIPT
const jwt = require('jsonwebtoken');
const SECRET = 'my_super_secret_key';
const token = jwt.sign(
{ userId: 42, role: 'admin' },
SECRET,
{ expiresIn: '2h' }
);
console.log('Token:', token);
const decoded = jwt.verify(token, SECRET);
console.log('Decoded:', decoded);
▶ 例:JWTをデコードしてペイロードを表示する(署名の検証は行わない)
JAVASCRIPT
const decoded = jwt.decode(token, { complete: true });
console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);
(2) bcrypt のパラメータと使用方法
| パラメータ | 推奨値 | 説明 |
|---|---|---|
| saltRounds | 10-12 | ラウンド数。数値が大きいほどセキュリティは高くなりますが、処理速度は低下します |
| パスワードの長さ | 8文字以上 | フロントエンドで検証 |
| アルゴリズム | Blowfish | bcrypt の基盤となるアルゴリズム |
▶ 例:パスワードのハッシュ化と検証
JAVASCRIPT
const bcrypt = require('bcrypt');
async function hashPassword(plainPassword) {
const saltRounds = 10;
const hash = await bcrypt.hash(plainPassword, saltRounds);
console.log('Hash:', hash);
return hash;
}
async function verifyPassword(plainPassword, hash) {
const match = await bcrypt.compare(plainPassword, hash);
console.log('Match:', match);
return match;
}
(async () => {
const hash = await hashPassword('MyPassword123');
await verifyPassword('MyPassword123', hash);
await verifyPassword('WrongPassword', hash);
})();
(3) HTTPセキュリティヘッダーとHelmet
Helmetは、HTTPレスポンスヘッダーを設定することでセキュリティを強化します:
| セキュリティヘッダー | 目的 | デフォルトで有効 |
|---|---|---|
| Content-Security-Policy | XSSを防止し、リソースの読み込み元を制限する | いいえ(手動での設定が必要) |
| X-Frame-Options | クリックジャッキングの防止 | はい |
| X-Content-Type-Options | MIMEスニッフィングの防止 | はい |
| Strict-Transport-Security | HTTPSを強制 | はい |
| X-XSS-Protection | ブラウザによるXSSフィルタリング | 非推奨(Helmetではデフォルトで有効化されなくなりました) |
▶ 例:Helmetの統合
JAVASCRIPT
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
}
}));
app.get('/', (req, res) => {
res.send('Secured with helmet!');
});
app.listen(3000);
(4) 一般的なセキュリティ上の脅威と対策
| 脅威 | 仕組み | 対策 |
|---|---|---|
| SQLインジェクション | ユーザー入力を連結して悪意のあるSQLを構築する | パラメータ化されたクエリ / ORM |
| XSS(クロスサイトスクリプティング) | ページへの悪意のあるスクリプトの注入 | 出力のエスケープ / CSP / Helmet |
| CSRF(クロスサイト・リクエスト・フォージェリー) | 認証済みユーザーになりすましてリクエストを送信すること | CSRFトークン/SameSiteクッキー |
| ブルートフォース攻撃 | パスワードの繰り返し入力 | レート制限 / 高いbcryptラウンド数 / アカウントのロックアウト |
| マン・イン・ザ・ミドル攻撃 | 通信データの傍受 | HTTPS / HSTS |
▶ 例:SQLインジェクションの防止(パラメータ化されたクエリ)
JAVASCRIPT
const { Pool } = require('pg');
const pool = new Pool();
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET, { expiresIn: '2h' });
res.json({ token });
});
▶ 例:XSSの防止(出力のエスケープ)
JAVASCRIPT
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const userInput = '<script>alert("xss")</script>';
console.log(escapeHtml(userInput));
▶ 例:CSRF対策(csurfミドルウェア)
JAVASCRIPT
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
app.post('/submit', csrfProtection, (req, res) => {
res.json({ message: 'Form submitted successfully' });
});
4. 実践演習
(1) 登録API
▶ 例:ユーザー登録API
JAVASCRIPT
app.post('/api/register', async (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existingUser = await pool.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'Username already exists' });
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const result = await pool.query(
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) RETURNING id, username, role',
[username, passwordHash, role || 'user']
);
const user = result.rows[0];
const token = jwt.sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.status(201).json({ user: { id: user.id, username: user.username, role: user.role }, token });
});
(2) ログインAPI
▶ 例:ユーザーログインAPI
JAVASCRIPT
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const result = await pool.query(
'SELECT id, username, password_hash, role FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.json({
user: { id: user.id, username: user.username, role: user.role },
token
});
});
(3) JWT認証ミドルウェア
▶ 例:authenticate ミドルウェア
JAVASCRIPT
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
(4) ロールベースのアクセス制御ミドルウェア
▶ 例:「authorize」ロールのミドルウェア
JAVASCRIPT
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
res.json({ message: 'User deleted' });
});
app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
res.json({ message: 'Welcome to admin dashboard' });
});
5. 包括的な例:完全な認証システム
TEXT
project/
├── server.js
├── middleware/
│ ├── auth.js
│ └── role.js
├── routes/
│ ├── auth.js
│ └── users.js
└── package.json
middleware/auth.js:
JAVASCRIPT
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'fallback_dev_secret';
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired. Please login again.' });
}
return res.status(403).json({ error: 'Invalid token.' });
}
}
module.exports = { authenticate, SECRET };
middleware/role.js:
JAVASCRIPT
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required.' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden. Insufficient permissions.' });
}
next();
};
}
module.exports = { authorize };
routes/auth.js:
JAVASCRIPT
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { authenticate, SECRET } = require('../middleware/auth');
const router = express.Router();
const users = [];
router.post('/register', async (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required.' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
const exists = users.find(u => u.username === username);
if (exists) {
return res.status(409).json({ error: 'Username already exists.' });
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const newUser = {
id: users.length + 1,
username,
passwordHash,
role: role || 'user'
};
users.push(newUser);
const token = jwt.sign(
{ userId: newUser.id, username: newUser.username, role: newUser.role },
SECRET,
{ expiresIn: '2h' }
);
res.status(201).json({
user: { id: newUser.id, username: newUser.username, role: newUser.role },
token
});
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required.' });
}
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.json({
user: { id: user.id, username: user.username, role: user.role },
token
});
});
router.get('/profile', authenticate, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
res.json({ id: user.id, username: user.username, role: user.role });
});
module.exports = router;
routes/users.js:
JAVASCRIPT
const express = require('express');
const { authenticate } = require('../middleware/auth');
const { authorize } = require('../middleware/role');
const router = express.Router();
const users = [];
router.get('/', authenticate, authorize('admin'), (req, res) => {
const safeList = users.map(u => ({ id: u.id, username: u.username, role: u.role }));
res.json(safeList);
});
router.delete('/:id', authenticate, authorize('admin'), (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'User not found.' });
}
users.splice(index, 1);
res.json({ message: 'User deleted.' });
});
module.exports = router;
server.js:
JAVASCRIPT
const express = require('express');
const helmet = require('helmet');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const app = express();
app.use(helmet());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error.' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
テストプロセス:
BASH
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"SecurePass123","role":"admin"}'
# Log In
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"SecurePass123"}'
# Accessing Protected Routes
curl http://localhost:3000/api/auth/profile \
-H "Authorization: Bearer <your_token>"
# Administrator Actions
curl -X DELETE http://localhost:3000/api/users/2 \
-H "Authorization: Bearer <admin_token>"
TEXT
# Registration Response
{
"user": { "id": 1, "username": "alice", "role": "admin" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# Login Response
{
"user": { "id": 1, "username": "alice", "role": "admin" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# Insufficient Permissions Response
{ "error": "Forbidden. Insufficient permissions." }
6. JWT認証のフローチャート
sequenceDiagram
participant Client as Client
participant Server as Server-side
participant DB as Database
Client->>Server: POST /api/login {username, password}
Server->>DB: Query User Records
DB-->>Server: Return User Data
Server->>Server: bcrypt.compare() Confirm Password
alt The password is correct
Server->>Server: jwt.sign() Issued Token
Server-->>Client: Back { token }
Client->>Client: Storage Token
Client->>Server: GET /api/profile<br/>Authorization: Bearer <token>
Server->>Server: jwt.verify() Verification Token
alt Token Valid
Server-->>Client: 200 Return User Data
else Token Invalid or Expired
Server-->>Client: 401/403 Access Denied
end
else Incorrect password
Server-->>Client: 401 Invalid credentials
end
7. 比較と参考文献
(1) JWT 対 セッション 対 OAuth
| ディメンション | JWT | セッション | OAuth 2.0 |
|---|---|---|---|
| 保存場所 | クライアント | サーバー | サーバー + クライアント |
| ステートレス | はい | いいえ | いいえ |
| スケーラビリティ | 分散システムをネイティブにサポート | 共有セッションストレージが必要 | 認証サーバーが必要 |
| ユースケース | API認証、マイクロサービス | 従来のWebアプリケーション | サードパーティログイン |
| セキュリティリスク | 取り消せないトークンの漏洩 | セッションハイジャック | リプレイ攻撃 |
| 複雑さ | 低 | 低 | 高 |
(2) bcrypt パラメータの選択
| saltRounds | おおよその所要時間 | 活用例 |
|---|---|---|
| 8 | 約40 ms | 開発およびテスト |
| 10 | 約160 ms | 本番環境での使用を推奨 |
| 12 | 約640 ms | 高い安全要件 |
| 14 | 約2.5秒 | 極めて安全なシナリオ |
(3) 一般的なセキュリティ上の脅威と対策
| 脅威 | 攻撃例 | 防御方法 | ツール/ライブラリ |
|---|---|---|---|
| SQLインジェクション | ' OR 1=1 -- |
パラメータ化クエリ | pg/mysql2 |
| XSS | <script>document.cookie</script> |
エスケープ + CSP | helmet/xss |
| CSRF | フォーム送信の偽装 | CSRFトークン + SameSite | csurf |
| ブルートフォース攻撃 | パスワードに対する辞書攻撃 | レート制限 + 高ラウンド数のbcrypt | express-rate-limit |
| マン・イン・ザ・ミドル攻撃 | HTTPトラフィックのスニッフィング | HTTPS + HSTS | helmet/Let's Encrypt |
(4) JWTペイロードの共通フィールド
| 項目 | 正式名称 | 説明 |
|---|---|---|
| iss | 発行体 | 発行体識別子 |
| sub | 件名 | 件名(通常はユーザーID) |
| aud | 視聴者 | 受信者ID |
| exp | 有効期限 | 有効期限時刻 (Unix タイムスタンプ) |
| iat | 発行場所 | 発行日 |
| jti | JWT ID | 一意の識別子(リプレイ防止) |
| userId | カスタム | ビジネスユーザーID |
| 役割 | カスタム | ユーザー役割 |
❓ よくある質問
Q JWTはどこに保存されますか?
A XSS攻撃による読み取りを防ぐため、
httpOnlyクッキーに保存することを推奨します。localStorageは便利ですが、XSSによる盗み出しのリスクがあるため、CSPと組み合わせて使用する必要があります。Q トークンの有効期限が切れた場合はどうすればよいですか?
A 一般的な「2トークン方式」では、有効期間が短いアクセストークンと、有効期間が長いリフレッシュトークンを併用します。アクセストークンの有効期限が切れたら、リフレッシュトークンを使用して新しいトークンを取得します。リフレッシュトークンはデータベースに保存されるため、事前に無効化することができます。
Q なぜbcryptはそれほど遅いのですか?
A bcryptのコスト係数はハッシュ処理のラウンド数を制御しており、ラウンドが1つ増えるごとに処理時間が2倍になります。この「遅さ」は設計上の仕様であり、ブルートフォース攻撃を行う際に、パスワードを1つ推測するたびに多大な計算コストがかかるようにするためです。
Q HTTPSは必須ですか?
A 本番環境では必須です。HTTPでは、トークンやパスワードが平文で送信されるため、中間者によって傍受される可能性があります。HTTPSによる暗号化通信は、最低限のセキュリティ要件です。
Q ブルートフォース攻撃はどのように防げますか?
A 多層防御です。具体的には、bcryptを用いてラウンド数を増やすことで1回の試行あたりの計算コストを高め、rate-limitでアクセス制限を行い、アカウントロック機能に加え、ログイン失敗後の応答遅延を設けることです。
Q JWTは事前の措置として無効化できますか?
A JWTは本質的にステートレスであるため、事前の措置として無効化することはできません。一般的な解決策としては、ブラックリストの管理(無効化されたトークンのJTIを有効期限が切れるまでRedisに保存する)、リフレッシュトークンと組み合わせて有効期間を短縮すること、および機密性の高い操作に対して二次認証を要求することが挙げられます。
Q HelmetでCSPを設定する際、どのような点に注意すべきですか?
A CSPルールが厳しすぎると、正当なリソースの読み込みがブロックされる可能性があります。開発中は
Content-Security-Policy-Report-Onlyを使用して違反レポートを監視し、徐々にポリシーを厳格化することをお勧めします。📖 まとめ
- 解説:保護されていないAPIの基本概念と使用方法
- 基本概念:定義と応用
- 技術解説における重要な概念と活用方法
- 基本概念と実践演習の活用方法
- 包括的な例:完全な認証システムの基本概念と使用方法
- JWT認証フローチャートの主要な概念と使用方法
- 比較・参照の主要な概念と用法
📝 練習問題
- 登録画面を実装してください。パスワードは8文字以上で、数字を1つ以上含んでいる必要があります。また、bcryptを使用して暗号化し、データベースに保存する必要があります。
- パスワードの検証後に、有効期間が1時間のJWTを発行するログインAPIを実装すること。ペイロードには、
userIdおよびroleを含める必要がある。 Authorization: Bearer <token>から JWT を抽出して検証するためのauthenticateミドルウェアを作成するauthorize('admin')ミドルウェアを作成し、DELETE /api/users/:idへのアクセスを「admin」ロールのユーザーに限定するようにします。- Helmetをプロジェクトに組み込み、CSPを設定して同一オリジンのスクリプトとスタイルのみが読み込まれるようにし、curlを使用してレスポンスヘッダーの変更を確認する



