Laravel Sanctum
ライブラリ
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,
// その他の設定...
];