inblog logo
|
DailyCoding
    Spring Boot

    14-3. 토큰 기반 인증 (Token-based Authentication)

    박은서's avatar
    박은서
    Feb 25, 2026
    14-3. 토큰 기반 인증 (Token-based Authentication)
    Contents
    1. 토큰 인증1️⃣ 토큰 인증이란?2️⃣ 토큰 인증의 대표 유형 2가지3️⃣ 동작원리(보안 관점에서 무엇을 검증하나)4️⃣ 스프링에서 JWT 토큰 인증 실행흐름5️⃣ 예시 코드

    1. 토큰 인증

    1️⃣ 토큰 인증이란?

    1) 핵심 아이디어

    • 서버가 로그인 성공 시 ‘이 사용자는 인증됨’을 나타내는 토큰 문자열을 발급
    • 이후 클라이언트는 요청마다 토큰을 실어 보내고,
    • 서버는 토큰을 검증해서 사용자 식별/권한 확인을 함

    2) 세션 인증 vs 토큰 인증

    • 세션
      • 서버 메모리/DB에 세션 상태를 저장
      • 클라이언트는 세션ID만 들고 다님 (상태ful)
    • 토큰
      • 인증 정보(또는 그 참조)를 토큰으로 들고 다님
      • 서버는 상태를 안 들고도 가능 (상태less 가능)

    2️⃣ 토큰 인증의 대표 유형 2가지

    1) JWT(JSON Web Token) — 자기 포함(Self-contained)

    • 토큰 안에 클레임(사용자ID, 만료시간, 권한 등)이 들어있음
    • 서버는 서명(Signature) 검증으로 위변조 여부 확인
    • 장단점
      • 장점: 서버가 DB 조회 없이도 검증 가능(확장성)
      • 단점: 토큰이 탈취되면 만료 전까지 위험(폐기/차단 설계가 중요)
    • JWT 구조
      • header.payload.signature
      • payload는 보통 Base64URL 인코딩이라 암호화가 아니라 “보여도 됨”(민감정보 넣으면 안 됨)

    2) Opaque Token(랜덤 토큰) — 참조형(Reference)

    • 토큰 자체는 의미 없는 랜덤 문자열
    • 서버/인증서버가 토큰 저장소(DB/Redis)에서 찾아서 유효성 확인(또는 introspection)
    • 장단점
      • 장점: 중앙에서 강제 폐기/회수 쉬움
      • 단점: 매 요청마다 저장소 조회 필요(캐시로 완화)

    3️⃣ 동작원리(보안 관점에서 무엇을 검증하나)

    서버가 보는 검증 포인트(일반적으로):
    1. 서명/무결성(JWT라면 signature 검증)
    1. 만료(exp) 및 발급자(iss), 대상(aud) 같은 표준 클레임
    1. 권한/스코프(scope, role) 확인 (인가 Authorization 단계)
    1. (선택) 폐기 목록(blacklist), 토큰 버전, 리프레시 토큰 회전 여부 등

    4️⃣ 스프링에서 JWT 토큰 인증 실행흐름

    1) 로그인 /auth/login

    1. 클라이언트가 username/password 전송
    1. 서버가 AuthenticationManager로 인증
    1. 성공 시:
        • Access Token(JWT, 짧게) 발급
        • Refresh Token(랜덤, 길게) 발급 + DB/Redis 저장
    1. 클라이언트에 반환(보통 JSON)

    2) API 호출

    1. 요청 헤더에 Authorization: Bearer <accessToken>
    1. JWT 인증 필터가 토큰을 파싱/검증
    1. 유효하면 SecurityContext에 Authentication 세팅
    1. 컨트롤러/서비스는 @AuthenticationPrincipal 또는 SecurityContextHolder로 사용자 조회

    3) Access 만료 → /auth/refresh

    1. Access 만료로 API에서 401
    1. 클라이언트가 refresh token으로 /auth/refresh
    1. 서버가 refresh token을 저장소에서 확인(유효/만료/폐기 여부)
    1. 새 access 발급(권장: refresh rotation도 함께)

    5️⃣ 예시 코드

    1) JWT 토큰 Provider

    package com.example.demo.auth; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Component; import java.security.Key; import java.time.Instant; import java.util.Date; import java.util.Map; @Component public class JwtTokenProvider { // 실습용. 운영에선 환경변수/Secret Manager + 충분히 긴 키 사용 private final Key key = Keys.hmacShaKeyFor( "dev-secret-dev-secret-dev-secret-dev-secret-1234".getBytes() ); private final String issuer = "my-api"; private final String audience = "my-client"; public String createAccessToken(String userId, String role, long ttlSeconds) { Instant now = Instant.now(); Instant exp = now.plusSeconds(ttlSeconds); return Jwts.builder() .issuer(issuer) .audience().add(audience).and() .subject(userId) // sub .claims(Map.of("role", role)) // 커스텀 클레임 .issuedAt(Date.from(now)) .expiration(Date.from(exp)) .signWith(key) .compact(); } public Jws<Claims> parseAndValidate(String token) { return Jwts.parser() .requireIssuer(issuer) .requireAudience(audience) .verifyWith((javax.crypto.SecretKey) key) .build() .parseSignedClaims(token); } }

    2) JWT 인증 필터 (Bearer 토큰 처리)

    package com.example.demo.auth; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String auth = request.getHeader("Authorization"); if (auth != null && auth.startsWith("Bearer ")) { String token = auth.substring(7); try { Jws<Claims> jws = jwtTokenProvider.parseAndValidate(token); Claims claims = jws.getPayload(); String userId = claims.getSubject(); String role = claims.get("role", String.class); var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); var authentication = new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // 토큰이 만료/위조/형식오류 등인 경우: SecurityContext 비우고 진행 SecurityContextHolder.clearContext(); // 보통은 여기서 바로 401로 끊기도 함(정책 선택) } } filterChain.doFilter(request, response); } }

    3) Security 설정 (필터 체인 + 접근 규칙)

    package com.example.demo.config; import com.example.demo.auth.JwtAuthenticationFilter; import com.example.demo.auth.JwtTokenProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }

    4) 실습용 사용자(인메모리) 설정

    package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.userdetails.*; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class UserConfig { @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { var user = User.withUsername("alice") .password(encoder.encode("pass123")) .roles("ADMIN") .build(); return new InMemoryUserDetailsManager(user); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }

    5) Refresh Token 저장/검증 (실습용: 메모리 Map)

    package com.example.demo.auth; import org.springframework.stereotype.Service; import java.security.SecureRandom; import java.time.Instant; import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service public class RefreshTokenService { private final SecureRandom random = new SecureRandom(); // token -> record private final Map<String, Record> store = new ConcurrentHashMap<>(); public record Record(String userId, Instant expiresAt) {} public String issue(String userId, long ttlSeconds) { byte[] bytes = new byte[32]; random.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); store.put(token, new Record(userId, Instant.now().plusSeconds(ttlSeconds))); return token; } public Record validate(String token) { Record r = store.get(token); if (r == null) return null; if (r.expiresAt().isBefore(Instant.now())) { store.remove(token); return null; } return r; } // rotation: 기존 토큰 폐기 public void revoke(String token) { store.remove(token); } }

    6) AuthController (로그인/리프레시)

    package com.example.demo.auth; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.*; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final UserDetailsService userDetailsService; // TTL (실습용) private final long accessTtlSec = 60 * 10; // 10분 private final long refreshTtlSec = 60 * 60 * 24 * 14; // 14일 public record LoginRequest(String username, String password) {} public record RefreshRequest(String refreshToken) {} @PostMapping("/login") public Map<String, String> login(@RequestBody LoginRequest req) { var authToken = new UsernamePasswordAuthenticationToken(req.username(), req.password()); authenticationManager.authenticate(authToken); var userDetails = userDetailsService.loadUserByUsername(req.username()); String userId = userDetails.getUsername(); // 실습용: username을 userId로 사용 String role = userDetails.getAuthorities().stream() .findFirst().map(a -> a.getAuthority().replace("ROLE_", "")) .orElse("USER"); String accessToken = jwtTokenProvider.createAccessToken(userId, role, accessTtlSec); String refreshToken = refreshTokenService.issue(userId, refreshTtlSec); return Map.of( "accessToken", accessToken, "refreshToken", refreshToken ); } @PostMapping("/refresh") public Map<String, String> refresh(@RequestBody RefreshRequest req) { var record = refreshTokenService.validate(req.refreshToken()); if (record == null) { throw new BadCredentialsException("Invalid refresh token"); } // rotation refreshTokenService.revoke(req.refreshToken()); String newRefresh = refreshTokenService.issue(record.userId(), refreshTtlSec); // role은 실습 단순화를 위해 다시 조회(실무: DB/캐시) var userDetails = userDetailsService.loadUserByUsername(record.userId()); String role = userDetails.getAuthorities().stream() .findFirst().map(a -> a.getAuthority().replace("ROLE_", "")) .orElse("USER"); String newAccess = jwtTokenProvider.createAccessToken(record.userId(), role, accessTtlSec); return Map.of( "accessToken", newAccess, "refreshToken", newRefresh ); } }

    7) 보호 API 예시 (현재 로그인 사용자 확인)

    package com.example.demo.api; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController public class MeController { @GetMapping("/me") public Map<String, Object> me(Authentication authentication) { return Map.of( "userId", authentication.getName(), "authorities", authentication.getAuthorities() ); } }

    8) Postman/curl로 테스트 흐름

    1. 로그인
      1. curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"pass123"}'
    1. 보호 API 호출
      1. curl http://localhost:8080/me \ -H "Authorization: Bearer <accessToken>"
    1. 만료되면 refresh
      1. curl -X POST http://localhost:8080/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refreshToken":"<refreshToken>"}'
     
    Share article
    Contents
    1. 토큰 인증1️⃣ 토큰 인증이란?2️⃣ 토큰 인증의 대표 유형 2가지3️⃣ 동작원리(보안 관점에서 무엇을 검증하나)4️⃣ 스프링에서 JWT 토큰 인증 실행흐름5️⃣ 예시 코드

    DailyCoding

    RSS·Powered by Inblog