Laravel Sanctum

認証LaravelPHPSPAAPIトークンモバイルセッション

ライブラリ

Laravel Sanctum

概要

Laravel Sanctumは、SPA(シングルページアプリケーション)、モバイルアプリケーション、シンプルなトークンベースAPIのための軽量認証システムです。2025年現在、LaravelエコシステムにおけるAPI認証の標準的選択肢として確固たる地位を築いています。OAuthの複雑さを排除しつつ、SPA向けのセッション認証とAPIトークン認証の両方をサポートする二重機能により、開発者に柔軟性と簡単さを提供します。CSRF保護、セッション認証、XSS攻撃に対する防御を統合した包括的なセキュリティソリューションです。

詳細

Laravel Sanctum 2025は、Laravel 12.xに完全統合され、php artisan install:apiコマンドでの簡単インストールが可能です。SPAではLaravelの組み込みクッキーベースセッション認証を使用し、サードパーティクライアント(モバイルアプリ)にはAPIトークンを提供する二重認証モードを採用しています。リクエストの送信元を自動判定し、SPAからの場合はクッキー認証、APIからの場合はAuthorizationヘッダーのトークン認証を適用します。トークンには能力(abilities)という概念でスコープを定義でき、細かい権限制御が可能です。また、トークンの有効期限設定や自動削除スケジューリングもサポートしています。

主な特徴

  • 二重認証モード: SPAのセッション認証とモバイル向けAPIトークン認証を統合
  • 簡単セットアップ: install:apiコマンドによる即座のプロジェクト準備
  • CSRF保護: SPA向けの包括的なクロスサイトリクエストフォージェリ対策
  • トークン能力管理: OAuth2スコープライクな細粒度権限制御
  • 自動認証判定: リクエスト送信元による認証方式の自動選択
  • Laravel統合: Eloquentモデル、ミドルウェア、コマンドの完全統合

メリット・デメリット

メリット

  • OAuthの複雑性なしでAPIトークン認証を簡単に実装可能
  • SPAとモバイルアプリの両方に対応する柔軟なアーキテクチャ
  • Laravelエコシステムとの完全統合で学習コストが最小限
  • CSRF保護とXSS攻撃防御による高いセキュリティレベル
  • 個人アクセストークンによる細かい権限管理とユーザー体験向上
  • Laravel Breezeやjetstream等のスターターキットとの完全互換性

デメリット

  • Laravel専用のためPHPフレームワーク以外では使用不可
  • 大規模なマイクロサービス環境では機能が限定的
  • OAuth2のような標準化されたプロトコルと比較してエコシステムが限定
  • 複雑なエンタープライズ認証要件(SAML、LDAP)には対応困難
  • トークンリフレッシュ機能が標準では提供されない
  • マルチテナント認証やロールベースアクセス制御の実装が別途必要

参考ページ

書き方の例

基本的なセットアップとインストール

# Laravel Sanctum のインストール
php artisan install:api

# データベースマイグレーション実行
php artisan migrate

# Sanctum設定ファイルの公開(オプション)
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# 期限切れトークンの定期削除スケジューリング
php artisan schedule:work

UserモデルでのAPIトークン設定

<?php
// app/Models/User.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    // トークン能力の定義
    public function getAllowedAbilities(): array
    {
        return [
            'read-posts',
            'create-posts',
            'update-posts',
            'delete-posts',
            'manage-users'
        ];
    }

    // 管理者権限チェック
    public function isAdmin(): bool
    {
        return $this->email === '[email protected]';
    }
}

SPA認証の設定(フロントエンド・バックエンド統合)

// config/sanctum.php の設定
<?php

return [
    // ステートフル(SPA)ドメインの設定
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort()
    ))),

    // セッション使用時のガード
    'guard' => ['web'],

    // トークンの有効期限(分単位、nullで無期限)
    'expiration' => null,

    // ミドルウェア設定
    'middleware' => [
        'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
        'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
    ],
];

// app/Http/Kernel.php での追加設定
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    protected $middlewareGroups = [
        'api' => [
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];
}

// SPA向け認証ルート(routes/web.php)
use App\Http\Controllers\Auth\AuthController;

Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum');

// CSRF保護エンドポイント
Route::get('/sanctum/csrf-cookie', function () {
    return response()->json(['message' => 'CSRF cookie set']);
});

モバイルアプリ向けAPIトークン認証

