API 보안 실무 체크리스트: OWASP Top 10부터 JWT·Rate Limit까지


“우리 API는 괜찮겠지”라는 생각이 가장 위험합니다. 응답자의 84%가 지난 1년 동안 API 보안 사고를 경험했으며, 이는 3년 연속 사고 비율이 상승하며 역대 최고치를 기록한 수치입니다. API 보안 실무 체크리스트는 개발자가 “나중에 챙겨야지” 하다가 결국 유출 사고가 터진 후에야 꺼내드는 문서가 아닙니다. 설계 단계부터 배포 이후 운영까지 전 주기에 걸쳐 선제적으로 적용하는 실행 가이드입니다. API 공격과 자동화된 남용은 기업에 연간 940억~1,860억 달러의 비용을 발생시키며 전체 보안 사고의 40%에 기여합니다. 이 글 하나로 OWASP API Security Top 10 2023 기준의 모든 취약점과 그에 대응하는 실전 코드를 함께 정복합니다. GttkoreaLevo


목차

  1. 왜 API 보안은 웹 보안과 다른가 — OWASP API Top 10 2023 개요
  2. 인증·인가 보안 체크리스트 — BOLA·Broken Auth 완전 차단
  3. 입력 검증·데이터 노출 방지 체크리스트
  4. Rate Limiting·비즈니스 로직 보호 체크리스트
  5. 전송 보안·헤더·설정 오류 방지 체크리스트
  6. 로깅·모니터링·사고 대응 체크리스트와 자동화 전략

