Skip to main content
Summary

보안은 나중에 붙이는 기능이 아니다

웹 애플리케이션은 인터넷에 공개된다. 사용자가 입력한 값, 브라우저가 보낸 쿠키, 업로드된 파일, URL의 글 번호까지 모두 외부에서 들어온다. 그래서 PHP 코드를 작성할 때는 처음부터 한 가지 원칙을 기억해야 한다.

외부에서 들어온 값은 믿지 않는다. 검증하고, 필요한 곳에서 안전하게 변환한 뒤 사용한다.

보안은 거창한 해킹 지식에서 시작하지 않는다. 입력값을 그대로 출력하지 않기, 비밀번호를 평문으로 저장하지 않기, 로그인 후 세션 ID를 바꾸기, 업로드 파일을 검사하기 같은 작은 습관에서 시작한다.

이번 장에서는 PHP 웹 애플리케이션에서 자주 만나는 다섯 가지 보안 주제를 다룬다.

  • XSS: 사용자 입력이 HTML/JavaScript로 실행되는 문제

  • CSRF: 로그인한 사용자가 원치 않는 요청을 보내게 되는 문제

  • 비밀번호 저장: 비밀번호를 안전하게 해시하는 방법

  • 세션 보호: 로그인 상태를 더 안전하게 유지하는 방법

  • 파일과 민감 정보 보호: 업로드, 경로, 설정 파일 노출 점검

PHP 웹 보안의 기본AI 생성 이미지

XSS: 출력할 때 안전하게 바꾸기

XSS는 Cross-Site Scripting의 줄임말이다. 쉽게 말하면 사용자가 입력한 문자열이 웹 페이지에서 코드처럼 실행되는 문제다.

예를 들어 게시글 제목을 그대로 출력한다고 하자.

<?php
$title = $_GET['title'] ?? '';
?>

<h2><?= $title ?></h2>

사용자가 다음과 같은 값을 보낸다면 어떻게 될까?

<script>alert('XSS');</script>

브라우저는 이것을 단순한 글자가 아니라 JavaScript 코드로 해석할 수 있다. 실제 공격에서는 알림창 정도가 아니라 사용자의 화면 조작, 악성 링크 삽입, 세션 탈취 시도 같은 문제가 이어질 수 있다.

해결 방법은 출력하기 전에 HTML에서 특별한 의미를 가지는 문자를 안전한 형태로 바꾸는 것이다. 이를 이스케이프라고 한다. PHP에서는 htmlspecialchars()를 사용한다.1

<?php
$title = $_GET['title'] ?? '';
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
?>

<h2><?= $safeTitle ?></h2>

htmlspecialchars()는 <, >, &, 따옴표 같은 문자를 HTML 코드가 아니라 글자로 보이게 바꾼다. ENT_QUOTES는 작은따옴표와 큰따옴표를 모두 처리하겠다는 뜻이고, 'UTF-8'은 문자 인코딩을 명확히 지정한 것이다.

매번 길게 쓰기 불편하므로 앞 장에서 만든 것처럼 안전한 출력 함수를 만들어 두면 좋다.

<?php
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

그다음 HTML에서는 다음처럼 사용한다.

<p>작성자: <?= e($authorName) ?></p>
<p>제목: <?= e($postTitle) ?></p>

중요한 점은 입력할 때 한 번 처리하면 끝이 아니라는 것이다. XSS 방어의 기본 위치는 "출력 직전"이다. 데이터베이스에 저장된 값도 사용자가 입력한 값일 수 있으므로 화면에 보여줄 때마다 이스케이프해야 한다.

<?php foreach ($posts as $post): ?>
    <article>
        <h2><?= e($post['title']) ?></h2>
        <p><?= nl2br(e($post['content'])) ?></p>
    </article>
<?php endforeach; ?>

nl2br()는 줄바꿈을 <br> 태그로 바꾼다. 이때도 먼저 e()로 안전하게 바꾼 뒤 nl2br()을 적용하는 흐름을 기억하자.

CSRF: 내가 누르지 않은 버튼이 눌리는 문제

CSRF는 Cross-Site Request Forgery의 줄임말이다. 쉽게 말하면 로그인한 사용자의 권한을 이용해 원치 않는 요청을 보내게 만드는 공격이다.2