// app/Http/Controllers/Auth/TokenController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class TokenController extends Controller
{
    // トークン発行(ログイン)
    public function issueToken(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
            'device_name' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['認証情報が正しくありません。'],
            ]);
        }

        // 能力を持つトークンの作成
        $abilities = $user->isAdmin() 
            ? ['*']  // 管理者は全権限
            : ['read-posts', 'create-posts', 'update-posts'];

        $token = $user->createToken(
            $request->device_name, 
            $abilities,
            now()->addWeek() // 1週間の有効期限
        )->plainTextToken;

        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'user' => $user,
            'abilities' => $abilities
        ]);
    }

    // トークン無効化(ログアウト)
    public function revokeToken(Request $request)
    {
        // 現在のトークンを無効化
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'ログアウトしました'
        ]);
    }

    // 全トークン無効化
    public function revokeAllTokens(Request $request)
    {
        // 全てのトークンを無効化
        $request->user()->tokens()->delete();

        return response()->json([
            'message' => '全デバイスからログアウトしました'
        ]);
    }

    // ユーザーのトークン一覧
    public function listTokens(Request $request)
    {
        $tokens = $request->user()->tokens()->get();

        return response()->json([
            'tokens' => $tokens->map(function ($token) {
                return [
                    'id' => $token->id,
                    'name' => $token->name,
                    'abilities' => $token->abilities,
                    'last_used_at' => $token->last_used_at,
                    'created_at' => $token->created_at,
                    'expires_at' => $token->expires_at,
                ];
            })
        ]);
    }

    // 特定トークンの削除
    public function revokeSpecificToken(Request $request, $tokenId)
    {
        $request->user()->tokens()->where('id', $tokenId)->delete();

        return response()->json([
            'message' => 'トークンが削除されました'
        ]);
    }
}

// routes/api.php
use App\Http\Controllers\Auth\TokenController;

Route::post('/sanctum/token', [TokenController::class, 'issueToken']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/sanctum/revoke', [TokenController::class, 'revokeToken']);
    Route::post('/sanctum/revoke-all', [TokenController::class, 'revokeAllTokens']);
    Route::get('/sanctum/tokens', [TokenController::class, 'listTokens']);
    Route::delete('/sanctum/tokens/{tokenId}', [TokenController::class, 'revokeSpecificToken']);
});

能力(Abilities)によるアクセス制御

// app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        // 読み取り権限チェック
        if ($request->user()->tokenCant('read-posts')) {
            return response()->json(['error' => 'Unauthorized'], 403);
        }

        $posts = Post::paginate(10);

        return response()->json($posts);
    }

    public function store(Request $request)
    {
        // 作成権限チェック
        if ($request->user()->tokenCant('create-posts')) {
            return response()->json(['error' => 'Insufficient permissions'], 403);
        }

        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'content' => 'required|string',
        ]);

        $post = $request->user()->posts()->create($validated);

        return response()->json($post, 201);
    }

    public function update(Request $request, Post $post)
    {
        // 更新権限と所有者チェック
        if ($request->user()->tokenCant('update-posts') || 
            $post->user_id !== $request->user()->id) {
            return response()->json(['error' => 'Unauthorized'], 403);
        }

        $validated = $request->validate([
            'title' => 'sometimes|required|string|max:255',
            'content' => 'sometimes|required|string',
        ]);

        $post->update($validated);

        return response()->json($post);
    }

    public function destroy(Request $request, Post $post)
    {
        // 削除権限チェック(管理者または投稿者)
        $canDelete = $request->user()->tokenCan('delete-posts') || 
                    ($post->user_id === $request->user()->id && $request->user()->tokenCan('update-posts'));

        if (!$canDelete) {
            return response()->json(['error' => 'Unauthorized'], 403);
        }

        $post->delete();

        return response()->json(['message' => '投稿が削除されました']);
    }
}

// routes/api.php での能力ミドルウェア使用
use App\Http\Controllers\PostController;

