// ❌ 넣지 말 것
Map.of("password", …, "ssn", "900101-1234567", "cardNo", …) // 페이로드는 평문. 로그·브라우저·프록시에 남는다
Map.of("permissions", 200개 문자열 목록) // 토큰 5KB → 모든 요청 헤더가 5KB. 8KB 제한에 걸림
// ✅ 넣을 것
Jwt.claims("auth-lesson", "kim", 900, Map.of(
"roles", List.of("ROLE_USER"), // 권한 그룹 수준. 세부 권한은 서버가 DB/캐시에서 조회
"tenant", "company-A", // 멀티테넌트 격리 키
"jti", UUID.randomUUID().toString() // 토큰 고유 ID. 2단계에서 폐기 목록(블랙리스트)에 쓴다
));토큰은 "누구인가"와 "무엇을 할 수 있는 큰 범주"만 담습니다. 상세 권한, 사용자 프로필, 설정은 토큰의 sub 로 서버가 조회합니다. 그래야 권한 변경이 토큰 재발급 없이 반영되고 토큰이 작게 유지됩니다.
// 검증자가 kid 별 키 맵을 갖는다. 새 키 추가 → 발급을 새 키로 전환 → 구 키 토큰이 모두 만료되면 구 키 제거
Map<String, byte[]> keys = Map.of("k1", oldSecret, "k2", newSecret);
public static Map<String, Object> verifyHs256Rotating(String token, Map<String, byte[]> keys) {
Parsed t = parse(token);
requireAlg(t, "HS256");
String kid = String.valueOf(t.header().get("kid"));
byte[] key = keys.get(kid);
if (key == null) throw new JwtException("알 수 없는 kid: " + kid); // 폐기된 키의 토큰은 여기서 거부된다
if (!MessageDigest.isEqual(hmac(t.signingInput(), key), t.signature())) throw new JwtException("서명 불일치");
checkTime(t.payload());
return t.payload();
}비밀키가 유출됐거나 정기 교체 시점이면 키를 바꿔야 하는데, 즉시 바꾸면 발급된 모든 토큰이 무효가 되어 전 사용자가 로그아웃됩니다. kid 를 두면 두 키가 공존하는 전환 기간을 둘 수 있습니다. 전환 기간은 Access 토큰 TTL 만큼이면 충분하고, 유출 대응이면 구 키를 즉시 제거해 강제 로그아웃시킵니다.
| 저장 위치 | XSS | CSRF | 특징 |
|---|---|---|---|
localStorage + Authorization 헤더 |
취약. 스크립트가 읽어 탈취 | 안전. 브라우저가 자동으로 안 붙임 | SPA 에서 흔함. XSS 한 번이면 끝 |
HttpOnly; Secure; SameSite=Strict 쿠키 |
안전. 스크립트가 못 읽음 | SameSite 로 대부분 방어. Lax 면 CSRF 토큰 병행 |
브라우저가 자동 전송. 서버는 헤더 대신 쿠키에서 꺼냄 |
// 쿠키 방식: 로그인 응답
ex.getResponseHeaders().add("Set-Cookie", "access=" + token + "; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900");
// 보호 자원: Authorization 헤더 대신 쿠키에서
String token = cookies(ex).get("access");이 레슨은 헤더 방식(API 서버·모바일 앱·서버 간 호출에 표준)을 썼습니다. 브라우저 SPA 라면 쿠키 방식이 XSS 에 더 강하고, 그때는 Spring Security 의 csrf().disable() 을 다시 생각해야 합니다. 2단계에서 Refresh 토큰을 쿠키에, Access 토큰을 메모리에 두는 조합을 다룹니다.
record LoginEvent(Instant at, String username, String ip, String result) {} // SUCCESS, BAD_PASSWORD, UNKNOWN_USER, LOCKED
List<LoginEvent> audit = new CopyOnWriteArrayList<>();
// login 의 각 분기에서
audit.add(new LoginEvent(Instant.now(), username, ex.getRemoteAddress().getAddress().getHostAddress(), "BAD_PASSWORD"));
// 이상 징후: 같은 IP 에서 1분에 서로 다른 아이디 20개 이상 → 크리덴셜 스터핑
Map<String, Long> perIp = audit.stream().filter(e -> e.at().isAfter(Instant.now().minusSeconds(60)) && !e.result().equals("SUCCESS"))
.collect(Collectors.groupingBy(LoginEvent::ip, Collectors.mapping(LoginEvent::username, Collectors.collectingAndThen(Collectors.toSet(), s -> (long) s.size()))));
perIp.forEach((ip, n) -> { if (n >= 20) System.out.println("차단 후보 IP " + ip + ": 1분에 " + n + "개 계정 시도"); });계정별 잠금은 한 계정을 집중 공격하는 경우만 막습니다. 유출된 아이디·비밀번호 목록으로 수천 계정을 각 1회씩 시도하는 크리덴셜 스터핑은 IP·시간 단위 집계로만 보입니다. Spring Security 는 AuthenticationSuccessEvent / AbstractAuthenticationFailureEvent 를 발행하므로 @EventListener 하나로 이 감사 로그를 모을 수 있습니다.