1. 왜 API 보안은 웹 보안과 다른가 — OWASP API Top 10 2023 개요 {#1}

API 보안 실무 체크리스트를 적용하기 전에, API 취약점이 일반 웹 취약점과 근본적으로 어떻게 다른지 이해해야 합니다. 같은 WAF(웹 방화벽)을 쓰더라도 API 공격은 걸러내지 못하는 경우가 많습니다.

API 공격이 일반 웹 공격보다 탐지하기 어려운 이유

일반 웹 취약점(XSS, CSRF 등)은 브라우저와 HTML이 관여하므로 패턴 기반 탐지가 비교적 쉽습니다. 반면 API 취약점은 다음 특성 때문에 탐지가 훨씬 어렵습니다.

[API 취약점의 세 가지 특성]

① 논리 기반 취약점
   "GET /api/orders/12345"
   → HTTP 요청 자체는 완전히 정상
   → 문제는 '12345'가 다른 사용자의 주문이라는 것
   → 패턴 매칭으로 탐지 불가, 비즈니스 로직 이해 필요

② 기계 간 통신 (M2M)
   → 사람이 아닌 프로그램이 자동으로 요청
   → 초당 수천 건의 정상처럼 보이는 요청 가능
   → 행동 분석 없이는 공격 식별 불가

③ 과도한 데이터 노출
   → 클라이언트 필터링을 믿고 모든 필드를 응답에 포함
   → 공격자는 앱 대신 API를 직접 호출해 전체 데이터 수신

OWASP API Security Top 10 2023 전체 목록

2023년 업데이트는 공격자가 실제로 동작하는 방식을 반영하며, Excessive Data Exposure와 Mass Assignment가 API3:2023 Broken Object Property Level Authorization으로 통합되고, SSRF가 독립 항목(API7:2023)으로 추가되었습니다. Practical DevSecOps

[OWASP API Security Top 10 2023]

순위  항목                                    핵심 위험
────────────────────────────────────────────────────────────────
API1  Broken Object Level Authorization      다른 사용자 객체 무단 접근
      (BOLA)                                 (전체 API 공격의 약 40%)

API2  Broken Authentication                  인증 우회, 계정 탈취

API3  Broken Object Property Level Auth      필드 수준 인가 미비
      (BOPLA)                                (과다 노출 + Mass Assignment 통합)

API4  Unrestricted Resource Consumption      DoS, 무제한 요청 허용

API5  Broken Function Level Authorization    관리자 기능 무단 호출
      (BFLA)                                 (수직 권한 상승)

API6  Unrestricted Access to Sensitive       비즈니스 로직 남용
      Business Flows                         (봇을 통한 티켓팅, 재고 소진)

API7  Server Side Request Forgery (SSRF)     내부 서비스·클라우드 메타데이터 노출

API8  Security Misconfiguration              기본 설정·불필요 엔드포인트 노출

API9  Improper Inventory Management          미사용·섀도우 API 방치

API10 Unsafe Consumption of APIs             외부 API 신뢰로 인한 공격 체인

실제 피해 사례: Duolingo API 취약점

2023년 1월 듀오링고 사용자 260만 명의 개인정보가 다크웹에 공개되었습니다. 당시 듀오링고 API는 이메일이나 사용자명만으로 사용자 정보에 접근할 수 있도록 설계되어 있었으며, 요청이 실제 사용자로부터 온 것인지 확인하는 보안 조치가 전혀 없어 사용자 데이터에 대한 접근이 제한되지 않았습니다. 이것이 BOLA(API1)와 Broken Authentication(API2)이 동시에 적용된 교과서적 사례입니다. Cloudbric


2. 인증·인가 보안 체크리스트 — BOLA·Broken Auth 완전 차단 {#2}

OWASP Top 10의 1, 2, 5번이 모두 인증·인가 영역입니다. API 보안 실무 체크리스트에서 가장 먼저, 가장 철저하게 점검해야 할 영역입니다.

✅ 인증(Authentication) 체크리스트

[ ] 1. Basic Auth를 사용하지 않는다

❌ Authorization: Basic dXNlcjpwYXNzd29yZA==
   (Base64 인코딩 = 암호화 아님, 쉽게 디코딩 가능)

✅ Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
   (JWT, 서명 검증 가능)

[ ] 2. JWT를 올바르게 검증한다

java

// ❌ 잘못된 JWT 검증 — alg:none 공격에 취약
public Claims parseJwt(String token) {
    // 알고리즘 검증 없이 파싱 → 공격자가 alg=none으로 서명 우회 가능
    return Jwts.parser()
               .parseClaimsJws(token)
               .getBody();
}

// ✅ 올바른 JWT 검증
@Bean
public JwtParser jwtParser() {
    return Jwts.parserBuilder()
        .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
        // 반드시 허용할 알고리즘을 명시적으로 지정
        .requireAudience("my-api")
        .build();
}

public Claims validateToken(String token) {
    try {
        return jwtParser()
            .parseClaimsJws(token)  // 서명 + 만료 시간 자동 검증
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new UnauthorizedException("토큰이 만료되었습니다");
    } catch (JwtException e) {
        throw new UnauthorizedException("유효하지 않은 토큰입니다");
    }
}

[ ] 3. JWT 만료 시간을 짧게 설정하고 Refresh Token을 분리한다

yaml

# application.yml 권장 설정
jwt:
  access-token-expiry: 900       # Access Token: 15분
  refresh-token-expiry: 604800   # Refresh Token: 7일
  algorithm: RS256               # HS256보다 RS256 권장 (비대칭 키)

# Access Token: 짧은 수명 → 탈취되어도 피해 최소화
# Refresh Token: DB에 저장 → 강제 무효화(로그아웃) 가능

[ ] 4. 로그인 실패 횟수를 제한한다 (브루트포스 방어)

java

@Service
public class LoginAttemptService {

    private final Cache<String, Integer> attemptsCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(15, TimeUnit.MINUTES)
            .build();

    private static final int MAX_ATTEMPTS = 5;

    public void loginFailed(String ip) {
        int attempts = getFailedAttempts(ip);
        attemptsCache.put(ip, attempts + 1);
    }

    public boolean isBlocked(String ip) {
        return getFailedAttempts(ip) >= MAX_ATTEMPTS;
    }

    // ✅ IP + 계정 ID 조합으로 더 정밀한 차단
    public boolean isBlocked(String ip, String userId) {
        return isBlocked(ip) || isBlocked(userId);
    }

    private int getFailedAttempts(String key) {
        Integer attempts = attemptsCache.getIfPresent(key);
        return attempts == null ? 0 : attempts;
    }
}

[ ] 5. OAuth 2.0 사용 시 PKCE를 적용한다

[Authorization Code Flow + PKCE (모바일/SPA 필수)]

클라이언트:
1. code_verifier = 랜덤 문자열 (43~128자)
2. code_challenge = BASE64URL(SHA256(code_verifier))
3. 인가 요청: GET /authorize
              ?response_type=code
              &code_challenge=xxxx
              &code_challenge_method=S256

인가 서버: 코드 발급 + code_challenge 저장

클라이언트:
4. 토큰 요청: POST /token
              code=yyyy
              &code_verifier=원본값  ← 중간자가 훔쳐도 code_verifier 없으면 무용지물

✅ 인가(Authorization) 체크리스트 — BOLA·BFLA 방어

[ ] 6. 모든 객체 접근에서 소유권을 서버에서 검증한다 (BOLA 방어)

java

// ❌ BOLA 취약 — URL의 ID만 믿음
@GetMapping("/api/orders/{orderId}")
public OrderResponse getOrder(@PathVariable Long orderId) {
    return orderService.findById(orderId); // 누구의 주문인지 확인 안 함
}

// ✅ BOLA 방어 — 현재 로그인 사용자의 소유 여부 검증
@GetMapping("/api/orders/{orderId}")
public OrderResponse getOrder(
        @PathVariable Long orderId,
        @AuthenticationPrincipal UserDetails userDetails) {

    Order order = orderService.findById(orderId);

    // 반드시 서버에서 소유권 검증
    if (!order.getUserId().equals(userDetails.getUserId())) {
        throw new ForbiddenException("해당 주문에 접근 권한이 없습니다");
    }

    return OrderResponse.from(order);
}

// ✅ 더 나은 방법: 쿼리 자체에 사용자 ID 포함 (DB 수준 격리)
@Query("SELECT o FROM Order o WHERE o.id = :id AND o.userId = :userId")
Optional<Order> findByIdAndUserId(Long id, Long userId);

[ ] 7. 자동 증가 ID 대신 UUID를 사용한다

java

// ❌ 예측 가능한 순차 ID → 열거 공격(Enumeration Attack) 취약
GET /api/users/1001
GET /api/users/1002  // 존재 여부 쉽게 파악 가능

// ✅ UUID → 열거 불가
GET /api/users/550e8400-e29b-41d4-a716-446655440000

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long internalId; // DB 내부 PK는 유지 (성능)

    @Column(unique = true, nullable = false)
    private UUID publicId = UUID.randomUUID(); // 외부 노출용 UUID 별도 관리
}

[ ] 8. 함수 수준 권한을 역할별로 명시적으로 검증한다 (BFLA 방어)

java

// ❌ URL 패턴으로만 관리자 기능 보호 (우회 가능)
// /admin/** 패턴 차단 → /Admin, /ADMIN, /api/v2/admin 우회 시도

// ✅ 메서드 수준 보안 + 역할 명시
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('USER') and #id == authentication.principal.id" +
                  " or hasRole('ADMIN')")
    public UserResponse getUser(@PathVariable Long id) { ... }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')") // 관리자만 삭제 가능
    public void deleteUser(@PathVariable Long id) { ... }

    @GetMapping("/all")
    @PreAuthorize("hasRole('ADMIN')") // 관리자만 전체 목록 조회
    public List<UserResponse> getAllUsers() { ... }
}

