본문 바로가기
IT/보안

[보안] OAuth 2.0 보안 허점 파헤치기: 실제 공격 사례와 방어 전략

by 수누다 2026. 6. 28.

목차

[보안] OAuth 2.0 보안 허점 파헤치기: 실제 공격 사례와 방어 전략

서비스 로그인 붙이다 보면 OAuth 2.0 보안 이야기를 꼭 만나게 됩니다. 처음엔 그냥 소셜 로그인 한 번 붙이면 끝나는 줄 알았거든요. 근데 운영 환경으로 올라가고, 모바일 앱이 붙고, 리버스 프록시(Reverse Proxy, 역방향 프록시) 뒤에 애플리케이션이 숨어버리면 얘기가 완전히 달라집니다. 제가 홈랩과 사내 테스트 환경에서 직접 굴려보니, OAuth 공격은 거창한 제로데이보다도 설정 실수, 검증 누락, 토큰 저장 위치 같은 데서 시작되는 경우가 훨씬 많더라고요. 이번 글에서는 OAuth 2.0 보안을 실제 공격 흐름 중심으로 풀어보고, 현업에서 바로 적용할 수 있는 방어 전략까지 정리해보겠습니다.

OAuth 2.0 보안 전체 흐름과 공격 지점을 보여주는 아키텍처 다이어그램

OAuth 2.0 인증 흐름에서 사용자, 애플리케이션, 인증 서버, 리소스 서버 사이의 이동 경로와 주요 공격 지점을 한눈에 보여주는 다이어그램입니다.

1. 왜 OAuth 2.0 보안이 자꾸 사고로 이어질까요

쉽게 말해 OAuth 2.0은 비밀번호를 직접 주고받지 않고 권한을 위임하는 방식입니다. 문제는 구조가 유연한 만큼, 잘못 붙이면 공격자에게도 길을 열어준다는 점입니다. 특히 아래 세 가지가 자주 문제를 만듭니다.

  • Redirect URI(리다이렉트 URI) 검증이 느슨한 경우가 많더라고요
  • state 값 검증이 빠져 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)가 가능한 경우
  • Access Token(액세스 토큰)을 브라우저에 무방비로 저장하는 경우

저도 예전에 테스트 앱 하나 만들면서 redirect URI를 와일드카드 비슷하게 처리했다가, "이거 설마 되겠어?" 했던 우회가 진짜 성립하는 걸 보고 식은땀 난 적이 있습니다. 드디어 됐다 싶었던 로그인 연동이, 보안 관점에서는 시작점이었던 거죠.

2. OAuth 2.0 핵심 개념, 헷갈리는 부분만 딱 짚어보겠습니다

OAuth 2.0 보안을 보려면 역할을 먼저 분리해서 봐야 합니다.

구성 요소 역할 보안 포인트
Resource Owner 사용자 피싱 페이지 유도 방지
Client 웹/모바일 애플리케이션 state, PKCE, redirect URI 검증
Authorization Server 인증 및 토큰 발급 정확한 클라이언트 검증
Resource Server API 서버 토큰 검증과 권한 범위 확인

여기서 중요한 포인트가 있습니다. 인증(Authentication, 본인 확인)인가(Authorization, 권한 부여)를 섞어 생각하면 설계가 꼬입니다. OAuth 2.0은 기본적으로 인가 프레임워크이고, 로그인은 보통 OpenID Connect(OIDC, 인증 레이어)를 함께 붙여 다룹니다. 이 차이를 놓치면 토큰을 너무 많은 곳에서 믿어버리게 되거든요.

Authorization Code Flow와 PKCE를 기본값으로 봐야 하는 이유

요즘은 Authorization Code Flow(인가 코드 흐름)PKCE(Proof Key for Code Exchange, 코드 교환용 증명 키)를 붙이는 구성이 사실상 기본입니다. 예전엔 Implicit Flow(암시적 흐름)도 많이 보였는데, 실제로 써보니까 브라우저 노출 면이 넓고 운영상 통제가 까다롭더라고요. 그래서 저는 브라우저 기반 앱도 가능하면 백엔드 연계나 BFF(Backend For Frontend, 프론트엔드 전용 백엔드) 구조를 함께 검토합니다.

3. 실제 OAuth 공격 사례, 패턴으로 보면 훨씬 잘 보입니다

특정 회사 사고를 끌어와 자극적으로 보기보다, 반복되는 공격 패턴으로 보는 게 실무에는 훨씬 도움이 되거든요. 아래 세 가지는 제가 테스트 환경에서 재현도 해봤고, 보안 리뷰 때도 정말 자주 보는 유형입니다.

