공공부하자개발 · 영어 학습 노트
자바
인증·로그인JWT · OAuth2 · 2FA 로 로그인 구현0/3 완료
  • 011단계: 비밀번호 해시와 JWT 로그인
  • 022단계: Access/Refresh 토큰과 OAuth2 인가 코드
  • 033단계: 2FA(TOTP) 와 로그인 흐름 통합
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 인증·로그인 › 01 / 3

1단계: 비밀번호 해시와 JWT 로그인

섹션 7진행 0 / 3
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 비밀번호는 "느린 해시 + 소금"으로 저장한다

방식 문제
평문 저장 DB 유출 = 전 회원 비밀번호 유출. 다른 사이트 계정까지 위험(재사용)
SHA-256(password) 빠르다. GPU 로 초당 수십억 회 → 8자 비밀번호는 수 시간에 전수 탐색. 같은 비밀번호는 같은 해시라 레인보우 테이블
SHA-256(salt + password) 레인보우 테이블은 막지만 여전히 빠르다
PBKDF2 / bcrypt / scrypt / Argon2 일부러 느리게(반복·메모리) 만든 해시. 사용자 1명 로그인에 0.1~1초는 괜찮지만 공격자의 1억 회 시도는 수년
java
// 저장 형식  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);      // 상수 시간 비교
}

세 가지가 핵심입니다.

  • salt 는 해시와 함께 저장합니다. 비밀이 아니며, 목적은 "같은 비밀번호 → 다른 해시" 입니다.
  • 반복 횟수도 함께 저장합니다. 나중에 정책을 올려도 기존 해시를 검증할 수 있고, 로그인 성공 직후 새 정책으로 재해시하는 점진적 마이그레이션이 됩니다(needsRehash).
  • 비교는 상수 시간으로 합니다. Arrays.equals 는 첫 다른 바이트에서 멈추므로 응답 시간 차이로 해시를 한 바이트씩 맞출 수 있습니다. MessageDigest.isEqual 은 끝까지 비교합니다.

OWASP 2023 권고는 PBKDF2-HMAC-SHA256 600,000 회입니다. 이 레슨은 시연 시간 때문에 210,000 회를 쓰고, 예제 1 에서 반복 횟수와 소요 시간의 관계를 실측합니다.

Spring Security 의 BCryptPasswordEncoder, Argon2PasswordEncoder 가 같은 형식($2a$10$…, $argon2id$…)으로 같은 일을 합니다. DelegatingPasswordEncoder 가 접두어를 보고 알고리즘을 고르는 것이 이 레슨의 pbkdf2$ 접두어와 같은 아이디어입니다.

2.2 JWT 의 구조 — Base64Url 로 감싼 JSON 두 덩이와 서명

text
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단계 레슨입니다.

2.3 HS256 vs RS256 — 대칭과 비대칭

HS256 (HMAC-SHA256) RS256 (RSA-SHA256)
키 비밀키 하나. 발급자와 검증자가 같은 키 개인키(서명) + 공개키(검증)
서명 크기 32 bytes 256 bytes (RSA-2048). 토큰이 2배 이상 길다
속도 빠름 서명 느림, 검증은 빠른 편
쓰는 곳 인증 서버 = 리소스 서버인 단일 서비스 인증 서버 1개, 리소스 서버 여러 개(MSA), 외부 파트너가 검증
키 배포 비밀키를 모든 검증 서버에 복사해야 함. 하나라도 유출되면 위조 가능 공개키만 배포(JWKS 엔드포인트). 개인키는 인증 서버 밖으로 안 나감
java
// 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 로 어느 키인지 고릅니다.

2.4 검증 순서 — 세 가지를 반드시, 이 순서로

java
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초 뒤에 거부되는 이유입니다.

2.5 Bearer 토큰과 HTTP 규약

text
요청:  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 를 붙여 프록시·브라우저 캐시에 남지 않게 합니다.

2.6 로그인 엔드포인트의 방어 — 정보 노출 최소화와 잠금

java
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번 틀려서 잠그는" 서비스 거부가 되지 않도록 설계해야 합니다.

⑤에서 성공 시 카운터를 리셋해야 정상 사용자가 가끔 틀린 것이 누적되지 않습니다.

2.7 jjwt 와 Spring Security 가 대신하는 것

java
// 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() + 인증 실패 이벤트 리스너
핵심 원리
  • 2.1 비밀번호는 "느린 해시 + 소금"으로 저장한다
  • 2.2 JWT 의 구조 — Base64Url 로 감싼 JSON 두 덩이와 서명
  • 2.3 HS256 vs RS256 — 대칭과 비대칭
  • 2.4 검증 순서 — 세 가지를 반드시, 이 순서로
  • 2.5 Bearer 토큰과 HTTP 규약
  • 2.6 로그인 엔드포인트의 방어 — 정보 노출 최소화와 잠금
  • 2.7 jjwt 와 Spring Security 가 대신하는 것
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제