<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>so-myoung.log</title>
        <link>https://velog.io/</link>
        <description>Backend Engineer</description>
        <lastBuildDate>Fri, 05 Jun 2026 16:59:54 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>so-myoung.log</title>
            <url>https://velog.velcdn.com/images/so-myoung/profile/3e019822-5d21-4569-a615-3ea04985b040/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. so-myoung.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/so-myoung" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[JWT 인증 시 Redis 기반 Refresh Token Rotation 적용하기]]></title>
            <link>https://velog.io/@so-myoung/JWT-%EC%9D%B8%EC%A6%9D-%EC%8B%9C-Redis-%EA%B8%B0%EB%B0%98-Refresh-Token-Rotation-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@so-myoung/JWT-%EC%9D%B8%EC%A6%9D-%EC%8B%9C-Redis-%EA%B8%B0%EB%B0%98-Refresh-Token-Rotation-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 05 Jun 2026 16:59:54 GMT</pubDate>
            <description><![CDATA[<p>소셜 로그인에서 Google/Kakao가 사용자를 인증해주더라도, 이후 <strong>서비스가 사용자를 계속 식별하고 보호 자원 요청을 검증하는 구조</strong>는 따로 설계해야 했다.</p>
<p>이를 위해 소셜 로그인 성공 이후 서비스 자체 Access Token과 Refresh Token을 발급하고, Redis 기반 Refresh Token Rotation을 적용해 토큰 재발급과 재사용 탐지를 처리했다.</p>
<p>이 과정에서 단순히 토큰을 발급하는 것보다, 각 토큰의 역할과 저장 위치, 무효화 방식을 명확히 나누는 것이 중요했다.</p>
<h3 id="1-인증-요구사항">1. 인증 요구사항</h3>
<p>인증 설계는 다음 네 가지 요구사항에서 출발했다.</p>
<ol>
<li>소셜 로그인: Google과 Kakao 두 Provider를 지원한다. 자체 비밀번호 로그인은 두지 않는다.</li>
<li>API 인증/인가: 모든 보호 자원 요청은 토큰으로 인증하고, 권한(Role)에 따라 인가한다. 서버는 상태를 보관하지 않는 Stateless 구조를 유지한다.</li>
<li>로그인 유지: 사용자가 매번 다시 로그인하지 않아도 일정 기간 로그인 상태가 유지되어야 한다.</li>
<li>로그아웃과 토큰 재발급: 로그아웃 시 발급된 토큰을 즉시 무효화하고, Access Token이 만료되면 다시 로그인하지 않고 조용히 재발급할 수 있어야 한다.</li>
</ol>
<p>이 요구사항은 서로 충돌한다. &quot;로그인 유지&quot;는 토큰 수명을 길게 만들고 싶어 하고, &quot;탈취 대응&quot;은 토큰 수명을 짧게 만들고 싶어 한다. 이 충돌을 푸는 것이 인증 모듈 전체의 주제다.
토큰 수명은 다음과 같이 설정했다.</p>
<blockquote>
</blockquote>
<p>Access Token: 30분
Refresh Token: 7일</p>
<h3 id="2-전체-인증-흐름">2. 전체 인증 흐름</h3>
<p>전체 흐름은 발급, 사용, 재발급 세 구간으로 나뉜다.</p>
<ol>
<li>소셜 로그인 성공 후 자체 JWT 발급</li>
<li>Access Token을 이용한 API 요청 인증</li>
<li>Refresh Token을 이용한 토큰 재발급</li>
</ol>
<p>소셜 로그인은 사용자가 누구인지 확인하는 진입점이고, 이후 서비스의 API 인증은 자체 발급한 Access Token과 Refresh Token을 기준으로 처리한다.</p>
<h4 id="2-1-소셜-로그인-성공-후-자체-jwt-발급">2-1. 소셜 로그인 성공 후 자체 JWT 발급</h4>
<p><img src="https://velog.velcdn.com/images/so-myoung/post/1d42bc6c-f162-4806-a86b-9f160f43f9d7/image.png" alt=""></p>
<p>소셜 로그인 성공 직후 서버는 Google/Kakao가 내려준 사용자 정보를 바탕으로 서비스 사용자를 조회하거나 새로 생성한다.</p>
<p>Provider 토큰은 외부 서비스에서 사용자가 인증되었다는 사실을 증명할 뿐, 서비스 내부의 사용자 식별자, 권한, 만료 정책까지 담지는 못한다. 따라서 소셜 로그인 결과를 서비스 사용자로 매핑한 뒤, 이후 API 인증에 사용할 자체 토큰을 발급하는 구조로 분리했다.</p>
<p>사용자 매핑은 소셜 식별자를 기준으로 기존 사용자를 찾고, 없으면 신규 사용자를 생성하는 방식으로 처리했다.</p>
<pre><code class="language-java">private User getOrSaveUser(OAuthProvider oAuthProvider, OAuth2UserInfo userInfo) {
    return socialLoginRepository
        .findByProviderAndProviderId(oAuthProvider.getSocialProvider(), userInfo.getProviderId())
        .map(SocialLogin::getUser)
        .orElseGet(() -&gt; createNewSocialUser(oAuthProvider, userInfo));
}</code></pre>
<p>소셜 로그인은 서비스에 들어오기 위한 진입점이고, 자체 JWT는 서비스 내부 API를 이용하기 위한 인증 수단이다. 외부 Provider의 인증 결과를 그대로 내부 API 인증에 사용하지 않고, 서비스 기준의 사용자 정보와 권한을 담은 토큰을 새로 발급한 이유가 여기에 있다.</p>
<p>이후 서버는 sid와 jti를 생성하고, Access Token과 Refresh Token을 발급한다. Refresh Token은 Redis에 메타데이터를 저장한 뒤 HttpOnly Cookie로 내려주고, Access Token은 바로 URL에 노출하지 않고 임시 코드를 거쳐 전달했다.</p>
<pre><code class="language-java">String sid = UUID.randomUUID().toString();
String jti = UUID.randomUUID().toString();

String accessToken = jwtProvider.createAccessToken(authentication, userId);
String refreshToken = jwtProvider.createRefreshToken(userId, jti, sid);

refreshTokenRepository.save(new RefreshToken(sid, jti, userId));
cookieUtil.addRefreshTokenCookie(response, refreshToken);

String temporaryCode = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(temporaryCode, accessToken, Duration.ofMinutes(1));</code></pre>
<p>Access Token을 리다이렉트 URL에 직접 실으면 주소창, 브라우저 히스토리, 서버 접근 로그, Referer 헤더 등에 남을 수 있다. 이를 피하기 위해 서버는 Access Token을 Redis에 1분짜리 임시 코드와 매핑하고, 프론트엔드에는 해당 코드만 전달했다.</p>
<p>프론트엔드는 전달받은 임시 코드를 서버에 전달해 Access Token으로 교환한다. 서버는 해당 코드로 Redis에서 Access Token을 조회한 뒤 즉시 삭제하므로, 같은 임시 코드를 두 번 사용할 수 없다.</p>
<pre><code class="language-java">public String loginWithCode(String code) {
    String accessToken = redisTemplate.opsForValue().get(code);
    if (accessToken == null) {
        throw new AuthException(AuthErrorCode.INVALID_AUTH_CODE);
    }
    redisTemplate.delete(code); // 일회성 보장
    return accessToken;
}</code></pre>
<p>최종적으로 Access Token은 응답 body에 담지 않고, <strong>Authorization 응답 헤더를 통해 전달했다.</strong></p>
<pre><code class="language-java">return ResponseEntity.ok()
        .headers(headerUtil.createAccessTokenHeaders(accessToken))
        .body(&quot;로그인에 성공했습니다.&quot;);</code></pre>
<p>프론트엔드는 이 응답 헤더에서 Access Token을 읽어 localStorage에 저장한다. 이후 보호 자원 요청 시 localStorage에서 Access Token을 꺼내 Authorization 요청 헤더에 Bearer 형식으로 다시 담아 보낸다.</p>
<pre><code class="language-http">Authorization: Bearer {accessToken}</code></pre>
<p><img src="https://velog.velcdn.com/images/so-myoung/post/b9d2c93e-3eb7-493b-8158-2391adfd4b0f/image.png" alt=""></p>
<p>Access Token을 localStorage에 저장한 이유는 프론트엔드에서 이후 API 요청마다 토큰을 쉽게 꺼내 사용할 수 있고, 새로고침 이후에도 Access Token을 유지할 수 있기 때문이다. SPA 구조에서는 요청 인터셉터에서 localStorage의 토큰을 읽어 Authorization 헤더에 붙이는 방식으로 인증 흐름을 단순하게 구성할 수 있다.</p>
<p>CORS 환경에서 프론트엔드가 Authorization 응답 헤더를 읽을 수 있도록 서버 설정에서는 Authorization을 exposedHeaders에 포함했다. 이 설정이 없으면 네트워크 탭에서는 헤더가 보여도, 프론트엔드 코드에서는 해당 헤더를 읽지 못할 수 있다.</p>
<pre><code class="language-java">configuration.setExposedHeaders(List.of(&quot;Authorization&quot;));</code></pre>
<p>이 구조에도 한계는 있다. 임시 코드 자체는 URL 쿼리로 전달되기 때문에 Referer나 로그에 남을 가능성을 완전히 없앨 수는 없다. 다만 <strong>TTL을 1분으로 제한하고 조회 즉시 삭제해 노출 시간을 줄였다.</strong></p>
<p>또한 localStorage는 JavaScript에서 접근할 수 있기 때문에 XSS 공격이 발생하면 Access Token이 탈취될 수 있다. 이 때문에 수명이 더 길고 재발급에 사용되는 Refresh Token은 JavaScript에서 접근할 수 없도록 HttpOnly Cookie로 분리했다.</p>
<p>정리하면, <strong>Access Token은 Authorization 응답 헤더로 전달한 뒤 프론트엔드의 localStorage에 저장해 API 요청에 사용하고, Refresh Token은 HttpOnly Cookie와 Redis를 통해 서버가 유효성을 관리하는 구조로 설계했다.</strong></p>
<h4 id="2-2-access-token을-이용한-api-요청-인증">2-2. Access Token을 이용한 API 요청 인증</h4>
<p><img src="https://velog.velcdn.com/images/so-myoung/post/320e8442-c7d9-45a1-a053-28aa79abdb0f/image.png" alt=""></p>
<p>소셜 로그인 이후 발급한 토큰은 역할에 따라 Access Token과 Refresh Token으로 나누었다.</p>
<p>토큰을 하나만 사용하면 보안성과 사용성을 동시에 만족시키기 어렵다. 수명을 짧게 잡으면 탈취 위험은 줄어들지만 사용자가 자주 다시 로그인해야 하고, 반대로 수명을 길게 잡으면 사용성은 좋아지지만 탈취 시 오래 악용될 수 있다.</p>
<p>그래서 매 API 요청에 사용되는 Access Token은 짧게 유지하고, 로그인 유지를 위한 Refresh Token은 별도로 관리하는 구조로 분리했다.</p>
<p>Access Token은 보호 자원 요청마다 <code>Authorization</code> 요청 헤더에 담겨 서버로 전달된다.</p>
<pre><code class="language-http">Authorization: Bearer {accessToken}</code></pre>
<p>서버는 요청이 들어오면 먼저 Authorization 헤더에서 Access Token을 추출하고, JWT 서명과 만료 시간을 검증한다. 이후 토큰의 jti를 꺼내 Redis 블랙리스트에 존재하는지 확인한다.</p>
<blockquote>
<p>블랙리스트 : 로그아웃 등으로 더 이상 허용하면 안 되는 토큰의 <code>jti</code>를 Redis에 저장해, 만료 전이라도 해당 Access Token의 사용을 차단하는 목록이다.</p>
</blockquote>
<pre><code class="language-java">// Access Token: 사용자 식별자, 권한, 토큰 식별자를 담는다
public String createAccessToken(Authentication auth, Long userId) {
    String authorities = auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(&quot;,&quot;));

    // 로그아웃 시 블랙리스트 조회 기준(JWT ID)
    String jti = UUID.randomUUID().toString();

    return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .setId(jti)
            .claim(AuthConstant.AUTH_CLAIM_KEY, authorities)
            // ... 만료 30분
            .compact();
}</code></pre>
<p><strong>Access Token에는 사용자 식별자와 권한 정보, 그리고 토큰 식별자인 jti를 담았다. 서버는 이 정보를 바탕으로 요청 사용자를 식별하고, 권한을 확인한다.</strong></p>
<p>다만 Access Token은 서버에 저장하지 않는 Stateless 토큰이기 때문에, 서명과 만료 시간이 유효하면 기본적으로 통과할 수 있다. 이 경우 로그아웃된 토큰도 만료 전까지는 계속 사용될 수 있다는 문제가 생긴다.</p>
<p>이를 막기 위해 <strong>로그아웃된 Access Token의 jti는 Redis 블랙리스트에 저장하고, 요청마다 해당 jti가 블랙리스트에 있는지 확인했다.</strong>
블랙리스트에 해당 jti가 존재하면 이미 로그아웃된 Access Token으로 판단하고 401 Unauthorized를 반환한다. 반대로 블랙리스트에 없으면 정상 토큰으로 보고 인증 정보를 설정한 뒤 보호 API 요청을 처리한다.</p>
<p>검증 흐름은 다음과 같다.</p>
<pre><code>1. Authorization 요청 헤더에서 Access Token 추출
2. JWT 서명 검증
3. 만료 시간 검증
4. Access Token의 jti 추출
5. Redis에서 BL:{jti} 조회
6. 블랙리스트에 있으면 401 응답
7. 블랙리스트에 없으면 인증 정보 설정 후 요청 처리</code></pre><p>반면 Refresh Token은 보호 자원 요청에 사용하지 않는다. Refresh Token은 Access Token 재발급을 위한 토큰이므로, JavaScript에서 접근할 수 없도록 HttpOnly Cookie로 관리하고 재발급 요청에서만 사용했다.</p>
<pre><code class="language-java">// Refresh Token: 세션 식별자 sid를 담는다
public String createRefreshToken(Long userId, String jti, String sid) {
    return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .setId(jti)
            .claim(AuthConstant.SID_CLAIM_KEY, sid)
            // ... 만료 7일
            .compact();
}</code></pre>
<p>Refresh Token에는 세션 식별자인 sid를 담았다. 이 sid는 Redis에 저장된 Refresh Token 메타데이터를 조회하는 기준으로 사용된다. 즉, Access Token은 API 요청 인증에 사용하고, Refresh Token은 재발급과 세션 유효성 검증에 사용하는 방식으로 역할을 나누었다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Access Token</th>
<th>Refresh Token</th>
</tr>
</thead>
<tbody><tr>
<td>용도</td>
<td>API 요청 인증</td>
<td>Access Token 재발급</td>
</tr>
<tr>
<td>수명</td>
<td>30분</td>
<td>7일</td>
</tr>
<tr>
<td>전달 방식</td>
<td><code>Authorization</code> 요청 헤더</td>
<td><code>HttpOnly Cookie</code></td>
</tr>
<tr>
<td>서버 저장 여부</td>
<td>저장하지 않음</td>
<td>Redis에 <code>sid</code>, <code>jti</code>, <code>userId</code> 저장</td>
</tr>
<tr>
<td>주요 claim</td>
<td><code>sub</code>, <code>auth</code>, <code>jti</code></td>
<td><code>sub</code>, <code>sid</code>, <code>jti</code></td>
</tr>
</tbody></table>
<h4 id="2-3-refresh-token을-이용한-토큰-재발급">2-3. Refresh Token을 이용한 토큰 재발급</h4>
<p><img src="https://velog.velcdn.com/images/so-myoung/post/dd013066-14e6-427f-96ab-42004c7906f9/image.png" alt=""></p>
<p>Access Token은 30분으로 짧게 유지했기 때문에, 만료될 때마다 사용자를 다시 로그인시키면 사용성이 떨어진다. 이를 보완하기 위해 Refresh Token으로 Access Token을 재발급하는 구조를 두었다.</p>
<p>다만 Refresh Token도 JWT이기 때문에 서명과 만료 시간만 검증하면 형식상 유효한 토큰인지 확인할 수는 있다. 하지만 그것만으로는 “서버가 현재 유효하다고 인정하는 Refresh Token인지”를 판단하기 어렵다. 로그아웃되었거나 이미 회전된 이전 Refresh Token도 만료 전이라면 서명 자체는 유효할 수 있기 때문이다.</p>
<p>그래서 Refresh Token의 상태를 Redis에 저장했다. 저장 기준은 <code>sid</code>이고, 현재 유효한 Refresh Token의 식별자인 <code>jti</code>를 값으로 관리했다.</p>
<pre><code class="language-java">@RedisHash(value = &quot;refreshToken&quot;, timeToLive = 604800) // 7일
public class RefreshToken {
    @Id
    private String sid;     // 로그인 세션 식별자
    private String jti;     // 현재 유효한 Refresh Token의 식별자
    private Long userId;

    public void updateJti(String newJti) {
        this.jti = newJti;
    }
}</code></pre>
<p>여기서 sid는 하나의 로그인 세션을 식별하는 값이다. 재발급이 반복되어도 같은 세션은 유지되어야 하므로 sid는 그대로 둔다. 반면 jti는 현재 유효한 Refresh Token을 식별하는 값이므로, 재발급할 때마다 새로 바뀐다.</p>
<p>즉 Redis에는 다음과 같은 형태로 저장된다.</p>
<blockquote>
</blockquote>
<p>key   : refreshToken:{sid}
value : userId, jti
TTL   : 7일</p>
<p>재발급 요청이 들어오면 서버는 쿠키에서 Refresh Token을 꺼내 서명과 만료 시간을 검증한 뒤, 토큰 안의 sid로 Redis에 저장된 세션 정보를 조회한다. 저장된 값이 없으면 이미 로그아웃되었거나 만료된 세션으로 보고 재발급을 거절한다.</p>
<p>저장된 값이 있다면, Redis에 저장된 jti와 요청으로 들어온 Refresh Token의 jti를 비교한다. 두 값이 일치해야만 현재 유효한 Refresh Token으로 판단한다.</p>
<pre><code class="language-java">@Transactional
public TokenResponse reissueTokens(String refreshToken) {
    RefreshToken savedToken = validateAndGetStoredRefreshToken(refreshToken);
    validateTokenReuse(savedToken, refreshToken);
    Authentication authentication = createAuthentication(savedToken.getUserId());
    return rotateTokens(savedToken, authentication);
}</code></pre>
<p>정상 요청이라면 Refresh Token Rotation을 수행한다. 기존 Refresh Token을 그대로 재사용하지 않고, 새 jti를 생성한 뒤 새로운 Access Token과 Refresh Token을 발급한다. 이후 Redis에 저장된 jti도 새 값으로 갱신한다.</p>
<pre><code class="language-java">private TokenResponse rotateTokens(RefreshToken savedToken, Authentication authentication) {
    String sid = savedToken.getSid();             // 세션은 유지
    Long userId = savedToken.getUserId();
    String newJti = UUID.randomUUID().toString(); // 토큰 식별자만 교체

    String newAccessToken = jwtProvider.createAccessToken(authentication, userId);
    String newRefreshToken = jwtProvider.createRefreshToken(userId, newJti, sid);

    savedToken.updateJti(newJti);
    refreshTokenRepository.save(savedToken);

    return TokenResponse.from(newAccessToken, newRefreshToken);
}</code></pre>
<p>이 구조의 핵심은 “세션은 유지하되, Refresh Token은 매번 교체한다”는 점이다. <strong>sid는 유지하고 jti만 회전시키기 때문에 같은 로그인 세션 안에서 현재 유효한 Refresh Token은 항상 하나만 존재한다.</strong></p>
<p>만약 이전 Refresh Token이 다시 들어오면 어떻게 될까? 정상 재발급 이후 Redis에는 이미 새 jti가 저장되어 있다. 이 상태에서 예전 Refresh Token의 jti가 들어오면 저장된 jti와 일치하지 않는다. 이 경우 서버는 Refresh Token 재사용으로 판단하고, 해당 세션을 삭제한 뒤 재발급을 거절한다.</p>
<pre><code class="language-java">private void validateTokenReuse(RefreshToken savedToken, String refreshToken) {
    String currentJti = jwtProvider.getJti(refreshToken);

    if (!savedToken.getJti().equals(currentJti)) {
        refreshTokenRepository.delete(savedToken); // 세션 전체 폐기
        throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN);
    }
}</code></pre>
<p>단순히 요청을 거절하는 데서 끝내지 않고 세션 자체를 삭제한 이유는, 이전 Refresh Token이 다시 등장했다는 것은 탈취 또는 재사용 가능성이 있다는 신호이기 때문이다. 서버는 정상 사용자와 공격자를 구분할 수 없으므로, 해당 세션의 토큰을 모두 무효화하고 다시 로그인하게 만드는 쪽을 선택했다.</p>
<p>정리하면 재발급 흐름은 다음과 같다.</p>
<pre><code>1. Refresh Token으로 재발급 요청
2. 서버가 Refresh Token의 서명과 만료 시간 검증
3. sid로 Redis의 refreshToken:{sid} 조회
4. Redis에 저장된 jti와 요청 Refresh Token의 jti 비교
5. 일치하면 새 Access Token, 새 Refresh Token 발급
6. Redis의 jti를 새 값으로 갱신
7. 불일치하면 Refresh Token 재사용으로 판단하고 세션 삭제</code></pre><p>이 구조에도 비용은 있다. 재발급 요청마다 Redis 조회가 필요하고, Redis 장애 시 재발급도 영향을 받는다. 또한 여러 탭에서 동시에 재발급 요청이 발생하면 한 요청은 성공하고 다른 요청은 이전 jti로 들어와 재사용으로 오탐될 수 있다. <strong>다만 Refresh Token 탈취와 재사용을 탐지할 수 있다는 점에서, 이 프로젝트에서는 Redis 기반 Refresh Token Rotation을 적용했다.</strong></p>
<h3 id="3-결론">3. 결론</h3>
<p>이 인증 모듈은 결국 “로그인 유지”와 “토큰 탈취 대응” 사이의 균형을 맞추는 과정이다.</p>
<p>Access Token은 API 요청마다 사용되므로 짧게 유지하고, Refresh Token은 로그인 유지를 위해 상대적으로 길게 가져갔다. 대신 Refresh Token을 그대로 신뢰하지 않고 Redis에 현재 유효한 <code>sid</code>, <code>jti</code>를 저장해 재발급 시마다 서버 기준으로 검증했다.</p>
<p>소셜 로그인 성공 후에는 Provider 토큰을 내부 API 인증에 직접 사용하지 않고, 서비스 기준의 사용자 식별자와 권한을 담은 자체 JWT를 발급했다. Access Token은 URL에 직접 노출하지 않도록 임시 코드를 거쳐 전달했고, 이후 프론트엔드가 <code>Authorization</code> 헤더에 담아 API 요청에 사용하도록 구성했다.</p>
<p>Refresh Token은 Redis에 상태를 저장하고 Rotation을 적용했다. 재발급 시마다 새로운 <code>jti</code>로 갱신하기 때문에, 이전 Refresh Token이 다시 들어오면 재사용 시도로 판단하고 해당 세션을 폐기할 수 있었다. 또한 로그아웃된 Access Token은 <code>jti</code> 블랙리스트로 관리해 만료 전이라도 차단할 수 있도록 했다.</p>
<p>물론 이 구조도 완벽하지는 않다. Redis 의존성이 생기고, 재발급 요청마다 Redis 조회가 필요하며, 여러 탭에서 동시에 재발급 요청이 발생하면 이전 <code>jti</code>로 인한 오탐 가능성도 있다. <strong>다만 TTL, 일회성 임시 코드, Refresh Token Rotation을 함께 적용해 토큰 노출과 재사용 위험을 줄이는 방향으로 설계했다.</strong></p>
<p>결론적으로 이 구조의 핵심은 Access Token은 짧게 사용하고, Refresh Token은 Redis에 상태를 두어 재발급과 무효화를 제어하는 것이다. 완전한 Stateless 구조는 일부 포기했지만, 로그아웃·재발급·탈취 대응을 명확히 처리할 수 있는 구조를 선택했다.</p>
<br>

<p>참고 자료: <a href="https://youtu.be/-qeJbdP-bmU?si=XDJmJ4OtBRkoPskC">JWT 탈취에 대비하기(안전한 jwt 사용하기)
</a></p>
]]></description>
        </item>
    </channel>
</rss>