Route::middleware('auth:sanctum')->group(function () {
    // 複数の能力が必要(AND条件)
    Route::get('/admin/users', function () {
        return response()->json(['users' => User::all()]);
    })->middleware('abilities:manage-users,read-posts');

    // いずれかの能力が必要(OR条件)
    Route::get('/posts', [PostController::class, 'index'])
        ->middleware('ability:read-posts,admin');

    Route::post('/posts', [PostController::class, 'store']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
    Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});

React SPA での Sanctum 認証実装

// frontend/src/api/auth.js
import axios from 'axios'

// Axios の設定
axios.defaults.withCredentials = true
axios.defaults.withXSRFToken = true

const API_BASE_URL = 'http://localhost:8000'

// CSRF クッキーの取得
export const getCsrfCookie = async () => {
  await axios.get(`${API_BASE_URL}/sanctum/csrf-cookie`)
}

// ログイン
export const login = async (email, password) => {
  await getCsrfCookie()
  
  const response = await axios.post(`${API_BASE_URL}/login`, {
    email,
    password
  })
  
  return response.data
}

// ログアウト
export const logout = async () => {
  const response = await axios.post(`${API_BASE_URL}/logout`)
  return response.data
}

// ユーザー情報取得
export const getUser = async () => {
  const response = await axios.get(`${API_BASE_URL}/user`)
  return response.data
}

// 投稿一覧取得
export const getPosts = async () => {
  const response = await axios.get(`${API_BASE_URL}/api/posts`)
  return response.data
}

// 投稿作成
export const createPost = async (postData) => {
  const response = await axios.post(`${API_BASE_URL}/api/posts`, postData)
  return response.data
}

// frontend/src/hooks/useAuth.js
import { useState, useEffect, createContext, useContext } from 'react'
import * as authAPI from '../api/auth'

const AuthContext = createContext()

export const useAuth = () => {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    checkAuth()
  }, [])

  const checkAuth = async () => {
    try {
      const userData = await authAPI.getUser()
      setUser(userData)
    } catch (error) {
      setUser(null)
    } finally {
      setLoading(false)
    }
  }

  const login = async (email, password) => {
    try {
      await authAPI.login(email, password)
      await checkAuth()
      return { success: true }
    } catch (error) {
      return { 
        success: false, 
        error: error.response?.data?.message || 'ログインに失敗しました' 
      }
    }
  }

  const logout = async () => {
    try {
      await authAPI.logout()
      setUser(null)
    } catch (error) {
      console.error('Logout error:', error)
    }
  }

  const value = {
    user,
    login,
    logout,
    loading,
    isAuthenticated: !!user
  }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

// frontend/src/components/LoginForm.jsx
import React, { useState } from 'react'
import { useAuth } from '../hooks/useAuth'

export const LoginForm = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)
  const { login } = useAuth()

  const handleSubmit = async (e) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    const result = await login(email, password)
    
    if (!result.success) {
      setError(result.error)
    }
    
    setLoading(false)
  }

  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>ログイン</h2>
      
      {error && <div className="error-message">{error}</div>}
      
      <div className="form-group">
        <label htmlFor="email">メールアドレス</label>
        <input
          type="email"
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      
      <div className="form-group">
        <label htmlFor="password">パスワード</label>
        <input
          type="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      
      <button type="submit" disabled={loading}>
        {loading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

モバイルアプリ(React Native)での実装例

// mobile/src/services/authService.js
import AsyncStorage from '@react-native-async-storage/async-storage'

const API_BASE_URL = 'https://api.example.com'

class AuthService {
  constructor() {
    this.token = null
  }

  // ストレージからトークンを読み込み
  async loadToken() {
    try {
      this.token = await AsyncStorage.getItem('access_token')
      return this.token
    } catch (error) {
      console.error('Failed to load token:', error)
      return null
    }
  }

  // トークンをストレージに保存
  async saveToken(token) {
    try {
      this.token = token
      await AsyncStorage.setItem('access_token', token)
    } catch (error) {
      console.error('Failed to save token:', error)
    }
  }

  // トークンを削除
  async removeToken() {
    try {
      this.token = null
      await AsyncStorage.removeItem('access_token')
    } catch (error) {
      console.error('Failed to remove token:', error)
    }
  }

  // ログイン
  async login(email, password, deviceName) {
    try {
      const response = await fetch(`${API_BASE_URL}/sanctum/token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          email,
          password,
          device_name: deviceName
        })
      })

      if (!response.ok) {
        const errorData = await response.json()
        throw new Error(errorData.message || 'ログインに失敗しました')
      }

      const data = await response.json()
      await this.saveToken(data.access_token)
      
      return { success: true, user: data.user }
    } catch (error) {
      return { success: false, error: error.message }
    }
  }

  // ログアウト
  async logout() {
    try {
      if (this.token) {
        await fetch(`${API_BASE_URL}/api/sanctum/revoke`, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${this.token}`,
          }
        })
      }
    } catch (error) {
      console.error('Logout error:', error)
    } finally {
      await this.removeToken()
    }
  }

  // API リクエスト
  async apiRequest(endpoint, options = {}) {
    await this.loadToken()

    const config = {
      headers: {
        'Content-Type': 'application/json',
        ...(this.token && { 'Authorization': `Bearer ${this.token}` }),
        ...options.headers
      },
      ...options
    }

    const response = await fetch(`${API_BASE_URL}${endpoint}`, config)

    if (response.status === 401) {
      await this.removeToken()
      throw new Error('認証が切れました。再度ログインしてください。')
    }

    if (!response.ok) {
      const errorData = await response.json()
      throw new Error(errorData.message || 'リクエストに失敗しました')
    }

    return response.json()
  }

  // ユーザー情報取得
  async getUser() {
    return this.apiRequest('/api/user')
  }

  // 投稿一覧取得
  async getPosts() {
    return this.apiRequest('/api/posts')
  }

  // 投稿作成
  async createPost(postData) {
    return this.apiRequest('/api/posts', {
      method: 'POST',
      body: JSON.stringify(postData)
    })
  }
}

