Copy & Run

파이썬 비밀번호 해시와 하드코딩된 시크릿 정리하기

MD5로 저장하고 코드에 비밀번호가 박힌 회원 가입 예제를 Bandit으로 검사한 뒤, argon2-cffi 해시와 환경 변수 분리로 고쳐 다시 통과시킵니다. 해시 문자열 구조와 needs_rehash 재해시도 직접 확인합니다.

실습 노트 · 2026-10-16 발행 · 15분 · 2026-10 확인

회원 가입과 로그인 함수가 든 작은 파이썬 파일을 만들어 MD5 저장과 하드코딩된 비밀번호를 Bandit으로 찾아내고, argon2-cffi와 환경 변수로 고친 뒤 같은 검사를 다시 돌립니다. 비밀번호와 토큰은 모두 이 글에서 만든 가짜 값이며, 실제 서비스의 값은 쓰지 않습니다.

준비물

항목 내용
Python 3.12
argon2-cffi 25.1.0
python-dotenv 1.2.4
Bandit 1.9.4
Git 5번 단계의 .gitignore 확인에만 필요
예상 소요 시간 약 20분

작업 폴더와 가상환경을 만들고 세 패키지를 설치합니다. 명령은 bash 기준이며 Windows에서는 Git Bash를 씁니다. 가상환경 활성화 명령은 운영체제마다 다릅니다.

운영체제 활성화 명령
Linux, macOS source .venv/bin/activate
Windows (Git Bash) source .venv/Scripts/activate

아래 블록은 Windows용 명령을 적었으므로 Linux와 macOS에서는 해당 줄만 바꿔 실행합니다.

mkdir -p ~/auth-demo/before ~/auth-demo/after && cd ~/auth-demo
python -m venv .venv
source .venv/Scripts/activate
pip install argon2-cffi python-dotenv bandit
python --version
pip list | grep -iE '^(argon2|python-dotenv|bandit)'

기대 출력입니다. 버전이 다르면 이후 출력 일부가 달라질 수 있습니다.

Python 3.12.10
argon2-cffi          25.1.0
argon2-cffi-bindings 26.1.0
bandit               1.9.4
python-dotenv        1.2.4

Python 3.12.10과 argon2-cffi 25.1.0, bandit 1.9.4, python-dotenv 1.2.4가 설치된 것을 확인합니다.
Python 3.12.10과 argon2-cffi 25.1.0, bandit 1.9.4, python-dotenv 1.2.4가 설치된 것을 확인합니다.

1. 고치기 전 코드 만들기

일부러 나쁘게 만든 첫 버전입니다. 비밀번호를 MD5로 저장하고, 데이터베이스 비밀번호와 토큰이 소스에 그대로 들어 있습니다.

cat > before/accounts.py <<'PY'
import hashlib

DB_PASSWORD = "Sup3rS3cret!"
SECRET_TOKEN = "tok_FAKE_0123456789abcdef"

users = {}


def open_db(host, password="Sup3rS3cret!"):
    return {"host": host, "password": password}


def sign_up(name, password):
    users[name] = hashlib.md5(password.encode()).hexdigest()


def log_in(name, password):
    return users.get(name) == hashlib.md5(password.encode()).hexdigest()


db = open_db("db.example.test", password="Sup3rS3cret!")
PY

MD5와 SHA-256 같은 범용 해시는 빠르게 계산되도록 만들어졌습니다. 같은 비밀번호는 항상 같은 값이 나오고 속도도 빨라서, 유출된 해시를 대입 공격으로 푸는 쪽에 유리합니다. 비밀번호 저장에는 느리게 계산하고 메모리를 쓰며 솔트를 자동으로 붙이는 전용 함수를 씁니다.

2. Bandit으로 먼저 찾아 보기

bandit -q -r before | grep -E 'Issue|Location'
echo "exit=${PIPESTATUS[0]}"

