Python WebAcademy Blog

Pythonのsecretsとは?パスワードやトークンをrandomで作ってはいけない理由を初心者向けに解説

|

パスワードのリセットURLやAPIキーを、randomで作っていませんか。randomの乱数は計算で先読みできるため、秘密の値には向いていません。標準ライブラリのsecretsを使えば、OSの乱数で予測できないトークンが1行で作れます。token_hexやtoken_urlsafeの使い分け、パスワードの作り方、比較で使うcompare_digestまで初心者向けに解説します。

パスワードのリセット用URLや、APIキー。プログラムの中で自分で作った経験はありますか。

そのとき、randomを使っていなかったでしょうか。実はそこが、静かに危ない場所です。

Pythonには、秘密の値を作るための専用モジュールが最初から入っています。名前はそのまま、secretsです。

今回は、なぜrandomではいけないのか、secretsを使うと何が変わるのかを、動くコードと一緒に見ていきます。

標準ライブラリなので、インストールは必要ありません。今日書いているコードに、そのまま取り入れられます。

randomで作った文字列は、あとから当てられる

まず、randomの何が問題なのかという話をさせてください。

randomはメルセンヌ・ツイスタという方式で数を作っています。速くて質のよい乱数ですが、暗号のために設計されたものではありません。

この方式には、出てきた数をある程度集めると、次に出る数を計算できてしまう性質があります。つまり、過去に発行されたトークンをいくつか手に入れた人は、これから発行されるトークンを先回りして作れます。

公式ドキュメントにも、このモジュールの疑似乱数生成器をセキュリティ目的で使ってはいけない、とはっきり書かれています。代わりにsecretsを見るように、とも添えられています。

random自体の使い方は、こちらで詳しく扱いました。【関連記事】Pythonのrandomとは?サイコロやシャッフルの書き方と、パスワードに使ってはいけない理由を解説

secretsは、OSが持っている乱数をそのまま使う

secretsはPython 3.6で標準ライブラリに加わりました。PEP 506という提案がもとになっています。

仕組みはとてもシンプルです。Python側で数を作るのをやめて、OSが用意した乱数の仕組みに任せてしまいます。

LinuxやmacOSなら/dev/urandom、WindowsならOSの暗号機能が使われます。機器の細かな揺らぎなども材料にしているため、外から次の値を予測できません。

予測できないことが前提になっている。これがrandomとのいちばん大きな違いです。

randomとsecretsは、どちらを選ぶか

判断の基準はひとつだけです。その値を他人に当てられたら困るかどうか。

作りたいもの 使うモジュール
サイコロ、シャッフル、テスト用のダミーデータ random
機械学習などで結果を再現したいとき random(seedを固定できる)
パスワード、パスワードリセットのトークン secrets
APIキー、セッションID、招待コード secrets
ワンタイムパスワードや認証コード secrets

迷ったらsecretsを選んでおけば、まず事故になりません。ほんの少し遅いだけで、それ以外に困ることはないからです。

トークンは、たった1行で作れる

では実際に動かしてみましょう。よく使うのは、トークンを作る3つの関数です。

import secrets

print(secrets.token_bytes())    # bytesがそのまま返る
print(secrets.token_hex())      # 16進数の文字列
print(secrets.token_urlsafe())  # URLに埋め込める文字列

手元のPython 3.11で実行すると、次のような値が出ました。実行するたびに変わります。

b'\x8f\x1e...'
9f2c4a1d8b3e...(64文字)
qP3xR7k-bT9wZ...(43文字)

3つの違いは、返ってくる形だけです。中身のランダムさは、どれも同じだけあります。

関数 返ってくるもの 向いている場所
token_bytes() bytes 暗号鍵として別の処理へ渡す
token_hex() 0〜9とa〜fの文字列 設定ファイルやDBに保存する
token_urlsafe() URLで使える文字列 リセットURLやクエリ文字列

URLに埋め込むならtoken_urlsafeを選んでください。プラスやスラッシュが出てこないので、エンコードの手間がいりません。

token_hexのほうは、英数字だけで見た目がおとなしいのが利点です。ログに残しても文字化けせず、手で打ち直すときも迷いません。

何バイトを指定すればいいのか

引数を省略すると、32バイト分のランダムさが使われます。この32という数字は、secrets.DEFAULT_ENTROPYという定数で決まっています。

公式ドキュメントには、2015年の時点で32バイト(256ビット)あればこのモジュールの一般的な用途には十分だと考えられている、と書かれています。

つまり引数は省略してかまいません。短く指定すると弱くなるだけで、いいことはありません。

ひとつだけ注意があります。token_urlsafe(16)の16はバイト数であって、文字数ではありません。返ってくる文字列は22文字になります。

パスワードを作るなら、choiceを組み合わせる

人が手で入力するパスワードには、token_hexの文字列は長すぎることがあります。

そんなときはsecrets.choiceの出番です。使ってよい文字を並べたものから、1文字ずつ安全に選び出します。

import secrets
import string

ALPHABET = string.ascii_letters + string.digits


def make_password(length=12):
    while True:
        password = ''.join(secrets.choice(ALPHABET) for _ in range(length))
        if not any(c.islower() for c in password):
            continue
        if not any(c.isupper() for c in password):
            continue
        if sum(c.isdigit() for c in password) < 3:
            continue
        return password