예를 들어 사용자가 게시판에 로그인한 상태라고 하자. 게시글 삭제 요청이 다음 주소로 동작한다면 위험하다.

/delete.php?id=10

공격자는 다른 사이트에 다음과 같은 이미지를 숨겨 둘 수 있다.

<img src="https://example.com/delete.php?id=10" alt="">

사용자가 그 사이트를 방문하면 브라우저는 이미지를 불러오려고 요청을 보낸다. 이때 example.com에 로그인 쿠키가 남아 있다면 서버는 사용자가 직접 삭제 버튼을 누른 것처럼 오해할 수 있다.

먼저 중요한 원칙이 있다.

  • 조회는 GET을 사용한다.

  • 생성, 수정, 삭제는 POST를 사용한다.

  • POST 요청에도 CSRF 토큰을 확인한다.

CSRF 토큰은 서버가 만든 임의의 문자열이다. 폼을 보여줄 때 세션에 저장하고, 폼 안에도 숨겨서 넣는다. 요청이 들어오면 세션의 토큰과 폼에서 온 토큰이 같은지 확인한다.

<?php
session_start();

function csrf_token(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['csrf_token'];
}

function verify_csrf_token(string $token): bool
{
    return isset($_SESSION['csrf_token'])
        && hash_equals($_SESSION['csrf_token'], $token);
}

폼에는 토큰을 숨겨 넣는다.

<form action="delete.php" method="post">
    <input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
    <input type="hidden" name="id" value="<?= e((string) $post['id']) ?>">
    <button type="submit">삭제</button>
</form>

요청을 처리하는 쪽에서는 먼저 토큰을 검사한다.

<?php
session_start();
require_once __DIR__ . '/functions.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('허용되지 않은 요청입니다.');
}

if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
    http_response_code(400);
    exit('올바르지 않은 요청입니다.');
}

$id = (int) ($_POST['id'] ?? 0);

// 여기에서 prepared statement로 삭제 처리

hash_equals()를 사용하는 이유는 단순 비교보다 토큰 비교에 더 적합하기 때문이다. 초보 단계에서는 "비밀 토큰 비교에는 hash_equals()를 쓴다" 정도로 기억하면 충분하다.

CSRF 토큰은 글쓰기, 수정, 삭제, 회원정보 변경, 비밀번호 변경처럼 서버 상태를 바꾸는 폼에 적용한다.

비밀번호는 저장하지 말고 해시를 저장한다

회원가입 기능을 만들 때 가장 위험한 실수는 비밀번호를 그대로 저장하는 것이다.

// 나쁜 예
$password = $_POST['password'];
// 이 값을 그대로 DB에 저장하면 안 된다.

비밀번호를 그대로 저장하면 데이터베이스가 유출되었을 때 사용자의 비밀번호가 즉시 노출된다. 많은 사용자는 여러 사이트에서 같은 비밀번호를 사용하므로 피해가 다른 서비스로 번질 수 있다.

비밀번호는 해시해서 저장해야 한다. 해시는 값을 일정한 규칙으로 바꾸는 방식이다. 비밀번호 해시는 원래 비밀번호를 다시 복원하는 용도가 아니라, 나중에 입력한 비밀번호가 맞는지 비교하기 위한 용도다.

PHP에서는 password_hash()와 password_verify()를 사용한다.3

회원가입 시에는 다음처럼 저장한다.

<?php
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

if ($email === '' || $password === '') {
    exit('이메일과 비밀번호를 입력하세요.');
}

$passwordHash = password_hash($password, PASSWORD_DEFAULT);

$stmt = $pdo->prepare(
    'INSERT INTO users (email, password_hash) VALUES (:email, :password_hash)'
);
$stmt->execute([
    'email' => $email,
    'password_hash' => $passwordHash,
]);

PASSWORD_DEFAULT는 PHP가 현재 권장하는 안전한 알고리즘을 사용하게 한다. 그래서 데이터베이스의 password_hash 열은 넉넉하게 VARCHAR(255) 정도로 잡는 것이 좋다. 해시 문자열 길이가 알고리즘에 따라 달라질 수 있기 때문이다.

로그인할 때는 사용자가 입력한 비밀번호를 다시 password_hash()로 해시해서 직접 비교하지 않는다. 반드시 password_verify()를 사용한다.

<?php
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