사례 1. state 누락으로 인한 로그인 CSRF

애플리케이션이 인증 요청을 보낼 때 state를 만들지 않거나, 만들어도 응답에서 검증하지 않으면 공격자가 자기 계정으로 발급한 인가 응답을 피해자 세션에 주입할 수 있습니다. 겉으로는 로그인 성공처럼 보이는데, 실제로는 피해자가 공격자 계정에 연결된 상태가 되는 거죠.

  • 증상: 사용자가 모르는 계정으로 연결됨
  • 원인: state 생성 또는 검증 누락
  • 방어: 요청별 고유 state 생성, 서버 세션과 비교, 1회성 사용

사례 2. Redirect URI 오검증으로 인가 코드 탈취

이건 진짜 위험합니다. Redirect URI를 정확히 일치시키지 않고 접두어(prefix) 비교나 부분 문자열 비교로 처리하면, 공격자가 비슷한 주소를 만들어 인가 코드나 토큰을 가로챌 수 있습니다. 처음엔 "도메인만 같으면 괜찮지 않나" 싶었는데, 하위 경로나 쿼리 문자열까지 엮이면 생각보다 쉽게 틈이 생기더라고요.

  • 증상: 정상 로그인 후 공격자 페이지로 코드 전달
  • 원인: 느슨한 redirect URI 검증
  • 방어: 사전 등록된 URI와 정확히 일치 비교

사례 3. PKCE 미적용으로 인가 코드 가로채기 위험

특히 퍼블릭 클라이언트(public client)인 모바일 앱, SPA(Single Page Application, 단일 페이지 애플리케이션) 계열에서 PKCE가 없으면 코드가 중간에서 노출됐을 때 교환 방어가 어렵습니다. PKCE는 code_verifiercode_challenge를 사용해서, 코드를 훔쳐도 토큰으로 못 바꾸게 막는 장치입니다.

  • 증상: 코드 탈취 후 토큰 교환 시도
  • 원인: PKCE 미적용
  • 방어: S256 기반 PKCE 강제
OAuth 2.0 보안에서 state 누락과 redirect URI, PKCE 문제를 비교한 공격 흐름 이미지

세 가지 대표적인 OAuth 공격 패턴이 어디서 시작되고 어느 지점에서 차단할 수 있는지 비교하는 흐름도입니다.

4. 실전 구현: 안전한 OAuth 2.0 보안 체크포인트를 코드로 붙여보겠습니다

여기서는 Python Flask(플라스크) 예시로 보겠습니다. 프레임워크는 달라도 핵심은 같습니다. state 검증, PKCE 적용, redirect URI 고정, 이 세 개는 기본값으로 가져가셔야 합니다.

Step 1. state 값을 만들고 세션에 묶기

  1. 로그인 시작 시 난수 기반 state 생성
  2. 서버 세션 또는 안전한 저장소에 보관
  3. 콜백에서 반드시 동일성 검증
import secrets
from flask import Flask, redirect, request, session, abort
from urllib.parse import urlencode

app = Flask(__name__)
app.secret_key = "replace-with-secure-secret"

AUTHORIZATION_ENDPOINT = "https://auth.example.com/oauth2/authorize"
CLIENT_ID = "demo-client"
REDIRECT_URI = "https://app.example.com/callback"

@app.route("/login")
def login():
    state = secrets.token_urlsafe(32)
    session["oauth_state"] = state

    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "openid profile email",
        "state": state,
    }
    return redirect(f"{AUTHORIZATION_ENDPOINT}?{urlencode(params)}")

@app.route("/callback")
def callback():
    returned_state = request.args.get("state")
    expected_state = session.pop("oauth_state", None)

    if not returned_state or returned_state != expected_state:
        abort(400, "invalid state")

    code = request.args.get("code")
    if not code:
        abort(400, "missing code")

    return "state validation passed"

이 코드는 단순하지만 중요합니다. 제가 직접 해보니 state를 검증하는 순간, 재현되던 로그인 CSRF 시나리오가 바로 막히더라고요.

Step 2. PKCE 붙이기

PKCE는 생각보다 어렵지 않습니다. code_verifier를 만들고, 그걸 SHA-256으로 해시해서 code_challenge를 보내면 됩니다.

import base64
import hashlib
import secrets