기대 출력입니다. -q는 시작 로그를 줄이고, grep은 이슈 줄과 위치 줄만 남깁니다. 아래 경로의 역슬래시(beforeccounts.py)는 Windows에서 실행한 결과이며, Linux와 macOS에서는 before/accounts.py로 나옵니다.

>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'Sup3rS3cret!'
   Location: before\accounts.py:3:14
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'tok_FAKE_0123456789abcdef'
   Location: before\accounts.py:4:15
>> Issue: [B107:hardcoded_password_default] Possible hardcoded password: 'Sup3rS3cret!'
   Location: before\accounts.py:9:0
>> Issue: [B324:hashlib] Use of weak MD5 hash for security. Consider usedforsecurity=False
   Location: before\accounts.py:14:18
>> Issue: [B324:hashlib] Use of weak MD5 hash for security. Consider usedforsecurity=False
   Location: before\accounts.py:18:30
>> Issue: [B106:hardcoded_password_funcarg] Possible hardcoded password: 'Sup3rS3cret!'
   Location: before\accounts.py:21:5
exit=1

여섯 건이 나옵니다. 각 항목이 가리키는 곳은 다음과 같습니다.

번호 찾는 것 이 코드에서 걸린 곳
B105 비밀번호 같은 이름의 변수에 문자열을 대입 DB_PASSWORD, SECRET_TOKEN
B107 함수 매개변수의 기본값이 문자열 open_db(..., password="...") 정의
B106 함수를 부를 때 password= 인자에 문자열을 직접 전달 open_db(..., password="...") 호출
B324 MD5 같은 약한 해시 사용 hashlib.md5 두 곳

Bandit은 변수 이름이 password, pass, secret, token 같은 말을 포함할 때 문자열 대입을 의심합니다. 이름에 이런 말이 없으면 못 찾으므로, 통과했다고 시크릿이 없다는 뜻은 아닙니다.

B105 두 건, B107, B324 두 건, B106이 보고되고 마지막 줄에 exit=1이 찍히는 것을 확인합니다.
B105 두 건, B107, B324 두 건, B106이 보고되고 마지막 줄에 exit=1이 찍히는 것을 확인합니다.

3. argon2-cffi로 해시와 검증 만들기

PasswordHasher는 Argon2id를 기본으로 쓰고 솔트를 알아서 만듭니다. 해시 문자열 하나에 알고리즘, 파라미터, 솔트, 해시가 모두 들어 있어서 따로 저장할 컬럼이 필요 없습니다.

먼저 해시 문자열이 어떻게 생겼는지 봅니다.

cat > structure.py <<'PY'
import time

from argon2 import PasswordHasher, extract_parameters

ph = PasswordHasher()
print("time_cost:", ph.time_cost, " memory_cost(KiB):", ph.memory_cost, " parallelism:", ph.parallelism)

start = time.perf_counter()
stored = ph.hash("correct horse battery")
print("hash 소요(초):", round(time.perf_counter() - start, 3))

print(stored)
for part in stored.split("$")[1:]:
    print("  ", part)
print(extract_parameters(stored))
print("같은 비밀번호를 다시 해시:", ph.hash("correct horse battery") != stored)
PY
python structure.py

기대 출력입니다. 솔트와 해시 값은 실행마다 다르고, 소요 시간도 PC마다 다릅니다.

time_cost: 3  memory_cost(KiB): 65536  parallelism: 4
hash 소요(초): 0.032
$argon2id$v=19$m=65536,t=3,p=4$Yaebh5a6Zry9jUVQ3wx4AQ$s/qAhfsHtuNzMEZmz7AsExv0groKtehmyoeBrwJup9Y
   argon2id
   v=19
   m=65536,t=3,p=4
   Yaebh5a6Zry9jUVQ3wx4AQ
   s/qAhfsHtuNzMEZmz7AsExv0groKtehmyoeBrwJup9Y