3. 입력 검증·데이터 노출 방지 체크리스트 {#3}

입력값을 신뢰하지 않는 것은 API 보안의 가장 기본 원칙입니다. 동시에 응답 데이터에서 필요 이상의 정보를 내보내지 않는 것도 똑같이 중요합니다.

✅ 입력 검증 체크리스트

[ ] 9. 모든 입력값에 화이트리스트 기반 검증을 적용한다

java

// ❌ 검증 없는 입력 처리
@PostMapping("/api/users")
public UserResponse createUser(@RequestBody UserRequest request) {
    return userService.create(request); // SQL Injection, XSS 취약
}

// ✅ Bean Validation + 커스텀 검증
@PostMapping("/api/users")
public UserResponse createUser(
        @Valid @RequestBody UserCreateRequest request) {
    return userService.create(request);
}

@Getter
public class UserCreateRequest {

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    @Size(max = 255)
    private String email;

    @NotBlank
    @Size(min = 8, max = 100, message = "비밀번호는 8~100자여야 합니다")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).*$",
             message = "비밀번호는 대소문자, 숫자, 특수문자를 포함해야 합니다")
    private String password;

    @NotBlank
    @Size(min = 1, max = 50)
    @Pattern(regexp = "^[가-힣a-zA-Z\\s]+$", message = "이름에 허용되지 않는 문자가 있습니다")
    private String name;

    @Min(0) @Max(150)
    private Integer age;
}

