아이디와 비밀번호로 로그인하고, 서버가 세션을 관리하는 구조입니다. 브라우저는 세션 ID를 쿠키로, 앱은 Authorization 헤더로 보냅니다. 만료, 폐기, 인가 같은 서버 규칙은 둘이 똑같습니다. 일반 사용자는 MFA 없이 쓰고, 관리자에게는 법이 요구하는 조건을 더합니다. 각 항목에 근거 문서 번호와 등급을 붙였고, 단계마다 직접 조작해 볼 수 있는 데모가 있습니다.
__Host- 쿠키에 Secure, HttpOnly, SameSite=StrictMFA를 쓰지 않으면 비밀번호가 계정을 지키는 유일한 관문입니다. 그래서 NIST SP 800-63B-4는 비밀번호를 단독으로 쓸 때 최소 15자를 요구하고, MFA와 함께 쓸 때만 8자까지 허용합니다. OWASP ASVS 6.2.1은 아직 "8자 이상, 15자 강력 권장"이라 우리는 더 엄격한 NIST 쪽을 따릅니다.
대신 대문자, 숫자, 특수문자를 섞으라는 조합 규칙과 주기적 변경 강제는 금지됐습니다. 사람들은 이런 규칙에 Password1!, Password2!처럼 예측 가능한 변형으로 대응합니다. 강도를 만드는 건 길이이고, 약한 비밀번호는 유출 목록과 흔한 비밀번호 목록으로 걸러냅니다. 서비스 이름이나 아이디가 들어간 비밀번호도 막습니다.
최대 길이는 "64자 이상 허용"이 기준입니다. 이 숫자는 NIST 지침 작성에 참여한 Jim Fenton이 "패스프레이즈를 막지 않으면서 많은 화면에 한 줄로 들어가는 길이"라는 사용성 이유로 제시한 값입니다. 그렇다고 무한정 받으면 느린 해시 계산으로 서버 CPU를 소모시키는 공격이 가능해서, 우리는 128자를 상한으로 둡니다.
앱은 비밀번호를 네이티브 화면에서 받습니다. WebView 안의 웹 페이지는 앱이 들여다보고 조작할 수 있고, 사용자는 그 화면이 진짜인지 주소창으로 확인할 수 없습니다. RFC 8252 §8.12가 OAuth 맥락에서 이 위험을 다루고, OAuth를 쓰지 않는 우리도 같은 원칙을 따릅니다.
가입이나 비밀번호 변경 때 서버가 하는 검사를 그대로 흉내 냅니다. 입력값은 이 페이지 안에서만 계산되고 어디로도 전송되지 않습니다. 예시 계정은 아이디 minsu, 서비스 이름 ourshop입니다.
서버가 막아야 할 공격은 두 가지입니다. 로그인 창에 비밀번호를 계속 넣어 보는 온라인 추측, 그리고 DB가 유출됐을 때 해시를 내 컴퓨터에서 깨 보는 오프라인 공격입니다.
온라인 추측은 실패 시도 제한으로 막습니다. 계정 하나에 실패가 쌓이면 응답을 점점 늦추고, 일정 횟수를 넘으면 잠시 잠급니다. 그런데 유출된 아이디·비밀번호 목록으로 계정마다 한 번씩만 시도하는 크리덴셜 스터핑은 계정 단위 제한에 걸리지 않습니다. 그래서 IP 단위 제한과 "한 IP가 여러 계정에 실패" 탐지를 함께 둡니다. 한국 고시 제5조도 인증에 일정 횟수 실패하면 접근을 제한하라고 요구합니다.
오프라인 공격은 느린 해시로 막습니다. SHA-256 같은 빠른 해시는 GPU로 초당 수십억 번 계산할 수 있어 비밀번호 저장에 쓰면 안 됩니다. OWASP는 Argon2id를 1순위로, bcrypt를 작업 계수 10 이상의 허용 선택지로 둡니다. 고시 제7조는 알고리즘을 지정하지 않고 일방향 암호화만 요구하므로 둘 다 법적 요건은 채웁니다.
bcrypt를 쓴다면 앞 72바이트만 읽는다는 점을 처리해야 합니다. 한글은 UTF-8에서 한 글자가 3바이트라 한글 24자 정도만 넘어도 뒷부분이 조용히 버려집니다. 72바이트를 넘는 입력을 거부하거나, HMAC으로 먼저 줄인 뒤 bcrypt에 넣습니다. Argon2id에는 이 제한이 없습니다.
실패 메시지는 "아이디 또는 비밀번호가 올바르지 않습니다" 하나로 통일합니다. 어느 쪽이 틀렸는지 알려주면 공격자가 가입된 아이디 목록을 만들 수 있습니다. MFA가 없는 대신, 처음 보는 기기에서 로그인하면 이메일로 알려 사용자가 탈취를 빨리 알아채게 합니다.
정책: 같은 계정에 3번째 실패부터 응답을 1초, 2초, 4초로 늦추고, 5번 실패하면 15분 잠급니다. 같은 IP가 10분 안에 서로 다른 계정 10개 이상에 실패하면 그 IP를 막습니다. 시계는 버튼으로 빨리 돌릴 수 있습니다.
이 브라우저의 WebCrypto로 실제 계산 시간을 잽니다. WebCrypto에는 Argon2id와 bcrypt가 없어서, 같은 "느린 해시" 계열인 PBKDF2-SHA256을 OWASP의 FIPS 환경 권장값인 600,000회로 대신 측정합니다. 핵심은 "한 번 확인하는 데 드는 시간"이 공격자에게는 "한 번 추측하는 데 드는 시간"이라는 점입니다.
아직 측정하지 않았습니다.
// Spring Security: Argon2id (Bouncy Castle 의존성 필요) @Bean fun passwordEncoder(): PasswordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8() // bcrypt를 쓴다면 72바이트 초과 입력은 거부 fun validateForBcrypt(raw: String) = require(raw.toByteArray(Charsets.UTF_8).size <= 72) { "비밀번호가 너무 깁니다" }
비밀번호가 맞으면 서버는 세션을 만들고 세션 ID를 발급합니다. 이후의 모든 요청은 이 ID 하나로 "로그인한 그 사람"임을 증명하니, 세션 ID는 비밀번호와 같은 무게의 비밀입니다.
RFC 10017 §7.1은 프론트와 API가 같은 도메인에 있는 1st-party 서비스라면 OAuth 토큰 대신 서버 측 쿠키 세션으로 인증 상태를 관리해도 된다고 명시합니다. 외부 클라이언트가 없으니 우리는 이 구조를 씁니다. 앱도 같은 세션 ID를 쓰고, 운반 방법만 다릅니다.
세션 ID는 추측할 수 없어야 합니다. ASVS 7.2.3은 CSPRNG(암호학적으로 안전한 난수 생성기)로 만들고 128비트 이상의 엔트로피를 가지라고 요구합니다. 우리는 256비트를 씁니다. 아래 계산기에서 보듯 128비트도 추측이 사실상 불가능해서, 256비트는 "비용이 거의 0이라 여유를 둔" 선택입니다. 참고로 Spring Session의 기본 ID는 UUID라 실제 랜덤 비트가 122비트이고, 엄밀히는 128비트 요건에 조금 못 미칩니다.
로그인할 때마다 새 ID를 발급하는 것(ASVS 7.2.4)은 세션 고정 공격을 막습니다. 공격자가 자기가 아는 세션 ID를 피해자 브라우저에 미리 심어 두고, 피해자가 그 ID인 채로 로그인하기를 기다리는 공격입니다.
서버 저장소에는 세션 ID 원문 대신 SHA-256 해시를 저장합니다. 표준의 필수 항목은 아니고 결정문 조건 ①로 우리가 정한 심층 방어입니다. DB 백업이나 Redis 덤프가 유출돼도 그 안의 값으로는 로그인할 수 없습니다. 256비트 랜덤 값이라 느린 해시나 salt는 필요 없습니다.
이 브라우저의 CSPRNG(crypto.getRandomValues)로 실제 세션 ID를 만듭니다. 서버 Kotlin의 SecureRandom과 같은 역할입니다. 왼쪽 값은 클라이언트에 주는 원문, 아래 해시는 DB에 저장되는 값입니다.
계산식: 아무 세션 하나라도 맞힐 때까지의 평균 시간 = 2비트 ÷ (세션 수 × 초당 추측) ÷ 2. 우주의 나이는 약 1.38 × 1010년입니다.
세 단계를 순서대로 눌러 보세요. "로그인 시 새 ID 발급"을 끄면 공격이 성공하고, 켜면 실패합니다. 실제로는 __Host- 쿠키가 하위 도메인에서 쿠키를 심는 것도 막고, 로그인 전에는 세션을 만들지 않으므로(결정문 ③) 공격 면 자체가 좁습니다. 이 데모는 그중 마지막 방어선만 보여줍니다.
// 256비트 세션 ID 생성과 저장용 해시 private val rng = SecureRandom() fun newSessionId(bytes: Int = 32): String { val buf = ByteArray(bytes).also { rng.nextBytes(it) } return Base64.getUrlEncoder().withoutPadding().encodeToString(buf) // 43자 } fun storageKey(raw: String): String = HexFormat.of().formatHex( MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()) )
발급한 세션 ID를 클라이언트가 보관하고 요청마다 다시 보내야 합니다. 브라우저와 앱은 이 보관과 운반 방법만 다르고, 서버가 하는 일은 같습니다.
브라우저는 쿠키를 씁니다. 쿠키는 브라우저가 자동으로 붙여 보내서 편하지만, 그 자동성 때문에 공격 표면도 생깁니다. RFC 10017 §6.1.3.2는 세션 쿠키에 Secure와 HttpOnly를 반드시(MUST) 켜고, SameSite=Strict, Path=/, Domain 미지정, 접두어 사용을 권장(SHOULD)합니다. 각 속성이 무엇을 막는지는 아래 실험실에서 하나씩 꺼 보며 확인할 수 있습니다.
__Host- 접두어는 이름이 아니라 브라우저가 강제하는 규칙입니다. 이름이 __Host-로 시작하면 브라우저는 Secure가 있고, Path=/이고, Domain이 없을 때만 쿠키를 저장합니다. 그래서 이벤트용 promo.example.com 같은 하위 도메인이 뚫려도 공격자가 우리 세션 쿠키를 덮어쓰거나 심을 수 없습니다. RFC 10017은 더 새로운 __Host-Http- 접두어도 예로 들지만, 브라우저 지원 범위를 따로 확인해야 해서 지금은 __Host-를 씁니다.
앱은 로그인 응답 본문으로 세션 ID를 받아 iOS Keychain이나 Android Keystore로 보호한 저장소에 두고, 요청마다 Authorization 헤더에 직접 붙입니다. 앱 코드가 명시적으로 붙이니 다른 사이트가 위조 요청을 만들 수 없고, CSRF 방어가 필요 없습니다. 대신 HTTP 클라이언트의 쿠키 저장을 꺼서, 앱 쪽에 쿠키로도 인증되는 경로가 생기지 않게 합니다.
처음 상태가 우리 설정입니다. 속성을 하나씩 꺼 보면서 어떤 공격이 열리는지 보세요. 브라우저가 아예 저장을 거부하는 조합도 표시합니다.
// Spring Session 쿠키 설정 @Bean fun cookieSerializer() = DefaultCookieSerializer().apply { setCookieName("__Host-SID") setUseSecureCookie(true) setUseHttpOnlyCookie(true) setSameSite("Strict") setCookiePath("/") // setDomainName은 호출하지 않음 }
# 앱: 로그인 응답 본문으로 받은 값을 # Keychain/Keystore에 두고 매 요청 헤더에 GET /api/orders HTTP/1.1 Host: api.example.com Authorization: Session Kx8Qm2…(43자) # iOS: httpShouldSetCookies = false # Android OkHttp: CookieJar.NO_COOKIES (기본값)
로그인 이후 모든 요청에서 서버는 세 가지를 확인합니다. 세션이 살아 있는가, 이 요청을 정말 사용자가 보냈는가, 그리고 선택적으로 세션을 처음 받은 그 기기에서 왔는가입니다.
세션이 살아 있는지는 시계 두 개로 판단합니다. 무활동 타임아웃은 마지막 요청 이후 경과 시간이고, 절대 만료는 로그인 시점부터의 총 시간입니다. 활동이 계속돼도 절대 만료가 오면 다시 로그인해야 합니다. 비밀번호만 쓰는 수준(NIST AAL1)에서 NIST의 최소선은 30일 이내 재인증이지만, 보안 우선이라 일반 사용자는 무활동 1시간에 최대 24시간으로 잡습니다. ASVS 7.1.1은 이 값과 NIST에서 벗어나는 이유를 문서로 남기라고 합니다.
브라우저 요청은 CSRF를 막아야 합니다. 쿠키가 자동으로 붙기 때문에, 공격 사이트가 사용자의 브라우저를 빌려 우리 서버로 요청을 보낼 수 있습니다. RFC 10017 §6.1.3.3은 SameSite=Strict를 기본으로 두되, 같은 사이트의 다른 하위 도메인에서 오는 공격에는 그것만으로 부족하니 CORS와 고정 커스텀 헤더를 함께 쓰라고 합니다. 커스텀 헤더가 붙은 교차 출처 요청은 브라우저가 항상 사전 확인(preflight)을 하므로, 서버가 허락하지 않으면 요청이 아예 나가지 않습니다.
XSS는 이 모든 방어를 우회합니다. 악성 스크립트가 우리 페이지 안에서 실행되면 사용자와 똑같이 요청을 보낼 수 있고, 이건 어떤 인증 방식도 막지 못합니다(RFC 10017 §5). 그래서 nonce 기반 CSP와 SRI로 XSS가 실행되는 것 자체를 막는 데 집중합니다.
기기 바인딩은 선택입니다. 브라우저는 DBSC로 5분짜리 단기 쿠키를 보안 칩 키 서명으로만 갱신하고, 앱은 DPoP(RFC 9449)로 요청마다 서명을 붙입니다. 둘 다 "세션 ID를 훔쳐 다른 기기에서 쓰는 것"을 막지만, 감염된 기기 안에서의 악용은 막지 못합니다. 보안 칩이 없는 기기에서 DBSC는 오류 없이 일반 쿠키로 동작합니다.
로그인 후 흐른 시간과 마지막 요청 후 흐른 시간을 움직여 보세요. 두 시계 중 하나라도 한도에 닿으면 세션이 끝납니다.
요청이 어디서 왔고 무엇을 하려는지 고르면, 브라우저와 서버의 방어층을 차례로 통과시켜 결과를 보여줍니다. 우리 서버의 커스텀 헤더 규칙은 X-Client: web입니다.
앱의 DPoP 기준입니다. 브라우저 DBSC도 원리는 같고, 서명을 단기 쿠키 갱신 때만 한다는 점이 다릅니다.
시나리오를 골라 보세요.
// DPoP 헤더에 담기는 proof (JWT, 요청마다 새로 서명) { "typ": "dpop+jwt", "alg": "ES256", "jwk": { /* 보안 칩 키의 공개키. 서버는 이 지문(jkt)을 세션에 저장 */ } } . { "htm": "POST", // 이 메서드에만 유효 "htu": "https://api.example.com/address", // 이 URL에만 유효 "iat": 1791789000, // 발급 시각 (±30초) "jti": "d3b0c7e2-…", // 1회용 ID, 재사용 거부 "nonce": "srv-node2-…", // 서버가 준 값 "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" } // 세션 ID의 해시
인증이 "당신이 누구인가"라면, 인가는 "당신이 이걸 해도 되는가"입니다. 실제 사고는 인증보다 인가에서 더 자주 납니다. OWASP API Security Top 10에서도 객체 단위 인가 실패(BOLA)가 1위입니다.
인가는 세 층으로 나눠 봅니다. 기능 단위(ASVS 8.2.1)는 "일반 사용자가 관리자 API를 부를 수 있나", 객체 단위(8.2.2)는 "민수가 지영의 주문을 볼 수 있나", 필드 단위(8.2.3)는 "민수가 자기 프로필을 고치면서 role까지 바꿀 수 있나"입니다. 셋 다 서버에서 검사해야 합니다(8.3.1). 프론트에서 버튼을 숨겨도 공격자는 API를 직접 호출합니다.
객체 단위는 조회 조건에 항상 소유자를 넣는 것으로 막습니다. 남의 주문 ID가 들어오면 403이 아니라 404를 돌려줘서, 그 주문이 존재한다는 사실도 숨깁니다.
필드 단위는 요청 DTO와 엔티티를 분리해서 막습니다. 요청 JSON을 엔티티에 그대로 바인딩하면, 클라이언트가 보내면 안 되는 필드까지 덮어써집니다(Mass Assignment).
세션 방식의 장점이 여기서도 드러납니다. 권한을 요청마다 DB에서 읽거나 변경 시 세션을 갱신하면 권한 박탈이 즉시 반영됩니다(ASVS 8.3.2). JWT라면 토큰이 만료될 때까지 옛 권한이 남습니다.
로그인한 사람은 민수(user 1001)입니다. 주문 5001은 민수의 것, 5002는 지영(user 1002)의 것입니다. 서버 구현을 바꿔 가며 GET /api/orders/{id}를 보내 보세요.
요청을 보내 보세요.
민수가 닉네임만 바꾸는 화면에서, 요청 본문에 몰래 role과 point를 끼워 넣었습니다. PATCH /api/me 처리 방식을 바꿔 가며 결과를 보세요.
요청을 보내 보세요.
// 객체 단위: 조회 조건에 항상 소유자 interface OrderRepository : JpaRepository<Order, Long> { fun findByIdAndUserId(id: Long, userId: Long): Order? } @GetMapping("/api/orders/{id}") fun get(@PathVariable id: Long, @AuthenticationPrincipal me: LoginUser) = orders.findByIdAndUserId(id, me.id) ?: throw NotFoundException() // 403 아닌 404 // 필드 단위: 바꿔도 되는 필드만 가진 요청 DTO data class UpdateProfileRequest(val nickname: String)
로그인한 세션이라도, 그 세션을 지금 누가 쓰고 있는지는 확신할 수 없습니다. 자리를 비운 PC, 탈취된 쿠키, XSS 모두 "로그인한 상태"로 요청을 보냅니다. 그래서 피해가 큰 작업 직전에는 비밀번호를 한 번 더 받습니다.
ASVS 7.5.1은 이메일, 전화번호, 비밀번호처럼 계정 복구에 쓰이는 정보를 바꾸기 전에 재인증을 요구합니다. 이 정보가 바뀌면 공격자가 계정을 영구히 가져갈 수 있기 때문입니다. 7.5.3(L3)은 결제 같은 고위험 작업 전 추가 확인을 요구합니다. MFA가 없으니 재인증 수단은 비밀번호입니다.
세션에 마지막 인증 시각(auth_time)을 두고, 민감 작업이 오면 그 시각이 5분 이내인지 봅니다. 오래됐으면 비밀번호를 다시 받고, 성공하면 auth_time을 갱신하면서 세션 ID도 새로 발급합니다(7.2.4). 재인증 전의 ID가 어딘가 새어 나갔더라도, 그 ID로는 높아진 신뢰를 쓸 수 없게 하기 위해서입니다.
이메일로 계정을 복구한 직후는 특히 위험합니다. 이메일 계정이 뚫렸다면 공격자가 복구했을 수 있으니, 복구 후 전체 세션을 폐기하고, 24~72시간 동안 민감 작업을 막고, 기존 이메일로 알립니다(결정문 ⑫).
정책: 이메일과 비밀번호 변경은 마지막 인증이 5분을 넘었으면 재인증, 결제는 매번 재인증, 주문 조회는 재인증 없음. 데모 비밀번호는 demo입니다.
세션 방식을 고른 가장 큰 이유가 여기 있습니다. 서버가 세션 행을 지우는 순간 그 세션은 바로 끝납니다. 다음 요청부터 조회 단계에서 막히고, 기다릴 만료 시간이 없습니다.
종료가 필요한 순간은 정해져 있습니다. 로그아웃(ASVS 7.4.1)은 그 세션 하나, 계정 정지나 삭제(7.4.2)는 그 사용자의 모든 세션, 비밀번호 변경(7.4.3)은 지금 쓰는 세션을 뺀 나머지입니다. 브라우저와 앱의 세션이 같은 테이블에 있으니 한 번의 삭제로 전부 끝납니다.
사용자가 로그인된 기기 목록을 보고 직접 끊을 수 있어야 합니다(7.5.2). MFA가 없는 구조에서 모르는 기기를 발견하는 거의 유일한 창구입니다. 운영자는 특정 사용자나 전체 사용자의 세션을 끊을 수 있어야 합니다(7.4.5).
앱은 로그아웃할 때 서버 세션 삭제와 함께 기기 저장소의 세션 ID도 지웁니다. 서버 쪽만 지우면 기기에 죽은 ID가 남고, 기기 쪽만 지우면 서버에 살아 있는 세션이 남습니다.
민수의 계정에 브라우저 세션 2개, 앱 세션 2개가 있습니다. 각 버튼이 실행하는 쿼리도 함께 보여줍니다. 7단계에서 비밀번호 변경을 하면 여기에도 반영됩니다.
-- 실행된 쿼리가 여기 표시됩니다
관리자는 다른 사용자의 개인정보를 열람하니, 관리자 계정 하나가 뚫리면 피해 범위가 전체 회원입니다. 그래서 일반 사용자 규칙 위에 법적 의무와 우리 선택을 더 얹습니다.
개인정보의 안전성 확보조치 기준(고시) 제6조 ②는 정당한 접근 권한자(정보주체 제외)가 외부에서 개인정보처리시스템에 접속할 때 인증서, 보안토큰, 일회용 비밀번호 같은 안전한 인증수단을 적용하라고 요구합니다. VPN 같은 안전한 접속수단으로 대신할 수 있다는 단서는 이용자가 아닌 정보주체의 개인정보를 처리하는 시스템에만 해당합니다. 우리 회원은 "이용자"이므로 VPN만으로는 부족합니다.
그래서 관리자에게 MFA를 쓰지 않으려면 사무실 내부망에서만 접속하게 해야 합니다. 외부 접속을 허용한다면 OTP나 보안키가 필요합니다.
무활동 자동 차단(제6조 ④)은 외부와 내부를 가리지 않고 적용됩니다. 법은 시간을 정하지 않으며, 우리는 NIST AAL3와 PCI DSS를 참고해 15분으로 정했습니다. 관리자 화면은 IP 허용 목록으로 막고 인터넷에 공개하지 않습니다(제6조 ①, ISMS-P 2.6.3).
관리자의 접속 조건을 고르면, 법적 요건과 우리 규칙을 순서대로 적용해 허용 여부를 판정합니다.
기록은 사고를 막지는 못하지만, 사고가 났을 때 무엇이 일어났는지 알 수 있는 유일한 수단이고 보호 조치를 했다는 법적 증거입니다.
고시는 접속기록을 계정, 접속일시, 접속지 정보, 처리한 정보주체 정보, 수행 업무 등을 전자적으로 기록한 것으로 정의하고, 보관과 정기 점검을 요구합니다. 관리자가 어떤 회원의 정보를 언제 봤는지가 남아야 합니다. 보관 기간과 점검 주기는 고시 원문에서 확인하세요.
이와 별도로 로그인 실패, 세션 폐기, 재인증, 권한 변경 같은 보안 이벤트를 남겨 이상 징후를 찾습니다(ASVS V16). 비밀번호와 세션 ID 원문은 절대 로그에 남기지 않습니다. 세션은 해시 앞부분만 남깁니다.
아래 표는 이 페이지의 데모에서 일어난 일이 실제로 어떻게 기록되는지 보여줍니다. 위의 데모를 조작하면 행이 추가됩니다.
| 시각 | 계정 | 접속지 | 수행 업무 | 대상 | 결과 |
|---|
표의 근거 칩을 누르면 여기로 옵니다. 각 문서가 누가 만든 것인지, 무엇을 다루는지, 의무인지 권고인지, 우리와 어떤 관계인지 정리했습니다.
__Host-, __Secure- 접두어가 여기서 정의됩니다. 최종 RFC 번호 부여 여부는 따로 확인이 필요하지만 주요 브라우저는 이미 구현했습니다.__Host- 쿠키와 Origin 검사, 익명 세션 미생성, ⑫ 이메일 복구 후 전체 폐기와 쿨다운, ⑮ 관리자 기기 바인딩 필수.