Parameters(type=<Type.ID: 2>, version=19, salt_len=16, hash_len=32, time_cost=3, memory_cost=65536, parallelism=4)
같은 비밀번호를 다시 해시: True

해시 문자열은 $로 나뉜 다섯 칸입니다.

칸 값 의미
1 argon2id 알고리즘 종류
2 v=19 Argon2 버전(0x13)
3 m=65536,t=3,p=4 메모리 64MiB(KiB 단위로 표기), 반복 3회, 병렬도 4
4 Yaebh5... 무작위 솔트(Base64)
5 s/qAhf... 계산된 해시(Base64)

기본값은 argon2-cffi API 문서(https://argon2-cffi.readthedocs.io/en/stable/api.html)에 따르면 21.2.0부터 RFC 9106의 저메모리 프로필(RFC_9106_LOW_MEMORY, 문서가 말하는 두 번째 권장 설정)을 따릅니다. RFC가 첫 번째로 권하는 고메모리 프로필(RFC_9106_HIGH_MEMORY)은 메모리를 2GiB 쓰므로 기본값이 아니며 필요하면 직접 지정합니다. 같은 비밀번호를 두 번 해시한 값이 서로 다른 것은 솔트가 매번 새로 만들어지기 때문입니다.

해시 문자열이 argon2id, v=19, m=65536,t=3,p=4, 솔트, 해시 다섯 칸으로 나뉘어 출력되는 것을 확인합니다. 값은 실행마다 다릅니다.
해시 문자열이 argon2id, v=19, m=65536,t=3,p=4, 솔트, 해시 다섯 칸으로 나뉘어 출력되는 것을 확인합니다. 값은 실행마다 다릅니다.

4. 수정한 코드 만들기

해시는 PasswordHasher로 바꾸고, 데이터베이스 비밀번호는 환경 변수에서 읽습니다. 소스에는 변수 이름만 남깁니다.

cat > after/accounts.py <<'PY'
import os

from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from dotenv import load_dotenv

load_dotenv()

ph = PasswordHasher()
DUMMY_HASH = ph.hash("not-a-real-password")
users = {}


def open_db(host, password):
    return {"host": host, "password": password}


def sign_up(name, password):
    users[name] = ph.hash(password)


def log_in(name, password):
    stored = users.get(name)
    try:
        ph.verify(stored or DUMMY_HASH, password)
    except (VerifyMismatchError, InvalidHashError):
        return False
    if stored is None:
        return False
    if ph.check_needs_rehash(stored):
        users[name] = ph.hash(password)
    return True


db = open_db("db.example.test", password=os.environ["DB_PASSWORD"])
PY

verify는 일치하면 True를 돌려주고, 틀리면 False가 아니라 VerifyMismatchError를 던집니다. 그래서 try로 받아 로그인 실패로 바꿉니다. 저장된 값이 해시 형식이 아니면 InvalidHashError가 나오므로 함께 처리했습니다.

없는 사용자도 DUMMY_HASH로 verify를 한 번 돌립니다. 존재하지 않는 이름에서 해시 계산 없이 바로 False를 돌려주면 응답 시간 차이로 가입 여부가 드러나기 때문입니다. 이 글의 환경에서 정상 로그인, 틀린 비밀번호, 없는 사용자의 소요 시간은 모두 0.04초 안팎이었습니다. 다만 이 예제에는 시도 횟수 제한이 없으므로 실제 서비스에서는 계정과 IP 단위의 실패 횟수 제한을 따로 붙여야 합니다.

5. .env로 시크릿 분리하기

python-dotenv의 load_dotenv()는 .env 파일을 읽어 환경 변수로 올립니다. 이 글의 가짜 값을 .env에 둡니다.

cat > .env <<'E'
DB_PASSWORD=Sup3rS3cret!
SECRET_TOKEN=tok_FAKE_0123456789abcdef
E

.env가 저장소에 올라가면 코드에 박아 둔 것과 같으므로, 먼저 Git이 이 파일을 어떻게 보는지 확인하고 .gitignore에 추가합니다.

git init -q
git status --short -- .env
printf '.env\n.venv/\n' > .gitignore
git status --short -- .env
git check-ignore -v .env

기대 출력입니다. 첫 status는 추적되지 않은 파일(??)로 보여 주고, .gitignore를 만든 뒤에는 아무것도 나오지 않습니다. 마지막 줄은 어느 규칙이 .env를 제외했는지 알려 줍니다.

?? .env
.gitignore:1:.env	.env

.gitignore를 만들기 전에는 .env가 ?? 로 보이고, 만든 뒤에는 상태 목록에서 사라지며 check-ignore가 .gitignore의 1번 줄 규칙을 가리키는 것을 확인합니다.
.gitignore를 만들기 전에는 .env가 ?? 로 보이고, 만든 뒤에는 상태 목록에서 사라지며 check-ignore가 .gitignore의 1번 줄 규칙을 가리키는 것을 확인합니다.

이미 커밋한 파일은 .gitignore를 나중에 추가해도 계속 추적됩니다. 시크릿을 한 번이라도 커밋했다면 파일을 지우는 것으로는 부족하고 해당 값을 새로 발급해야 합니다.

6. 로그인과 재해시 확인하기

수정한 코드를 실제로 돌려 봅니다.

cat > demo.py <<'PY'
import sys

sys.path.insert(0, "after")
import accounts

accounts.sign_up("alice", "correct horse battery")
print("정상 비밀번호:", accounts.log_in("alice", "correct horse battery"))
print("틀린 비밀번호:", accounts.log_in("alice", "wrong password"))
print("없는 사용자:", accounts.log_in("nobody", "x"))
print("DB 비밀번호 길이:", len(accounts.db["password"]))
PY
python demo.py

기대 출력입니다. 마지막 줄의 12는 .env의 DB_PASSWORD 글자 수입니다.

정상 비밀번호: True
틀린 비밀번호: False
없는 사용자: False
DB 비밀번호 길이: 12

이제 check_needs_rehash를 확인합니다. 예전 약한 파라미터로 만든 해시를 하나 넣고, 로그인에 성공하면 현재 파라미터로 바뀌는지 봅니다.

cat > rehash.py <<'PY'
import sys

sys.path.insert(0, "after")
import accounts
from argon2 import PasswordHasher

old = PasswordHasher(time_cost=1, memory_cost=8192, parallelism=1)
accounts.users["carol"] = old.hash("old-parameters")
print("전:", accounts.users["carol"][:36])
print("재해시 필요:", accounts.ph.check_needs_rehash(accounts.users["carol"]))

print("로그인:", accounts.log_in("carol", "old-parameters"))
print("후:", accounts.users["carol"][:36])
print("재해시 필요:", accounts.ph.check_needs_rehash(accounts.users["carol"]))
PY
python rehash.py

기대 출력입니다. 앞 36글자만 잘라 찍었고, 그 뒤의 솔트 일부는 실행마다 다릅니다.

전: $argon2id$v=19$m=8192,t=1,p=1$nlJQUb
재해시 필요: True
로그인: True
후: $argon2id$v=19$m=65536,t=3,p=4$jAwIH
재해시 필요: False

비밀번호 원문은 로그인할 때만 손에 들어오므로, 파라미터를 올린 뒤의 재해시는 로그인 성공 시점에 하는 방식이 됩니다. log_in의 마지막 if 블록이 그 일을 합니다. 파라미터는 서버 성능에 맞춰 정합니다. 공식 파라미터 문서(https://argon2-cffi.readthedocs.io/en/stable/parameters.html)는 time_cost를 1부터 올려 보며 원하는 소요 시간에 맞추고, 메모리 사용량은 호출 하나가 감당할 수 있는 만큼으로 잡으라고 안내합니다.

로그인 세 경우가 True, False, False로 나오고, m=8192,t=1,p=1 해시가 로그인 뒤 m=65536,t=3,p=4로 바뀌어 재해시 필요가 False가 되는 것을 확인합니다. 해시 값은 실행마다 다릅니다.
로그인 세 경우가 True, False, False로 나오고, m=8192,t=1,p=1 해시가 로그인 뒤 m=65536,t=3,p=4로 바뀌어 재해시 필요가 False가 되는 것을 확인합니다. 해시 값은 실행마다 다릅니다.

7. Bandit을 다시 돌려 통과 확인하기

bandit -r after 2>/dev/null | grep -E 'No issues|Low:|Medium:|High:'
echo "exit=${PIPESTATUS[0]}"

기대 출력입니다. Low, Medium, High가 두 번 나오는 것은 심각도 집계와 신뢰도 집계가 각각 있기 때문입니다.

	No issues identified.
		Low: 0
		Medium: 0
		High: 0
		Low: 0
		Medium: 0
		High: 0
exit=0

수정한 폴더에서 No issues identified가 나오고 exit=0으로 끝나는 것을 확인합니다.
수정한 폴더에서 No issues identified가 나오고 exit=0으로 끝나는 것을 확인합니다.

자주 막히는 곳

KeyError: 'DB_PASSWORD'로 시작하자마자 종료됩니다. .env가 없거나 변수 이름이 다릅니다. os.environ["DB_PASSWORD"]는 값이 없으면 바로 예외를 던지므로, 설정 누락을 늦게 발견하는 것보다 낫습니다. .env의 이름과 철자를 확인합니다.

VerifyMismatchError가 그대로 올라와 서버 오류가 납니다. PasswordHasher.verify는 틀린 비밀번호에 False를 주지 않고 예외를 던집니다. 이 글의 log_in처럼 try로 받아서 실패 응답으로 바꿉니다.

.env에 적은 값이 안 먹고 다른 값이 쓰입니다. load_dotenv()는 이미 설정된 환경 변수를 덮어쓰지 않습니다. 셸에서 DB_PASSWORD=from-shell python demo.py로 실행하면 DB 비밀번호 길이가 12가 아니라 10으로 나옵니다. .env를 우선하려면 load_dotenv(override=True)를 씁니다.

InvalidHashError가 납니다. 저장된 값이 Argon2 해시 문자열이 아닙니다. 평문이나 예전 MD5 값이 컬럼에 남아 있을 때 생깁니다. 저장돼 있는 것은 MD5 해시뿐이고 비밀번호 원문이 없어서 서버가 직접 Argon2로 다시 해시할 수는 없습니다. 이행 방법 하나는 저장된 MD5 값을 입력으로 Argon2 해시를 한 번 더 만들어 저장해 두고, 로그인할 때 입력 비밀번호의 MD5를 구한 뒤 같은 순서로 검증하는 것입니다. 성공하면 그때 원문으로 일반 Argon2 해시를 새로 저장합니다. 이 감싸기 방식은 ph.hash(md5_hex)와 ph.verify(저장값, md5_hex)로 맞는 비밀번호와 틀린 비밀번호를 가려내는 것까지만 짧게 돌려 확인했고, 데이터베이스 이행 절차 전체는 실행하지 못했습니다.

DeprecationWarning: argon2.hash_password is deprecated가 나옵니다. 옛 예제에 나오는 argon2.hash_password()와 verify_password()는 폐기된 함수입니다. PasswordHasher를 씁니다. python -W error로 경고를 오류로 바꿔 argon2.hash_password(b'x')를 호출했을 때 DeprecationWarning: argon2.hash_password is deprecated, use argon2.PasswordHasher instead가 그대로 출력됐습니다.

Bandit이 통과했는데 시크릿이 남아 있습니다. 변수 이름에 password, secret, token 같은 말이 없으면 Bandit이 문자열 대입을 보지 않습니다. 저장소 전체의 시크릿 탐지는 gitleaks나 detect-secrets 같은 전용 도구를 함께 씁니다.

이어서 해 볼 것

  • .env.example 만들기: 실제 값 대신 변수 이름만 적은 파일을 커밋해 두면, 저장소를 받은 사람이 어떤 변수가 필요한지 알 수 있습니다.
  • 운영 환경에서 시크릿 주입: .env는 개발용으로 두고, 배포 환경에서는 플랫폼의 시크릿 저장소나 환경 변수 설정으로 값을 넣습니다. .env가 없어도 load_dotenv()는 오류를 내지 않습니다.
  • 파라미터를 서버에 맞추기: PasswordHasher.from_parameters에 argon2.profiles.RFC_9106_LOW_MEMORY를 넘겨 보고(고메모리 프로필인 RFC_9106_HIGH_MEMORY는 메모리를 2GiB 쓰므로 서버 사양을 먼저 확인합니다), 실제 서버에서 해시 한 번이 얼마나 걸리는지 재 봅니다.
  • CI에 Bandit 붙이기: 이 글의 bandit -r after를 파이프라인에 넣으면 하드코딩된 비밀번호가 다시 들어오는 것을 막습니다. 설정 방법은 CI에 Semgrep과 Bandit 보안 게이트 붙이기에 있습니다.

확인 범위

직접 실행한 부분은 1번부터 7번 전체(4번의 log_in은 없는 사용자에 더미 해시를 쓰는 최종 코드로 다시 돌려 확인)와 자주 막히는 곳에 적은 다섯 가지 상황(.env 없음, verify 예외, 셸 환경 변수 우선, 폐기 함수 경고, 해시가 아닌 값의 InvalidHashError)입니다. 환경은 Windows의 Git Bash, Python 3.12.10 가상환경, argon2-cffi 25.1.0, python-dotenv 1.2.4, Bandit 1.9.4입니다.

해시 값과 소요 시간은 실행과 PC마다 다릅니다. 데이터베이스에 저장하는 부분, 기존 MD5 사용자를 옮기는 전체 절차, gitleaks와 detect-secrets, 운영 환경의 시크릿 저장소 연동, override=True 동작은 실행하지 못했습니다. 이 도구들의 설명은 다음 공식 문서를 참고했습니다. argon2-cffi는 https://argon2-cffi.readthedocs.io/en/stable/api.html 과 https://argon2-cffi.readthedocs.io/en/stable/parameters.html, python-dotenv는 https://saurabh-kumar.com/python-dotenv/, Bandit은 https://bandit.readthedocs.io/en/latest/plugins/b105_hardcoded_password_string.html 입니다. 적용 전에 직접 돌려 확인하시기 바랍니다.

이 글의 다음 단계

  • Flask 파일 업로드 검증과 경로 조작 방어하기원본 파일명을 그대로 저장하는 허술한 업로드가 폴더 밖에 파일을 남기는 것을 연습용 앱에서 확인합니다. 확장자 허용 목록, 크기 제한, 저장 이름 재생성, 경로 확인을 넣은 수정본에서 막히는 것까지 직접 확인합니다.
  • Flask에서 소유자 검사로 IDOR 막기로그인만 확인하고 주문의 주인은 확인하지 않는 Flask API가 남의 주문을 돌려주는 것을 pytest 한 건으로 확인합니다. 쿼리에 소유자 조건을 넣고 번호를 받는 엔드포인트에 같은 데코레이터를 붙인 뒤, 사용자와 역할 조합 표로 회귀 테스트를 만듭니다.

시큐어코딩 (Python, Java) 과정의 일부입니다

이 실습은 교육에서 참가자마다 독립된 환경으로 진행합니다. 원하는 일정을 고르면 문의 양식에 미리 채워집니다.

과정 보기

교육 진행 순서와 자주 묻는 질문 보기

같은 카테고리의 글