[ ] 10. SQL Injection을 Parameterized Query로 방어한다

java

// ❌ SQL Injection 취약
String query = "SELECT * FROM users WHERE email = '" + email + "'";
// 공격자 입력: ' OR '1'='1 → 전체 사용자 조회

// ✅ JPA Parameterized Query (자동 이스케이프)
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);

// ✅ 네이티브 쿼리도 파라미터 바인딩 사용
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);

// ❌ 절대 금지: 동적 문자열 조합
entityManager.createQuery("FROM User WHERE name = '" + name + "'"); // 위험

[ ] 11. 파일 업로드 시 타입·크기·확장자를 검증한다

java

@PostMapping("/api/files/upload")
public FileResponse uploadFile(@RequestParam MultipartFile file) {

    // 1. 파일 크기 제한
    if (file.getSize() > 10 * 1024 * 1024) { // 10MB
        throw new BadRequestException("파일 크기는 10MB를 초과할 수 없습니다");
    }

    // 2. 확장자 화이트리스트 (블랙리스트 방식은 우회 가능)
    String originalFilename = file.getOriginalFilename();
    String extension = FilenameUtils.getExtension(originalFilename).toLowerCase();
    Set<String> allowedExtensions = Set.of("jpg", "jpeg", "png", "pdf");
    if (!allowedExtensions.contains(extension)) {
        throw new BadRequestException("허용되지 않는 파일 형식입니다");
    }

    // 3. Magic Byte 검증 (확장자 위조 방지)
    byte[] header = new byte[8];
    file.getInputStream().read(header);
    if (!isValidMagicBytes(header, extension)) {
        throw new BadRequestException("파일 내용이 확장자와 일치하지 않습니다");
    }

    // 4. 파일명 무작위화 (경로 탐색 공격 방지)
    String safeFilename = UUID.randomUUID() + "." + extension;
    // 웹 루트 외부에 저장
    Path savePath = Paths.get("/secure/uploads/", safeFilename);
    Files.copy(file.getInputStream(), savePath);

    return new FileResponse(safeFilename);
}

✅ 데이터 노출 방지 체크리스트 (BOPLA 방어)

[ ] 12. 응답에 필요한 필드만 포함하는 DTO를 별도로 정의한다

java

// ❌ Entity를 그대로 반환 — 비밀번호, 내부 ID 등 민감 정보 노출
@GetMapping("/api/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
    // password, salt, internalNotes, adminFlags 등도 함께 노출!
}

// ✅ 목적별 DTO 분리
public record UserPublicResponse(
    UUID id,           // 내부 Long ID 대신 공개용 UUID
    String name,
    String email,
    LocalDate joinedAt
    // password, role, internalId 등은 절대 포함하지 않음
) {
    public static UserPublicResponse from(User user) {
        return new UserPublicResponse(
            user.getPublicId(),
            user.getName(),
            user.getEmail(),
            user.getCreatedAt().toLocalDate()
        );
    }
}

// ✅ Mass Assignment 방지: @JsonIgnore 또는 읽기 전용 설정
public class UserUpdateRequest {
    private String name;
    private String email;
    // role, adminFlag 등 민감 필드는 아예 포함하지 않음
    // 설령 요청 JSON에 포함되어도 역직렬화 무시
}

[ ] 13. 에러 응답에 내부 구현 정보를 노출하지 않는다

java

// ❌ 스택 트레이스·DB 스키마 노출
{
  "error": "org.hibernate.exception.ConstraintViolationException",
  "message": "ERROR: duplicate key value violates unique constraint users_email_key",
  "trace": "at org.hibernate...."  // DB 테이블명, 컬럼명 노출
}