def generate_pkce_pair():
    code_verifier = secrets.token_urlsafe(64)
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return code_verifier, code_challenge

code_verifier, code_challenge = generate_pkce_pair()
session["code_verifier"] = code_verifier

params = {
    "response_type": "code",
    "client_id": CLIENT_ID,
    "redirect_uri": REDIRECT_URI,
    "scope": "openid profile email",
    "state": session["oauth_state"],
    "code_challenge": code_challenge,
    "code_challenge_method": "S256",
}

토큰 교환 시에는 저장한 code_verifier를 함께 보내야 합니다. 이 부분 빠뜨리면 "분명 PKCE 넣었는데 왜 안 되지?" 하고 삽질 좀 하게 됩니다 ㅎㅎ

Step 3. Redirect URI는 정확히 고정하기

운영 환경에서 제일 많이 깨지는 부분이기도 합니다. 프록시 뒤에서 http로 인식하거나, path가 달라지거나, 포트가 틀어지면 인증 서버와 애플리케이션의 인식이 어긋납니다.

oauth:
  client_id: demo-client
  redirect_uri: https://app.example.com/callback
  allowed_redirect_uris:
    - https://app.example.com/callback

여기서 중요한 포인트! 부분 일치 금지, 와일드카드 지양, 환경별 URI 명확 분리입니다.

Step 4. 로컬에서 공격 시나리오 점검하기

제가 홈랩에서 자주 쓰는 방식은 curl로 콜백을 강제로 때려보는 겁니다. 최소한의 재현만 해도 어디서 검증이 빠졌는지 바로 보입니다.

curl -i "https://app.example.com/callback?code=test-code&state=wrong-state"

curl -i "https://app.example.com/callback?code=test-code"

정상이라면 둘 다 거절돼야 합니다. 하나라도 통과하면 바로 수정해야 합니다.

OAuth 2.0 보안 구현에서 state와 PKCE 설정을 설명하는 구성 이미지

안전한 OAuth 클라이언트 구현에서 state, PKCE, redirect URI 고정이 각각 어디에 들어가는지 보여주는 구성 이미지입니다.

5. OAuth 2.0 보안 운영 팁: 코드보다 설정에서 더 많이 무너집니다

사실 OAuth 공격은 코드보다 운영 설정에서 더 자주 터집니다. 애플리케이션 보안 관점에서 특히 아래 항목은 꼭 챙기셔야 합니다.

  • Access Token 저장 위치: 브라우저의 localStorage는 XSS에 취약할 수 있으니 신중히 접근
  • HTTPS 강제: 토큰과 코드가 평문으로 흘러가면 끝입니다
  • Scope 최소화: 꼭 필요한 권한만 요청
  • 토큰 수명 짧게: 장기 유효 토큰은 사고 반경을 키웁니다
  • Refresh Token 보호: 서버 측 저장과 회전(rotation) 전략 검토
  • 로그 마스킹: 토큰, 인가 코드, 사용자 식별자 노출 금지

이건 제가 정말 여러 번 봤는데요. 개발 편의 때문에 디버그 로그에 전체 콜백 URL을 남기면, 거기에 code나 token이 그대로 찍히는 경우가 있습니다. 테스트 때는 편한데, 운영에서는 그대로 사고 증거가 되더라고요.

6. ⚠️ 트러블슈팅: 제가 실제로 자주 겪었던 문제들

문제 1. 프록시 뒤에서 redirect URI가 http로 바뀌는 경우

Nginx나 Ingress(인그레스, 외부 트래픽 진입점) 뒤에 둘 때 앱이 원래 스킴을 못 알아차리면 https:// 대신 http://로 redirect URI를 만들어버립니다.

  • 해결: X-Forwarded-Proto 신뢰 설정
  • 해결: 프레임워크별 proxy headers 설정 확인
  • 해결: 인증 서버 등록 URI와 앱 생성 URI를 동일하게 맞춤

문제 2. state를 세션에 저장했는데 콜백에서 사라지는 경우

도메인, 서브도메인, SameSite 쿠키 정책이 엮이면 세션이 유지되지 않을 수 있습니다. 저도 처음엔 코드만 의심했는데, 알고 보니 쿠키 속성이 문제였던 적이 많았습니다.

  • 해결: 세션 쿠키 도메인과 SameSite 정책 점검
  • 해결: 로드밸런서 환경에서 세션 일관성 확인
  • 해결: 다중 인스턴스면 중앙 세션 저장소 사용

문제 3. 모바일 앱에서 PKCE는 넣었는데 교환 실패