export default new AuthService()

// mobile/src/hooks/useAuth.js
import React, { createContext, useContext, useEffect, useState } from 'react'
import authService from '../services/authService'
import { Platform } from 'react-native'

const AuthContext = createContext()

export const useAuth = () => {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    checkAuth()
  }, [])

  const checkAuth = async () => {
    try {
      const userData = await authService.getUser()
      setUser(userData)
    } catch (error) {
      setUser(null)
    } finally {
      setLoading(false)
    }
  }

  const login = async (email, password) => {
    const deviceName = `${Platform.OS}-${Platform.Version}`
    const result = await authService.login(email, password, deviceName)
    
    if (result.success) {
      setUser(result.user)
    }
    
    return result
  }

  const logout = async () => {
    await authService.logout()
    setUser(null)
  }

  const value = {
    user,
    login,
    logout,
    loading,
    isAuthenticated: !!user,
    apiRequest: authService.apiRequest.bind(authService)
  }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

トークン管理とセキュリティ最適化

// app/Console/Commands/PruneExpiredTokens.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Laravel\Sanctum\PersonalAccessToken;

class PruneExpiredTokens extends Command
{
    protected $signature = 'tokens:prune {--hours=24}';
    protected $description = 'Prune expired tokens older than specified hours';

    public function handle()
    {
        $hours = $this->option('hours');
        
        $deletedCount = PersonalAccessToken::where('expires_at', '<', now())
            ->where('expires_at', '<', now()->subHours($hours))
            ->delete();

        $this->info("Deleted {$deletedCount} expired tokens older than {$hours} hours.");
    }
}

// app/Console/Kernel.php での定期実行設定
<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected function schedule(Schedule $schedule)
    {
        // 毎日深夜にトークンを削除
        $schedule->command('sanctum:prune-expired --hours=24')->daily();
        
        // カスタムコマンドでの実行
        $schedule->command('tokens:prune --hours=72')->weekly();
    }
}

// カスタム Personal Access Token モデル
<?php
// app/Models/PersonalAccessToken.php

namespace App\Models;

use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;

class PersonalAccessToken extends SanctumPersonalAccessToken
{
    protected $fillable = [
        'name',
        'token',
        'abilities',
        'expires_at',
        'last_used_at',
    ];

    protected $casts = [
        'abilities' => 'json',
        'last_used_at' => 'datetime',
        'expires_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    // セキュリティ機能の追加
    public function isExpired(): bool
    {
        return $this->expires_at && $this->expires_at->isPast();
    }

    public function isInactive(int $days = 30): bool
    {
        return $this->last_used_at && $this->last_used_at->diffInDays(now()) > $days;
    }

    // ログイン元情報の追加
    public function scopeActiveTokens($query)
    {
        return $query->where(function ($query) {
            $query->whereNull('expires_at')
                  ->orWhere('expires_at', '>', now());
        });
    }

    public function scopeInactiveTokens($query, int $days = 30)
    {
        return $query->where('last_used_at', '<', now()->subDays($days));
    }
}

// config/sanctum.php でカスタムモデルを使用
return [
    'personal_access_token_model' => App\Models\PersonalAccessToken::class,
    
    // その他の設定...
];