// ✅ 일관된 에러 형식 + 내부 정보 숨김
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // 내부 로그는 상세하게
        log.error("Unexpected error: {}", e.getMessage(), e);

        // 외부 응답은 일반적으로
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "요청을 처리할 수 없습니다"));
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ConstraintViolationException e) {
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_ERROR", "입력값을 확인해주세요"));
        // DB 상세 오류 메시지는 숨김
    }
}

4. Rate Limiting·비즈니스 로직 보호 체크리스트 {#4}

요청 수 제한 없이 운영되는 API는 DDoS, 브루트포스, 크리덴셜 스터핑 등 자동화 공격에 무방비 상태입니다.

✅ Rate Limiting 체크리스트

[ ] 14. 엔드포인트별 차등 Rate Limit을 적용한다

java

// Spring Boot + Bucket4j를 이용한 Rate Limiting

@Component
public class RateLimitingFilter extends OncePerRequestFilter {

    // 엔드포인트별 버킷 설정
    private final Map<String, Bandwidth> endpointLimits = Map.of(
        "/api/auth/login",          Bandwidth.simple(5,  Duration.ofMinutes(1)),  // 로그인: 1분에 5회
        "/api/auth/password/reset", Bandwidth.simple(3,  Duration.ofHours(1)),    // 비밀번호 재설정: 1시간 3회
        "/api/auth/register",       Bandwidth.simple(3,  Duration.ofHours(1)),    // 회원가입: 1시간 3회
        "/api/",                    Bandwidth.simple(60, Duration.ofMinutes(1))   // 일반 API: 1분 60회
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {

        String ip = getClientIp(request);
        String path = request.getRequestURI();
        String bucketKey = ip + ":" + getMatchingPattern(path);

        Bucket bucket = bucketCache.computeIfAbsent(bucketKey,
            k -> Bucket.builder()
                .addLimit(getLimit(path))
                .build());

        if (bucket.tryConsume(1)) {
            chain.doFilter(request, response);
        } else {
            response.setStatus(429); // Too Many Requests
            response.setHeader("Retry-After", "60");
            response.setHeader("X-RateLimit-Limit", "60");
            response.setHeader("X-RateLimit-Remaining", "0");
            response.getWriter().write(
                "{\"error\":\"TOO_MANY_REQUESTS\",\"message\":\"잠시 후 다시 시도해주세요\"}"
            );
        }
    }

    private String getClientIp(HttpServletRequest request) {
        // X-Forwarded-For 헤더 우선 확인 (리버스 프록시 환경)
        String xff = request.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) {
            return xff.split(",")[0].trim(); // 첫 번째 IP가 실제 클라이언트
        }
        return request.getRemoteAddr();
    }
}

[ ] 15. 응답 페이로드 크기와 페이지네이션을 강제한다

java

// ❌ 무제한 응답 — 메모리·대역폭 고갈 공격(API4) 취약
@GetMapping("/api/products")
public List<Product> getAllProducts() {
    return productRepository.findAll(); // 수백만 건 응답 가능
}

// ✅ 강제 페이지네이션 + 최대 크기 제한
@GetMapping("/api/products")
public Page<ProductResponse> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

    // 최대 페이지 크기 강제 제한
    int maxSize = Math.min(size, 100); // 최대 100개
    Pageable pageable = PageRequest.of(page, maxSize,
        Sort.by(Sort.Direction.DESC, "createdAt"));

    return productRepository.findAll(pageable)
        .map(ProductResponse::from);
}

✅ 비즈니스 로직 보호 체크리스트 (API6 방어)

[ ] 16. 자동화 봇을 탐지하고 차단하는 로직을 추가한다

python

# Python/FastAPI 예시: 비정상 행동 탐지

from fastapi import Request
import time

class BotDetectionMiddleware:
    """비즈니스 로직 남용 탐지 미들웨어"""

    SUSPICIOUS_PATTERNS = {
        "ticket_purchase": {"max_per_user": 2, "window_seconds": 3600},
        "product_add_cart": {"max_per_user": 50, "window_seconds": 300},
        "promo_code_try":   {"max_per_user": 5,  "window_seconds": 3600},
    }

    async def check_business_limit(self, user_id: str, action: str) -> bool:
        pattern = self.SUSPICIOUS_PATTERNS.get(action)
        if not pattern:
            return True

        key = f"bl:{action}:{user_id}"
        count = await redis_client.incr(key)
        if count == 1:
            await redis_client.expire(key, pattern["window_seconds"])

        if count > pattern["max_per_user"]:
            await self.alert_suspicious_activity(user_id, action, count)
            return False

        return True

    async def alert_suspicious_activity(self, user_id, action, count):
        # SIEM, Slack, PagerDuty 등으로 실시간 알림
        await alerting_service.send(
            f"[보안 경고] user={user_id} action={action} count={count}"
        )

