이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Authorization code, access token, refresh token은 저장과 로그 관점에서 어떻게 다르게 다뤄야 하는가?
- provider token을 저장해야 할 때 어떤 암호화, scope, 회전, 폐기 정책이 필요한가?
- token refresh 실패를 사용자 재동의, provider 장애, 보안 이벤트로 어떻게 구분하는가?
개요
OAuth2/OIDC 로그인 운영에서 token은 인증 성공 뒤에도 계속 위험 자산으로 남는다. Authorization code는 짧게 한 번만 쓰는 grant이고, access token은 provider API 접근 권한이며, refresh token은 새 access token을 받을 수 있는 장기 credential이다.
서버가 provider API를 호출해야 한다면 access token이나 refresh token을 저장할 수 있다. 이때 token 저장소는 단순 편의 캐시가 아니라 secret 저장소다. 암호화, 접근 제어, scope 최소화, 회전, 폐기, 로그 마스킹이 필요하다.
token refresh 실패는 단순 예외가 아니다. 사용자가 동의를 철회했는지, provider 장애인지, refresh token 재사용 탐지인지, 내부 저장소 복호화 실패인지에 따라 대응이 달라진다.
이 문서의 핵심은 “로그인 성공 후 token을 어디에 보관하는가”를 운영 보안 설계로 보는 것이다.
공격 시나리오
- 공격자는 access log, exception log, APM trace에서 authorization code나 token 원문을 찾는다.
- refresh token이 DB에 평문으로 있으면 DB read 권한 탈취만으로 provider API 접근이 가능해진다.
- scope가 과도하면 token 하나가 메일, 캘린더, 드라이브 등 넓은 리소스에 접근한다.
- refresh token rotation을 고려하지 않으면 탈취 token 재사용을 탐지하지 못한다.
- token refresh 실패를 모두 재로그인으로 처리하면 provider 장애와 사용자 동의 철회를 구분하지 못한다.
- 계정 연결 해제 후 provider token을 폐기하지 않으면 연결이 끊긴 뒤에도 외부 API 호출이 가능하다.
취약한 요청/응답 예시
level=INFO event=oauth_token_response provider=google access_token=ya29.a0... refresh_token=1//0g... expires_in=3599 scope="openid email profile calendar"socialTokenRepository.save(new SocialToken(
memberId,
provider,
tokenResponse.getAccessToken().getTokenValue(),
tokenResponse.getRefreshToken().getTokenValue()
));문제는 token 원문이 로그와 DB에 그대로 남고, scope와 만료, 폐기 정책이 저장 모델에 드러나지 않는다는 점이다.
주제별 핵심 판단
- authorization code는 저장하지 않고 한 번 교환한 뒤 폐기한다.
- access token은 짧게 살며 provider Resource Server 호출에만 사용한다.
- refresh token은 장기 credential이므로 암호화와 접근 제어가 필요하다.
- token scope는 provider API 사용 목적에 맞게 최소화한다.
- refresh token rotation 또는 sender-constrained token 정책을 provider가 지원하는지 확인한다.
- token revoke와 내부 연결 해제를 함께 처리한다.
- token 원문은 로그, metric, trace, exception message에 남기지 않는다.
개선된 요청/응답 예시
public record StoredProviderToken(
Long memberId,
String provider,
String encryptedRefreshToken,
Instant accessTokenExpiresAt,
Set<String> scopes,
Instant updatedAt
) {}public void saveAuthorizedClient(OAuth2AuthorizedClient client, Authentication principal) {
TokenEnvelope envelope = tokenCipher.encrypt(client.getRefreshToken().getTokenValue());
providerTokenRepository.upsert(
principal.getName(),
client.getClientRegistration().getRegistrationId(),
envelope.cipherText(),
client.getAccessToken().getExpiresAt(),
client.getAccessToken().getScopes()
);
}저장 모델에는 token 원문 대신 암호문, scope, 만료, 갱신 시각, provider 식별자가 남는다. access token을 꼭 저장할 필요가 없다면 refresh 시점에만 보유한다.
방어 설계
- provider API 호출이 필요한 기능과 필요한 scope를 먼저 정한다.
- 필요하지 않은 provider token은 저장하지 않는다.
- 저장해야 하는 refresh token은 애플리케이션 레벨 암호화 또는 secret manager 기반 envelope encryption을 적용한다.
- token 저장소 접근 권한은 일반 사용자 조회 권한과 분리한다.
- refresh token rotation이 있으면 새 refresh token 저장과 이전 token 무효화를 원자적으로 처리한다.
- social account 해제, 회원 탈퇴, provider 동의 철회 시 내부 token을 삭제하고 가능하면 provider revoke endpoint를 호출한다.
- token refresh 실패 reason을
consent_revoked,invalid_grant,provider_unavailable,decryption_failed처럼 구분한다. - access log, application log, APM trace에서 token 필드 마스킹을 검증한다.
Spring/HTTP 예시
@Service
public class EncryptedAuthorizedClientService implements OAuth2AuthorizedClientService {
@Override
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient,
Authentication principal) {
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
if (refreshToken == null) {
return;
}
TokenEnvelope encrypted = tokenCipher.encrypt(refreshToken.getTokenValue());
repository.save(TokenRecord.from(authorizedClient, principal.getName(), encrypted));
audit.record("provider_token_saved",
authorizedClient.getClientRegistration().getRegistrationId(),
principal.getName());
}
@Override
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String registrationId,
String principalName) {
TokenRecord record = repository.find(registrationId, principalName);
return authorizedClientFactory.rehydrate(record);
}
}@DeleteMapping("/api/me/social-connections/{provider}")
public ResponseEntity<Void> unlink(@PathVariable String provider,
@AuthenticationPrincipal DomainUser user) {
socialAccountService.unlink(user.memberId(), provider);
authorizedClientService.removeAuthorizedClient(provider, user.username());
return ResponseEntity.noContent().build();
}예시는 저장 경계를 보여 주는 개념 코드다. 실제 구현에서는 Spring Security의 OAuth2AuthorizedClientService와 provider별 token response 특성을 확인해야 한다.
운영 로그와 감사 지점
level=INFO event=provider_token_refreshed result=allowed memberId=1004 provider=google scopes=calendar.readonly expiresAt=2026-07-01T10:30:00Z traceId=8ad3c901level=WARN event=provider_token_refresh_failed result=blocked memberId=1004 provider=google reason=invalid_grant action=reauth_required traceId=8ad3c901- token 원문과 authorization code 원문은 절대 남기지 않는다.
- refresh 성공은 provider, scope, expiresAt, memberId, traceId 중심으로 남긴다.
- refresh 실패 reason을 구분해 사용자 재동의와 provider 장애를 나눈다.
- token 복호화 실패는 보안 이벤트와 장애 이벤트 양쪽으로 본다.
- 계정 연결 해제와 token 삭제가 같은 traceId로 이어져야 한다.
실전 판단 기준
- provider API를 호출하지 않는 서비스라면 provider token 저장 자체가 불필요할 수 있다.
- refresh token 평문 저장은 DB read 사고를 provider API 접근 사고로 키운다.
- scope가 넓으면 token 유출 피해도 넓다.
- refresh token rotation을 고려하지 않으면 새 token 저장 실패와 이전 token 폐기가 꼬일 수 있다.
- token refresh 실패를 모두 로그아웃으로 처리하면 장애와 보안 이벤트가 섞인다.
- 연결 해제 후 token 삭제가 없으면 사용자가 기대한 privacy boundary가 깨진다.
테스트 포인트
- access log와 application log에 code, access token, refresh token이 남지 않는지 확인한다.
- DB token 컬럼이 평문이 아닌지 확인한다.
- social account 해제 후 provider token이 삭제되는지 확인한다.
- provider refresh 실패
invalid_grant가 사용자 재동의 flow로 이어지는지 확인한다. - provider 장애가 전체 로그인 장애로 확산되지 않는지 확인한다.
- scope 변경 시 기존 token 재동의와 저장 정책이 어떻게 동작하는지 확인한다.
위험 신호!
- token response 전체를 debug log로 출력한다.
- refresh token이 DB에 평문으로 저장된다.
- provider token 저장소를 일반 member 조회 repository와 같은 권한으로 접근한다.
- scope가 기능보다 넓고 검토 기록이 없다.
- social account 해제 후 token revoke/delete가 없다.
- refresh 실패 reason을 기록하지 않는다.
- APM trace나 metric label에 token 일부가 들어간다.
확인 질문
확인 질문
- authorization code는 어떻게 보관해야 하는가?
- 저장하지 않고 짧은 시간 안에 한 번 token으로 교환한 뒤 원문을 남기지 않아야 한다.
- refresh token은 왜 access token보다 더 민감한가?
- 새 access token을 계속 발급받을 수 있는 장기 credential이기 때문이다.
- token refresh 실패 로그에서 구분해야 하는 reason은 무엇인가?
- 동의 철회, invalid grant, provider 장애, 내부 복호화 실패, rotation 충돌 같은 원인을 구분해야 한다.