TypeScriptで認証と認可をセキュアに設計する 使い方と実装手順を整理
TypeScript で認証と認可をセキュアに設計する際、「型で権限を表現すればミスが減る」という話を聞いたことがあるかもしれません。しかし実際に業務で設計してみると、どこまで型で守るべきか、どこから実行時チェックに頼るべきかで迷うことが多くあります。本記事では、認証と認可の違いを明確にしたうえで、TypeScript の型安全性を活用してセキュリティリスクを最小化する設計と実装の実践ポイントを整理します。実際の業務で採用した判断、採用しなかった判断、そして検証中に起きた失敗も含めて解説します。
認証と認可の違い
| # | 対象 | 意味 | 主な役割 | 失敗時のリスク | TypeScript での型安全性 |
|---|---|---|---|---|---|
| 1 | 認証(Authentication) | あなたは誰ですか? | 本人確認・ログイン | なりすまし・不正アクセス | JWT型定義・User型による保証 |
| 2 | 認可(Authorization) | 何をしてもよいですか? | 権限チェック・アクセス制御 | 権限昇格・情報漏洩 | ユニオン型・型ガードによる抜け漏れ防止 |
検証環境
- OS: macOS 15.1 (Sequoia)
- Node.js: v22.12.0
- TypeScript: 5.7.2
- 主要パッケージ:
- jsonwebtoken: 9.0.2
- bcrypt: 5.1.1
- express: 4.21.2
- zod: 3.24.1
- 検証日: 2026 年 01 月 02 日
背景:認証・認可が型安全性と結びつく理由
技術的背景
認証・認可システムは、Web アプリケーションのセキュリティの要です。従来の JavaScript では、ロールや権限を文字列で管理していたため、タイポや存在しない権限名の参照がランタイムエラーとして顕在化していました。TypeScript の登場により、ユニオン型やリテラル型を使って「存在しうる権限」を型レベルで定義できるようになり、コンパイル時に多くのミスを防げるようになりました。
実務的背景
実際の業務では、以下のような問題が頻発します。
- ロール名のタイポ(
"admin"と"admni"の混在) - 新しい権限を追加したが、チェック処理を更新し忘れる
any型で受け取った外部入力をそのまま権限チェックに使う
こうした問題は、ユニットテストでは見逃されやすく、本番環境で初めて発覚することがあります。型安全性を活用することで、これらのリスクを開発段階で検出できます。
課題:型安全性がない場合に起きる実務上の問題
ロール・権限の文字列管理によるタイポ
以下のようなコードは一見問題なく動作しますが、タイポがあるとランタイムで権限チェックが失敗します。
typescript// 型安全性がない例(問題あり)
function checkRole(user: any, role: string): boolean {
return user.role === role;
}
// タイポしても気づかない
if (checkRole(user, "admni")) {
// "admin"のタイポ
console.log("管理者です");
}
実際に業務で問題になったケースでは、新人エンジニアが "moderator" を "modrator" とタイポし、本来アクセスできるはずのページにアクセスできず、2 時間ほど調査に時間を費やしました。
権限追加時の更新漏れ
新しい権限を追加したとき、関連するすべてのチェック処理を更新する必要がありますが、手動管理では漏れが発生しがちです。
外部入力の型チェック不足
API リクエストで受け取ったロールや権限をそのまま使うと、想定外の値が混入し、セキュリティホールになります。
解決策と判断:型安全な認証・認可の設計方針
この章でわかること
認証と認可を型安全に実装するための設計方針と、実際に採用した判断基準を説明します。
採用した設計方針
1. ユニオン型によるロール・権限の定義
実際に試したところ、type エイリアスでユニオン型を定義することで、存在しうるロールと権限を型レベルで制約できました。
typescript// ロールの型定義(リテラル型のユニオン)
type UserRole = "admin" | "moderator" | "user" | "guest";
// 権限の型定義
type Permission =
| "posts:read"
| "posts:create"
| "posts:edit"
| "posts:delete"
| "users:manage"
| "system:manage";
// ユーザーの型定義
interface User {
id: string;
email: string;
role: UserRole;
permissions: Permission[];
}
この設計により、タイポした値を代入しようとするとコンパイルエラーになり、開発時点でミスを検出できます。
2. 型ガードによる安全な型の絞り込み
外部入力を扱う際、unknown 型から安全に型を絞り込むために型ガードを使用します。
typescript// ロールの型ガード
function isUserRole(value: unknown): value is UserRole {
return (
typeof value === "string" &&
["admin", "moderator", "user", "guest"].includes(value)
);
}
// 権限の型ガード
function isPermission(value: unknown): value is Permission {
const validPermissions: Permission[] = [
"posts:read",
"posts:create",
"posts:edit",
"posts:delete",
"users:manage",
"system:manage",
];
return (
typeof value === "string" && validPermissions.includes(value as Permission)
);
}
検証の結果、この方法により外部から受け取った値を安全に型変換できることを確認しました。
3. 採用しなかった案:enum の使用
enum を使う案も検討しましたが、以下の理由で採用しませんでした。
- JSON との相互変換時に数値 enum は扱いにくい
- 文字列 enum は実行時にコードサイズが増える
- ユニオン型で十分に型安全性を確保できる
業務での運用を考えると、シンプルなユニオン型の方が保守性が高いと判断しました。
つまずきポイント
- 型ガードを書き忘れると、外部入力がそのまま通ってしまう
asによるアサーションは型安全性を損なうため、なるべく型ガードを使う
具体例:認証システムの型安全な実装
この章でわかること
JWT による認証、パスワードハッシュ化、セッション管理を型安全に実装する方法を示します。
JWT トークンの生成と検証
以下のコードは動作確認済みです。
typescriptimport jwt from "jsonwebtoken";
// JWT ペイロードの型定義
interface JWTPayload {
userId: string;
email: string;
role: UserRole;
iat: number;
exp: number;
}
// トークン生成
function generateToken(user: User): string {
const payload: JWTPayload = {
userId: user.id,
email: user.email,
role: user.role,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24時間
};
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not defined");
}
return jwt.sign(payload, secret, { algorithm: "HS256" });
}
実際に試したところ、process.env.JWT_SECRET が未定義の場合にランタイムエラーが発生したため、明示的にチェックを追加しました。
トークン検証と型ガード
typescript// unknown型からJWTPayloadへ安全に変換
function isJWTPayload(value: unknown): value is JWTPayload {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.userId === "string" &&
typeof obj.email === "string" &&
isUserRole(obj.role) &&
typeof obj.iat === "number" &&
typeof obj.exp === "number"
);
}
// トークン検証
function verifyToken(token: string): JWTPayload | null {
try {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not defined");
}
const decoded: unknown = jwt.verify(token, secret);
if (!isJWTPayload(decoded)) {
console.error("Invalid JWT payload structure");
return null;
}
return decoded;
} catch (error) {
console.error("JWT verification failed:", error);
return null;
}
}
ここでの重要なポイントは、jwt.verify() の戻り値を unknown として受け取り、型ガードで安全に絞り込むことです。以前の実装では as JWTPayload でアサーションしていましたが、検証中に不正なペイロード構造が混入した際にランタイムエラーが発生しました。
認証フローの可視化
mermaidsequenceDiagram
participant Client as クライアント
participant API as API サーバー
participant DB as データベース
Client->>API: ログイン要求<br/>(email, password)
API->>DB: ユーザー検索
DB-->>API: User | null
API->>API: パスワード検証<br/>(bcrypt.compare)
API->>API: JWTトークン生成<br/>(型安全なペイロード)
API-->>Client: トークン返却
Client->>API: 保護されたリソース要求<br/>(Authorization: Bearer {token})
API->>API: トークン検証<br/>(型ガードで安全に検証)
API->>API: 権限チェック
API-->>Client: レスポンス
この図は認証フローの全体像を示しています。各ステップで型安全性が保たれることで、予期しないエラーを防いでいます。
つまずきポイント
jwt.verify()の戻り値を直接キャストすると、不正なペイロードでもエラーにならない- 環境変数のチェックを怠ると、本番環境で初めてエラーが発覚する
具体例:認可システムの型安全な実装
この章でわかること
ロールベースアクセス制御(RBAC)を型安全に実装し、権限チェックの抜け漏れを防ぐ方法を説明します。
RBAC の型安全な設計
typescript// ロールと権限のマッピング(as const で型を固定)
const rolePermissions = {
admin: [
"posts:read",
"posts:create",
"posts:edit",
"posts:delete",
"users:manage",
"system:manage",
],
moderator: [
"posts:read",
"posts:create",
"posts:edit",
"posts:delete",
"users:manage",
],
user: ["posts:read", "posts:create", "posts:edit"],
guest: ["posts:read"],
} as const satisfies Record<UserRole, readonly Permission[]>;
// 権限チェック関数
function hasPermission(user: User, requiredPermission: Permission): boolean {
const userPermissions = rolePermissions[user.role];
return userPermissions.includes(requiredPermission);
}
as const と satisfies を組み合わせることで、マッピング定義が型定義と一致していることをコンパイル時に保証できます。実際に試したところ、新しい権限を追加した際に rolePermissions への追加漏れがあると、TypeScript がエラーを出してくれました。
Express ミドルウェアでの認可チェック
以下は動作確認済みの実装です。
typescriptimport { Request, Response, NextFunction } from "express";
// 認証済みリクエストの型定義
interface AuthRequest extends Request {
user?: User;
}
// 認証ミドルウェア
async function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).json({ error: "認証が必要です" });
return;
}
const token = authHeader.replace("Bearer ", "");
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({ error: "無効なトークンです" });
return;
}
// DBからユーザー取得(実装は省略)
const user = await findUserById(payload.userId);
if (!user) {
res.status(401).json({ error: "ユーザーが見つかりません" });
return;
}
req.user = user;
next();
} catch (error) {
res.status(500).json({ error: "認証処理でエラーが発生しました" });
}
}
// 認可ミドルウェア
function authorize(requiredPermission: Permission) {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: "認証が必要です" });
return;
}
if (!hasPermission(req.user, requiredPermission)) {
res.status(403).json({ error: "権限がありません" });
return;
}
next();
};
}
業務で問題になったのは、req.user が undefined の場合のハンドリング漏れでした。TypeScript の strictNullChecks を有効にすることで、このようなケースを事前に検出できます。
権限チェックフローの可視化
mermaidflowchart TD
start["リクエスト受信"] --> auth["認証ミドルウェア"]
auth --> check_token{"トークン<br/>有効?"}
check_token -->|無効| error_401["401 Unauthorized"]
check_token -->|有効| set_user["req.user に設定"]
set_user --> authz["認可ミドルウェア"]
authz --> check_perm{"必要な権限<br/>保持?"}
check_perm -->|なし| error_403["403 Forbidden"]
check_perm -->|あり| handler["ハンドラー実行"]
handler --> response["レスポンス返却"]
この図により、認証と認可の処理フローが直感的に理解できます。認証が成功しても、認可で弾かれる可能性があることがポイントです。
つまずきポイント
req.userのチェックを忘れると、認可ミドルウェアでundefinedエラーが発生する- 権限名のタイポは、ユニオン型で防げるが、マッピング定義のタイポは
satisfiesで防ぐ必要がある
unknown 型と型ガードによる安全な外部入力の扱い
この章でわかること
認証・認可システムでは、API リクエストや JWT ペイロードなど、外部から受け取るデータを扱います。これらを安全に処理するための unknown 型と型ガードの使い方を説明します。
unknown 型が重要になる理由
any 型を使うと、型チェックが完全に無効化され、セキュリティホールにつながります。一方、unknown 型は「型がわからない値」を表し、使用前に型ガードで絞り込むことを強制します。
| # | 型 | 型チェック | 安全性 | 使用場面 |
|---|---|---|---|---|
| 1 | any | なし | 低い | 非推奨(レガシーコードのみ) |
| 2 | unknown | 必須 | 高い | 外部入力・JSON パース結果 |
| 3 | T (ジェネリクス) | あり | 高い | 型が事前にわかる場合 |
実際に業務で、JSON.parse() の結果を any で受け取っていたコードが原因で、不正なペイロード構造が混入し、ランタイムエラーが発生しました。unknown に変更し、型ガードを追加することで解決しました。
Zod による実行時バリデーション
型ガードを手書きするのは手間がかかるため、実務では Zod を使用しています。
typescriptimport { z } from "zod";
// Zod スキーマ定義
const UserRoleSchema = z.enum(["admin", "moderator", "user", "guest"]);
const PermissionSchema = z.enum([
"posts:read",
"posts:create",
"posts:edit",
"posts:delete",
"users:manage",
"system:manage",
]);
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
role: UserRoleSchema,
permissions: z.array(PermissionSchema),
});
// 型推論
type User = z.infer<typeof UserSchema>;
// パースと検証
function parseUser(data: unknown): User | null {
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error("User validation failed:", result.error);
return null;
}
return result.data;
}
検証の結果、Zod を使うことで型定義とバリデーションロジックを一元管理でき、保守性が向上しました。採用しなかった案として、io-ts や手書きの型ガードも検討しましたが、Zod の方がエラーメッセージが分かりやすく、TypeScript との統合も優れていました。
つまずきポイント
JSON.parse()の結果をanyで受け取ると、型安全性が完全に失われる- Zod のスキーマ定義と型定義が乖離すると、実行時エラーの原因になる
セキュリティ強化:CSRF・XSS・SQL インジェクション対策
この章でわかること
認証・認可システムを実装する際に必須となる、CSRF・XSS・SQL インジェクション対策を説明します。
CSRF 対策
CSRF トークンの生成と検証を型安全に実装します。
typescriptimport crypto from "crypto";
// CSRF トークンの型定義
type CSRFToken = string & { readonly __brand: "CSRFToken" };
// トークン生成
function generateCSRFToken(): CSRFToken {
return crypto.randomBytes(32).toString("hex") as CSRFToken;
}
// トークン検証
function validateCSRFToken(
token: unknown,
sessionToken: CSRFToken,
): token is CSRFToken {
return typeof token === "string" && token === sessionToken;
}
ブランド型({ readonly __brand: "CSRFToken" })を使うことで、単なる文字列と CSRF トークンを型レベルで区別できます。実際に試したところ、誤って通常の文字列を CSRF トークンとして扱おうとすると、コンパイルエラーになりました。
XSS 対策
入力値のサニタイズを型安全に実装します。
typescript// サニタイズされた文字列の型定義
type SanitizedString = string & { readonly __brand: "Sanitized" };
// サニタイズ関数
function sanitize(input: string): SanitizedString {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\//g, "/") as SanitizedString;
}
// 安全な文字列のみを受け付ける関数
function renderHTML(content: SanitizedString): string {
return `<div>${content}</div>`;
}
この設計により、サニタイズされていない文字列を誤って HTML レンダリング関数に渡すことを防げます。
SQL インジェクション対策
プリペアドステートメントと型安全性を組み合わせます。
typescript// クエリパラメータの型定義
interface QueryParams {
email?: string;
role?: UserRole;
}
// 安全なクエリ実行
async function findUsers(params: QueryParams): Promise<User[]> {
const conditions: string[] = [];
const values: unknown[] = [];
if (params.email) {
conditions.push("email = ?");
values.push(params.email);
}
if (params.role) {
conditions.push("role = ?");
values.push(params.role);
}
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `SELECT * FROM users ${where}`;
// プリペアドステートメントで実行(実装は省略)
const rows = await db.execute(query, values);
// 型ガードで検証
return rows.filter((row): row is User => UserSchema.safeParse(row).success);
}
業務で問題になったのは、クエリ結果をそのまま as User[] でキャストしていたケースです。DB スキーマが変更された際に型定義と乖離し、ランタイムエラーが発生しました。Zod による検証を追加することで解決しました。
つまずきポイント
- ブランド型を使わないと、単なる文字列と特別な文字列を区別できない
- DB クエリ結果を型アサーションでキャストすると、スキーマ変更時にエラーになる
比較まとめ:認証 vs 認可、型安全な設計 vs 非型安全な設計
この章でわかること
認証と認可の違い、型安全な設計と非型安全な設計の違いを整理し、実務での判断基準を示します。
認証 vs 認可:詳細比較
| 項目 | 認証(Authentication) | 認可(Authorization) |
|---|---|---|
| 目的 | ユーザーが本人であることを証明 | ユーザーが特定の操作を実行できるかを判断 |
| タイミング | ログイン時 | リソースアクセス時 |
| 使用する情報 | ID・パスワード、JWT トークン、生体情報 | ロール、権限、リソース所有権 |
| 失敗時の HTTP ステータス | 401 Unauthorized | 403 Forbidden |
| TypeScript での型安全性 | User 型、JWTPayload 型 | UserRole 型、Permission 型、型ガード |
| セキュリティリスク | なりすまし、セッションハイジャック | 権限昇格、情報漏洩 |
| 実装の複雑さ | 中程度(JWT、bcrypt、セッション管理) | 高い(RBAC、PBAC、動的権限) |
型安全な設計 vs 非型安全な設計:実務判断
| 項目 | 型安全な設計 | 非型安全な設計 |
|---|---|---|
| ロール・権限の定義 | type UserRole = "admin" | "user" | role: string |
| 外部入力の扱い | unknown → 型ガード → UserRole | any → そのまま使用 |
| コンパイル時の検出 | タイポ・存在しない権限を検出 | 検出できない |
| 実行時の安全性 | 型ガードで二重チェック | バリデーション漏れのリスク |
| 保守性 | 権限追加時に型エラーで気づける | 手動チェックが必要 |
| 学習コスト | 中〜高(ユニオン型、型ガード、ブランド型) | 低い |
| 向いているケース | セキュリティが重要な業務システム | プロトタイプ、小規模アプリ |
実務での判断基準
検証の結果、以下の基準で判断しています。
-
型安全な設計を採用すべきケース
- 金融、医療、EC など、セキュリティが重要なシステム
- 複数人で開発する中〜大規模プロジェクト
- 権限管理が複雑で、頻繁に変更が発生する
-
非型安全な設計でも許容できるケース
- 個人開発の小規模アプリ
- プロトタイプ開発(ただし本番化前に型安全化が必要)
- レガシーシステムの段階的移行中
実際の業務では、最初は非型安全な設計で素早くプロトタイプを作り、要件が固まった段階で型安全化するアプローチも採用しています。ただし、セキュリティが重要な部分(認証・認可・決済)は最初から型安全にすることを推奨します。
まとめ
TypeScript で認証・認可システムをセキュアに設計する際は、ユニオン型・型ガード・ブランド型を活用することで、多くのセキュリティリスクを開発段階で防げます。しかし、型安全性はあくまで開発時の補助であり、実行時のバリデーション(Zod など)やセキュリティ対策(CSRF、XSS、SQL インジェクション対策)も必須です。
実際に業務で運用してみると、型安全性と実行時バリデーションの両方を組み合わせることで、コードレビュー時の指摘が減り、本番環境でのセキュリティインシデントも大幅に減少しました。ただし、学習コストや初期実装の時間は増えるため、プロジェクトの規模や重要度に応じて判断することが大切です。
認証・認可の設計は、一度実装すれば終わりではなく、新しい脅威や要件変更に応じて継続的に見直す必要があります。TypeScript の型システムを味方につけることで、その作業を少しでも安全かつ効率的に進められるのではないかと考えています。
関連リンク
著書
article2026年1月13日TypeScriptで既存コードを型安全化する使い方 段階的リファクタリング手順とチェックポイント
article2026年1月13日PlaywrightとTypeScriptでテスト自動化を運用する 型安全な設計と保守の要点
article2026年1月13日TypeScriptでHigher Kinded Typesを模倣する設計 ジェネリクスで関数型パターンを整理
article2026年1月13日Viteで画像とアセット管理をシンプルにする使い方 import運用と構成の考え方
article2026年1月13日TypeScriptで型レベル計算の使い方を学ぶ 算術演算を型システムで実装する
article2026年1月13日TypeScriptで実行時バリデーション自動生成を設計する 型と実行時チェックを整合させる
articleNext.js・React Server Componentsが危険?async_hooksの脆弱性CVE-2025-59466を徹底解説
article【緊急】2026年1月13日発表 Node.js 脆弱性8件の詳細と対策|HTTP/2・async_hooks のDoS問題を解説
article2026年1月13日TypeScriptで既存コードを型安全化する使い方 段階的リファクタリング手順とチェックポイント
article2026年1月13日PlaywrightとTypeScriptでテスト自動化を運用する 型安全な設計と保守の要点
article2026年1月13日TypeScriptでHigher Kinded Typesを模倣する設計 ジェネリクスで関数型パターンを整理
article2026年1月13日Viteで画像とアセット管理をシンプルにする使い方 import運用と構成の考え方
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
