보안에 대한 질문은 맞았다.
다만 그것을 해결하는 방법이 틀렸다.
이 글은 그 과정에서 배운 것들의 기록이다.

발단 — 토큰 탈취를 고려한 설계

사용자 프로필 수정 API를 만들면서 이런 고민이 생겼다.

"Access Token이 탈취되면 공격자가 다른 사람의 정보를 수정할 수 있지 않을까?"

그래서 폼에 이메일을 추가해서 로그인한 사용자의 이메일과 비교하는 방식으로 검증을 추가했다.

// 이런 의도로 작성된 코드
@PutMapping("/user")
public ResponseEntity<UserInfoDTO> editUserProfile(
        @RequestBody @Validated UserEditForm form,
        BindingResult bindingResult,
        @AuthenticationPrincipal PrincipalDetails principalDetails) {

    UserLoginDTO userLoginDTO = principalDetails.getUserLoginDTO();

    // 토큰 탈취 방어 의도로 추가한 검증
    if (!userLoginDTO.getEmail().equals(form.getEmail())) {
        bindingResult.rejectValue("email", "user.email.mismatch", "...");
        throw new ValidationException(bindingResult);
    }

    UserInfoDTO result = userService.updateUserProfile(userLoginDTO.getId(), form);
    return ResponseEntity.ok(result);
}
// 이메일 검증을 위해 불필요하게 추가된 필드
@Getter @Setter
public class UserEditForm {
    @NotNull
    private String countryCode; // 실제 수정 대상

    @NotBlank
    @Pattern(regexp = EMAIL_PATTERN, message = "{email.format}")
    private String email;       // 검증 목적으로만 추가
}

보안을 고려한 설계였지만, 다시 생각해보니 문제가 있었다.


문제 1 — 이미 인증이 완료된 상태다

@AuthenticationPrincipal은 Spring Security가 토큰을 검증하고 인증된 사용자 정보를 주입해준다. 이 시점에서 이미 "올바른 사용자의 요청"이라는 증명이 끝난 상태다.

// Spring Security가 이미 검증한 사용자
@AuthenticationPrincipal PrincipalDetails principalDetails

// 여기서 이메일로 또 검증하는 건 이중 검증
if (!userLoginDTO.getEmail().equals(form.getEmail())) { ... }

Spring Security를 신뢰하지 않는 것과 다를 게 없다.


문제 2 — 토큰 탈취에 실질적인 방어가 되지 않는다

더 근본적인 문제는, 이메일 검증이 토큰 탈취를 막는 실질적인 방어 수단이 되지 않는다는 것이다.

토큰을 탈취한 공격자는
→ 피해자의 이메일도 알고 있을 가능성이 높음
→ JWT라면 토큰 안에 이메일 클레임이 포함된 경우가 많음
→ 이메일 검증을 추가해도 공격자가 우회하기 쉬움

이메일을 추가로 검증한다고 보안이 강화되는 게 아니었다.


문제 3 — 불필요한 복잡도가 생긴다

1. 클라이언트가 매번 이메일을 함께 전송해야 함  →  불필요한 데이터 전송
2. 폼의 목적(countryCode 변경)과 무관한 필드가 섞임  →  혼란
3. 이메일이 변경되면 클라이언트 로직도 수정해야 함  →  불필요한 결합

토큰 탈취는 다른 레벨에서 방어해야 한다

보안 질문 자체는 올바른 방향이었다. 다만 방어 수단이 잘못됐다. 토큰 탈취 방어는 토큰 자체의 생명주기와 저장 방식으로 해결해야 한다.

1. Access Token 만료 시간을 짧게

Access Token  →  15분 ~ 1시간
Refresh Token →  7일 ~ 30일

탈취되더라도 짧은 시간 안에 만료되도록 만든다.

2. Refresh Token Rotation

Refresh Token 사용 시 새로운 Refresh Token 발급
→ 탈취된 Refresh Token은 한 번만 사용 가능
→ 재사용 감지 시 모든 토큰 무효화

3. 안전한 토큰 저장

❌ localStorage  →  XSS 공격에 취약
✅ HttpOnly 쿠키  →  JavaScript로 접근 불가

4. HTTPS 강제

HTTP  →  네트워크 도청으로 토큰 탈취 가능
HTTPS →  전송 구간 암호화로 도청 방지

5. 의심스러운 요청 감지

→ 짧은 시간 내 다른 IP에서 요청
→ 비정상적인 User-Agent 변경
→ 감지 시 해당 토큰 무효화 또는 재인증 요구

개선된 코드

이메일 필드를 제거하고 인증 정보만으로 처리한다.

// ✅ UserEditForm — 실제 수정 대상만
@Getter @Setter
public class UserEditForm {

    @NotNull
    private String countryCode;
}
// ✅ 컨트롤러 — 인증 정보로 충분
@PutMapping("/user")
public ResponseEntity<UserInfoDTO> editUserProfile(
        @RequestBody @Validated UserEditForm form,
        BindingResult bindingResult,
        @AuthenticationPrincipal PrincipalDetails principalDetails) {

    if (bindingResult.hasErrors()) {
        throw new ValidationException(bindingResult);
    }

    UserInfoDTO result = userService.updateUserProfile(
        principalDetails.getUserLoginDTO().getId(), form
    );
    return ResponseEntity.ok(result);
}

정리

고민한 것  →  "토큰이 탈취되면 어떡하지?"        ✅ 올바른 질문
선택한 것  →  "폼에 이메일을 추가해서 검증하자"   ❌ 효과 없는 방어
올바른 것  →  토큰의 생명주기와 저장 방식으로 방어  ✅

보안 문제는 그 문제가 발생하는 레이어에서 방어해야 한다. 토큰 탈취는 토큰 레벨에서, 입력값 위변조는 검증 레벨에서, 권한 문제는 인가 레벨에서 방어한다. 엉뚱한 레이어에서 방어하려 하면 실질적인 보안 효과 없이 복잡도만 높아진다.

@AuthenticationPrincipal이 있으면 그게 이미 신원 검증이다.
Spring Security를 신뢰하고, 토큰 보안은 토큰 자체를 지키는 방식으로 해결한다.