[ ] 17. SSRF 취약점을 방어한다 (API7)

java

// SSRF: 공격자가 내부 서버·클라우드 메타데이터에 접근하는 취약점
// 예: 사용자가 제공한 URL로 서버가 HTTP 요청을 보내는 경우

// ❌ SSRF 취약
@PostMapping("/api/webhooks/test")
public String testWebhook(@RequestBody WebhookRequest request) {
    // request.url 이 http://169.254.169.254/latest/meta-data/ (AWS 메타데이터) 일 수 있음!
    return httpClient.get(request.getUrl());
}

// ✅ SSRF 방어: URL 화이트리스트 + 내부 IP 차단
@Component
public class SafeHttpClient {

    private static final Set<String> BLOCKED_HOSTS = Set.of(
        "169.254.169.254",  // AWS/GCP 메타데이터
        "metadata.google.internal",
        "localhost", "127.0.0.1", "0.0.0.0"
    );

    private static final List<String> BLOCKED_CIDR = List.of(
        "10.0.0.0/8",       // 사설 네트워크
        "172.16.0.0/12",
        "192.168.0.0/16"
    );

    public String safeGet(String url) {
        URI uri = URI.create(url);

        // 1. 허용된 스킴만 사용 (http/https만 허용)
        if (!List.of("http", "https").contains(uri.getScheme().toLowerCase())) {
            throw new BadRequestException("허용되지 않는 URL 스킴입니다");
        }

        // 2. 호스트 검증 (DNS 리바인딩 공격도 방어하려면 IP 해석 후 재검증)
        String host = uri.getHost();
        if (BLOCKED_HOSTS.contains(host) || isPrivateIp(host)) {
            throw new ForbiddenException("내부 네트워크 주소는 허용되지 않습니다");
        }

        return httpClient.execute(new HttpGet(uri));
    }
}

5. 전송 보안·헤더·설정 오류 방지 체크리스트 {#5}

코드 수준의 보안이 완벽해도 전송 구간이 열려 있거나 보안 헤더가 없으면 무용지물입니다.

✅ 전송 보안 체크리스트

[ ] 18. 모든 API 통신에 HTTPS(TLS 1.2 이상)를 강제한다

nginx

# Nginx 설정: HTTP → HTTPS 강제 리다이렉트
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$server_name$request_uri;  # 영구 리다이렉트
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # TLS 1.2 이상만 허용 (TLS 1.0, 1.1 비활성화)
    ssl_protocols TLSv1.2 TLSv1.3;

    # 강력한 암호화 스위트만 허용
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
                ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # HSTS: 브라우저가 항상 HTTPS를 사용하도록 강제
    add_header Strict-Transport-Security
        "max-age=31536000; includeSubDomains; preload" always;
}

[ ] 19. API 키와 시크릿을 코드에 하드코딩하지 않는다

bash

# ❌ 절대 금지: 코드에 직접 하드코딩
DB_PASSWORD=supersecret123
JWT_SECRET=myverylongsecretkey

# ✅ 환경 변수 또는 시크릿 관리 서비스 사용
# AWS Secrets Manager 예시
aws secretsmanager get-secret-value --secret-id prod/myapp/db

# ✅ Spring Boot: @Value + 환경 변수
@Value("${JWT_SECRET}")
private String jwtSecret;

# ✅ .env 파일 사용 시 반드시 .gitignore에 추가
echo ".env" >> .gitignore
echo "*.pem" >> .gitignore
echo "application-prod.yml" >> .gitignore

✅ 보안 헤더 체크리스트

[ ] 20. 필수 보안 헤더를 모든 API 응답에 포함한다

java

