서비스에 로그인을 붙이려고 하면 질문이 줄줄이 따라온다.
- 비밀번호는 DB에 어떻게 저장하지?
- 로그인한 사람이라는 걸 다음 요청에서는 어떻게 알아보지?
- 세션이랑 JWT 중에 뭘 써야 하지?
- "구글로 로그인" 버튼은 어떻게 동작하지?
각각 정답이 있는 질문이고, 대부분의 서비스가 비슷한 답에 도착한다. 이 글은 그 답을 순서대로 따라가서 로그인을 만든다. 그다음엔 같은 시스템을 공격자 쪽에서 다시 본다. 로그인이 "된다"와 "안전하다"는 다른 얘기라서다. 영수가 로그인하는 흐름이 잘 돌아가도, 철수가 그 흐름의 틈으로 영수인 척하면 소용이 없다.
인증과 인가
용어부터 나눠 두자.
인증(Authentication) 은 "너 누구야"에 답하는 일이다. 아이디와 비밀번호를 확인하고, 이 사람이 영수라는 걸 확인한다.
인가(Authorization) 는 "너 이거 해도 돼?"에 답하는 일이다. 영수가 남의 결제 내역을 볼 수 있는지, 관리자 페이지에 들어갈 수 있는지를 따진다.
로그인은 인증이고, 영수가 남의 데이터를 조회하지 못하게 막는 건 인가다. 둘을 섞어 쓰는 글이 많지만 사고가 나는 곳이 다르다. 이 글은 인증만 다룬다.
비밀번호는 해시로 저장한다
가장 먼저 정할 것은 비밀번호 저장이다. 답은 하나다. 원문을 저장하지 않고, 해시를 저장한다.
해시는 한쪽으로만 가는 변환이다. 1234를 넣으면 알아볼 수 없는 문자열이 나오고, 그 문자열에서 1234를 되돌릴 수는 없다. 로그인할 때는 사용자가 입력한 비밀번호를 같은 방식으로 해시해서 저장된 값과 비교한다. DB가 통째로 유출돼도 원문 비밀번호는 바로 드러나지 않는다.
여기서 흔한 실수가 두 가지 있다.
SHA-256 같은 빠른 해시를 그대로 쓰는 것. 해시가 빠르면 공격자에게도 유리하다. GPU 하나로 초당 수십억 번을 대입해 볼 수 있어서, 흔한 비밀번호는 순식간에 풀린다. 비밀번호용 해시는 일부러 느리고 메모리를 많이 쓰게 만든 것을 써야 한다. OWASP는 Argon2id를 첫 번째로 권하고, 쓸 수 없는 환경이면 bcrypt나 scrypt를 쓰라고 한다.
솔트(salt)를 빼먹는 것. 같은 비밀번호는 같은 해시가 나온다. 그러면 공격자가 흔한 비밀번호의 해시를 미리 만들어 두고 DB와 대조할 수 있다. 사용자마다 무작위 값(솔트)을 섞어서 해시하면 이게 막힌다.
다행히 솔트 생성과 저장은 라이브러리가 알아서 한다. 직접 짤 것이 없다.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
# 가입할 때
stored = ph.hash("my-password") # 솔트와 파라미터가 결과 문자열 안에 같이 들어 있다
# 로그인할 때
try:
ph.verify(stored, "my-password")
except VerifyMismatchError:
... # 비밀번호가 틀렸다
로그인 상태를 유지하는 방법
비밀번호 확인은 한 번이면 된다. 문제는 그다음이다. HTTP는 요청마다 독립적이라, 영수가 방금 로그인했다는 사실을 서버가 다음 요청에서는 기억하지 못한다. 그래서 로그인에 성공하면 "나 로그인했던 사람이야"를 증명하는 무언가 를 클라이언트에게 쥐여 주 고, 이후 요청마다 그걸 같이 보내게 한다.
이 "무언가"를 만드는 방식이 크게 두 가지다. 세션과 토큰(JWT)이다.
세션 방식
세션은 서버가 로그인 상태를 기억하는 방식이다.
1. 로그인 요청 ─────────────▶ 서버: 비밀번호 확인
서버: 세션 저장소에 { sid_8f3a: 영수 } 저장
2. ◀───────────── Set-Cookie: sid=sid_8f3a
3. 내 정보 조회 (Cookie: sid=sid_8f3a) ─▶ 서버: 저장소에서 sid_8f3a 조회 → 영수
4. ◀───────────── 영수의 정보
서버는 무작위로 만든 긴 문자열(세션 ID)을 쿠키로 내려 주고, 세션 ID가 누구의 것인지는 서버 쪽 저장소(Redis나 DB)에 적어 둔다. 브라우저는 이후 요청마다 쿠키를 알아서 붙여 보낸다.
세션 ID 자체에는 아무 정보도 없다. 그냥 저장소를 찾아가는 번호표다. 그래서 장점이 분명하다.
- 로그아웃이 확실하다. 저장소에서 지우면 그 즉시 끝난다.
- 계정이 탈취된 것 같으면 그 사용자의 세션을 전부 지울 수 있다.
- 사용자 권한이 바뀌면 다음 요청부터 바로 반영된다.
단점은 요청마다 저장소를 조회해야 한다는 것, 서버가 여러 대면 저장소를 공유해야 한다는 것이다. 다만 Redis 조회 한 번은 대부분의 서비스에서 문제가 되는 비용이 아니다.
세션 ID는 곧 열쇠다
세션 ID를 알아낸 사람은 비밀번호 없이도 영수가 된다. 그러니 세션 ID를 만들고, 쓰고, 버리는 전 과정이 보안이다.
예측할 수 없게 만든다. user_id + 현재 시각 같은 식으로 만들면 규칙을 추측당한다. 운영체제가 제공하는 보안 난수 생성기로 충분히 길게 만든다. OWASP는 엔트로피 64비트 이상을 요구하고, 실무에서는 128비트 이상을 쓴다. random 모듈은 보안용이 아니다. 이전 출력으로 다음 값을 예측할 수 있으니 secrets(또는 각 언어의 CSPRNG)를 쓴다.
import secrets
sid = secrets.token_urlsafe(32) # 256비트 난수
저장소에는 세션 ID 원문 대신 해시를 저장하는 방법도 있다. 저장소가 유출돼도 그 값으로는 쿠키를 만들 수 없다. 세션 ID는 이미 무작위 값이라 SHA-256 정도의 빠른 해시로 충분하다. 비밀번호와 달리 느린 해시가 필요 없다.
로그인할 때 새로 발급한다. 세션 고정(Session Fixation) 공격이 있다. 공격자가 먼저 사이트에 접속해 세션 ID를 하나 받는다. 그 ID를 심은 링크를 영수에게 보낸다. 영수가 그 상태로 로그인하면, 서버가 같은 세션 ID에 "영수로 로그인함"을 기록한다. 공격자는 이미 그 ID를 알고 있으니 그대로 영수가 된다. 막는 방법은 간단하다. 로그인에 성공하는 순간 기존 세션을 버리고 새 세션 ID를 발급한다. 권한이 올라가는 시점(일반 모드에서 관리자 모드 전환 등)에도 똑같이 한다. 대부분의 프레임워크에 regenerate나 rotate 같은 이름의 기능이 있다.
만료는 두 종류를 둔다.
- 유휴 만료(idle timeout): 마지막 활동으로부터 일정 시간이 지나면 만료. 자리 비운 사이 열려 있는 세션을 줄인다.
- 절대 만료(absolute timeout): 아무리 계속 써도 로그인 후 N일이 지나면 만료. 훔친 세션이 영원히 살아남는 걸 막는다.
일반 서비스면 유휴 30분며칠, 절대 1430일 정도에서 정한다. 금융이나 관리자 페이지처럼 민감할수록 짧게 잡는다.
로그아웃은 서버에서 지운다. 쿠키만 지우는 건 로그아웃이 아니다. 쿠키를 미리 복사해 둔 공격자의 요청은 여전히 통한다. 서버 저장소에서 세션을 삭제하는 것이 진짜 로그아웃이다. 같은 이유로 비밀번호를 바꾸거나 재설정했을 때, 2단계 인증 설정을 바꿨을 때, 사용자가 "모든 기기에서 로그아웃"을 눌렀을 때는 그 사용자의 세션을 전부 폐기한다. "로그인된 기기 목록" 화면은 이걸 쉽게 해 준다. 세션 저장소에 기기 이름, IP, 마지막 접속 시각을 같이 적어 두면 되고, 모르는 기기가 보이면 사용자가 직접 끊을 수 있다.
민감한 작업은 다시 확인한다. 로그인 상태라고 해서 모든 걸 허락하지는 않는다. 비밀번호 변경, 이메일 변경, 결제 수단 등록, 계정 삭제 같은 작업은 비밀번호를 한 번 더 받거나 2단계 인증을 다시 요구한다. 세션을 훔친 사람이 계정을 완전히 가로채는 걸 막는 마지막 장치다.
쿠키는 속성이 전부다
세션이든 토큰이든 브라우저에서는 보통 쿠키에 실려 다닌다. 그 러니 쿠키 속성이 곧 방어선이다.
Set-Cookie: sid=sid_8f3a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
HttpOnly: 자바스크립트가 쿠키를 읽지 못한다. XSS 공격으로 스크립트가 심어져도 세션 ID를 훔쳐 가기 어렵다.Secure: HTTPS에서만 쿠키를 보낸다.SameSite: 다른 사이트에서 시작된 요청에 쿠키를 붙일지 정한다. CSRF(남의 사이트가 내 로그인 상태를 이용해 요청을 보내는 공격)를 상당 부분 막아 준다.
SameSite 값은 이렇게 다르다.
| 값 | 동작 |
|---|---|
Strict | 다른 사이트에서 오는 요청에는 쿠키를 절대 붙이지 않는다. 링크를 타고 들어와도 처음엔 로그아웃처럼 보인다. |
Lax | 링크 클릭 같은 최상위 GET 이동에만 쿠키를 붙인다. 다른 사이트의 POST, 이미지, iframe 요청에는 안 붙인다. |
None | 항상 붙인다. Secure가 필수다. 다른 사이트에 임베드되는 서비스에만 쓴다. |
일반 서비스는 Lax가 무난하다. 메신저에서 링크를 눌러 들어와도 로그인이 유지되면서, 외부 사이트가 몰래 보내는 POST는 막는다.
주의할 점이 하나 있다. SameSite의 "사이트"는 등록 가능한 도메인(example.com) 단위다. blog.example.com과 app.example.com은 같은 사이트다. 서브도메인 하나가 뚫리면 SameSite로는 못 막는다.
__Host- 접두사는 한 단계 더 조인다. 쿠키 이름을 __Host-sid로 지으면 브라우저가 이 조건을 강제한다.
Set-Cookie: __Host-sid=sid_8f3a; Secure; HttpOnly; SameSite=Lax; Path=/
Secure가 있어야 하고, Path=/여야 하고, Domain 속성이 없어야 한다. Domain을 지정하지 않으면 쿠키가 발급한 호스트에만 묶이고 서브도메인으로 퍼지지 않는다. 서브도메인에서 쿠키를 덮어쓰는 공격을 막아 준다.
토큰의 종류
세션과는 다른 방식으로 넘어가기 전에, "토큰"이라는 말이 너무 넓게 쓰여서 헷갈리기 쉬우니 실무에서 만나는 것을 나눠 보자.
| 종류 | 생김새 | 서버가 확인하는 방법 |
|---|---|---|
| 세션 ID | 무작위 문자열 | 저장소 조회 (불투명 토큰) |
| JWT | 서명된 JSON | 서명 검증 (자기 완결형) |
| 리프레시 토큰 | 무작위 문자열 | DB 조회 |
| API 키 / PAT | 무작위 문자열 | DB 조회 |
불투명 토큰(opaque token) 은 문자열 자체에 아무 의미가 없다. 서버 저장소를 조회해야 누구 것인지 안다. 폐기가 쉽고 노출되는 정보가 없다. 다른 서버가 불투명 토큰을 검증해야 한다면, 발급 서버에 "이 토큰 유효해?"라고 묻는 토큰 인트로스펙션(RFC 7662)이라는 표준 방법이 있다.
자기 완결형 토큰은 JWT 처럼 정보가 토큰 안에 있다. 조회 없이 검증되지만 폐기가 어렵다.
API 키와 PAT(Personal Access Token) 는 사람이 아니라 프로그램이 쓰는 장기 토큰이다. 이런 것들은 이렇게 다룬다.
- DB에는 해시만 저장한다. 이 값은 이미 긴 무작위 값이라 SHA-256이면 충분하다.
- 사용자에게는 발급할 때 딱 한 번만 보여 준다. 이후에는 앞 몇 글자(
sk_live_ab12...)만 보여 준다. 접두사를 붙이면 유출됐을 때 어떤 종류인지 알아보기 쉽다. - 권한 범위(scope)를 좁게 준다. 조회만 필요한 키에 삭제 권한은 필요 없다.
- 만료일을 두고, 폐기·재발급 화면을 만든다.
그리고 이 모든 토큰은 Bearer 토큰이라는 성질을 공유한다. "들고 있는 사람이 곧 주인"이라는 뜻이다. 서버는 토큰만 맞으면 누가 보냈는지 따지지 않는다. 그래서 토큰은 비밀번호만큼 조심해서 다뤄야 한다. 로그에 찍지 않고, URL 쿼리(?token=...)에 넣지 않는다. URL은 서버 로그, 브라우저 기록, Referer 헤더로 새어 나간다. 토큰은 Authorization: Bearer ... 헤더나 쿠키로만 보낸다.
토큰 값을 직접 비교해야 할 때는 == 대신 상수 시간 비교를 쓴다. 문자열이 앞에서부터 하나씩 비교되다 틀린 곳에서 멈추면, 응답 시간 차이로 값을 한 글자씩 추측하는 공격(타이밍 공격)이 이론적으로 가능하다.
import hmac
hmac.compare_digest(a, b)
DB에서 해시로 조회하는 구조라면 이 문제는 자연스럽게 피해 간다. 해시가 달라지면 비교 대상이 완전히 달라지기 때문이다.
JWT 방식
JWT(JSON Web Token)는 세션과 반대로 서버가 기억하지 않는 방식이다. 로그인 정보를 토큰 안에 담아서 클라이언트에게 준다.
JWT는 점으로 구분된 세 덩어리다.
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc2... . 4xT9...
헤더 페이로드 서명
(알고리즘 정보) (누구인지, 언제까지 유효한지) (위변조 방지 도장)
페이로드를 디코딩하면 이런 JSON이다.
{ "sub": "user_123", "exp": 1788000000 }
서버는 비밀 키로 서명을 만들어 붙여서 내려 주고, 요청이 오면 서명이 맞는지만 검사한다. 서명이 맞고 만료 시간이 안 지났으면 저장소를 조회하지 않고도 user_123이라고 믿는다.
import time
import jwt # PyJWT
SECRET = "..." # 환경 변수에서 읽는다
token = jwt.encode(
{"sub": "user_123", "exp": int(time.time()) + 15 * 60},
SECRET,
algorithm="HS256",
)
# 검증할 때는 허용할 알고리즘을 직접 지정한다
claims = jwt.decode(token, SECRET, algorithms=["HS256"])
JWT에서 가장 많이 하는 오해는 암호화된 것이 아니라는 점이다. 페이로드는 base64로 인코딩됐을 뿐이라 누구나 읽을 수 있다. 서명은 "바뀌지 않았다"만 보장한다. 그래서 sub, exp, 권한 정도만 넣고, 비밀번호나 이메일·전화번호·주소 같은 개인정보는 넣지 않는다. 토큰이 모든 요청마다 오가니 크기도 작을수록 좋다. 정말 내용을 숨겨야 한다면 JWE(암호화된 JWT)라는 규격이 있지만, 그럴 때는 불투명 토큰이 더 단순하다.
액세스 토큰과 리프레시 토큰
JWT의 약점은 한번 발급하면 서버가 거둬들일 방법이 없다는 것이다. 서버가 아무것도 기억하지 않으니, 만료 시간이 오기 전까지는 서명이 맞는 한 계속 통한다. 토큰이 유출되면 만료될 때까지 속수무책이다.
그래서 실무에서는 토큰을 두 개로 나눠 쓴다.
액세스 토큰 수명 5~15분 API 요청마다 보낸다. 서버는 서명만 검사한다.
리프레시 토큰 수명 며칠~몇 주 액세스 토큰이 만료됐을 때 새로 받는 용도로만 쓴다.
액세스 토큰은 짧게 살게 해서 유출돼도 피해 시간을 줄인다. 대신 15분마다 다시 로그인시키면 곤란하니, 수명이 긴 리프레시 토큰으로 새 액세스 토큰을 조용히 받아 온다. 리프레시 토큰은 서버가 DB에 기록해 둔다. 로그아웃이나 탈취 대응은 이 기록을 지우는 것으로 처리한다.
1. 로그인 ──────────────────▶ 액세스(15분) + 리프레시(14일) 발급
2. API 요청 (액세스) ───────────▶ 서명 검사만 하고 응답
3. 15분 뒤, 액세스 만료 ────────▶ 401
4. 리프레시로 갱신 요청 ────────▶ DB에서 리프레시 확인 → 새 액세스 + 새 리프레시
4번에서 리프레시 토큰도 새로 발급하고 이전 것을 폐기하는 방식을 리프레시 토큰 회전(rotation) 이라고 한다. 이미 폐기된 리프레시 토큰이 다시 들어오면 누가 훔쳐 쓰고 있다는 신호이므로, 그 사용자의 토큰을 전부 무효화한다.
여기까지 오면 눈치챌 것이다. 리프레시 토큰은 결국 서버가 기억하는 값이다. JWT를 쓰면서도 상태 저장을 완전히 피하지는 못한다.
JWT를 안전하게 쓰는 법
JWT가 실제로 뚫리는 지점은 따로 있다. 공식 모범 사례는 RFC 8725에 정리돼 있다.
서명 알고리즘: 대칭 키와 비대칭 키
서명 방식은 크게 두 가지다.
- HS256 (대칭): 서명할 때와 검증할 때 같은 비밀 키를 쓴다. 단순하다. 하지만 검증하는 서버도 서명할 수 있는 키를 갖는다. 서버가 여러 개고 각자 검증해야 한다면 위조 가능한 키가 여기저기 퍼진다.
- RS256 / ES256 (비대칭): 개인 키로 서명하고 공개 키로 검증한다. 개인 키는 인증 서버만 갖고, 나머지 서버는 공개 키만 갖는다. 공개 키가 노출돼도 토큰을 위조할 수 없다.
서비스가 하나면 HS256으로 충분하다. 서버가 여럿이거나 외부에 검증을 맡길 일이 있으면 비대칭을 쓴다. 이 경우 인증 서버가 공개 키를 /.well-known/jwks.json 같은 주소(JWKS)로 공개하고, 각 서버가 가져가서 검증한다. 토큰 헤더의 kid(키 ID)로 어떤 키로 서명했는지 알 수 있어서, 키를 교체해도 옛 키와 새 키를 함께 공개하면 무중단으로 넘어갈 수 있다.
import jwt # PyJWT
jwks = jwt.PyJWKClient("https://auth.example.com/.well-known/jwks.json")
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
key,
algorithms=["RS256"], # 허용 알고리즘을 직접 고정
audience="my-api", # 이 토큰이 우리 API용인지
issuer="https://auth.example.com", # 누가 발급했는지
)
대표적인 공격 두 가지
alg: none 공격. JWT 헤더에는 서명 알고리즘이 적혀 있다. 일부 라이브러리가 헤더에 none이 적혀 있으면 서명 검사를 건너뛰던 시절이 있었다. 공격자는 페이로드를 "sub": "admin"으로 바꾸고 헤더를 none으로 고쳐서 보낸다. 요즘 라이브러리는 대부분 막았지만, 검증할 때 허용 알고리즘을 코드에서 직접 지정하는 습관이 근본 해결이다.
알고리즘 혼동 공격. 서버가 RS256을 쓰고 공개 키를 공개하고 있다고 하자. 공격자가 헤더를 HS256으로 바꾸고, 그 공개 키를 HS256의 비밀 키 삼아 서명한다. 검증 코드가 헤더의 알고리즘을 믿고 공개 키를 그대로 넘기면, 공개 키로 만든 서명이 "맞다"고 나온다. 이것도 algorithms=["RS256"]로 고정하면 막힌다.
둘 다 요점은 같다. 토큰이 말하는 대로 검증하지 말고, 서버가 정한 규칙으로 검증한다.
서명만으로는 부족하다. 클레임도 확인한 다
서명이 맞아도 "우리 토큰"이라는 보장은 아니다. 다른 서비스가 같은 인증 서버에서 발급받은 토큰일 수 있다. 확인할 클레임은 이렇다.
| 클레임 | 의미 | 확인할 것 |
|---|---|---|
exp | 만료 시각 | 지났으면 거부 |
nbf | 이 시각 전엔 무효 | 아직 안 됐으면 거부 |
iss | 발급자 | 우리가 믿는 인증 서버인지 |
aud | 대상 | 우리 API가 대상인지 |
sub | 사용자 ID | 이 값으로 사용자를 찾는다 |
jti | 토큰 고유 ID | 폐기 목록 관리용 |
특히 aud를 빼먹는 경우가 많다. 서비스 A용으로 발급한 토큰이 서비스 B에서도 통하면, 한 곳이 뚫렸을 때 피해가 번진다.
JWT 폐기는 어떻게 하지
수명이 짧아도 "지금 당장 이 토큰을 죽여야 하는" 순간이 있다. 실무에서는 세 가지로 푼다.
- 수명을 짧게 한다. 5~15분. 가장 흔하고 가장 효과적이다. 폐기가 필요 없도록 설계하는 방법이다.
- 폐기 목록(denylist)을 둔다. 폐기할 토큰의
jti를 Redis에 넣고, 그 토큰이 만료될 때까지만 보관한다. 검증할 때마다 목록을 조회하니 "서버가 기억하지 않는다"는 장점은 일부 포기한다. - 사용자별 토큰 버전을 둔다. 사용자 테이블에
token_version을 두고, JWT에도 같은 값을 넣는다. 비밀번호를 바꾸거나 "모든 기기 로그아웃"을 하면 버전을 올린다. 버전이 다른 토큰은 전부 무효가 된다.
결국 JWT로 즉시 폐기를 하려 면 서버가 뭔가를 기억해야 한다. 즉시 폐기가 꼭 필요한 서비스라면 처음부터 세션이 낫다는 말이 여기서 나온다.
세션과 JWT, 뭘 쓸까
정리하면 이렇다.
| 세션 | JWT (액세스 + 리프레시) | |
|---|---|---|
| 상태 저장 | 서버 (요청마다 조회) | 토큰 안 (리프레시만 서버) |
| 로그아웃·강제 만료 | 즉시 | 액세스 토큰 수명만큼 지연 |
| 서버 여러 대 | 저장소 공유 필요 | 서명 키만 공유하면 됨 |
| 구현 복잡도 | 낮음 | 높음 (갱신, 회전, 폐기 처리) |
| 잘 맞는 곳 | 웹 서비스, 브라우저 중심 | 모바일 앱, 서비스 간 호출 |
내 기준은 단순하다. 브라우저로 쓰는 일반 웹 서비스라면 세션부터 시작한다. 구현이 쉽고, 뒤탈이 적고, 로그아웃이 진짜로 된다. "요즘은 다 JWT 아니야?"라는 이유로 JWT를 고르면 얻는 것보다 직접 짜야 하는 것이 많다.
JWT가 빛나는 곳은 서비스가 여러 개로 쪼개진 구조다. 인증 서버 하나가 토큰을 발급하면, 나머지 서버들이 각자 서명만 검사하고 인증 서버에 매번 묻지 않는다. 모바일 앱처럼 쿠키가 어색한 클라이언트에서도 편하다.
브라우저에서 토큰은 어디에 두나
JWT를 브라우저에서 쓴다면 저장 위치가 고민이다.
| 저장 위치 | XSS로 탈취 | CSRF | 비고 |
|---|---|---|---|
localStorage | 가능 | 해당 없음 | 스크립트가 그냥 읽는다 |
HttpOnly 쿠키 | 읽기 불가 | 방어 필요 | 가장 권장 |
| 자바스크립트 메모리 | 어려움 | 해당 없음 | 새로고침하면 사라진다 |
localStorage는 편해서 많이 쓰지만, 페이지에 악성 스크립트가 한 줄이라도 실행되면 토큰을 통째로 가져간다. HttpOnly 쿠키는 스크립트가 읽을 수 없으니 이 위험이 없다. 대신 쿠키는 브라우저가 알아서 붙이기 때문에 CSRF 방어가 따라온다.
현실적인 조합은 이렇다. 리프레시 토큰은 HttpOnly + Secure + SameSite 쿠키에, 액세스 토큰은 자바스크립트 메모리에 둔다. 새로고침하면 메모리의 액세스 토큰이 사라지지만, 쿠키의 리프레시 토큰으로 조용히 다시 받아 온다. 가장 중요한 장기 토큰이 스크립트에 보이지 않는다.
웹 공격 셋: XSS, CSRF, CORS
세션이든 토큰이든 아무리 잘 만들어도 웹 페이지가 뚫리면 소용없다. 인증과 얽혀 자주 나오는 세 가지를 짚는다.
XSS
XSS(Cross-Site Scripting) 는 공격자가 내 페이지에 자바스크립트를 심는 공격이다. 게시판 댓글에 <script>...</script>를 적었는데, 사이트가 그걸 걸러내지 않고 그대로 출력하면 다른 사용자의 브라우저에서 그 스크립트가 실행된다. 스크립트는 그 사용자의 권한으로 무엇이든 할 수 있다. 토큰을 읽어 가고, 대신 요청을 보낸다.
방어는 겹쳐서 한다.
- 출력할 때 이스케이프한다. React나 Vue 같은 프레임워크는 기본으로 해 준다.
dangerouslySetInnerHTML이나v-html처럼 날것으로 HTML을 넣는 기능을 쓸 때 조심한다. - CSP(Content Security Policy) 헤더를 건다. 어디서 온 스크립트만 실행할지 브라우저에 알려 준다. 심어진 스크립트가 있어도 실행을 막는다.
- 쿠키에
HttpOnly를 건다. 스크립트가 실행돼도 세션 ID는 못 읽는다. 다만 스크립트가 사용자 권한으로 요청을 보내는 건 막지 못한다. 완전한 방어는 아니라는 뜻이다.
Content-Security-Policy: default-src 'self'; script-src 'self'
CSRF
CSRF(Cross-Site Request Forgery) 는 사용자가 로그인해 있는 걸 이용한다. 영수가 우리 서비스에 로그인한 채로 악성 사이트를 연다. 그 사이트에 숨겨진 폼이 example.com/account/delete로 요청을 보낸다. 브라우저는 쿠키를 자동으로 붙이니, 서버는 영수가 한 요청으로 안다.
방어는 이렇다.
SameSite=Lax이상의 쿠키. 다른 사이트에서 오는 POST에는 쿠키가 안 붙는다. 요즘 브라우저는 속성을 안 적어도 기본으로Lax처럼 동작하지만, 명시하는 편이 안전하다.- 상태를 바꾸는 요청은 GET으로 받지 않는다.
Lax는 링크 이동 같은 GET에는 쿠키를 붙이기 때문이다. - CSRF 토큰. 서버가 폼마다 무작위 토큰을 내려 주고, 요청에 그 토큰이 같이 오는지 확인한다. 공격자는 이 값을 알 수 없다.
Origin헤더 확인. 요청이 우리 도메인에서 왔는지 서버가 검사한다.
Authorization 헤더로 토큰을 보내는 방식은 CSRF에 면역이다. 브라우저가 헤더를 자동으로 붙이지 않기 때문이다. 대신 그 토큰을 스크립트가 들고 있어야 하니 XSS에 취약해진다. 쿠키는 XSS에 강하고 CSRF에 약하고, 헤더 토큰은 반대다. 어느 쪽이든 한쪽 방어는 직접 해야 한다.
CORS
CORS는 흔히 오해하는 개념이라 짚어 둔다. CORS는 공격 방어 장치가 아니라, 브라우저의 "같은 출처 정책"을 필요한 만큼 풀어 주는 장치다. 다른 출처의 스크립트가 우리 API의 응답을 읽어도 되는지를 브라우저가 서버 헤더를 보고 정한다.
- 서버로 요청이 가는 것 자체를 막지 못한다. 막는 건 응답을 읽는 것뿐이다. 그래서 CORS가 CSRF 방어를 대신하지 못한다.
Access-Control-Allow-Origin: *는 아무 사이트나 응답을 읽게 한다. 인증이 필요한 API에는 쓰지 않는다. 쿠키를 같이 보내는 요청(Access-Control-Allow-Credentials: true)에서는*자체가 허용되지 않는다.- 허용할 출처를 목록으로 고정한다. 요청의
Origin값을 검사 없이 그대로 응답에 되돌려 주는 코드는 사실상*와 같다.
로그인 화면을 노리는 공격
토큰이나 세션을 훔치지 않고 로그인 화면을 정면으로 두드리는 공격도 있다.
브루트포스는 한 계정에 비밀번호를 계속 바꿔 대입한다. 크리덴셜 스터핑은 다른 사이트에서 유출된 이메일·비밀번호 목록을 우리 사이트에 대량으로 시도한다. 사람들이 비밀번호를 재사용하는 탓에 성공률이 꽤 나온다. 요즘 계정 탈취의 상당수가 이 방식이다.
방어는 이렇다.
- 시도 횟수 제한(rate limit). 계정별, IP별로 제 한한다. 실패가 쌓일수록 대기 시간을 늘리는 방식이 일반적이다. 계정을 영구 잠금하면 남의 계정을 일부러 잠가 버리는 공격에 쓰이니, 일시 지연이 낫다.
- 유출된 비밀번호 거부. 가입이나 비밀번호 변경 때 이미 유출된 비밀번호인지 확인한다. Have I Been Pwned의 Pwned Passwords API는 비밀번호의 SHA-1 해시 앞 5글자만 보내서 조회하는 방식(k-익명성)이라, 비밀번호가 외부로 나가지 않는다.
- 2단계 인증. 비밀번호가 털려도 두 번째 요소가 막는다. 가장 효과가 큰 방어다.
- 계정 열거 막기. "존재하지 않는 아이디입니다"와 "비밀번호가 틀렸습니다"를 나눠 알려 주면, 공격자가 어떤 이메일이 가입돼 있는지 알아낼 수 있다. 로그인 실패 메시지는 "아이디 또는 비밀번호가 올바르지 않습니다" 하나로 쓴다. 가입 화면의 "이미 가입된 이메일입니다"도 같은 문제를 만든다.
- 비밀번호 정책은 길이 중심으로. NIST 지침은 대문자·특수문자 강제 같은 복잡도 규칙과 주기적 변경 강제를 하지 말라고 하고, 유출 목록 대조와 충분한 길이를 권한다. 규칙을 억지로 맞추면
Password1!같은 비밀번호만 늘어난다.
비밀번호 재설정은 또 하나의 로그인이다
"비밀번호를 잊으셨나요" 링크는 이메일 소유만 증명하면 계정을 넘겨주는 문이다. 그래서 로그인만큼 신경 써야 한다.
import secrets, hashlib
from datetime import datetime, timedelta, timezone
token = secrets.token_urlsafe(32)
db.save_reset(
user_id=user.id,
token_hash=hashlib.sha256(token.encode()).hexdigest(), # 원문은 저장하지 않는다
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
)
send_mail(user.email, f"https://example.com/reset?token={token}")
- 토큰은 보안 난수로 만들고, DB에는 해시만 저장한다.
- 유효기간은 짧게(15~60분), 한 번 쓰면 폐기한다.
- 요청 화면은 가입 여부가 드러나지 않게 "해당 이메일이 있으면 메일을 보냈습니다"로 통일한다.
- 재설정이 끝나면 그 사용자의 모든 세션과 리프레시 토큰을 폐기한다.
- 새 비밀번호를 넣은 뒤 자동으로 로그인시키기보다, 직접 로그인하게 하는 편이 안전하다.
소셜 로그인
"구글로 로그인", "카카오로 로그인" 버튼은 사용자도 편하고 우리도 좋다. 비밀번호를 직접 받아 저장하지 않으니 유출 걱정이 줄고, 가입 단계가 짧아져서 이탈도 준다.
뒤에서 동작하는 규격이 OAuth 2.0 과 OIDC(OpenID Connect) 다. 두 규격의 역할은 다르다.
- OAuth 2.0은 "이 앱이 내 구글 정보에 접근해도 좋다"는 권한 위임 규격이다.
- OIDC는 그 위에 "이 사람이 누구인지"를 알려 주는 인증 층을 얹은 규격이다.
소셜 로그인은 OIDC를 쓰는 것이고, 흐름은 Authorization Code 방식이 표준이다.
브라우저 우리 서버 구글
│ 구글로 로그인 클릭 │ │
│────────────────▶│ │
│◀── 구글 로그인 페이지로 리다이렉트 ──│ │
│──────────────────────────────────────▶│ 사용자가 구글에서 로그인·동의
│◀──────────── 우리 서버로 리다이렉트 (?code=abc) ───│
│────────────────▶│ code 전달 │
│ │─ code + 비밀 정보 ────▶│
│ │◀─ ID 토큰, 액세스 토큰 ─│
│ │ ID 토큰 검증 → 영수 확인
│◀── 우리 서비스의 세션 발급 ──│
핵심은 두 가지다.
비밀번호는 구글만 본다. 사용자가 구글 화면에서 직접 로그인하고, 우리는 비밀번호를 구경도 못 한다. 우리가 받는 건 "구글이 이 사람을 확인했다"는 증명(ID 토큰)이다.
브라우저를 거치는 건 일회용 code뿐이다. 진짜 토큰은 우리 서버가 code를 구글에 내밀어서 직접 받아 온다. 토큰이 주소창이나 브라우저 기록에 남지 않는다.
ID 토큰을 검증하고 나면 우리 서비스는 그 사람의 구글 ID(sub)로 사용자를 찾거나 만든다. 이후 로그인 상태는 앞에서 본 세션이나 JWT로 똑같이 유지한다. 소셜 로그인은 "비밀번호 확인" 단계를 구글에 맡긴 것이고, 그다음은 우리 몫이다.
버튼 뒤에서 일어나는 일
구글이든 카카오든 애플이든 뼈대는 같다. 이메일을 안 주거나, 이름을 첫 로그인 때만 주거나, 시크릿 형식이 다른 식의 차이는 있지만 공급자 문서에서 그 부분만 찾아 맞추면 된다. 여기서는 그 공통 뼈대를 조금 더 들여다본다.
먼저 앱을 등록한다. 로그인 버튼을 달기 전에 공급자의 개발자 콘솔에 우리 서비스를 등록한다. 그러면 세 가지가 생긴다.
client_id: 공급자가 우리 앱을 알아보는 이름. 공개돼도 괜찮다.client_secret: 우리 서버만 아는 비밀. 토큰을 받을 때 "진짜 그 앱이 맞다"를 증명한다.redirect_uri: 로그인이 끝나고 사용자를 돌려보낼 주소. 등록한 주소로만 돌려보낸다.
로그인 페이지로 보낼 때는 이런 값을 붙인다. 사용자가 버튼을 누르면 우리 서버는 브라우저를 공급자의 인가 주소로 보낸다.
https://accounts.provider.com/authorize
?response_type=code
&client_id=my-app
&redirect_uri=https://example.com/auth/callback
&scope=openid email profile
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
| 값 | 역할 |
|---|---|
response_type=code | Authorization Code 방식으로 하겠다 |
scope | 무엇을 달라 고 할지. openid가 있어야 ID 토큰이 온다 |
state | 콜백이 우리가 시작한 로그인에서 온 건지 확인 |
nonce | ID 토큰이 이번 로그인을 위해 발급된 건지 확인 |
code_challenge | PKCE. code를 가로챈 사람이 쓰지 못하게 |
scope가 동의 화면에 그대로 나온다. "이 앱이 이메일과 프로필에 접근하려 합니다" 같은 문구가 여기서 만들어진다. 많이 달라고 할수록 사용자는 망설인다. 로그인에 필요한 만큼만 요청한다.
콜백에서 토큰 두 개를 받는다. 사용자가 동의하면 공급자는 redirect_uri로 code를 붙여 돌려보낸다. 우리 서버는 이 code와 client_secret(그리고 PKCE 원본 값)을 공급자의 토큰 주소로 보내고, 이런 응답을 받는다.
{
"access_token": "ya29.a0Af...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"refresh_token": "1//0g...",
"token_type": "Bearer",
"expires_in": 3600
}
둘의 쓰임새는 다르다.
- ID 토큰은 우리에게 주는 신분증이다. 공급자가 서명한 JWT이고, 이 사람이 누구인지 담겨 있다. 로그인 판단은 이걸로 한다.
- 액세스 토큰은 공급자 API를 부르는 열쇠다. 프로필 사진을 가져오거나, 캘린더에 일정을 넣을 때 쓴다. 우리 서비스의 로그인 증명이 아니다.
이 구분이 중요한 이유가 있다. 액세스 토큰만 보고 "이 토큰으로 사용자 정보를 조회했더니 영수더라"로 로그인시키면, 다른 앱에서 발급된 영수의 액세스 토큰으로도 우리 서비스에 들어올 수 있다. 액세스 토큰에는 "우리 앱용"이라는 표시가 없기 때문이다. ID 토큰은 aud에 우리 client_id가 들어 있어서 이걸 확인할 수 있다.
ID 토큰의 페이로드는 이렇게 생겼다.
{
"iss": "https://accounts.provider.com",
"sub": "10769150350006150715",
"aud": "my-app",
"exp": 1788003600,
"iat": 1788000000,
"nonce": "n-0S6_WzA2Mj",
"email": "user@example.com",
"email_verified": true
}
앞에서 본 JWT 검증이 그대로 적용된다. 공급자의 공개 키로 서명을 확인하고, iss가 그 공급자인지, aud가 우리 client_id인지, exp가 안 지났는지, nonce가 우리가 보낸 값인지 본다.
공개 키는 디스커버리 문서에서 찾는다. OIDC 공급자는 /.well-known/openid-configuration이라는 주소에 자기 정보를 JSON으로 공개한다. 인가 주소, 토큰 주소, 서명 공개 키 주소(jwks_uri) 같은 것들이다. 대부분의 OIDC 라이브러리는 공급자 주소만 알려 주면 이 문서를 읽어서 나머지를 알아서 맞춘다. 새 공급자를 붙일 때 코드가 거의 늘지 않는 이유다.
앱에서는 시작점만 다르다. 모바일 앱은 브라우저 리다이렉트 대신 공급자의 SDK나 운영체제의 로그인 화면을 쓴다. 앱은 결과로 ID 토큰(또는 code)을 받아 우리 서버로 보낸다. 서버가 할 일은 같다. 앱이 "이 사람 sub는 1234예요"라고 보내는 값을 믿지 않고, 공급자가 서명한 ID 토큰을 받아 직접 검증한다. 앱과 웹이 서로 다른 client_id를 쓴다면 둘 다 허용 목록에 넣고 aud를 확인한다.
우리 계정과는 (공급자, sub)로 묶는다. sub는 공급자 안에서 그 사용자를 가리키는 바뀌지 않는 ID다. 이메일은 사용자가 바꿀 수 있고, 공급자에 따라 안 주거나 가려진 주소를 주기도 해서 식별자로 쓰면 안 된다. 한 사람이 여러 공급자로 로그인할 수 있으니 소셜 계정을 별도 테이블로 뺀다.
users social_accounts
id | name user_id | provider | provider_sub
1 | 영수 1 | google | 10769150350006150715
1 | apple | 001234.a1b2c3...
(provider, provider_sub)에 유니크 제약을 걸고, 로그인할 때는 이 쌍으로 사용자를 찾는다. 이미 가입한 사람이 다른 공급자로 들어오면 자동으로 합치기보다, 로그인한 상태에서 "계정 연결하기"를 누르게 하는 편이 안전하다.
자주 하는 실수
OAuth와 OIDC는 규격이 복잡해서 구현 실수가 잦다. 최신 보안 권고는 RFC 9700에 정리돼 있다.
- PKCE를 안 쓴다. 로그인을 시작할 때 만든 임시 값의 해시를 먼저 보내고, 나중에 원본 값을 내밀어 "처음에 시작한 그 클라이언트가 맞다"를 증명하는 방식이다.
code를 가로채도 원본 값 없이는 토큰으로 바꿀 수 없다. 비밀 키를 숨길 수 없는 모바일 앱과 SPA는 필수고, 서버 앱도 같이 쓰라고 권한다. state값을 안 쓴다. 로그인을 시작할 때 무작위 값을 만들어 세션에 저장하고, 콜백에서 같은 값이 돌아오는지 확인한다. 안 하면 공격자가 자기 계정의code를 영수의 브라우저에 밀어 넣는 로그인 CSRF가 가능하다.redirect_uri를 느슨하게 등록한다. 와일드카드나 접두사 매칭을 허용하면code가 공격자의 주소로 넘어갈 수 있다. 인증 서버에 등록하는 주소는 정확히 일치하는 값 하나씩만 넣는다.- ID 토큰을 검증하지 않는다. 서명,
iss,aud,exp, 그리고 로그인 요청 때 보낸nonce를 확인한다. - 이메일만 믿고 계정을 합친다. 공급자가
email_verified가 참이라고 알려 준 경우에만 기존 계정과 연결한다. 확인하지 않고 합치면, 남의 이메일로 가입한 사람이 그 계정을 가로챌 수 있다. - 클라이언트 시크릿을 프런트엔드에 넣는다. 브라우저나 앱 번들에 들어간 시크릿은 비밀이 아니다. 시크릿은 서버에서만 쓴다.
2단계 인증과 패스키
비밀번호만으로는 유출과 재사용 공격을 막기 어렵다. 그래서 요즘은 두 가지가 기본이 되고 있다.
2단계 인증(MFA). 비밀번호에 앱 OTP(구글 OTP 같은 것)나 보안 키를 더한다. SMS 인증은 편하지만 번호를 가로채는 공격이 있어서, 가 능하면 앱 OTP나 패스키가 낫다.
패스키(Passkey). 비밀번호 대신 기기의 지문·얼굴 인식으로 로그인하는 방식이다. 기기 안에 개인 키가 있고 서버에는 공개 키만 있다. 서버가 털려도 뺏길 비밀번호가 없고, 가짜 사이트에서는 아예 동작하지 않아서 피싱에도 강하다. 구글, 애플, 마이크로소프트가 모두 지원해서 이미 많은 서비스가 도입하고 있다.
직접 만들지 않는 선택
여기까지 읽으면 짜야 할 것이 꽤 많다. 비밀번호 해시, 세션이나 토큰 발급과 회전, 비밀번호 재설정, 이메일 인증, 소셜 로그인 연동, 로그인 시도 제한, 2단계 인증까지. 하나하나는 어렵지 않아도 하나라도 틈이 생기면 사고가 된다.
그래서 실무에서는 인증을 직접 만들지 않고 검증된 솔루션을 가져다 쓰는 경우가 많다.
- 매니지드 서비스: Auth0, AWS Cognito, Firebase Authentication, Clerk. 가입·로그인·소셜 로그인·MFA를 통째로 맡긴다. 사용자 수에 따라 비용이 든다.
- 직접 운영하는 오픈소스: Keycloak, Ory. 데이터를 우리가 갖고 싶거나 비용을 아껴야 할 때 쓴다. 운영은 우리 몫이다.
- 프레임워크 라이브러리: Auth.js(Next.js), Spring Security, Django 내장 인증, Rails의 Devise. 앱 안에 붙이는 방식이다.
처음 만드는 서비스라면 쓰는 프레임워크의 기본 인증 기능에서 시작하는 게 가장 무난하다. 소셜 로그인과 MFA가 빨리 필요하거나 팀에 인증을 맡을 사람이 없다면 매니지드 서비스가 낫다. 직접 만드는 쪽은 인증 자체가 서비스의 핵심인 경우에나 고민할 일이다.
어느 쪽을 고르든 이 글의 내용은 쓸모가 있다. 프레임워크나 인증 서비스가 기본으로 해 주는 것과 내가 직접 챙 겨야 하는 것을 구분하려면, 각 장치가 무엇을 막는지 알고 있어야 한다.
로그인 바깥에서 챙길 것
로그인 기능과 상관없이 서버 전체에 해당하는 것들이다. 여기가 뚫리면 앞에서 한 일이 소용없어진다.
HTTPS는 예외 없이. 평문 HTTP로는 쿠키와 토큰이 중간에서 그대로 보인다. 여기에 HSTS 헤더를 걸면 브라우저가 이후로는 HTTP로 접속하는 시도 자체를 HTTPS로 바꿔 준다.
Strict-Transport-Security: max-age=31536000; includeSubDomains
비밀 키는 코드에 없어야 한다. JWT 서명 키, DB 비밀번호, 외부 API 키가 깃 저장소에 들어가면 이미 유출된 것으로 본다. 환경 변수나 시크릿 매니저(AWS Secrets Manager, Vault 등)에서 읽고, 주기적으로 교체한다. 교체가 쉬워야 유출됐을 때 대응이 된다. 비대칭 JWT에서 kid와 JWKS가 필요한 이유다.
로그에 비밀을 남기지 않는다. 요청 로그에 Authorization 헤더, 쿠키, 비밀번호, 재설정 토큰이 찍히지 않게 한다. 로그는 개발자 여럿이 보고 외부 수집 서비스로도 흘러가서, 토큰이 한번 찍히면 회수가 어렵다.
보안 이벤트는 기록한다. 로그인 성공과 실패, 비밀번호 변경, 2단계 인증 설정 변경, 토큰 폐기 같은 이벤트를 남긴다. 사고가 났을 때 무슨 일이 있었는지 확인하는 유일한 단서다. 사용자에게도 새 기기에서 로그인했을 때나 비밀번호가 바뀌었을 때 메일로 알려 준다.
체크리스트
비밀번호와 로그인
- 비밀번호는 Argon2id나 bcrypt로 해시한다. SHA-256 단독이나 평문 저장은 안 된다.
- 시도 횟수를 제한하고, 유출된 비밀번호를 거부하고, 2단계 인증을 제공한다.
- 로그인 실패, 가입, 재설정 화면에서 가입 여부가 드러나지 않게 한다.
- 재설정 토큰은 해시 저장, 짧은 수명, 일회용으로 한다.
세션과 쿠키
- 세션 ID는 CSPRNG로 128비트 이상 만들고, 로그인과 권한 상승 때 새로 발급한다.
- 유휴 만료와 절대 만료를 둔다.
- 로그아웃은 서버 저장소에서 지운다. 비밀번호가 바뀌면 전체 세션을 폐기한다.
- 민감한 작업은 재인증을 요구한다.
- 쿠키에
HttpOnly,Secure,SameSite=Lax를 붙인다. 가능하면__Host-접두사를 쓴다.
토큰과 JWT
- 토큰은 로그와 URL에 남기지 않는다.
- API 키는 해시로 저장하고 한 번만 보여 준다. 권한 범위를 좁힌다.
- JWT는 허용 알고리즘을 고정하고,
exp,iss,aud를 검증한다. - 서버가 여럿이면 비대칭 서명(RS256/ES256)과 JWKS를 쓴다.
- 액세스 토큰 수명은 짧게, 페이로드에는 민감정보를 넣지 않는다.
- 브라우저에서 리프레시 토큰은
HttpOnly쿠키에 둔다. 갱신할 때마다 회전시킨다.
웹 공격
- 출력은 이스케이프하고 CSP를 건다.
- 쿠키 인증이면
SameSite와 CSRF 토큰으로 CSRF를 막는다. - CORS는 허용 출처를 목록으로 고정하고
*를 쓰지 않는다.
소셜 로그인
- Authorization Code에 PKCE를 쓰고,
state와 정확한redirect_uri를 지키고, ID 토큰을 검증한다. - 로그인 판단은 액세스 토큰이 아니라 ID 토큰으로 한다. 앱에서 받은 ID 토큰도 서버가
aud까지 검증한다. - 사용자는 이메일이 아니라
(공급자, sub)로 식별한다.
운영
- HTTPS와 HSTS, 코드 밖의 비밀 키, 로그 점검, 보안 이벤트 기록을 갖춘다.
이 목록을 처음부터 다 지키기는 어렵다. 프레임워크 기본 인증이나 인증 서비스를 쓰고 있다면 상당수는 이미 되어 있을 테니, 안 된 것부터 하나씩 확인해 보면 된다.
참고 자료
- OWASP Cheat Sheet Series – 비밀번호 저장, 세션 관리, CSRF, XSS 항목
- JSON Web Token Best Current Practices – RFC 8725
- Best Current Practice for OAuth 2.0 Security – RFC 9700
