Skip to content
Published on

OAuth2&JWT完全攻略:認証・認可の全て — 開発者のための実践ガイド

Authors

はじめに

認証(Authentication)と認可(Authorization)は、全てのWebアプリケーションの基盤であり、最も重要なセキュリティ要素です。OAuth2は2012年にRFC 6749として標準化されて以来、10年以上にわたり事実上の認可標準として定着し、JWTはステートレストークンの代名詞となりました。

しかし、多くの開発者がOAuth2フローを混同したり、JWTのセキュリティの落とし穴にはまることがあります。この記事では、認証と認可の違いからOAuth2の全フロー、JWT深堀り、実践実装(Spring Security、Next.js Auth.js、Go)、セキュリティ脆弱性と対策まで体系的に解説します。


1. 認証(Authentication)vs 認可(Authorization)

核心的な違い

区分認証(Authentication)認可(Authorization)
質問「あなたは誰ですか?」「何ができますか?」
目的ユーザー身元確認アクセス権限確認
タイミング先に実行認証後に実行
方法パスワード、生体認証、OTPロール、権限、ポリシー
プロトコルOIDC、SAML、WebAuthnOAuth2、RBAC、ABAC
例え身分証確認入室権限確認

現実世界の例え

空港セキュリティチェック:

1. 認証(Authentication):パスポート検査
   - 「この人は本当に田中太郎か?」
   - パスポート(パスワード)で身元確認

2. 認可(Authorization):搭乗券確認
   - 「田中太郎はこの飛行機に乗れるか?」
   - 搭乗券(権限)でアクセス範囲を決定
   - ビジネスラウンジアクセス?一般搭乗?

よくある間違い

間違った理解:
  「OAuth2は認証プロトコルである」 (X)
  「OAuth2は認可フレームワークである」 (O)

  OAuth2自体は「あなたは誰ですか?」に対する答えを提供しません。
  認証が必要ならOAuth2の上にOIDC(OpenID Connect)を使用する必要があります。

2. Session vs Tokenベース認証

Sessionベース認証

フロー:
1. ユーザーログイン(ID/PW)
2. サーバーがセッション作成(サーバーメモリまたはRedis)
3. セッションIDをクッキーでクライアントに送信
4. 毎リクエストでクッキーによりセッションID送信
5. サーバーがセッションストアからユーザー情報を検索
// Express + express-session例
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const redisClient = redis.createClient();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,       // HTTPSのみ
    httpOnly: true,      // JavaScriptアクセスブロック
    maxAge: 24 * 60 * 60 * 1000, // 24時間
    sameSite: 'lax',     // CSRF防止
  },
}));

// ログイン
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in successfully' });
});

// 認証ミドルウェア
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

Tokenベース認証

フロー:
1. ユーザーログイン(ID/PW)
2. サーバーがJWT作成・署名
3. JWTをクライアントに送信
4. 毎リクエストでAuthorizationヘッダーにJWTを含む
5. サーバーがJWT署名を検証(セッションストア不要)

比較表

特性SessionToken(JWT)
状態Stateful(サーバーにセッション保存)Stateless(トークンに情報含む)
保存場所サーバー(メモリ/Redis)+ クライアント(クッキー)クライアント(クッキー/localStorage)
スケーラビリティセッション同期必要(Sticky Session/Redis)優秀(署名検証のみ)
セキュリティセッションハイジャック注意トークン窃取、XSS注意
サイズセッションIDのみ(小さい)JWT全体(相対的に大きい)
無効化即座に可能(セッション削除)困難(期限まで有効、ブラックリスト必要)
複数サーバーRedis等の共有ストア必要サーバー間共有不要
モバイルクッキー管理が複雑Authorizationヘッダーで簡単
マイクロサービスセッション共有問題JWT伝播容易

いつ何を使うか?

Sessionを使う場合:
  - 伝統的なサーバーサイドレンダリングWebアプリ(MPA)
  - 即座のセッション無効化が重要な場合
  - 単一サーバーまたは少数のサーバー

