T-CREATOR

<div />

TypeScriptで認証と認可をセキュアに設計する 使い方と実装手順を整理

2026年1月2日
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 constsatisfies を組み合わせることで、マッピング定義が型定義と一致していることをコンパイル時に保証できます。実際に試したところ、新しい権限を追加した際に 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.userundefined の場合のハンドリング漏れでした。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 型は「型がわからない値」を表し、使用前に型ガードで絞り込むことを強制します。

#型チェック安全性使用場面
1anyなし低い非推奨(レガシーコードのみ)
2unknown必須高い外部入力・JSON パース結果
3T (ジェネリクス)あり高い型が事前にわかる場合

実際に業務で、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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;")
    .replace(/\//g, "&#x2F;") 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 Unauthorized403 Forbidden
TypeScript での型安全性User 型、JWTPayloadUserRole 型、Permission 型、型ガード
セキュリティリスクなりすまし、セッションハイジャック権限昇格、情報漏洩
実装の複雑さ中程度(JWT、bcrypt、セッション管理)高い(RBAC、PBAC、動的権限)

型安全な設計 vs 非型安全な設計:実務判断

項目型安全な設計非型安全な設計
ロール・権限の定義type UserRole = "admin" | "user"role: string
外部入力の扱いunknown → 型ガード → UserRoleany → そのまま使用
コンパイル時の検出タイポ・存在しない権限を検出検出できない
実行時の安全性型ガードで二重チェックバリデーション漏れのリスク
保守性権限追加時に型エラーで気づける手動チェックが必要
学習コスト中〜高(ユニオン型、型ガード、ブランド型)低い
向いているケースセキュリティが重要な業務システムプロトタイプ、小規模アプリ

実務での判断基準

検証の結果、以下の基準で判断しています。

  • 型安全な設計を採用すべきケース

    • 金融、医療、EC など、セキュリティが重要なシステム
    • 複数人で開発する中〜大規模プロジェクト
    • 権限管理が複雑で、頻繁に変更が発生する
  • 非型安全な設計でも許容できるケース

    • 個人開発の小規模アプリ
    • プロトタイプ開発(ただし本番化前に型安全化が必要)
    • レガシーシステムの段階的移行中

実際の業務では、最初は非型安全な設計で素早くプロトタイプを作り、要件が固まった段階で型安全化するアプローチも採用しています。ただし、セキュリティが重要な部分(認証・認可・決済)は最初から型安全にすることを推奨します。

まとめ

TypeScript で認証・認可システムをセキュアに設計する際は、ユニオン型・型ガード・ブランド型を活用することで、多くのセキュリティリスクを開発段階で防げます。しかし、型安全性はあくまで開発時の補助であり、実行時のバリデーション(Zod など)やセキュリティ対策(CSRF、XSS、SQL インジェクション対策)も必須です。

実際に業務で運用してみると、型安全性と実行時バリデーションの両方を組み合わせることで、コードレビュー時の指摘が減り、本番環境でのセキュリティインシデントも大幅に減少しました。ただし、学習コストや初期実装の時間は増えるため、プロジェクトの規模や重要度に応じて判断することが大切です。

認証・認可の設計は、一度実装すれば終わりではなく、新しい脅威や要件変更に応じて継続的に見直す必要があります。TypeScript の型システムを味方につけることで、その作業を少しでも安全かつ効率的に進められるのではないかと考えています。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;