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, 'Unauthorized. 이 영역은 역할이 필요합니다: ' . $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 = []를 사용하지 마세요
// 좋음: 채울 수 있는 속성을 허용 목록으로 지정
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 보호가 있는 라우트만 제외 (webhook 등)
protected $except = [
'stripe/*', // Stripe webhook은 자체 서명 검증 사용
// 'api/*'를 무분별하게 제외하지 마세요 — stateful Sanctum 라우트는 CSRF가 필요합니다.
// 특정 stateless webhook/엔드포인트 라우트만 제외하세요.
];
}
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> {{-- 소독 후 안전 --}}
// Markdown 처리
use GrahamCampbell\Markdown\Facades\Markdown;
public function renderMarkdown(string $markdown): string
{
return Markdown::convertToHtml($markdown);
}
API 보안
속도 제한
// app/Http/Kernel.php
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// config/app.php 또는 AppServiceProvider에서 사용자 정의 속도 제한
protected function configureRateLimiting(): void
{
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->ip());
});
RateLimiter::for('sensitive', function (Request $request) {
return Limit::perMinute(2)->by($request->user()?->id ?: $request->ip());
});
}
// 라우트에서 사용
Route::middleware('throttle:login')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
API 응답 보안
// 민감한 데이터를 노출하지 않도록 API 리소스 사용
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// 'password', 'role' 등 민감한 데이터 제외
];
}
}
// 컨트롤러에서 사용
public function show(User $user): UserResource
{
$this->authorize('view', $user);
return new UserResource($user);
}
// JSON 응답에서 민감한 속성을 자동으로 숨기기
protected $hidden = ['password', 'remember_token'];
CORS 구성
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => env('CORS_SUPPORTS_CREDENTIALS', false),
];
// 프로덕션에서 allowed_origins를 특정 도메인으로 제한
// 예: 'allowed_origins' => ['https://example.com'],
// '*'는 프로덕션에서 피해야 함
안전한 배포
환경 변수
# 필수 변수
APP_KEY=base64:... # 생성: php artisan key:generate
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
# 데이터베이스
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=app_user
DB_PASSWORD=strong_password_here
# 세션
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true
SESSION_ENCRYPT=true
# 이메일 (SMTP)
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
# Casthand
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
# Sanctum
SANCTUM_STATEFUL_DOMAINS=example.com
SESSION_DOMAIN=.example.com
파일 권한
# storage 및 bootstrap/cache 디렉토리가 웹 서버에 의해 쓰기 가능한지 확인
# 소유권 설정 (Ubuntu/Debian)
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 755 storage bootstrap/cache
# 또는 그룹 기반 권한
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
# 민감한 파일 보호
chmod 640 .env
chmod 644 .gitignore
보안 헤더
// app/Http/Middleware/SecureHeaders.php
class SecureHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->header('X-Content-Type-Options', 'nosniff');
$response->header('X-Frame-Options', 'DENY');
$response->header('X-XSS-Protection', '1; mode=block');
$response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Content-Security-Policy는 애플리케이션에 맞게 조정 필요
// $response->header('Content-Security-Policy', "default-src 'self'");
return $response;
}
}
// Kernel.php에 등록
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\SecureHeaders::class,
],
];
일반적인 함정
.env에 민감한 정보 노출
# 절대 하지 마세요:
git add .env # 실수로 민감한 자격 증명 커밋
# 대신:
# .env.example을 사용하고 .env를 .gitignore에 유지하세요
# .env를 외부에서 안전하게 주입 (예: CI/CD 비밀, AWS Secrets Manager)
dd() 또는 dump()를 프로덕션에 남기기
// 절대 하지 마세요:
// 디버그 코드를 프로덕션에 커밋
dd($sensitiveData); // SQL 쿼리, 비밀번호 노출 가능
// 배포 전 코드 검토에서 확인
// AppServiceProvider::boot()에서 앱의 APP_DEBUG 확인
if (app()->environment('production')) {
// 디버그 출력 비활성화
Config::set('app.debug', false);
}
취약한 의존성
# 정기적으로 취약점 검사
composer audit
# 의존성 업데이트
composer update
# 보안 권고 확인
# https://github.com/advisories?query=Laravel
Laravel 보안 체크리스트
-
APP_DEBUG가 프로덕션에서false로 설정됨 -
APP_KEY가 설정되고 안전함 (php artisan key:generate) - HTTPS가 강제 적용됨
-
.env가 버전 관리에 커밋되지 않음 - 데이터베이스 자격 증명이 안전함
- 세션 구성이 안전함 (
secure,http_only,same_site) - CSRF 보호가 활성화됨
- Blade 템플릿에서 입력이 이스케이프됨
- 접근 제어에 Gates/Policies 사용
- 대량 할당이
$fillable로 제한됨 - 비밀번호가
bcrypt또는argon2id로 해싱됨 - API 엔드포인트에 속도 제한이 있음
- 보안 헤더가 설정됨 (
X-Frame-Options,X-XSS-Protection, etc.) -
storage및bootstrap/cache권한이 안전함 - 취약한 의존성 없음 (
composer audit) - 로그인 시 세션이 재생성됨 (세션 고정 방지)
- API 응답이 민감한 데이터를 숨김
- CORS가 신뢰할 수 있는 출처로 제한됨
-
Content-Security-Policy헤더가 구성됨 - 이메일 확인이 활성화됨
- 실패한 로그인 시도가 기록되고 제한됨
- 프로덕션에서
$guarded = []가 사용되지 않음 - 모든 사용자 입력이 검증되고 소독됨