| 방식 | 문제 |
|---|---|
| 평문 저장 | DB 유출 = 전 회원 비밀번호 유출. 다른 사이트 계정까지 위험(재사용) |
SHA-256(password) |
빠르다. GPU 로 초당 수십억 회 → 8자 비밀번호는 수 시간에 전수 탐색. 같은 비밀번호는 같은 해시라 레인보우 테이블 |
SHA-256(salt + password) |
레인보우 테이블은 막지만 여전히 빠르다 |
| PBKDF2 / bcrypt / scrypt / Argon2 | 일부러 느리게(반복·메모리) 만든 해시. 사용자 1명 로그인에 0.1~1초는 괜찮지만 공격자의 1억 회 시도는 수년 |
// 저장 형식 pbkdf2$반복횟수$salt(base64)$hash(base64)
public String hash(char[] password) {
byte[] salt = new byte[16];
SecureRandom.nextBytes(salt); // 사용자마다 다른 난수. 같은 비밀번호도 다른 해시
byte[] dk = pbkdf2(password, salt, iterations); // PBKDF2WithHmacSHA256, 256비트
return "pbkdf2$" + iterations + "$" + b64(salt) + "$" + b64(dk);
}
public static boolean verify(char[] password, String stored) {
String[] p = stored.split("\\$"); // 저장된 문자열에서 반복 횟수와 salt 를 꺼내
byte[] actual = pbkdf2(password, Base64.decode(p[2]), Integer.parseInt(p[1]));
return MessageDigest.isEqual(Base64.decode(p[3]), actual); // 상수 시간 비교
}세 가지가 핵심입니다.
needsRehash).Arrays.equals 는 첫 다른 바이트에서 멈추므로 응답 시간 차이로 해시를 한 바이트씩 맞출 수 있습니다. MessageDigest.isEqual 은 끝까지 비교합니다.OWASP 2023 권고는 PBKDF2-HMAC-SHA256 600,000 회입니다. 이 레슨은 시연 시간 때문에 210,000 회를 쓰고, 예제 1 에서 반복 횟수와 소요 시간의 관계를 실측합니다.
Spring Security 의 BCryptPasswordEncoder, Argon2PasswordEncoder 가 같은 형식($2a$10$…, $argon2id$…)으로 같은 일을 합니다. DelegatingPasswordEncoder 가 접두어를 보고 알고리즘을 고르는 것이 이 레슨의 pbkdf2$ 접두어와 같은 아이디어입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJpc3MiOiJhdXRoLWxlc3NvbiIsInN1YiI6ImtpbSIsImlhdCI6MTc4ODkyNzM0OSwiZXhwIjoxNzg4OTMwOTQ5fQ . MTZ5ElrVIdW1fQu1oMdPlym6YRHKKEuIizgLwzUu4kU
└──────── header ────────┘ └──────────────────────── payload ────────────────────────┘ └────────── signature ──────────┘
{"alg":"HS256","typ":"JWT"} {"iss":"auth-lesson","sub":"kim","iat":1788927349,"exp":1788930949} HMAC-SHA256(secret, header.payload)| 부분 | 내용 | 주의 |
|---|---|---|
| header | 서명 알고리즘(alg), 타입, 키 식별자(kid) |
검증자가 alg 를 믿으면 안 된다. 기대하는 알고리즘을 코드에 고정 |
| payload | 클레임: iss sub iat exp nbf aud + 사용자 정의(roles) |
암호화가 아니라 인코딩. 누구나 읽는다 |
| signature | header.payload 를 비밀키(HS256) 또는 개인키(RS256)로 서명 |
한 글자만 바꿔도 서명이 안 맞는다 |
표준 클레임의 뜻은 iss 발급자, sub 주체, iat 발급 시각, exp 만료, nbf 유효 시작, aud 대상입니다. 페이로드는 누구나 디코딩해 읽으므로 비밀번호·주민번호를 넣으면 안 됩니다. 서명이 변조를 막는 것, 그것이 JWT 의 전부입니다.
JWT 가 세션 쿠키와 다른 점은 서버가 상태를 갖지 않는다는 것입니다. 세션은 서버 메모리나 Redis 에 "세션 ID → 사용자" 표가 있어야 하고, 서버가 여러 대면 그 표를 공유해야 합니다. JWT 는 토큰 자체에 사용자 정보와 서명이 있어 어느 서버든 비밀키(또는 공개키)만 있으면 검증합니다.
대가로 발급한 토큰을 만료 전에 취소할 수 없습니다. 로그아웃, 강제 종료, 권한 변경이 즉시 반영되지 않습니다. 그래서 Access 토큰을 짧게(5~30분) 두고 Refresh 토큰으로 갱신하는데, 그것이 2단계 레슨입니다.
| HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) | |
|---|---|---|
| 키 | 비밀키 하나. 발급자와 검증자가 같은 키 | 개인키(서명) + 공개키(검증) |
| 서명 크기 | 32 bytes | 256 bytes (RSA-2048). 토큰이 2배 이상 길다 |
| 속도 | 빠름 | 서명 느림, 검증은 빠른 편 |
| 쓰는 곳 | 인증 서버 = 리소스 서버인 단일 서비스 | 인증 서버 1개, 리소스 서버 여러 개(MSA), 외부 파트너가 검증 |
| 키 배포 | 비밀키를 모든 검증 서버에 복사해야 함. 하나라도 유출되면 위조 가능 | 공개키만 배포(JWKS 엔드포인트). 개인키는 인증 서버 밖으로 안 나감 |
// HS256
String signingInput = base64url(header) + "." + base64url(payload);
byte[] sig = HMAC-SHA256(secret, signingInput); // Mac.getInstance("HmacSHA256")
// RS256
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey); sig.update(signingInput.getBytes(US_ASCII));
byte[] signature = sig.sign();HS256 비밀키는 최소 256비트 난수여야 합니다. "mysecret" 같은 문자열은 사전 공격으로 수초 만에 찾힙니다. 키는 환경변수나 비밀 저장소에서 주입하고 코드·설정 파일·git 에 넣지 않습니다. RS256 의 kid(key id) 헤더는 키 회전용입니다. 인증 서버가 새 키로 서명을 시작해도 이전 키로 서명된 토큰이 만료될 때까지 두 공개키를 모두 배포하고, 검증자는 kid 로 어느 키인지 고릅니다.
public static Map<String, Object> verifyHs256(String token, byte[] secret) {
Parsed t = parse(token); // 3부분 분리, 디코딩
requireAlg(t, "HS256"); // ① 헤더 alg 가 기대값인가 (none, RS256 거부)
if (!MessageDigest.isEqual(hmac(t.signingInput(), secret), t.signature()))
throw new JwtException("서명 불일치"); // ② 서명 재계산 후 상수 시간 비교
checkTime(t.payload()); // ③ exp 지났나, nbf 아직인가 (leeway 30초)
return t.payload();
}①을 첫 번째에 두는 이유가 있습니다. 2015년에 여러 JWT 라이브러리가 헤더의 alg 를 믿고 그 알고리즘으로 검증했습니다. 그 결과 두 가지 공격이 통했습니다.
alg=none 으로 바꾸고 서명을 비우면 "서명 없음 = 검증 통과" 가 됐습니다.alg=HS256 으로 바꾸고 공개키를 HMAC 비밀키로 써서 서명하면, RS256 검증자가 공개키로 HMAC 을 검증해 통과시켰습니다(키 혼동).두 취약점 모두 "검증자가 기대하는 알고리즘을 코드에 고정" 하면 사라집니다. jjwt 는 Jwts.parser().verifyWith(key) 에서 키 타입으로 알고리즘을 고정합니다.
leeway 는 서버 간 시계 오차 허용치입니다. 발급 서버와 검증 서버의 시계가 몇 초 어긋나면 방금 발급한 토큰이 "아직 유효하지 않음"이 될 수 있어 30초 정도를 둡니다. 예제 4 의 만료 시연에서 TTL 2초 토큰이 실제로는 32초 뒤에 거부되는 이유입니다.
요청: Authorization: Bearer eyJhbGciOi…
응답: 401 Unauthorized + WWW-Authenticate: Bearer realm="…" 토큰 없음
401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token" 토큰 있으나 무효(서명·만료)
403 Forbidden 토큰 유효하나 권한 부족401 과 403 을 구분하는 것이 중요합니다. 401 은 "누군지 모르겠다, 다시 인증하라"이고 403 은 "누군지 알겠는데 안 된다"입니다. 클라이언트는 401 을 받으면 토큰을 갱신하거나 로그인 화면으로 보내고, 403 은 권한 안내를 띄웁니다. 둘을 섞으면 프론트엔드가 무한 로그인 루프에 빠지거나 권한 오류를 로그인 오류로 표시합니다.
WWW-Authenticate 헤더 값은 ASCII 만 가능합니다. 이 레슨을 작성하며 한글 오류 사유를 헤더에 넣었다가 JDK HttpClient 가 "Invalid header value" 로 응답 자체를 거부했습니다.
사유는 JSON 본문에, 헤더에는 error="invalid_token" 코드만 넣습니다. 토큰이 든 응답에는 Cache-Control: no-store 를 붙여 프록시·브라우저 캐시에 남지 않게 합니다.
Optional<User> found = users.find(username);
if (found.isEmpty()) {
PasswordHasher.verify(password, DUMMY_HASH); // ① 없는 사용자도 해시 계산 → 응답 시간 동일
json(ex, 401, "아이디 또는 비밀번호가 올바르지 않습니다"); // ② 같은 메시지
return;
}
if (u.locked()) { json(ex, 423, "계정이 잠겼습니다"); return; } // ③ 5회 실패 → 잠금
if (!verify(password, u.passwordHash())) { users.save(u.failed()); json(ex, 401, 같은 메시지); return; }
if (hasher.needsRehash(u.passwordHash())) u = u.withHash(hasher.hash(password)); // ④ 점진적 재해시
users.save(u.succeeded()); // ⑤ 실패 카운터 리셋①과 ②는 계정 존재 여부를 알려 주지 않기 위한 것입니다. "존재하지 않는 아이디" 와 "비밀번호 오류" 를 구분해 주면 공격자가 유효한 아이디 목록을 만들 수 있습니다. 메시지를 같게 해도 없는 사용자는 해시 계산을 건너뛰어 응답이 10배 빠르므로, 더미 해시를 계산해 시간까지 같게 만듭니다.
③은 무차별 대입 방어입니다. 실무에서는 IP 별 속도 제한, CAPTCHA, 잠금 해제 시간(15분)을 조합합니다. 잠금 자체가 "남의 계정을 5번 틀려서 잠그는" 서비스 거부가 되지 않도록 설계해야 합니다.
⑤에서 성공 시 카운터를 리셋해야 정상 사용자가 가끔 틀린 것이 누적되지 않습니다.
// jjwt 0.12 — 이 레슨의 Jwt.hs256 / verifyHs256
SecretKey key = Keys.hmacShaKeyFor(secretBytes); // 256비트 미만이면 예외를 던져 준다
String token = Jwts.builder().issuer("auth-lesson").subject(user.getUsername())
.claim("roles", roles).issuedAt(now).expiration(exp).signWith(key).compact();
Claims claims = Jwts.parser().verifyWith(key).clockSkewSeconds(30).build() // 키 타입으로 alg 고정, leeway
.parseSignedClaims(token).getPayload(); // 서명·만료 검증 실패 시 JwtException
// Spring Security — 이 레슨의 AuthServer.authenticated
public class JwtAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
String auth = req.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
try {
Claims c = jwtParser.parseSignedClaims(auth.substring(7)).getPayload();
var authorities = ((List<String>) c.get("roles")).stream().map(SimpleGrantedAuthority::new).toList();
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(c.getSubject(), null, authorities));
} catch (JwtException e) { /* 인증 없이 통과 → 뒤에서 401 */ }
}
chain.doFilter(req, res);
}
}
// SecurityFilterChain: 이 레슨의 requiredRole 검사
http.authorizeHttpRequests(a -> a.requestMatchers("/admin/**").hasRole("ADMIN").requestMatchers("/me").authenticated().anyRequest().permitAll())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) // 세션 쿠키 안 씀 = JWT 무상태
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.csrf(c -> c.disable()); // 쿠키 인증이 아니므로 CSRF 불필요 (2단계에서 다시 검토)| 이 레슨 | Spring Security + jjwt |
|---|---|
PasswordHasher |
PasswordEncoder(BCrypt·Argon2), DelegatingPasswordEncoder |
Jwt.hs256 / verifyHs256 |
Jwts.builder() / Jwts.parser() |
AuthServer.login |
AuthenticationManager + UserDetailsService, 또는 직접 /login 컨트롤러 |
AuthServer.authenticated |
OncePerRequestFilter 로 SecurityContext 채우기 |
requiredRole 검사 |
authorizeHttpRequests().hasRole() 또는 @PreAuthorize |
| 401/403 응답 | AuthenticationEntryPoint / AccessDeniedHandler |
| 계정 잠금 | isAccountNonLocked() + 인증 실패 이벤트 리스너 |