"비밀번호 복잡도 규칙"이 틀린 이유 (NIST SP 800-63B)
수십 년간 표준 비밀번호 조언은 — 대문자 1, 소문자 1, 숫자 1, 특수문자 1 이상, 90일마다 변경 강제, 최근 5개 재사용 금지. 대부분의 기업 IT 부서가 여전히 정확히 이걸 의무화합니다. NIST(미국 국립표준기술연구소, 연방 사이버보안 지침을 정의하는 기관)는 2017년 SP 800-63B를 발행했고 2024년 주요 개정에서 명시적으로 — 그거 다 그만하라고 못박았습니다.
근거는 실세계 결과 측정입니다. 복잡도 규칙과 강제 순환은 비밀번호를 더 좋게 만드는 게 아니라 더 나쁘게 만듭니다. "대문자 + 숫자 + 특수문자 필수"를 강제하면 압도적으로 많이 나오는 결과는 예측 가능한 변환 — Password1!, Password2!, Password3! — 이고, 사람 기반 추측 도구가 이걸 쉽게 열거합니다. 90일마다 순환을 강제하면 사용자는 직전 비밀번호에 카운터를 붙입니다. 정규식상 "복잡"해 보이지만 제약 없는 동등 비밀번호 대비 entropy 이득은 사실상 0이고, 마찰만 폭증해 비밀번호가 재사용되거나 포스트잇에 적힙니다.
NIST SP 800-63B 5.1.1.2(memorized secrets) — "검증자는 다른 구성 규칙(예: 다양한 문자 타입 혼합 요구)을 부과해서는 안 된다(SHOULD NOT). 검증자는 임의의 주기(예: periodic)로 memorized secret 변경을 요구해서는 안 된다(SHOULD NOT)." 변경은 침해 증거가 있을 때만. 최소 길이는 8(랜덤 생성은 6), 최대는 적어도 64, 모든 printable ASCII와 Unicode가 허용되어야 함(MUST). 문자 클래스 제한이 사라졌습니다.
복잡도 규칙을 대체하는 건 denylist입니다. NIST는 비밀번호를 알려진 침해 값 목록에 대조할 것을 의무화합니다 — 침해 corpus에 나타난 비밀번호, 사전 단어, 반복 시퀀스(aaaaaaaa, qwerty), 컨텍스트 특정 문자열(사용자명, 사이트명). 무료 서비스 Have I Been Pwned는 k-anonymity로 통합 가능한 API를 제공합니다 — SHA-1 해시 앞 5글자 전송, 그 prefix를 가진 모든 해시 수신, 로컬에서 비교. 2026년 수백만 서비스가 이걸 씁니다.
여기서 도출되는 실용 조언 — 긴 랜덤 비밀번호(생성기에서 16자 이상), 사이트별 하나, 비밀번호 매니저에 저장. 또는 기억 가능한 passphrase(목록에서 무작위 영단어 4개 이상, ~50+ bit entropy). 강제 순환 그만. 이모지·Unicode 차단 그만. 특수문자 강제 그만. 명백히 나쁜 것만 차단하고 그 너머의 길이와 모양은 사용자에게.
// HIBP k-anonymity check (no full hash leaves your server)
async function checkPwned(password) {
const hash = sha1Hex(password).toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const res = await fetch('https://api.pwnedpasswords.com/range/' + prefix);
const text = await res.text();
for (const line of text.split('\n')) {
const [s, count] = line.trim().split(':');
if (s === suffix) return parseInt(count, 10); // # of breaches
}
return 0;
}
// 2026 sign-up flow per NIST SP 800-63B
async function validateSignup(password) {
if (password.length < 8) return 'too short';
if (password.length > 64) return 'too long';
if (/^(.)\1{4,}$/.test(password)) return 'too repetitive';
const breached = await checkPwned(password);
if (breached > 0) return `appeared in ${breached} breaches`;
return null; // OK — no character-class checks
}Entropy 수학 — 비밀번호는 얼마나 길어야 하는가
비밀번호 강도는 Shannon entropy로 정량화됩니다 — H = log2(N^L), N은 알파벳 크기, L은 길이. 단위는 비트이고, 각 비트는 공격자가 필요로 하는 평균 추측 횟수를 2배로 늘립니다. 26자 알파벳에서 균일 무작위로 뽑은 길이 10 비밀번호는 26^10 ≈ 1.4 × 10^14가지, 즉 log2(1.4 × 10^14) ≈ 47 bit entropy. 같은 길이의 95자 printable ASCII는 95^10 ≈ 6 × 10^19, 약 66 bit.
핵심은 "균일 무작위"입니다. 사람이 고른 비밀번호는 알파벳이 시사하는 것보다 훨씬 entropy가 낮습니다 — 사람은 공간의 극히 일부에서 고르기 때문. 유출 corpus(RockYou, LinkedIn, Adobe) 연구는 일관되게 사람이 고른 8자 비밀번호의 effective entropy를 18~22 bit로 추정합니다 — 50% 확률 크랙에 약 4~8백만 번 추측, 단일 GPU로 수 초 내 충분. 같은 글자수를 생성기에서 만들면 50+ bit, 약 10^15 추측 — 전용 하드웨어에서 수년 단위.
현대 공격자 역량 캘리브레이션 — 2026년 단일 Nvidia RTX 4090은 bcrypt cost-5 해시를 하루 약 2000억 회 계산. 100 GPU 소규모 클러스터는 20조. 몇 시간 hashcat 클라우드 임대로 더 큰 자릿수 가능. 비밀번호 해싱 알고리즘의 work factor가 거대한 차이를 만듭니다 — bcrypt cost 12(현재 OWASP 권장)에서는 같은 하드웨어가 하루 약 8만 해시이므로 cost 5 대비 약 2.5억 배 더 오래 걸립니다. 비밀번호 해시 선택이 비밀번호 길이만큼이나 중요한 이유입니다.
2026년 운영 지침(전형적 Argon2id work factor 가정) — 95-symbol 알파벳에서 12자 무작위는 약 79 bit entropy, 향후 10년간 사실상 크랙 불가능. 16자는 ~105 bit, 국가 행위자 예산에도 가까운 미래에 크랙 불가능. Passphrase는 7,776 단어 목록(EFF wordlist)에서 무작위 4단어 = log2(7776^4) ≈ 51 bit — 12자 비밀번호와 비슷하지만 훨씬 기억하기 쉬움. 5단어는 65 bit, 사람 기억 용도의 실용 sweet spot.
미묘한 함정 — entropy는 균일 무작위 선택을 가정합니다. 생성기가 Math.random()을 쓰면 entropy 주장은 거짓 — V8의 Math.random()은 xorshift128+이고, 공격자가 출력 몇 개를 포착하면 예측 가능. 비밀번호급 출력은 항상 crypto.getRandomValues(new Uint32Array(...))(브라우저) 또는 crypto.randomBytes(...)(Node) 사용. 그 차이가 79 bit entropy와 0의 차이입니다.
// Entropy table (assumes uniform random)
//
// alphabet | length | bits | crack time at 1e9 guesses/sec
// --------- | ------ | ----- | -----------------------------
// 26 | 8 | 37.6 | 2 minutes
// 62 | 8 | 47.6 | 1 day
// 95 | 8 | 52.5 | ~50 days
// 95 | 12 | 78.7 | ~10 million years
// 95 | 16 | 105.0 | ~10^15 years (heat death of sun)
//
// — and remember: real cracking goes through Argon2id which is
// ~10^6 to ~10^9 times slower than raw hashing.
// CORRECT: cryptographic random
function generate(length, alphabet) {
const out = new Array(length);
const r = new Uint32Array(length);
crypto.getRandomValues(r);
for (let i = 0; i < length; i++) out[i] = alphabet[r[i] % alphabet.length];
return out.join('');
}
// WRONG: Math.random — predictable, NOT for passwords
function bad(length) {
let s = '';
for (let i = 0; i < length; i++) s += String.fromCharCode(97 + Math.floor(Math.random() * 26));
return s;
}
// EFF passphrase
import wordlist from './eff_large_wordlist.json'; // 7776 words
function passphrase(n = 5) {
const r = new Uint32Array(n);
crypto.getRandomValues(r);
return [...r].map(x => wordlist[x % 7776]).join('-');
}Passkey, FIDO2, WebAuthn — 비밀번호 이후의 세계
비밀번호에는 어떤 길이나 해싱으로도 못 고치는 구조적 문제가 있습니다 — bearer secret이라는 점. 사용자가 알고, 서버가 해시를 저장하고, 네트워크가 운반합니다. 그 secret이 존재하는 모든 곳에서 피싱·침해·도청·재전송이 가능합니다. Passkey는 업계의 조율된 답입니다.
Passkey는 사용자 기기에서 생성되어 기기의 secure enclave에 저장되는 공개키-개인키 쌍입니다(Apple Secure Enclave, Android StrongBox, Windows Hello TPM, YubiKey 같은 하드웨어 FIDO2 키). 가입 시 기기가 키 쌍을 만들고 공개키만 서버로 전송. 로그인 시 서버가 랜덤 challenge를 보내면 기기가 개인키로 서명하고 서버가 공개키로 검증. 개인키는 기기를 절대 떠나지 않습니다. challenge는 매 로그인 바뀝니다. 피싱할 공유 secret이 없습니다.
표준 — FIDO2(FIDO Alliance의 우산), WebAuthn(W3C 브라우저 API, 2019년부터 Recommendation), CTAP2(브라우저와 인증 하드웨어 사이 프로토콜). Apple, Google, Microsoft, Mozilla가 2022~2023년 동기화된 passkey 지원을 출시했고, Apple iCloud Keychain과 Google Password Manager가 종단간 암호화로 사용자 기기 간 passkey를 복제합니다. 2026년 기준 모든 주요 사이트(Google, Apple, Microsoft, GitHub, PayPal, Amazon, Shopify 등)가 passkey-only 로그인을 지원합니다.
피싱 저항성은 구조적입니다. WebAuthn 시그니처는 relying party identifier(도메인명)를 포함하고, 브라우저는 잘못된 origin에서 passkey 사용을 거부합니다. google.com.attacker.example에 Google 자격증명을 입력하도록 속은 사용자는 거기서 Google passkey를 쓸 수 없습니다 — 브라우저가 origin을 확인하고 기기가 서명을 거부. FIDO가 소비자에게 출시된 가장 효과적인 안티피싱 기술인 이유입니다.
구현 패턴(서버 측) — 가입 시 WebAuthn registration response 수신, attestation 검증, 그 사용자에 대해 public key + credential ID + counter 저장. 로그인 시 랜덤 challenge 생성·전송, 서명된 assertion 수신, 저장된 public key로 시그니처 검증, counter가 단조 증가인지 확인(복제 인증기 저항), origin이 도메인과 일치하는지 확인. 라이브러리 — @simplewebauthn/server(Node), webauthn-rs(Rust), java-webauthn-server(Java).
2026년 대부분 앱이 따르는 전환 패턴 — 호환성을 위해 비밀번호 로그인은 유지하되 첫 로그인에 passkey 등록을 제안, 이후 로그인은 passkey 우선. GitHub가 정확히 이렇게 합니다. passkey 제공 후 12~18개월 안에 선두 사이트들은 활성 사용자의 60~80%가 최소 1개 passkey를 등록했고 비밀번호 사용이 그만큼 감소했다고 보고합니다. 종착지는 "passkey 기본, 마이그레이션용 fallback으로만 password" 그리고 궁극적으로는 신규 계정의 password-free 인증입니다.
// Browser: register a passkey
const opts = await fetch('/passkey/register/options').then(r => r.json());
const cred = await navigator.credentials.create({
publicKey: {
challenge: base64ToArrayBuffer(opts.challenge),
rp: { name: 'Acme', id: 'acme.example' },
user: { id: opts.userId, name: 'alice', displayName: 'Alice' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 /* ES256 */ }],
authenticatorSelection: { userVerification: 'preferred' },
}
});
await fetch('/passkey/register', { method: 'POST', body: JSON.stringify(cred) });
// Browser: sign in with passkey
const opts2 = await fetch('/passkey/login/options').then(r => r.json());
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64ToArrayBuffer(opts2.challenge),
rpId: 'acme.example',
}
});
await fetch('/passkey/login', { method: 'POST', body: JSON.stringify(assertion) });
// Server: verify with @simplewebauthn/server (Node)
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const verify = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://acme.example',
expectedRPID: 'acme.example',
authenticator: storedCred,
});
if (verify.verified) await db.users.updateCounter(userId, verify.authenticationInfo.newCounter);