// Spring Security 보안 헤더 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                // XSS 공격 방어 (구형 브라우저용)
                .xssProtection(xss -> xss.block(true))

                // 클릭재킹 방어
                .frameOptions(frame -> frame.deny())

                // MIME 스니핑 방어
                .contentTypeOptions(Customizer.withDefaults())

                // HTTPS 강제 (1년)
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))

                // 참조 페이지 정보 최소화
                .referrerPolicy(referrer ->
                    referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN))
            )
            // API는 세션 불필요 (Stateless)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // CSRF: Stateless API는 비활성화 (JWT 사용 시)
            .csrf(csrf -> csrf.disable());

        return http.build();
    }
}

[ ] 21. CORS를 필요한 출처만 허용한다

java

// ❌ 모든 출처 허용 — 최악의 설정
@CrossOrigin(origins = "*")  // 절대 금지 (인증 포함 시 특히 위험)

// ✅ 허용된 출처만 명시
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // 허용 출처 명시적 지정
    config.setAllowedOrigins(List.of(
        "https://app.example.com",
        "https://admin.example.com"
    ));

    // 허용 메서드 제한
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));

    // 허용 헤더 제한
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));

    // 인증 정보 포함 허용 (cookies, Authorization 헤더)
    config.setAllowCredentials(true);

    // Preflight 캐시: 1시간
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

✅ API 설계 보안 체크리스트 (API8·API9 방어)

[ ] 22. API 버전 관리와 미사용 엔드포인트 비활성화

java

// ✅ API 버전 명시 (v1, v2 구분)
@RequestMapping("/api/v1/users")  // 명확한 버전 접두사

// ✅ 운영 환경에서 개발용 엔드포인트 비활성화
@Profile("!production")  // 운영 환경에서는 이 컨트롤러 비활성화
@RestController
@RequestMapping("/api/dev")
public class DevController {
    @GetMapping("/reset-db")
    public String resetDatabase() { ... } // 개발 전용 엔드포인트
}

// ✅ Actuator 엔드포인트 제한 (Spring Boot)
# application-production.yml
management:
  endpoints:
    web:
      exposure:
        include: health, info  # health, info만 공개
        # 절대 공개 금지: env, beans, mappings, heapdump, threaddump
  endpoint:
    health:
      show-details: never  # 내부 상세 정보 숨김