대부분은 code_verifier를 로그인 시작 시점과 토큰 교환 시점에 다르게 쓰거나, Base64 URL 인코딩 처리가 어긋난 경우였습니다.

  • 해결: 최초 생성한 code_verifier를 그대로 유지
  • 해결: code_challenge_method=S256 확인
  • 해결: URL-safe Base64 처리와 패딩 제거 확인

7. 검증과 결과: 무엇을 통과해야 "안전하다"고 말할 수 있을까요

보안은 느낌으로 끝내면 안 됩니다. 체크리스트로 검증해야 합니다. 저는 아래 항목을 통과해야 최소 기준은 넘겼다고 봅니다.

  1. state가 없거나 틀리면 콜백이 거절된다
  2. 등록되지 않은 redirect URI는 인증 서버에서 거절된다
  3. PKCE 없이 또는 잘못된 verifier로는 토큰 교환이 실패한다
  4. 토큰과 code가 애플리케이션 로그에 남지 않는다
  5. 브라우저 개발자 도구에서 민감한 토큰 노출 면이 최소화돼 있다
  6. 권한 범위(scope)가 필요한 수준으로 제한돼 있다

이렇게 점검하고 나면 OAuth 2.0 보안이 훨씬 단단해집니다. 화려한 기능 추가는 없어 보여도, 실제로는 장애와 사고를 줄이는 가장 값진 작업이더라고요.

OAuth 2.0 보안 검증 결과와 체크리스트를 보여주는 대시보드 이미지

state 검증, PKCE, redirect URI 검사, 로그 마스킹 여부를 통과/실패로 시각화한 결과 이미지입니다.

8. OAuth 공격 방어 전략 한눈에 정리

공격 유형 주요 원인 방어 전략
로그인 CSRF state 검증 누락 고유 state 생성, 세션 바인딩, 1회성 사용
인가 코드 탈취 redirect URI 오검증 정확 일치 비교, 사전 등록 URI만 허용
코드 재사용/가로채기 PKCE 미적용 S256 PKCE 강제
토큰 유출 부적절한 저장/로그 출력 로그 마스킹, 저장 위치 최소화, HTTPS 강제
과도한 권한 넓은 scope 요청 최소 권한 원칙 적용

혹시 지금 운영 중인 서비스가 있다면, 위 표 기준으로 하나씩 점검해보세요. 특히 OAuth 2.0 보안은 한 군데만 잘한다고 끝나는 게 아니라, 클라이언트와 인증 서버, 프록시와 브라우저 저장소까지 같이 봐야 합니다.

OAuth 2.0 보안 공격 유형과 방어 전략을 요약한 인포그래픽

대표적인 OAuth 공격 유형과 대응책을 좌우 비교 형태로 정리한 인포그래픽입니다.

9. 마무리: OAuth 2.0 보안은 기능이 아니라 운영 습관입니다

정리해보면, OAuth 2.0 보안의 핵심은 복잡한 암호 기술보다도 기본기입니다. state 검증, PKCE 적용, redirect URI 정확 일치, 토큰 노출 최소화. 이 네 가지만 제대로 해도 공격면이 눈에 띄게 줄어듭니다. 저도 처음엔 문서만 보고 붙였다가 왜 자꾸 이상한 케이스가 생기나 했었는데, 결국 문제는 대부분 "설마 이 정도는 괜찮겠지"에서 시작됐습니다.

다음 글에서는 OAuth와 함께 자주 등장하는 OpenID Connect(OIDC, 인증 레이어)의 ID Token 검증 포인트를 따로 다뤄볼 예정입니다. 이전 글에서 다룬 리버스 프록시와 쿠키 보안 설정을 함께 보시면 더 이해가 잘 되실 겁니다.

FAQ. 자주 헷갈리는 질문

Q1. SPA에서도 OAuth를 안전하게 쓸 수 있나요?

가능합니다. 다만 PKCE를 기본으로 하고, 토큰 저장 전략과 XSS 대응을 같이 설계해야 합니다.

Q2. state와 nonce는 같은 건가요?

완전히 같지는 않습니다. state는 요청 위조 방지에, nonce는 주로 재생 공격 방지와 토큰 검증 맥락에서 쓰입니다.

Q3. redirect URI를 여러 개 등록해도 괜찮나요?

괜찮습니다. 다만 각각을 명시적으로 등록하고 정확히 일치 비교해야 합니다. 범용 패턴 허용은 조심하셔야 합니다.