if (!$user || !password_verify($password, $user['password_hash'])) {
    exit('이메일 또는 비밀번호가 올바르지 않습니다.');
}

// 로그인 성공
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email'];

로그인 실패 메시지는 "이메일이 없습니다" 또는 "비밀번호가 틀렸습니다"처럼 자세히 나누지 않는 편이 좋다. 공격자에게 어떤 이메일이 가입되어 있는지 알려 줄 수 있기 때문이다.

또한 비밀번호 검증에 성공한 뒤에는 session_regenerate_id(true)를 호출한다. 이 내용은 다음 절에서 이어서 설명한다.

세션을 안전하게 다루기

세션은 로그인 상태처럼 중요한 정보를 서버에 저장하는 기능이다. 브라우저는 세션 ID가 담긴 쿠키를 들고 다니고, 서버는 그 ID를 보고 어떤 사용자인지 찾는다.

문제는 세션 ID가 로그인 상태의 열쇠처럼 동작한다는 점이다. 세션 ID가 공격자에게 넘어가거나, 공격자가 미리 정한 세션 ID로 사용자가 로그인하게 만들면 위험하다. 후자를 세션 고정 공격이라고 한다.

로그인에 성공하면 세션 ID를 새로 발급하자.

session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];

true를 전달하면 기존 세션 파일을 제거하도록 요청한다. 이렇게 하면 로그인 전의 세션 ID가 로그인 후에도 그대로 유지되는 위험을 줄일 수 있다.

세션 쿠키 옵션도 중요하다. session_start()를 호출하기 전에 설정해야 한다.

<?php
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

각 옵션의 의미는 다음과 같다.

  • secure: HTTPS 연결에서만 쿠키를 보낸다.

  • httponly: JavaScript에서 세션 쿠키에 접근하지 못하게 한다.

  • samesite: 다른 사이트에서 시작된 요청에 쿠키가 함께 전송되는 범위를 줄인다.

  • lifetime: 0이면 브라우저 세션이 끝날 때 쿠키가 사라진다.

개발 환경이 http://localhost라면 secure => true 때문에 쿠키가 저장되지 않는 경우가 있다. 실제 운영 환경에서는 HTTPS를 사용하고 secure를 켜야 한다. 로컬 학습 중에는 환경에 따라 조정하되, 운영에서는 반드시 HTTPS를 기준으로 생각하자.

일정 시간 활동이 없으면 로그아웃시키는 것도 좋은 습관이다.

<?php
$timeout = 1800; // 30분

if (isset($_SESSION['last_activity']) && time() - $_SESSION['last_activity'] > $timeout) {
    session_unset();
    session_destroy();
    header('Location: /login.php?timeout=1');
    exit;
}

$_SESSION['last_activity'] = time();

로그아웃할 때는 세션 데이터와 쿠키를 함께 정리한다.

<?php
session_start();

$_SESSION = [];

if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain']);
}

session_destroy();
header('Location: /login.php');
exit;

세션에는 필요한 최소 정보만 저장한다. 비밀번호, 신용카드 번호, 주민등록번호 같은 민감한 값은 세션에 넣지 않는다.

파일 업로드는 확장자만 믿지 않는다

파일 업로드는 편리하지만 위험하다. 공격자가 PHP 파일을 이미지처럼 업로드하고, 그 파일이 서버에서 실행되면 심각한 문제가 생길 수 있다. 그래서 업로드 파일은 다음 기준으로 검사해야 한다.4

  • 업로드 오류 코드 확인

  • 파일 크기 제한

  • 허용할 MIME 타입 검사

  • 허용할 확장자 검사

  • 원본 파일명 대신 새 파일명 사용

  • 가능하면 웹에서 직접 실행되지 않는 위치에 저장

기본 예제를 보자.

<?php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    exit('잘못된 요청입니다.');
}

if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
    exit('업로드에 실패했습니다.');
}

$file = $_FILES['image'];