6. 로깅·모니터링·사고 대응 체크리스트와 자동화 전략 {#6}

보안 사고는 언제 발생할지 알 수 없습니다. API 보안 실무 체크리스트의 마지막 단계는 “탐지”와 “대응” 능력을 갖추는 것입니다.

✅ 로깅 체크리스트

[ ] 23. 보안 관련 이벤트를 구조화된 형식으로 기록한다

java

// 보안 감사 로그 구조 정의
@Slf4j
@Component
public class SecurityAuditLogger {

    // 구조화된 보안 로그 (JSON)
    public void logAuthEvent(String event, String userId,
                              String ip, boolean success) {
        // 민감 정보(비밀번호, 토큰 전체) 는 절대 로깅하지 않음
        log.info("""
            {
              "event": "{}",
              "userId": "{}",
              "ip": "{}",
              "success": {},
              "timestamp": "{}",
              "userAgent": "{}"
            }
            """,
            event, maskUserId(userId), ip, success,
            Instant.now(), currentUserAgent()
        );
    }

    // ❌ 절대 로깅 금지 항목
    // - 비밀번호 (평문·해시 모두)
    // - JWT 토큰 전체 (서명 부분)
    // - 신용카드 번호 (마스킹 후 가능: 1234-****-****-5678)
    // - 주민등록번호, 여권번호
    // - API 시크릿 키

    private String maskUserId(String userId) {
        if (userId == null || userId.length() <= 4) return "****";
        return userId.substring(0, 2) + "****" + userId.substring(userId.length() - 2);
    }
}

[ ] 24. 이상 징후 탐지를 위한 알람을 설정한다

yaml

# Prometheus + Alertmanager 경고 규칙 예시
groups:
  - name: api_security_alerts
    rules:
      # 로그인 실패 급증
      - alert: HighLoginFailureRate
        expr: rate(api_auth_failures_total[5m]) > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "로그인 실패 급증 탐지"
          description: "5분간 로그인 실패 {{ $value }}건/초"

      # 429 응답 급증 (Rate Limit 초과)
      - alert: HighRateLimitHits
        expr: rate(http_responses_total{status="429"}[5m]) > 50
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Rate Limit 초과 급증"

      # 401/403 급증 (인증·인가 공격 시도)
      - alert: HighAuthErrorRate
        expr: rate(http_responses_total{status=~"401|403"}[5m]) > 20
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "인증·인가 오류 급증 — BOLA/BFLA 공격 가능성"

      # 비정상적인 응답 크기 (데이터 유출 가능성)
      - alert: AbnormalResponseSize
        expr: histogram_quantile(0.99, http_response_size_bytes) > 10485760
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "비정상적 대용량 응답 탐지 — 데이터 유출 가능성"

✅ CI/CD 파이프라인 보안 자동화

[ ] 25. 배포 파이프라인에 보안 검사를 통합한다

yaml

# GitHub Actions 보안 파이프라인 예시
name: API Security Pipeline

on: [push, pull_request]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      # 1. 의존성 취약점 스캔 (OWASP Dependency Check)
      - name: OWASP Dependency Check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'my-api'
          failBuildOnCVSS: 7  # CVSS 7점 이상 취약점 발견 시 빌드 실패

      # 2. 정적 분석 (SpotBugs + Find Security Bugs)
      - name: SpotBugs Security Scan
        run: mvn spotbugs:check -Dspotbugs.plugins=com.h3xstream.findsecbugs

      # 3. 시크릿 노출 스캔
      - name: Secret Scanning
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          fail: true

      # 4. SAST (Semgrep)
      - name: Semgrep Security Analysis
        uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/java
            p/jwt
            p/sql-injection

      # 5. 컨테이너 이미지 취약점 스캔
      - name: Container Scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-api:latest
          severity: 'HIGH,CRITICAL'
          exit-code: '1'  # 심각 취약점 발견 시 배포 차단

최종 배포 전 보안 점검 원페이지 요약

[API 배포 전 최종 보안 체크리스트 요약]

인증·인가
□ JWT alg:none 공격 방어 설정 완료
□ 토큰 만료 시간 설정 (Access: ≤15분, Refresh: ≤7일)
□ 모든 객체 접근에 소유권 검증 (BOLA 방어)
□ 역할별 함수 수준 권한 검증 (BFLA 방어)
□ 로그인 실패 횟수 제한 (IP + 계정 조합)

입력·출력
□ 모든 입력값 화이트리스트 기반 검증
□ SQL/NoSQL Injection → Parameterized Query
□ 응답 DTO 분리 (민감 필드 제외)
□ 에러 메시지에 내부 정보 미포함
□ 파일 업로드 타입·크기·Magic Byte 검증

전송·설정
□ HTTPS 강제 + HSTS 헤더
□ TLS 1.2 이상만 허용
□ 보안 헤더 전체 설정 (X-Frame, CSP, CORS)
□ API 키·시크릿 환경 변수 관리
□ 개발용 엔드포인트 비활성화 확인

속도 제한·모니터링
□ 엔드포인트별 Rate Limit 설정
□ 보안 이벤트 구조화 로깅
□ 이상 징후 알람 설정
□ CI/CD 파이프라인 보안 스캔 통합
□ 의존성 취약점 주기적 업데이트

결론

API 보안 실무 체크리스트는 세 가지 원칙으로 요약됩니다. 첫째, OWASP API Top 10 2023을 기준으로 BOLA·Broken Auth·BOPLA 순으로 인증·인가 취약점을 최우선으로 점검합니다. 둘째, 입력은 신뢰하지 않고, 출력은 최소화하며, 전송 구간은 반드시 암호화합니다. 셋째, 코드 수준의 방어만으로는 부족하며 Rate Limiting·구조화 로깅·CI/CD 보안 스캔을 파이프라인에 내재화해야 합니다. 오늘 당장 운영 중인 API의 /api/users/{id} 엔드포인트에서 다른 사용자의 ID로 요청했을 때 데이터가 반환되는지 직접 테스트하는 것부터 시작해보세요.


답글 남기기

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