Refresh Token Rotation (RTR)은 클라이언트가 리프레시 토큰을 사용할 때마다 서버가 새로운 리프레시 토큰을 발급하고, 이전 토큰을 무효화하는 방식이다. 이를 통해 탈취된 토큰의 재사용을 방지하고 보안을 강화한다.
JWT, Access Token & Refresh Token이란?: 프론트엔드 로그인 방식: 세션, 토큰, 그리고 JWT
Set-Cookie
포함하여 Cookie에 저장HTTP only
secure
SameSite=Strict
리스폰스 헤더에
Set-Cookie
포함하여 쿠키에 저장하게 되면, 브라우저는 서버에서 받은 쿠키를 자동으로 저장하고, 동일한 도메인으로 요청할 때 자동으로 전송한다. 이를 통해 클라이언트(프론트엔드)에서 별도로 토큰을 관리하거나 전송 로직을 구현할 필요가 없다. 또한, 토큰이 브라우저의 스크립트에 노출되지 않아 보안성도 향상된다.
쿠키는 속성을 통해 보안을 강화할 수 있다.
HttpOnly
는 JavaScript에서 쿠키 접근을 차단한다.secure
는 쿠키가 HTTPS 연결에서만 전송되도록 제한한다.SameSite=Strict
는 쿠키가 동일 사이트 내에서만 전송되도록 설정한다.
토큰의 유효 기간은 프로젝트의 성격에 따라 유연하게 조정될 수 있다. 일반적으로 Access Token은 보안을 위해 짧게 설정하고, Refresh Token은 사용자 경험을 고려해 비교적 길게 설정한다. 예를 들어, 민감한 데이터를 다루는 서비스에서는 더 짧은 유효 기간을 선택하고, 사용자 편의가 중요한 서비스에서는 유효 기간을 길게 설정할 수 있다.
구분 | 방식1: 백엔드 재발급 | 방식2: 프론트엔드 재발급 |
---|---|---|
보안성 | 서버에서 중앙 통제, 민감 정보 보호 | 클라이언트 복호화 시 민감 정보 노출 위험 |
서버 부하 | 모든 요청 검증으로 부하 증가 | 클라이언트에서 만료 확인으로 부하 감소 |
네트워크 비용 | 모든 요청에 검증 포함 | 만료된 경우에만 추가 요청 발생 |
구현 복잡성 | 클라이언트 구현 단순 | 클라이언트 측 로직 복잡성 증가 |
exp
)을 확인한다.Next.js Middleware란?: Routing: Middleware
참고: Next.js 14버전 App Router 사용중임
import { ResponseCookies } from 'next/dist/compiled/@edge-runtime/cookies'
import { cookies } from 'next/headers'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import isValidToken from '@/lib/is-valid-token'
export async function middleware(request: NextRequest) {
const API_URL = process.env.NEXT_PUBLIC_API_URL
// API URL을 환경 변수에서 가져옴
const cookieStore = cookies()
// 요청에서 쿠키를 읽기 위해 cookieStore 생성
const accesstoken = cookieStore.get('accesstoken')
// 요청에서 'accesstoken' 쿠키를 가져옴
const refreshtoken = cookieStore.get('refreshtoken')
// 요청에서 'refreshtoken' 쿠키를 가져옴
if (!accesstoken?.value || !refreshtoken?.value) {
// 액세스 토큰 또는 리프레시 토큰이 없을 경우 로그인 페이지로 리다이렉트
return NextResponse.redirect(new URL('/login', request.url))
}
const { isAccessTokenValid, isRefreshTokenValid } = isValidToken({
accesstoken: accesstoken.value,
refreshtoken: refreshtoken.value,
})
// 토큰의 유효성을 검사
if (!isRefreshTokenValid) {
// 리프레시 토큰이 유효하지 않을 경우 로그인 페이지로 리다이렉트
return NextResponse.redirect(new URL('/login', request.url))
}
if (!isAccessTokenValid) {
// 액세스 토큰이 유효하지 않을 경우 액세스 토큰을 재발급
try {
const response = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `refreshtoken=${refreshtoken.value}`,
},
credentials: 'include',
})
// API를 호출하여 새 액세스 토큰과 리프레시 토큰을 요청
if (!response.ok) {
// 응답이 성공적이지 않으면 로그인 페이지로 리다이렉트
return NextResponse.redirect(new URL('/login', request.url))
}
if (response.ok) {
// 응답이 성공적이면 다음 요청을 처리
const res = NextResponse.next()
const responseCookies = new ResponseCookies(response.headers)
// 응답 헤더에서 쿠키를 읽음
const accessToken = responseCookies.get('accesstoken')
// 응답에서 'accesstoken' 쿠키를 가져옴
const refreshToken = responseCookies.get('refreshtoken')
// 응답에서 'refreshtoken' 쿠키를 가져옴
if (accessToken) {
// 새 액세스 토큰을 설정
res.cookies.set('accesstoken', accessToken.value, {
httpOnly: accessToken.httpOnly,
sameSite: accessToken.sameSite,
path: accessToken.path,
secure: accessToken.secure,
})
}
if (refreshToken) {
// 새 리프레시 토큰을 설정
res.cookies.set('refreshtoken', refreshToken.value, {
httpOnly: refreshToken.httpOnly,
sameSite: refreshToken.sameSite,
path: refreshToken.path,
secure: refreshToken.secure,
})
}
return res
}
} catch (error) {
// 토큰 재발급 중 오류가 발생하면 콘솔에 출력하고 로그인 페이지로 리다이렉트
console.error('엑세스 토큰 재발급 중 오류 발생:', error)
return NextResponse.redirect(new URL('/login', request.url))
}
}
}
export const config = {
matcher: ['/security'],
// 이 미들웨어를 '/security' 경로에서만 실행
}
function isValidToken({
accesstoken,
refreshtoken,
}: {
accesstoken?: string
refreshtoken?: string
}): {
isAccessTokenValid?: boolean
isRefreshTokenValid?: boolean
} {
// 현재 시간을 초 단위로 가져오기 (Unix Timestamp 형식)
const currentTime = Math.floor(Date.now() / 1000)
// 결과 객체를 초기화 (유효성 여부를 저장)
const result: {
isAccessTokenValid?: boolean
isRefreshTokenValid?: boolean
} = {}
try {
// 액세스 토큰이 존재하면 디코딩하여 만료 시간(`exp`) 확인
if (accesstoken) {
// JWT의 payload 부분(base64) 디코딩
const accessTokenPayload = JSON.parse(atob(accesstoken.split('.')[1]))
// 현재 시간과 만료 시간을 비교하여 유효성 판단
result.isAccessTokenValid = accessTokenPayload.exp > currentTime
}
// 리프레쉬 토큰이 존재하면 디코딩하여 만료 시간(`exp`) 확인
if (refreshtoken) {
// JWT의 payload 부분(base64) 디코딩
const refreshTokenPayload = JSON.parse(atob(refreshtoken.split('.')[1]))
// 현재 시간과 만료 시간을 비교하여 유효성 판단
result.isRefreshTokenValid = refreshTokenPayload.exp > currentTime
}
} catch (error) {
// 디코딩 과정에서 발생한 오류를 로그로 출력
console.error('토큰 디코딩 실패:', error)
}
// 유효성 결과를 반환
return result
}
export default isValidToken
저도 토큰 갱신에 대해서 고민하고 있었는데 감사합니다