if ($file['size'] > 2 * 1024 * 1024) {
    exit('파일은 2MB 이하만 업로드할 수 있습니다.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
    'image/gif' => 'gif',
];

if (!array_key_exists($mime, $allowed)) {
    exit('허용되지 않은 파일 형식입니다.');
}

$extension = $allowed[$mime];
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
$uploadDir = __DIR__ . '/uploads';

if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

$destination = $uploadDir . '/' . $filename;

if (!move_uploaded_file($file['tmp_name'], $destination)) {
    exit('파일을 저장하지 못했습니다.');
}

// DB에는 원본 경로 전체가 아니라 필요한 파일명 또는 상대 경로만 저장한다.

사용자가 보낸 원본 파일명은 믿지 않는다.

// 위험한 예
$destination = __DIR__ . '/uploads/' . $_FILES['image']['name'];

원본 파일명에는 공백, 한글, 특수문자뿐 아니라 경로 조작을 노린 문자열이 들어올 수 있다. 파일명은 서버에서 새로 만드는 편이 안전하다.

업로드 폴더가 공개 웹 디렉터리 안에 있다면, PHP 파일이 실행되지 않도록 웹 서버 설정도 필요하다. 학습 단계에서는 "업로드 파일은 검사하고, 이름을 바꾸고, 실행되지 않게 저장한다"는 원칙을 먼저 기억하자.

경로 조작과 민감 정보 노출 막기

사용자 입력을 파일 경로에 직접 붙이면 위험하다.

// 위험한 예
$page = $_GET['page'] ?? 'home';
require __DIR__ . '/pages/' . $page . '.php';

공격자가 ../../config 같은 값을 보내면 의도하지 않은 파일에 접근하려는 시도가 될 수 있다. 이런 공격을 경로 조작 또는 디렉터리 트래버설이라고 한다.

안전한 방법은 허용 목록을 사용하는 것이다.

<?php
$pages = [
    'home' => __DIR__ . '/pages/home.php',
    'about' => __DIR__ . '/pages/about.php',
    'contact' => __DIR__ . '/pages/contact.php',
];

$key = $_GET['page'] ?? 'home';

if (!array_key_exists($key, $pages)) {
    http_response_code(404);
    exit('페이지를 찾을 수 없습니다.');
}

require $pages[$key];

또 하나 중요한 점은 설정 파일을 공개 디렉터리에 두지 않는 것이다. 웹 서버가 직접 제공하는 폴더에는 공개해도 되는 파일만 두어야 한다.

좋은 구조는 다음과 같다.5

project/
├── app/
│   ├── config/
│   └── src/
├── storage/
└── public/
    ├── index.php
    ├── css/
    └── images/

웹 서버의 문서 루트는 project/public으로 잡는다. 그러면 app/config/database.php 같은 파일은 브라우저에서 직접 접근할 수 없다.

오류 메시지도 주의해야 한다. 개발 환경에서는 오류를 화면에 보여 주는 것이 도움이 되지만, 운영 환경에서는 데이터베이스 접속 정보, 파일 경로, SQL 일부가 노출될 수 있다.

운영 환경에서는 다음 원칙을 지킨다.

ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

사용자에게는 간단한 메시지를 보여 주고, 자세한 내용은 로그에 남긴다.

try {
    // 데이터베이스 처리
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500);
    exit('처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.');
}

작은 게시글 삭제 기능에 보안 적용하기

지금까지의 내용을 작은 삭제 기능에 적용해 보자. 이 기능은 로그인한 사용자만 자신의 게시글을 삭제할 수 있어야 한다.

삭제 버튼을 출력하는 페이지다.

<?php
// post_show.php
session_start();
require_once __DIR__ . '/functions.php';

if (empty($_SESSION['user_id'])) {
    header('Location: /login.php');
    exit;
}
?>

<h2><?= e($post['title']) ?></h2>
<p><?= nl2br(e($post['content'])) ?></p>

<form action="/post_delete.php" method="post">
    <input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
    <input type="hidden" name="id" value="<?= e((string) $post['id']) ?>">
    <button type="submit">삭제</button>
</form>

삭제 처리 파일이다.

<?php
// post_delete.php
session_start();
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/db.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('허용되지 않은 요청입니다.');
}

if (empty($_SESSION['user_id'])) {
    header('Location: /login.php');
    exit;
}

if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
    http_response_code(400);
    exit('올바르지 않은 요청입니다.');
}

$postId = (int) ($_POST['id'] ?? 0);
$userId = (int) $_SESSION['user_id'];

$stmt = $pdo->prepare('DELETE FROM posts WHERE id = :id AND user_id = :user_id');
$stmt->execute([
    'id' => $postId,
    'user_id' => $userId,
]);

header('Location: /posts.php');
exit;