Token(JWT)を使う場合:
  - SPA(React、Vue、Angular)
  - モバイルアプリ
  - マイクロサービスアーキテクチャ
  - サードパーティAPIアクセス
  - サーバーレス環境

3. OAuth2フレームワーク

4つの役割

1. Resource Owner(リソースオーナー)
   → ユーザー本人。「自分のデータへのアクセスを許可する主体」

2. Client(クライアント)
   → アクセスを要求するアプリケーション。「ユーザーデータが必要なアプリ」

3. Authorization Server(認可サーバー)
   → ユーザーを認証しトークンを発行。「Google、GitHub、Keycloak等」

4. Resource Server(リソースサーバー)
   → 保護されたリソースをホスティング。「APIサーバー」

OAuth2エンドポイント

エンドポイント目的
/authorizeユーザー認証と認可コード発行
/token認可コードをAccess Tokenに交換
/revokeトークン無効化
/introspectトークン有効性検査(RFC 7662)
/userinfoOIDCユーザー情報取得
/.well-known/openid-configurationOIDCディスカバリー文書

4. OAuth2フロー深堀り

Authorization Code Flow(サーバーアプリケーション用)

最も基本的で安全なフローです。サーバーサイドアプリケーションに適しています。

ステップバイステップフロー:

1. クライアント → 認可サーバー(認可リクエスト)
   GET /authorize?
     response_type=code&
     client_id=CLIENT_ID&
     redirect_uri=https://app.example.com/callback&
     scope=read write&
     state=random_csrf_token

2. ユーザーが認可サーバーでログイン・同意

3. 認可サーバー → クライアント(認可コード)
   302 Redirect: https://app.example.com/callback?
     code=AUTHORIZATION_CODE&
     state=random_csrf_token

4. クライアント → 認可サーバー(トークン交換)[バックチャネル、サーバー間]
   POST /token
     grant_type=authorization_code&
     code=AUTHORIZATION_CODE&
     redirect_uri=https://app.example.com/callback&
     client_id=CLIENT_ID&
     client_secret=CLIENT_SECRET

5. 認可サーバー → クライアント(トークンレスポンス)
   {
     "access_token": "eyJhbGciOiJSUzI...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "refresh_token": "dGhpcyBpcyBhIHJl...",
     "scope": "read write"
   }

Authorization Code + PKCE(SPA、モバイル用)

SPAとモバイルアプリはClient Secretを安全に保存できないため、PKCE(Proof Key for Code Exchange)を使用します。

PKCE追加ステップ:

1. クライアントでcode_verifier生成(43-128文字のランダム文字列)
2. code_challenge = Base64URL(SHA256(code_verifier))
3. 認可リクエストにcode_challenge + code_challenge_method=S256を含む
4. トークン交換時にcode_verifierを送信(サーバーが検証)
// PKCE実装(JavaScript)
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64URLEncode(array);
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(new Uint8Array(digest));
}

