Laravel 보안 모범 사례
Laravel 애플리케이션을 일반적인 취약점으로부터 보호하기 위한 포괄적인 보안 가이드입니다.
활성화 시점
- Laravel 인증 및 권한 부여 설정 (Sanctum, Passport, Jetstream, Breeze)
- 사용자 역할, 권한 및 정책 구현
- 프로덕션 보안 설정 및 환경 변수 구성
- Laravel 애플리케이션의 보안 취약점 검토
- Laravel 애플리케이션을 프로덕션에 배포
- 안전한 Eloquent 쿼리 및 마이그레이션 작성
프로덕션 구성
필수 프로덕션 설정
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // 중요: 프로덕션에서는 절대 true로 설정하지 마세요
'key' => env('APP_KEY'), // 반드시 설정해야 함: php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// 부트 시 APP_KEY 확인
// bootstrap/app.php 또는 서비스 프로바이더
if (empty(config('app.key'))) {
throw new RuntimeException('APP_KEY가 설정되지 않았습니다. 실행: php artisan key:generate');
}
환경 파일 보안
# 절대 .env를 버전 관리에 커밋하지 마세요
# .gitignore는 기본적으로 .env를 포함합니다
# 대신 .env.example을 자리 표시자와 함께 사용하세요
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# 부트 시 필수 변수 검증
// AppServiceProvider::boot()에서
$requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username'];
foreach ($requiredKeys as $key) {
if (empty(config($key))) {
throw new RuntimeException("필수 구성 키 누락: {$key}");
}
}
HTTPS 강제 적용
// AppServiceProvider::boot() 또는 미들웨어
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php의 신뢰할 수 있는 프록시 (로드 밸런서)
// 특정 IP 범위 사용 — *는 모든 것을 신뢰하여 X-Forwarded-* 스푸핑 허용
// AWS: '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
// 미들웨어를 통해 프로덕션에서 HTTPS 강제 적용
// app/Http/Middleware/ForceHttps.php
public function handle($request, Closure $next)
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
인증
Sanctum (API 토큰 인증)
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
)));
'expiration' => 60 * 24, // 토큰 만료 시간 (분 단위, null = 만료 없음)
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
// 권한(abilities)이 있는 토큰 발급
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// 라우트에서 권한 검증
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', function () {
// 사용자에게 'read' 권한이 있어야 함
abort_unless(Auth::user()->tokenCan('read'), 403);
// ...
})->middleware('abilities:read');
Route::post('/orders', function () {
// 사용자에게 'write' 권한이 있어야 함
abort_unless(Auth::user()->tokenCan('write'), 403);
// ...
})->middleware('abilities:write');
});
비밀번호 보안
// config/hashing.php
// 기본값은 bcrypt입니다. Argon2id가 더 강력합니다.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // 더 강력한 해싱을 위해 증가
],
'argon' => [
'memory' => 65536,
'threads' => 4,
'time' => 4,
],
// RegisterRequest에서 비밀번호 검증
public function rules(): array
{
return [
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // haveibeenpwned 확인
],
];
}
// 로그인 시도 속도 제한
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// 사용자에게 의심스러운 로그인 알림
$user->notify(new SuspiciousLoginNotification($request->ip()));
}
}
세션 관리
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
// 로그인 시 세션 재생성
// App\Http\Controllers\Auth\AuthenticatedSessionController
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // 중요: 세션 고정 방지
return redirect()->intended(RouteServiceProvider::HOME);
}
// 로그아웃 시 세션 무효화
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
권한 부여
게이트(Gates)
// App\Providers\AuthServiceProvider
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post): bool {
return $user->id === $post->user_id;
});
Gate::define('publish-post', function (User $user): bool {
return $user->role === 'editor' || $user->role === 'admin';
});
// before()를 사용한 슈퍼 관리자 재정의
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // 모든 권한 부여
}
return null; // 일반 검증으로 대체
});
}
// 컨트롤러에서 사용
public function update(Request $request, Post $post): RedirectResponse
{
Gate::authorize('update-post', $post);
// 또는: $this->authorize('update-post', $post);
// 또는: abort_unless(Auth::user()->can('update-post', $post), 403);
// ...
}
정책(Policies)
// App\Policies\PostPolicy
class PostPolicy
{
use HandlesAuthorization;
public function viewAny(?User $user): bool
{
return true; // 공개 목록
}
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail(); // 이메일 인증 필수
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30;
}
public function restore(User $user, Post $post): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, Post $post): bool
{
return $user->role === 'super-admin';
}
}
// AuthServiceProvider에 등록
protected $policies = [
Post::class => PostPolicy::class,
];
// 컨트롤러 사용
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', compact('post'));
}
// Blade 사용
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan
@cannot('update', $post)
<span>You cannot edit this post</span>
@endcannot
미들웨어 권한 부여
// 라우트에서 미들웨어 사용
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Route::get('/posts/create', [PostController::class, 'create'])
->middleware('can:create,App\Models\Post');
// 사용자 정의 권한 부여 미들웨어
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string $role): mixed
{
if (!$request->user() || $request->user()->role !== $role) {
abort(403, '권한 없음. 이 영역은 역할이 필요합니다: ' . $role);
}
return $next($request);
}
}
// Kernel에 등록
protected $routeMiddleware = [
'role' => \App\Http\Middleware\CheckRole::class,
];
// 라우트 사용
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Eloquent 보안
대량 할당 보호
// 나쁜 예: $guarded = []는 모든 컬럼을 대량 할당 가능하게 함
// 프로덕션에서 절대 $guarded = []를 사용하지 마세요
// 좋은 예: fillable 속성 허용 목록 설정
final class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'avatar',
];
// 절대 'role', 'is_admin', 'is_verified'를 여기에 추가하지 마세요
}
// 좋은 예: 요청에서 채울 수 있는 필드를 명시적으로 제어
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create($request->safe()->only([
'name', 'email', 'phone', 'avatar'
]));
// $request->safe()는 검증된 데이터만 사용
// $request->only()는 검증 규칙 없이 자체적으로 안전하지 않음
}
// 나쁜 예: 요청 데이터로 직접 사용자 생성
User::create($request->all()); // 대량 할당에 취약!
// 더 좋은 예: 생성을 위해 DTO 사용
$user = User::create($request->validated()); // 검증된 필드만
SQL 인젝션 방지
// 좋은 예: Eloquent는 자동으로 쿼리를 파라미터화
User::where('email', $userInput)->first();
User::whereRaw('email = ?', [$userInput])->first();
// 좋은 예: Query Builder도 파라미터화
DB::table('users')->where('email', $userInput)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$userInput]);
// 나쁜 예: 원시 문자열 보간
DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // 취약!
User::whereRaw("email = '{$userInput}'")->first(); // 취약!
// 나쁜 예: 이스케이프되지 않은 입력과 함께 whereRaw/orderByRaw 사용
User::orderByRaw($userInput); // 취약!
User::groupByRaw($userInput); // 취약!
// 나쁜 예: 연결과 함께 DB::statement 사용
DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // 취약!
속성 캐스팅
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // boolean으로 캐스팅하면 문자열 인젝션 방지
'settings' => 'array', // 자동으로 json_encode/json_decode
'metadata' => 'encrypted:array', // Laravel 11+ 암호화 캐스팅
'password' => 'hashed', // Laravel 10+ 설정 시 자동 해싱
];
}
모델 보안
final class User extends Authenticatable
{
// JSON/API 응답에서 민감한 속성 숨기기
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// 안전한 계산된 속성만 추가
protected $appends = ['full_name']; // 안전
// 절대 민감한 계산 데이터를 추가하지 마세요
}
final class Post extends Model
{
// 소프트 삭제된 레코드를 필터링하는 글로벌 스코프
use SoftDeletes;
// 지연 로딩을 제한하여 N+1 방지 (선택적 엄격 모드)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
CSRF 보호
기본 보호
// Laravel CSRF는 VerifyCsrfToken 미들웨어를 통해 기본적으로 활성화
// app/Http/Kernel.php (protected $middlewareGroups['web'])
// 모든 POST/PUT/PATCH/DELETE 양식은 @csrf를 포함해야 함
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
<button type="submit">Create</button>
</form>
라우트 제외 (주의해서)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// 외부 CSRF 보호(웹훅 등)가 있는 라우트만 제외
protected $except = [
'stripe/*', // Stripe 웹훅은 자체 서명 검증 사용
// 포괄적인 'api/*'는 피하세요 — 상태 저장 Sanctum 라우트에는 CSRF가 필요합니다.
// 특정 상태 비저장 웹훅/엔드포인트 라우트만 제외하세요.
];
}
JavaScript와 CSRF
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Axios 예제 (Laravel은 Axios를 기본 제공)
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector(
'meta[name="csrf-token"]'
).getAttribute('content');
// Fetch 예제
fetch('/posts', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
</script>
XSS 방지
Blade 템플릿 보안
{{-- 안전: Blade에 의해 자동 이스케이프 처리 --}}
{{ $userInput }}
{{-- 위험: 원시 출력 — 사용자 입력에 절대 사용하지 마세요 --}}
{!! $userInput !!}
{{-- 안전: {!! !!}는 사용자가 제어하는 신뢰할 수 있는 콘텐츠에만 사용 --}}
{!! $trustedHtmlFromYourServer !!}
{{-- 좋은 예: 특정 이스케이프 지시문 사용 --}}
@js($data) {{-- JavaScript용 JSON 인코드 --}}
@json($data) {{-- 템플릿에서 JSON 인코드 --}}
{{-- 나쁜 예: 원시 HTML에 직접 사용자 입력 --}}
<div>{!! $user->bio !!}</div> {{-- 사용자가 bio를 제공하는 경우 취약 --}}
안전한 HTML 처리
// 일부 HTML을 허용해야 하는 경우 허용 목록 접근 방식 사용
use HTMLPurifier; // 필요: composer require ezyang/htmlpurifier
public function sanitizeHtml(string $dirty): string
{
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$purifier = new \HTMLPurifier($config);
return $purifier->purify($dirty);
}
// Blade에서:
<div>{!! $sanitizedHtml !!}</div>
// Laravel의 Str::markdown 사용 (신뢰할 수 있는 입력에만)
use Illuminate\Support\Str;
$html = Str::markdown($userInput); // XSS를 완전히 방지하지는 않음
Content Security Policy (CSP)
// app/Http/Middleware/ContentSecurityPolicy.php
namespace App\Http\Middleware;
use Closure;
class ContentSecurityPolicy
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'nonce-" . base64_encode(random_bytes(16)) . "'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: https:; " .
"font-src 'self'; " .
"frame-src 'none'; " .
"object-src 'none'; " .
"base-uri 'self'; " .
"form-action 'self'"
);
return $response;
}
}
// Kernel에 등록
// app/Http/Kernel.php
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\ContentSecurityPolicy::class,
],
];
// Blade에서 nonce 사용
<script nonce="{{ $cspNonce }}">
// JavaScript 코드
</script>
API 보안
속도 제한
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting(): void
{
// API 라우트 속도 제한
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// 로그인 시도 속도 제한
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
}
// routes/api.php에서 사용
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
});
API 입력 검증
// app/Http/Requests/Api/StorePostRequest.php
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // 미들웨어가 인증 처리
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id', 'distinct'],
];
}
// 실패 시 JSON 오류 반환
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'message' => '검증 실패',
'errors' => $validator->errors(),
], 422));
}
}
// 컨트롤러 사용
public function store(StorePostRequest $request): JsonResponse
{
$post = Post::create($request->validated());
return response()->json($post, 201);
}
API 응답 보안
// 헤더를 통한 정보 노출 방지
// AppServiceProvider::boot() 또는 미들웨어
public function boot(): void
{
// 애플리케이션 버전 숨기기
header_remove('X-Powered-By');
// JSON 응답에 대한 CORS 헤더
// config/cors.php에서 구성 (Laravel 7+)
}
// 안전한 API 리소스 응답
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// 민감한 필드 제외: role, is_admin, password 등
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
CORS 구성
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => ['X-Total-Count', 'X-Page'],
'max_age' => 0,
'supports_credentials' => true, // Sanctum 쿠키 기반 인증에 필요
];
프로덕션 배포
파일 권한
# 디렉토리에 올바른 권한 설정
chmod 755 /path/to/laravel
chmod 775 /path/to/laravel/storage
chmod 775 /path/to/laravel/bootstrap/cache
# 소유권 설정 (웹 서버 사용자)
chown -R www-data:www-data /path/to/laravel
# 또는
chown -R $USER:www-data /path/to/laravel
배포 전 체크리스트
# 1. 디버그 모드 비활성화
APP_DEBUG=false
# 2. APP_KEY 설정
php artisan key:generate
# 3. HTTPS 강제 적용 (프록시를 통해 처리하지 않는 경우)
# AppServiceProvider에서 URL::forceScheme('https')
# 4. 캐시 지우기
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 5. 불필요한 서비스 비활성화
php artisan telescope:clear # 개발 환경에서 사용
php artisan horizon:terminate # 프로덕션에서 사용하지 않는 경우
보안 헤더
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy',
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
);
// CSP는 위에서 별도 미들웨어로 처리
return $response;
}
}
// Kernel에 등록
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\SecurityHeaders::class,
],
];
일반적인 취약점
디버그 모드
// 절대 프로덕션에서 APP_DEBUG=true로 설정하지 마세요
// 민감한 구성 값, 데이터베이스 자격 증명 등을 노출합니다
// .env
APP_DEBUG=false # 프로덕션
// 대신 사용자 정의 오류 처리
// app/Exceptions/Handler.php
public function register(): void
{
$this->reportable(function (Throwable $e) {
// 프로덕션에서 오류 기록 (절대 화면에 표시하지 않음)
Log::error($e->getMessage(), ['trace' => $e->getTraceAsString()]);
});
$this->renderable(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => '리소스를 찾을 수 없습니다.'], 404);
}
return response()->view('errors.404', [], 404);
});
}
의존성 보안
# 정기적으로 취약점 스캔
composer audit
# 의존성 업데이트
composer update
# 수정 불가능한 취약점 확인
composer audit --format=json | jq '."advisories" | length'
빠른 참조
| 영역 | 구성 파일 | 필수 설정 |
|---|---|---|
| 인증 | config/auth.php, config/sanctum.php | 적절한 가드, 토큰 만료, HTTPS |
| 권한 | App\Providers\AuthServiceProvider | Gate/Policy 정의, 슈퍼 관리자 처리 |
| CSRF | app/Http/Middleware/VerifyCsrfToken.php | 모든 양식에 @csrf, JavaScript 헤더 |
| XSS | Blade 템플릿 | {{ }} 사용, {!! !!}는 신뢰할 수 있는 콘텐츠에만 |
| API | config/cors.php, config/sanctum.php | CORS 구성, 속도 제한, 검증 |
| 세션 | config/session.php | 보안 쿠키, HTTP 전용, SameSite |
| 데이터베이스 | config/database.php | SSL 연결, 강력한 비밀번호 |
| 로깅 | config/logging.php | 프로덕션에서 스택/슬랙/데이터독 사용 |
보안 검증
# 확인 명령
php artisan about # 구성 확인
php artisan route:list # 노출된 라우트 확인
php artisan config:show database # 데이터베이스 설정 표시
# 보안 감사 명령 (패키지 필요)
composer require --dev beyondcode/laravel-erkennung
php artisan security:check