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

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

- 주소: https://copyandrun.com/blog/python-password-hash-and-secrets/
- 분야: 시큐어코딩
- 발행일: 2026-10-16
- 태그: Python, argon2-cffi, python-dotenv, Bandit

회원 가입과 로그인 함수가 든 작은 파이썬 파일을 만들어 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에서는 해당 줄만 바꿔 실행합니다.

```bash
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)'
```

기대 출력입니다. 버전이 다르면 이후 출력 일부가 달라질 수 있습니다.

```text
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가 설치된 것을 확인합니다.](https://copyandrun.com/img/python-password-hash-and-secrets/01-venv-versions.png)

## 1. 고치기 전 코드 만들기

일부러 나쁘게 만든 첫 버전입니다. 비밀번호를 MD5로 저장하고, 데이터베이스 비밀번호와 토큰이 소스에 그대로 들어 있습니다.

```bash
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으로 먼저 찾아 보기

```bash
bandit -q -r before | grep -E 'Issue|Location'
echo "exit=${PIPESTATUS[0]}"
```

기대 출력입니다. `-q`는 시작 로그를 줄이고, `grep`은 이슈 줄과 위치 줄만 남깁니다. 아래 경로의 역슬래시(`beforeccounts.py`)는 Windows에서 실행한 결과이며, Linux와 macOS에서는 `before/accounts.py`로 나옵니다.

```text
>> 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이 찍히는 것을 확인합니다.](https://copyandrun.com/img/python-password-hash-and-secrets/02-bandit-before.png)

## 3. argon2-cffi로 해시와 검증 만들기

`PasswordHasher`는 Argon2id를 기본으로 쓰고 솔트를 알아서 만듭니다. 해시 문자열 하나에 알고리즘, 파라미터, 솔트, 해시가 모두 들어 있어서 따로 저장할 컬럼이 필요 없습니다.

먼저 해시 문자열이 어떻게 생겼는지 봅니다.

```bash
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마다 다릅니다.

```text
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, 솔트, 해시 다섯 칸으로 나뉘어 출력되는 것을 확인합니다. 값은 실행마다 다릅니다.](https://copyandrun.com/img/python-password-hash-and-secrets/03-argon2-hash-structure.png)

## 4. 수정한 코드 만들기

해시는 `PasswordHasher`로 바꾸고, 데이터베이스 비밀번호는 환경 변수에서 읽습니다. 소스에는 변수 이름만 남깁니다.

```bash
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`에 둡니다.

```bash
cat > .env <<'E'
DB_PASSWORD=Sup3rS3cret!
SECRET_TOKEN=tok_FAKE_0123456789abcdef
E
```

`.env`가 저장소에 올라가면 코드에 박아 둔 것과 같으므로, 먼저 Git이 이 파일을 어떻게 보는지 확인하고 `.gitignore`에 추가합니다.

```bash
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`를 제외했는지 알려 줍니다.

```text
?? .env
.gitignore:1:.env	.env
```

![.gitignore를 만들기 전에는 .env가 ?? 로 보이고, 만든 뒤에는 상태 목록에서 사라지며 check-ignore가 .gitignore의 1번 줄 규칙을 가리키는 것을 확인합니다.](https://copyandrun.com/img/python-password-hash-and-secrets/05-env-gitignore.png)

이미 커밋한 파일은 `.gitignore`를 나중에 추가해도 계속 추적됩니다. 시크릿을 한 번이라도 커밋했다면 파일을 지우는 것으로는 부족하고 해당 값을 새로 발급해야 합니다.

## 6. 로그인과 재해시 확인하기

수정한 코드를 실제로 돌려 봅니다.

```bash
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` 글자 수입니다.

```text
정상 비밀번호: True
틀린 비밀번호: False
없는 사용자: False
DB 비밀번호 길이: 12
```

이제 `check_needs_rehash`를 확인합니다. 예전 약한 파라미터로 만든 해시를 하나 넣고, 로그인에 성공하면 현재 파라미터로 바뀌는지 봅니다.

```bash
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글자만 잘라 찍었고, 그 뒤의 솔트 일부는 실행마다 다릅니다.

```text
전: $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가 되는 것을 확인합니다. 해시 값은 실행마다 다릅니다.](https://copyandrun.com/img/python-password-hash-and-secrets/04-login-and-rehash.png)

## 7. Bandit을 다시 돌려 통과 확인하기

```bash
bandit -r after 2>/dev/null | grep -E 'No issues|Low:|Medium:|High:'
echo "exit=${PIPESTATUS[0]}"
```

기대 출력입니다. `Low`, `Medium`, `High`가 두 번 나오는 것은 심각도 집계와 신뢰도 집계가 각각 있기 때문입니다.

```text
	No issues identified.
		Low: 0
		Medium: 0
		High: 0
		Low: 0
		Medium: 0
		High: 0
exit=0
```

![수정한 폴더에서 No issues identified가 나오고 exit=0으로 끝나는 것을 확인합니다.](https://copyandrun.com/img/python-password-hash-and-secrets/06-bandit-after.png)

## 자주 막히는 곳

**`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 보안 게이트 붙이기](/blog/ci-semgrep-bandit-gate/)에 있습니다.

## 확인 범위

직접 실행한 부분은 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 입니다. 적용 전에 직접 돌려 확인하시기 바랍니다.