function base64URLEncode(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// 使用例
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

// 認可リクエスト
const authUrl = `https://auth.example.com/authorize?` +
  `response_type=code&` +
  `client_id=CLIENT_ID&` +
  `redirect_uri=https://app.example.com/callback&` +
  `scope=openid profile email&` +
  `code_challenge=${codeChallenge}&` +
  `code_challenge_method=S256&` +
  `state=${generateState()}`;

Client Credentials Flow(サーバー間通信)

ユーザーが関与しないサーバー間通信に使用します。

ステップ:

1. クライアント → 認可サーバー(直接トークンリクエスト)
   POST /token
     grant_type=client_credentials&
     client_id=SERVICE_A_ID&
     client_secret=SERVICE_A_SECRET&
     scope=api.read

2. 認可サーバー → クライアント(Access Token)
   {
     "access_token": "eyJhbGciOiJSUzI...",
     "token_type": "Bearer",
     "expires_in": 3600
   }
   (Refresh Tokenなし — 期限切れ時に再リクエスト)
# PythonでClient Credentialsリクエスト
import requests

response = requests.post('https://auth.example.com/token', data={
    'grant_type': 'client_credentials',
    'client_id': 'service-a',
    'client_secret': 'secret-value',
    'scope': 'api.read api.write',
})

token = response.json()['access_token']

Device Code Flow(IoT、CLI)

キーボード入力が難しいデバイス(スマートTV、CLIツール)に使用します。

ステップ:

1. デバイス → 認可サーバー(Device Codeリクエスト)
   POST /device/code
     client_id=TV_APP_ID&scope=openid profile

2. 認可サーバー → デバイス(Device Code + User Code)
   {
     "device_code": "DEVICE_CODE_HERE",
     "user_code": "ABCD-1234",
     "verification_uri": "https://auth.example.com/device",
     "expires_in": 1800,
     "interval": 5
   }

3. デバイスが画面に表示:「https://auth.example.com/device でABCD-1234を入力してください」

4. ユーザーが別のデバイス(スマホ、PC)でURL接続 → コード入力 → ログイン → 同意

5. デバイスが定期的にトークンをポーリング
   POST /token
     grant_type=urn:ietf:params:oauth:grant-type:device_code&
     device_code=DEVICE_CODE_HERE&
     client_id=TV_APP_ID

6. 認可完了後Access Token受信

Implicit Flow(非推奨)

Implicit Flowが廃止された理由:

1. Access TokenがURL Fragmentに露出(#access_token=...)
2. ブラウザ履歴にトークン記録
3. Referrerヘッダーによるトークン漏洩
4. トークン窃取攻撃に脆弱
5. Refresh Token使用不可

代替:Authorization Code + PKCE
   → ブラウザベースのアプリもこの方式を使用すべきです。
   → OAuth 2.1ドラフトでImplicit Flowは公式に削除

フロー選択ガイド

アプリケーションタイプ推奨フロー
サーバーサイドWebアプリ(Node、Spring、Django)Authorization Code
SPA(React、Vue、Angular)Authorization Code + PKCE
モバイルアプリ(iOS、Android)Authorization Code + PKCE
サーバー間(マイクロサービス、バッチ)Client Credentials
IoT、スマートTV、CLIDevice Code
レガシー(使用禁止)Implicit(廃止)

5. JWT深堀り

JWT構造

JWTはドット(.)で区切られた3つの部分で構成されます。

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A29DNSl0EiXLdwJ6xC6AfgZWF1bOsS...
[Header].[Payload].[Signature]

Header(Base64URL):
{
  "alg": "RS256",    // 署名アルゴリズム
  "typ": "JWT",      // トークンタイプ
  "kid": "key-id-1"  // キーID(キーローテーション時)
}

Payload(Base64URL):
{
  "sub": "1234567890",     // Subject(ユーザーID)
  "name": "John Doe",
  "email": "john@example.com",
  "role": "admin",
  "iss": "https://auth.example.com",  // Issuer(発行者)
  "aud": "https://api.example.com",   // Audience(受信者)
  "iat": 1516239022,       // Issued At(発行時間)
  "exp": 1516242622,       // Expiration(有効期限)
  "jti": "unique-token-id" // JWT ID(一意識別子)
}

Signature:
RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  privateKey
)

署名アルゴリズム比較

アルゴリズムタイプキー使用時期
HS256対称一つの秘密鍵単一サーバー、内部システム
RS256非対称公開鍵 + 秘密鍵マイクロサービス、外部検証
ES256非対称(ECDSA)公開鍵 + 秘密鍵モバイル、パフォーマンス重要時
EdDSA非対称(Ed25519)公開鍵 + 秘密鍵最新、高パフォーマンス、高セキュリティ
HS256 vs RS256:

HS256(対称):
  - 署名:secretKeyで署名
  - 検証:同じsecretKeyで検証
  - 問題:全サービスがsecretKeyを知る必要あり → 漏洩リスク

RS256(非対称):
  - 署名:privateKeyで署名(認可サーバーのみ保有)
  - 検証:publicKeyで検証(誰でも可能)
  - 長所:privateKey漏洩なしに他サービスがトークン検証可能
  - 推奨:マイクロサービス環境

JWT検証コード

// Node.js JWT検証(jsonwebtoken)
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// JWKS(JSON Web Key Set)から公開鍵取得
const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,
  rateLimit: true,
});

function getSigningKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

// JWT検証ミドルウェア
function verifyToken(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.substring(7);

  jwt.verify(token, getSigningKey, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  }, (err, decoded) => {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired' });
      }
      return res.status(401).json({ error: 'Invalid token' });
    }
    req.user = decoded;
    next();
  });
}

JWT注意事項

絶対にやってはいけないこと:
  1. JWTペイロードにパスワード、個人情報を保存(Base64は暗号化ではない!)
  2. "alg": "none"を許可(未署名トークンの受け入れ)
  3. 署名アルゴリズム変更攻撃を無視(RS256 → HS256ダウングレード)
  4. expクレームなしでトークン発行(永久に有効なトークン)
  5. トークンサイズを無限に大きくする(毎リクエストで送信される)

必ずやるべきこと:
  1. 常に署名検証(アルゴリズム固定)
  2. exp、iss、audクレームを検証
  3. HTTPSのみ使用
  4. 適切な有効期限を設定
  5. 必要最小限のクレームのみ含む

6. Access Token + Refresh Token戦略

トークンライフサイクル

Access Token:
  - 短い寿命:15分〜1時間
  - 毎APIリクエストで使用
  - 期限切れ時はRefresh Tokenで更新

Refresh Token:
  - 長い寿命:7日〜30日
  - Access Token更新にのみ使用
  - より厳格に保護

トークン保存場所

保存場所XSS安全CSRF安全推奨
httpOnly CookieO(JSアクセス不可)SameSiteで防止推奨
localStorageX(JSアクセス可能)O(自動送信なし)非推奨
sessionStorageXO非推奨
メモリ(JS変数)O(永続保存なし)O補助的使用
// 推奨:httpOnlyクッキーでトークン保存(サーバー側)
res.cookie('access_token', accessToken, {
  httpOnly: true,    // JavaScriptアクセス不可
  secure: true,      // HTTPSでのみ送信
  sameSite: 'lax',   // CSRF防止
  maxAge: 15 * 60 * 1000, // 15分
  path: '/',
});

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7日
  path: '/api/auth/refresh', // refreshエンドポイントでのみ送信
});

Refresh Token Rotation

通常方式:
  Refresh Token A → 新Access Token発行(Refresh Token A維持)
  問題:Refresh Tokenが窃取された場合、攻撃者が継続使用可能

Rotation方式:
  Refresh Token A → 新Access Token + 新Refresh Token B発行
                    (Refresh Token A無効化)
  利点:窃取されたRefresh Token使用時に即座に検知可能

検知メカニズム:
  1. 既に使用されたRefresh Token Aでリクエスト
  2. サーバー:「このトークンは既に交換済み → 窃取の可能性!」
  3. そのユーザーの全Refresh Tokenを無効化
  4. ユーザーに再ログインを要求
// Refresh Token Rotation実装
async function refreshTokenHandler(req, res) {
  const { refresh_token } = req.body;

  // DBでRefresh Tokenを検索
  const storedToken = await db.refreshTokens.findOne({ token: refresh_token });

  if (!storedToken) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // 既に使用されたトークンか確認(再利用検知)
  if (storedToken.used) {
    // 窃取の可能性 → そのユーザーの全トークンを無効化
    await db.refreshTokens.deleteMany({ userId: storedToken.userId });
    return res.status(401).json({ error: 'Token reuse detected. All sessions revoked.' });
  }

  // 既存トークンを使用済みとしてマーク
  await db.refreshTokens.updateOne(
    { token: refresh_token },
    { used: true, usedAt: new Date() }
  );

  // 新トークン発行
  const newAccessToken = generateAccessToken(storedToken.userId);
  const newRefreshToken = generateRefreshToken();

  await db.refreshTokens.insertOne({
    token: newRefreshToken,
    userId: storedToken.userId,
    parentToken: refresh_token,
    used: false,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  });

  res.json({
    access_token: newAccessToken,
    refresh_token: newRefreshToken,
  });
}

