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
crud 요청에 대한 토큰 만료에 대해서는 어떻게 대처하고 있으신지 궁금하네요.
페이지가 로드된 이후의 사용자 액션은 middleware를 거치지 않기 때문에 토큰의 유효기간이 지난 경우, 갱신이 필요할텐데요. 여기에 대한 부분이 궁금하네요.
저도 토큰 갱신에 대해서 고민하고 있었는데 감사합니다