여기에는 여러 보안 습관이 함께 들어 있다.

  • 삭제는 GET이 아니라 POST로 처리한다.

  • CSRF 토큰을 확인한다.

  • 로그인 여부를 확인한다.

  • WHERE id = :id AND user_id = :user_id로 본인 글만 삭제한다.

  • SQL은 prepared statement로 실행한다.

  • 처리 후 header()로 이동하고 exit한다.

보안은 한 가지 함수로 끝나지 않는다. 입력, 출력, 인증, 권한, 데이터베이스, 세션을 함께 점검해야 한다.

실습 과제

  1. 앞 장에서 만든 로그인 코드가 있다면 로그인 성공 직후 session_regenerate_id(true)를 추가해 보자.

  2. 글쓰기, 수정, 삭제 폼에 CSRF 토큰을 추가하고 처리 파일에서 검증해 보자.

  3. 게시글 제목과 본문을 출력하는 모든 위치에 e() 함수가 적용되어 있는지 확인해 보자.

  4. 비밀번호 저장 코드가 평문 저장인지 확인하고, password_hash()와 password_verify() 구조로 바꿔 보자.

  5. 이미지 업로드 코드에서 파일 크기, MIME 타입, 새 파일명 생성, move_uploaded_file() 사용 여부를 점검해 보자.

자주 발생하는 오류 점검

htmlspecialchars()를 썼는데도 화면이 깨진다

문자 인코딩이 맞지 않을 수 있다. HTML 문서와 PHP 출력 모두 UTF-8을 기준으로 맞춘다.

<meta charset="UTF-8">

그리고 htmlspecialchars($value, ENT_QUOTES, 'UTF-8')처럼 인코딩을 명시한다.

CSRF 토큰이 항상 올바르지 않다고 나온다

session_start()가 토큰 생성 파일과 검증 파일 양쪽에 모두 있는지 확인하자. 또한 폼의 name="csrf_token"과 PHP에서 읽는 $_POST['csrf_token'] 이름이 같은지도 확인해야 한다.

로그인해도 세션이 유지되지 않는다

쿠키 설정을 확인하자. 로컬에서 HTTPS 없이 테스트하는데 secure => true로 설정하면 브라우저가 세션 쿠키를 저장하지 않을 수 있다. 운영 환경에서는 HTTPS를 사용하고 secure를 켜는 것이 원칙이다.

비밀번호 검증이 계속 실패한다

로그인할 때 새로 password_hash()를 만들어 비교하면 안 된다. 해시는 매번 달라질 수 있다. 저장된 해시와 사용자가 입력한 비밀번호를 password_verify($password, $storedHash)로 비교해야 한다.

업로드한 이미지가 보이지 않는다

저장 경로와 공개 URL을 구분해야 한다. 서버 내부 경로는 /var/www/project/uploads/a.jpg처럼 생겼고, 브라우저에서 접근하는 URL은 /uploads/a.jpg처럼 다를 수 있다. DB에는 상황에 맞게 공개용 상대 경로나 파일명만 저장하는 편이 좋다.

핵심 정리

  • 사용자 입력은 그대로 출력하지 말고 htmlspecialchars()로 이스케이프한다.

  • 서버 상태를 바꾸는 폼에는 CSRF 토큰을 넣고 검증한다.

  • 비밀번호는 평문 저장하지 말고 password_hash()로 해시해 저장한다.

  • 로그인 검증은 password_verify()를 사용한다.

  • 로그인 성공 후 session_regenerate_id(true)로 세션 ID를 새로 발급한다.

  • 세션 쿠키에는 HttpOnly, Secure, SameSite 같은 옵션을 적용한다.

  • 업로드 파일은 크기, MIME 타입, 확장자, 저장 위치, 파일명을 모두 점검한다.

  • 설정 파일과 내부 코드는 공개 디렉터리 밖에 둔다.

  • 운영 환경에서는 자세한 오류를 화면에 보여 주지 않고 로그에 남긴다.

참고

1How to secure PHP web applications and prevent attacks?

2How to Secure a PHP Login System: Complete Security Guide for 2026

3How to Secure a PHP Login System: Complete Security Guide for 2026

4Common PHP Security Issues and How to Prevent Them

5How to secure PHP web applications and prevent attacks?

책 목록

PHP 교과서: 기초 문법부터 게시판 프로젝트와 배포까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기