7. OpenID Connect(OIDC)

OIDCとは?

OIDCはOAuth2の上に認証(Authentication)レイヤーを追加したプロトコルです。OAuth2が「何ができるか」(認可)に集中するのに対し、OIDCは「誰であるか」(認証)を提供します。

ID Token

OIDCの核心はID Token(JWT)です。

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "my-client-id",
  "exp": 1678886400,
  "iat": 1678882800,
  "nonce": "random-nonce-value",
  "name": "John Doe",
  "email": "john@gmail.com",
  "email_verified": true,
  "picture": "https://lh3.googleusercontent.com/photo.jpg"
}

OIDC Scopes

Scope含まれるクレーム
openidsub(必須)
profilename、family_name、given_name、picture等
emailemail、email_verified
addressaddress
phonephone_number、phone_number_verified

Access Token vs ID Token

特性Access TokenID Token
目的APIアクセス認可ユーザー認証
受信者リソースサーバー(API)クライアントアプリ
含む情報scope、権限ユーザープロフィール
検証リソースサーバーでクライアントで
APIへ送信OX(絶対にAPIに送らないこと)

8. 実践実装

Spring Security OAuth2

// Spring Boot 3 + Spring Security 6設定
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthoritiesClaimName("roles");
        converter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
        return jwtConverter;
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri(
            "https://auth.example.com/.well-known/jwks.json"
        ).build();
    }
}

Next.js Auth.js v5

// auth.ts(Auth.js v5設定)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    Google({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
    }),
    Credentials({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const user = await verifyCredentials(
          credentials.email as string,
          credentials.password as string,
        );
        return user || null;
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user, account }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      if (account) {
        token.accessToken = account.access_token;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id as string;
      session.user.role = token.role as string;
      return session;
    },
  },
  pages: {
    signIn: '/login',
    error: '/auth/error',
  },
  session: {
    strategy: 'jwt',
    maxAge: 24 * 60 * 60, // 24時間
  },
});
// middleware.ts
import { auth } from './auth';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard');

  if (isProtected && !isLoggedIn) {
    return Response.redirect(new URL('/login', req.nextUrl));
  }
});

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};

Go + OAuth2

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

var (
    oauth2Config *oauth2.Config
    oidcVerifier *oidc.IDTokenVerifier
)

func init() {
    ctx := context.Background()
    provider, _ := oidc.NewProvider(ctx, "https://accounts.google.com")

    oauth2Config = &oauth2.Config{
        ClientID:     "CLIENT_ID",
        ClientSecret: "CLIENT_SECRET",
        RedirectURL:  "http://localhost:8080/callback",
        Endpoint:     provider.Endpoint(),
        Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
    }

    oidcVerifier = provider.Verifier(&oidc.Config{
        ClientID: "CLIENT_ID",
    })
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    state := generateState()
    http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    code := r.URL.Query().Get("code")

    // トークン交換
    token, err := oauth2Config.Exchange(ctx, code)
    if err != nil {
        http.Error(w, "Token exchange failed", http.StatusInternalServerError)
        return
    }

    // ID Token抽出・検証
    rawIDToken, ok := token.Extra("id_token").(string)
    if !ok {
        http.Error(w, "No ID token", http.StatusInternalServerError)
        return
    }

    idToken, err := oidcVerifier.Verify(ctx, rawIDToken)
    if err != nil {
        http.Error(w, "ID token verification failed", http.StatusInternalServerError)
        return
    }

    // クレーム抽出
    var claims struct {
        Email   string `json:"email"`
        Name    string `json:"name"`
        Picture string `json:"picture"`
    }
    idToken.Claims(&claims)

    json.NewEncoder(w).Encode(claims)
}