print(make_password())   # 例: 7aQ4mZt1Rk9X

whileで回しているのは、条件を満たすまで作り直すためです。足りない文字をあとから差し込むと、位置に偏りが出てしまいます。

この書き方は公式ドキュメントのレシピとして紹介されているものです。条件を変えたいときは、ifの中身だけを書き換えてください。

比べ方にも、落とし穴がある

作るところを直しても、まだ穴が残っています。受け取った値と正解を比べる場所です。

イコール2つで文字列を比べると、先頭から順に照合して、違った時点で処理が止まります。合っている文字が多いほど、判定にかかる時間がわずかに伸びるということです。

差は1秒の何百万分の1ほどです。それでも何万回も試せば、正解の文字を先頭から1文字ずつ割り出せてしまいます。

これを防ぐのがsecrets.compare_digestです。中身が違っていても最後まで比べるので、時間に差が出ません。

import os
import secrets

EXPECTED_KEY = os.environ['API_KEY']   # 正解のキーは環境変数から読む


def check_api_key(received):
    return secrets.compare_digest(received, EXPECTED_KEY)


print(check_api_key('wrong-key'))   # False

ひとつ注意点があります。compare_digestに日本語などの非ASCII文字を渡すとエラーになるので、キーは英数字で作ってください。

私は10年ほどエンジニアとして開発に関わってきましたが、このイコール2つの比較は、セキュリティ診断の指摘でいちばんよく見た項目のひとつです。動いてしまうので、レビューでも素通りしやすいのが厄介なところでした。

トークンは、作って終わりではない

予測できないトークンが作れるようになると、つい安心してしまいます。けれど運用の面では、もうひとつ考えることが残っています。

それは、いつまで有効にするかです。

パスワードリセットのリンクを例に考えてみましょう。メールで送ったリンクが半年後もそのまま使えるとしたら、どうでしょうか。

受信箱に残ったメールを見た誰かが、あとからアカウントを乗っ取れてしまいます。トークンの強さとは無関係に、時間が穴になるわけです。

期限と使い捨てをセットで考える

対策はむずかしくありません。発行した時刻を一緒に保存して、一定時間を過ぎたら無効にします。

さらに、1回使ったら消す。この2つを入れるだけで、漏れたときの被害がぐっと小さくなります。

私は10年ほどエンジニアとして開発に関わってきましたが、以前ここで痛い目を見ました。招待用のURLに期限を付け忘れ、退職した方の手元に残っていたリンクが半年後に使われて、慌てて全件を無効化したことがあります。

トークンの作り方は正しかったのに、です。安全な値を作ることと、安全に運用することは別の仕事だと、そのとき覚えました。

作った秘密は、置き場所まで面倒を見る

安全に作れたトークンも、置き場所を間違えれば意味がありません。

いちばんよくある失敗が、APIキーをコードに直接書いてGitHubへ上げてしまうことです。公開リポジトリなら、数分で拾われると思ってください。

秘密の値はコードの外に出して、環境変数から読み込みます。この手順はこちらで手を動かしながら確認できます。【関連記事】環境変数とは?PythonでAPIキーを安全に扱う.envと os.environ の基本を初心者向けに解説

利用者のパスワードを預かる場合は、そもそも元の文字列を保存してはいけません。ハッシュに変えてから保存します。【関連記事】Pythonのhashlibとは?パスワードをそのまま保存してはいけない理由とハッシュの基本を初心者向けに解説

つまずきやすいところ

最後に、初心者がひっかかりやすい点を3つ挙げておきます。

ひとつめは、自分のファイルにsecrets.pyという名前を付けてしまうこと。import secretsが自分のファイルを読みに行き、token_hexなど無いと言われて混乱します。標準ライブラリと同じ名前は避けてください。

ふたつめは、IDとトークンの取り違えです。uuidは重複しないことが目的、secretsは予測できないことが目的で、狙っているものが違います。当てられたら困る値にはsecretsを使いましょう。【関連記事】Pythonのuuidとは?重複しないIDを作る仕組みとPython 3.14で加わったuuid7を初心者向けに解説

みっつめは、速度です。secretsは毎回OSに問い合わせるぶん、randomより時間がかかります。

とはいえ、体感できるほどの差が出るのは何十万回と呼び出したときです。数百万件のダミーデータを作るような場面だけ、素直にrandomへ切り替えてください。

まとめと、次の一歩

secretsは、覚えることがとても少ないモジュールです。

秘密の値を作るときはtoken_urlsafeかtoken_hex、パスワードならchoice、比べるときはcompare_digest。当面はこれだけで足ります。

次の一歩として、今あなたが書いているコードの中でrandomを検索してみてください。その乱数が他人に知られて困るものなら、importを1行書き換えるだけで守りが変わります

安全なコードは、難しい技術で守るものとは限りません。正しい道具を選ぶだけで済むことも、実はたくさんあります。

ここまでお読みいただきありがとうございました。

参考情報

次のアクション

記事で学んだ内容を実際に動かしてみよう

Python WebAcademyでは、ブラウザ上でコードを書きながら基礎から実践まで体系的に学べます。

Python WebAcademyの学習画面

あわせて読む

関連記事

ブログ一覧へ

Python学習ロードマップ

まずはこの3講座から

記事で気になったテーマを、順番に手を動かしながら学べます。

ロードマップを見る