func main() {
    http.HandleFunc("/login", handleLogin)
    http.HandleFunc("/callback", handleCallback)
    fmt.Println("Server running on :8080")
    http.ListenAndServe(":8080", nil)
}

9. セキュリティ脆弱性と対策

CSRF(Cross-Site Request Forgery)

攻撃:
  ユーザーがログイン状態で悪意あるサイトを訪問
  → 悪意あるサイトがユーザーのクッキーを利用してAPIリクエスト

対策:
  1. SameSiteクッキー属性設定(LaxまたはStrict)
  2. CSRFトークン使用
  3. Origin/Refererヘッダー検証
  4. Authorizationヘッダー使用(クッキーの代わりに)

XSSによるトークン窃取

攻撃:
  XSS脆弱性を通じてJavaScript実行
  → localStorageのトークン窃取

対策:
  1. httpOnlyクッキーにトークン保存(JavaScriptアクセス不可)
  2. CSP(Content Security Policy)ヘッダー設定
  3. 入力値サニタイジング
  4. DOMPurify等のXSS防止ライブラリ使用

トークン漏洩

攻撃:
  URLパラメータにトークンを含む
  → Referrerヘッダー、ブラウザ履歴、サーバーログから漏洩

対策:
  1. トークンをURLに含めない
  2. AuthorizationヘッダーまたはhttpOnlyクッキー使用
  3. Referrer-Policyヘッダー設定

リプレイ攻撃

攻撃:
  有効なトークンを傍受して再利用

対策:
  1. 短いトークン有効期限
  2. jti(JWT ID)クレームで一回性確認
  3. nonce使用(OIDC)
  4. トークンバインディング(DPoP — Demonstration of Proof-of-Possession)

JWTアルゴリズム混同攻撃

攻撃:
  RS256環境で攻撃者がalgをHS256に変更
  → 公開鍵をHS256の秘密鍵として使用して署名偽造

対策:
  1. サーバーで許可アルゴリズムを固定(algorithms: ['RS256'])
  2. algクレームを信頼しない
  3. 最新のJWTライブラリを使用

OWASP Top 10認証関連

A07:2021 — Identification and Authentication Failures

チェックリスト:
  1. 弱いパスワードを許可しない
  2. ブルートフォース攻撃防止(アカウントロック、Rate Limiting)
  3. 多要素認証(MFA)サポート
  4. セッション固定攻撃防止(ログイン時にセッションID変更)
  5. 安全なパスワード保存(bcrypt、Argon2)
  6. トークン/セッション有効期限の実装
  7. ログアウト時のトークン/セッション完全無効化

10. プロダクションチェックリスト

セキュリティ基本:
  [ ] HTTPS必須(HTTPリダイレクト)
  [ ] 適切なトークン有効期限(Access: 15分、Refresh: 7日)
  [ ] httpOnly + Secure + SameSiteクッキー使用
  [ ] CORS正しく設定

トークン管理:
  [ ] Refresh Token Rotation適用
  [ ] トークンブラックリスト/無効化メカニズム
  [ ] JWT署名アルゴリズム固定(RS256/ES256)
  [ ] キーローテーション計画策定

入力検証:
  [ ] redirect_uriホワイトリスト検証
  [ ] stateパラメータでCSRF防止
  [ ] PKCE適用(SPA、モバイル)
  [ ] 入力値サニタイジング

モニタリング:
  [ ] ログイン失敗モニタリングとアラート
  [ ] 異常なトークン使用パターン検知
  [ ] Rate Limiting適用
  [ ] 監査ログ記録

インフラ:
  [ ] シークレット管理(AWS Secrets Manager、Vault)
  [ ] 環境変数で機密情報管理
  [ ] 定期的なセキュリティ監査

11. 面接質問15選

Q1. OAuth2でAuthorization Code FlowがImplicit Flowより安全な理由は?

Authorization Code Flowは認可コードをバックチャネル(サーバー間)でトークンに交換するため、トークンがブラウザに露出しません。Implicit FlowはトークンがURL Fragmentに直接露出し、ブラウザ履歴、Referrerヘッダー、中間者攻撃に脆弱です。

Q2. JWTのペイロードは暗号化されていますか?

いいえ。JWTのペイロードはBase64URLでエンコードされているだけなので、誰でもデコードできます。署名は完全性を保証しますが機密性は保証しません。ペイロードの暗号化が必要な場合はJWE(JSON Web Encryption)を使用する必要があります。

Q3. PKCEはなぜ必要ですか?

SPAとモバイルアプリはClient Secretを安全に保存できません。PKCEは動的に生成されたcode_verifier/code_challengeペアを使用し、認可コードが傍受されてもトークンに交換できないようにします。認可コード傍受攻撃を防止します。

Q4. Access Tokenの適切な有効期限は?

一般的に15分から1時間です。短いほどセキュリティに良いですがユーザー体験が悪化します(頻繁な更新)。Refresh Tokenと共に使用してUXとセキュリティのバランスを取ります。非常に機密性の高い操作(銀行)は5分、一般アプリは15〜30分が適切です。

Q5. SessionベースとTokenベース認証の違いは?

Sessionベースはサーバーに状態を保存(Stateful)し、セッションIDのみクライアントに渡します。即座の無効化が可能ですが、スケーリング時にセッション同期が必要です。Tokenベースはトークンに情報を含み(Stateless)サーバーストレージ不要。スケーラビリティに優れますが即座の無効化が困難です。

Q6. Refresh Token Rotationとは?

毎回のRefresh Token使用時に新しいRefresh Tokenを発行し、既存のものを無効化する技法です。窃取されたRefresh Tokenが使用されると既に交換済みのトークンなので検知でき、そのユーザーの全トークンを無効化して被害を最小化できます。

Q7. OAuth2とOIDCの違いは?

OAuth2はリソースアクセス権限を付与する認可フレームワークです。OIDCはOAuth2の上に認証レイヤーを追加したプロトコルで、ID Tokenを通じてユーザーの身元を確認します。OAuth2だけでは誰であるか分かりませんが、OIDCでは分かります。

Q8. JWTをlocalStorageに保存してはいけない理由は?

localStorageはJavaScriptでアクセス可能なため、XSS攻撃時にトークンが窃取されます。httpOnlyクッキーに保存するとJavaScriptからアクセスできなくなり、XSSから保護されます。SameSite属性でCSRFも防止できます。

Q9. HS256とRS256の違いと使用時期は?

HS256は対称鍵アルゴリズムで一つの秘密鍵で署名と検証をします。単一サーバー環境に適しています。RS256は非対称鍵アルゴリズムで秘密鍵で署名し公開鍵で検証します。マイクロサービスで認可サーバーのみ秘密鍵を持ち、他サービスは公開鍵で検証できるため安全です。

Q10. CORSとOAuth2の関係は?

SPAからOAuth2トークンエンドポイントにリクエストする際、CORS設定が必要です。認可サーバーがSPAのOriginを許可する必要があります。ただしAuthorization Code Flowではトークン交換はバックエンドで行うためCORS問題はありません。PKCEを使用するSPAでフロントから直接トークンリクエストする場合はCORS設定が必須です。

Q11. ID TokenをAPIサーバーに送信しても良いですか?

いけません。ID Tokenはクライアントアプリでユーザー身元確認のためのもので、APIアクセスにはAccess Tokenを使用すべきです。ID Tokenのaudience(aud)はクライアントアプリであり、APIサーバーではありません。混用するとセキュリティ問題が発生します。

Q12. Client Credentials Flowはいつ使いますか?

ユーザーが関与しないサーバー間通信(Machine-to-Machine)で使用します。例:マイクロサービス間API呼び出し、バッチ処理、クロンジョブ。ユーザーコンテキストがないためRefresh Tokenがなく、期限切れ時に再リクエストします。

Q13. JWTアルゴリズム混同攻撃とは?

RS256で署名された環境で攻撃者がJWTのalgヘッダーをHS256に変更し、公開鍵(公開情報)をHS256の秘密鍵として使用して有効な署名を作る攻撃です。サーバーがalgクレームを信頼すると偽造トークンを受け入れます。対策:サーバーで許可アルゴリズムを固定します。

Q14. トークン無効化(Revocation)はどう実装しますか?

JWTはStatelessなのでサーバーから直接無効化できません。方法:1)トークンブラックリスト(Redisに無効化されたjtiを保存)、2)短い有効期限 + Refresh Token無効化、3)トークンバージョン(ユーザー別token_versionフィールド、バージョン不一致時に拒否)。2番目が最も実用的です。

Q15. DPoP(Demonstration of Proof-of-Possession)とは?

トークンが窃取されても攻撃者が使用できないよう、トークン使用時にクライアントが秘密鍵で署名した証明を一緒に提出するメカニズムです。Access TokenとDPoP Proofを一緒に提出してトークン所有者のみが使用できるようにします。RFC 9449で定義されています。


12. クイズ

Q1. OAuth2の4つの役割は何ですか?

Resource Owner(リソースオーナー - ユーザー)、Client(クライアント - アプリ)、Authorization Server(認可サーバー - トークン発行)、Resource Server(リソースサーバー - API)です。

Q2. JWTの3つの構成要素は?

Header(アルゴリズム、タイプ情報)、Payload(クレーム - ユーザー情報、有効期限等)、Signature(署名 - 完全性検証)です。それぞれBase64URLでエンコードされ、ドット(.)で区切られます。

Q3. Refresh Tokenはなぜ必要ですか?

Access Tokenの有効期限を短く維持しながらもユーザー体験を損なわないためです。Access Tokenが期限切れになるとRefresh Tokenで新しいAccess Tokenを取得し、ユーザーが再ログインしなくても済みます。

Q4. OIDCのID TokenとAccess Tokenの違いは?

ID Tokenはユーザー認証情報を含むJWTで、クライアントアプリでユーザー身元確認に使用します。Access TokenはAPIアクセス権限を表し、リソースサーバーに送信します。ID TokenをAPIサーバーに送ってはいけません。

Q5. stateパラメータの役割は?

CSRF(Cross-Site Request Forgery)攻撃を防止します。認可リクエスト時にランダム値をstateとして含め、コールバックで同じ値が返されるか確認します。攻撃者が偽造した認可レスポンスを区別できます。


参考資料

  • RFC 6749:The OAuth 2.0 Authorization Framework
  • RFC 7519:JSON Web Token(JWT)
  • RFC 7636:Proof Key for Code Exchange(PKCE)
  • RFC 7662:OAuth 2.0 Token Introspection
  • RFC 9449:OAuth 2.0 Demonstrating Proof of Possession(DPoP)
  • RFC 9457:Problem Details for HTTP APIs
  • OpenID Connect Core 1.0
  • OAuth 2.1 Draft
  • OWASP Authentication Cheat Sheet
  • Auth0 Documentation
  • Keycloak Documentation

まとめ

認証と認可は全てのアプリケーションセキュリティの核心です。OAuth2とJWTは強力ですが、正しく実装しなければむしろセキュリティ脆弱性になります。

キーポイントの振り返り:

  1. OAuth2は認可フレームワーク — 認証が必要ならOIDCを使用
  2. Authorization Code + PKCEがSPA/モバイルの標準
  3. JWTペイロードは暗号化ではなくエンコード — 機密情報保存禁止
  4. トークンはhttpOnlyクッキーに保存 — localStorageはXSSに脆弱
  5. Refresh Token Rotation適用で窃取検知
  6. 署名アルゴリズム固定(RS256/ES256)— アルゴリズム混同攻撃防止
  7. 短いAccess Token寿命 + Refresh Tokenでバランス

このガイドを基に安全でユーザーフレンドリーな認証システムを構築しましょう。