Reactで、パスワードを作るボタンを作る方法
ボタンを押すたびに、ランダムなパスワードを作って表示する部品を React で作りました。長さと使う文字の種類を選べて、できたパスワードはコピーのボタンでクリップボードに入れられます。
作るときに気をつけるのは、乱数の取り方です。乱数は Math.random() ではなく crypto.getRandomValues() から取ります。そのうえで、乱数を文字の数で割った余りで文字を選ぶと、割り切れない場合は一部の文字が出やすくなります。その偏りを Node.js で数えて確かめ、偏らない選び方に直しました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げ、パスワード管理ツールとの組み合わせは試していません。

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。押した瞬間だけ、変わった表示をピンクにしています。撮影のときは、フォントと文字の大きさを記事の CSS から変えています。
Math.random() ではなく crypto.getRandomValues() を使う
MDN の Math.random() の説明には、Math.random() の乱数は暗号に使えるほど安全ではなく、セキュリティに関わる目的には Crypto.getRandomValues() を使うように書かれています。パスワードはまさにその用途なので、Math.random() は使いません。
MDN の getRandomValues() の説明にあるとおり、crypto.getRandomValues() は、渡した Uint32Array などの配列を、暗号に使える強さの乱数で埋めて返します。同じページには、Crypto のうち保護されたコンテキストの外でも使えるのは getRandomValues() だけだ、とも書かれています。ブラウザだけでなく、Node.js 22 でも crypto.getRandomValues() をそのまま呼べました。後で Node.js で偏りを数えるときは、記事と同じコードを使っています。
割った余りで文字を選ぶと、偏りが出る
乱数から文字を1つ選ぶとき、乱数を文字の数で割った余りを使う書き方がすぐに思いつきます。ところが、乱数の取りうる値の数が、文字の数で割り切れないと偏りが出ます。
たとえば、0〜255 の1バイトの乱数を、小文字・大文字・数字の 62 文字で割ると、256 は 62 の4倍に 8 余ります。そのため、余りが 0〜7 になる値は5通り、8〜61 になる値は4通りで、並びの先頭8文字(a〜h)だけが出やすくなります。Node.js で、それぞれ 620万回選んで数えた結果です。
1バイト % 62 : 期待値 100000 回、最多 121956 回、最少 96265 回、最多/最少 = 1.267、多い順の10文字 = fdcaghbe82
先頭8文字(a〜h)の平均 121369 回、残り54文字の平均 96834 回、比 1.253
randomInt(62): 期待値 100000 回、最多 100867 回、最少 99164 回、最多/最少 = 1.017、多い順の10文字 = QLsJoa4IAe
1バイトの余りで選ぶと、a〜h の8文字が、ほかの文字の約1.25倍出ました。計算どおり、5通りと4通りの比です。下の randomInt(62) で選んだほうは、どの文字もほぼ同じ回数でした。
偏りをなくすには、割り切れる範囲を超えた値を捨てて、引き直します。32ビットの乱数(0〜232−1)を使う関数は次のとおりです。
// 0 以上 max 未満の整数を、偏りなく返す
export function randomInt(max: number): number {
const range = 2 ** 32;
// max で割り切れる範囲だけを使い、はみ出した値は捨てて引き直す
const limit = range - (range % max);
const buf = new Uint32Array(1);
while (true) {
crypto.getRandomValues(buf);
if (buf[0] < limit) return buf[0] % max;
}
}
limit は、232 以下で最大の、max の倍数です。limit 以上の値を捨てれば、残った値はどの余りにも同じ数ずつ当たります。捨てる値は、max が 62 なら4個、74 なら44個です。232(約43億)個のうちのこれだけなので、引き直しはほとんど起きません。
パスワードを作る関数
文字の種類と、乱数で文字を選んでパスワードを作る関数です。上の randomInt も同じファイルに入っています。
export const CHARSETS = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digit: '0123456789',
symbol: '!#$%&*+-=?@_',
} as const;
export type CharsetKey = keyof typeof CHARSETS;
// 見分けにくい文字(大文字のアイ、小文字のエル、数字の1、大文字のオー、数字の0、小文字のオー)
const AMBIGUOUS = /[Il1O0o]/g;
export function buildPools(sets: CharsetKey[], excludeAmbiguous: boolean): string[] {
return sets.map((k) => (excludeAmbiguous ? CHARSETS[k].replace(AMBIGUOUS, '') : CHARSETS[k]));
}
// 0 以上 max 未満の整数を、偏りなく返す
export function randomInt(max: number): number {
const range = 2 ** 32;
// max で割り切れる範囲だけを使い、はみ出した値は捨てて引き直す
const limit = range - (range % max);
const buf = new Uint32Array(1);
while (true) {
crypto.getRandomValues(buf);
if (buf[0] < limit) return buf[0] % max;
}
}
export function generatePassword(length: number, sets: CharsetKey[], excludeAmbiguous: boolean): string {
const pools = buildPools(sets, excludeAmbiguous);
if (pools.length === 0 || length < pools.length) {
throw new RangeError('文字の種類が選ばれていないか、長さが足りません');
}
const all = pools.join('');
// 選んだ種類の文字がすべて入るまで、丸ごと作り直す
while (true) {
let password = '';
for (let i = 0; i < length; i++) password += all[randomInt(all.length)];
if (pools.every((pool) => [...pool].some((c) => password.includes(c)))) return password;
}
}
// 使う文字の数と長さから、組み合わせの数を2の何乗かで表す(目安)
export function estimateBits(length: number, sets: CharsetKey[], excludeAmbiguous: boolean): number {
const size = buildPools(sets, excludeAmbiguous).join('').length;
return size === 0 ? 0 : Math.floor(length * Math.log2(size));
}
使う文字を全部つなげた all から、長さの数だけ randomInt で選びます。「見分けにくい文字を使わない」を選んだときは、大文字の I(アイ)、小文字の l(エル)、数字の 1、大文字の O(オー)、数字の 0、小文字の o(オー)を除きます。手で書き写したり、読み上げたりするときに取り違えやすい文字です。
選んだ種類の文字が必ず入るようにするために、足りない種類があったら、そのパスワードは捨てて丸ごと作り直しています。丸ごと作り直せば、条件を満たすパスワードの中から等しく選ぶことになります。
作り直しが何回起きるかは、長さで大きく変わります。4種類(74文字)を使うときに、1回で4種類がそろう割合を、組み合わせの数から計算しました。長さ8では、generatePassword を10万回呼んで実際に数えた、1個あたりに作った回数も、計算と同じ約2.19回でした。
| 長さ | 1回で4種類がそろう割合 | 平均で何回作るか |
|---|---|---|
| 4 | 6.49% | 15.40回 |
| 8 | 45.65% | 2.19回 |
| 16 | 84.47% | 1.18回 |
| 32 | 98.69% | 1.01回 |
Node.js で1万回作る時間を測ると、長さ16で 659ms、作り直しの多い長さ4で 2339ms でした。長さ4でも1個あたり1ms未満なので、ボタンを1回押すたびに作る使い方なら、時間を気にする必要はありません。
ボタンと設定の部品
画面の部品は次のとおりです。長さはスライダー、使う文字はチェックボックスで選び、「パスワードを作る」を押したときだけ generatePassword を呼びます。
import { useState } from 'react';
import { CHARSETS, estimateBits, generatePassword, type CharsetKey } from './generatePassword';
const LABELS: Record<CharsetKey, string> = {
lower: '小文字(a〜z)',
upper: '大文字(A〜Z)',
digit: '数字(0〜9)',
symbol: '記号(!#$%&*+-=?@_)',
};
const KEYS = Object.keys(CHARSETS) as CharsetKey[];
export function PasswordGenerator() {
const [length, setLength] = useState(16);
const [sets, setSets] = useState<CharsetKey[]>(KEYS);
const [excludeAmbiguous, setExcludeAmbiguous] = useState(false);
const [password, setPassword] = useState('');
const [copied, setCopied] = useState(false);
// 長さの下限(4)は種類の数(最大4)以上なので、種類が1つ以上あれば作れる
const canGenerate = sets.length > 0;
const toggleSet = (key: CharsetKey) =>
setSets((prev) => KEYS.filter((k) => (k === key ? !prev.includes(k) : prev.includes(k))));
const handleGenerate = () => {
setPassword(generatePassword(length, sets, excludeAmbiguous));
setCopied(false);
};
const handleCopy = async () => {
await navigator.clipboard.writeText(password);
setCopied(true);
};
return (
<div className="pg">
<label className="pg-length">
長さ:{length}文字
<input
type="range"
min={4}
max={64}
value={length}
onChange={(e) => setLength(Number(e.target.value))}
/>
</label>
<fieldset className="pg-sets">
<legend>使う文字</legend>
{KEYS.map((key) => (
<label key={key}>
<input type="checkbox" checked={sets.includes(key)} onChange={() => toggleSet(key)} />
{LABELS[key]}
</label>
))}
<label>
<input
type="checkbox"
checked={excludeAmbiguous}
onChange={(e) => setExcludeAmbiguous(e.target.checked)}
/>
見分けにくい文字(I l 1 O 0 o)を使わない
</label>
</fieldset>
<button type="button" className="pg-generate" onClick={handleGenerate} disabled={!canGenerate}>
パスワードを作る
</button>
{!canGenerate && <p className="pg-hint">使う文字を1種類以上選んでください。</p>}
<div className="pg-result">
<input
aria-label="作ったパスワード"
value={password}
readOnly
spellCheck={false}
autoComplete="off"
/>
<button type="button" onClick={handleCopy} disabled={password === ''}>
{copied ? 'コピーしました' : 'コピー'}
</button>
</div>
{canGenerate && (
<p className="pg-bits">
組み合わせの数:約 2<sup>{estimateBits(length, sets, excludeAmbiguous)}</sup> 通り
</p>
)}
</div>
);
}
パスワードは、ボタンのクリックで作って state に入れています。描画のたびに作る書き方にすると、長さを動かしただけでパスワードが変わってしまいます。検証環境では、長さを16文字から20文字に変えた直後もパスワードは前のままで、ボタンを押すと20文字になりました。
スライダーの最小値は4にしました。文字の種類は最大4つなので、長さが種類の数より短くなることはありません。MDN の input type="range" の説明にあるとおり、値は min より小さくなりません。検証環境でも、スライダーで Home キーを押すと4文字、End キーを押すと64文字で止まりました。種類をすべて外したときは、ボタンを押せなくして「使う文字を1種類以上選んでください。」と出します。
コピーのボタンは、MDN の writeText() の説明にある navigator.clipboard.writeText() を呼んでいます。検証環境では、クリップボードの中身が表示中のパスワードと一致し、ボタンの表示が「コピーしました」に変わりました。新しいパスワードを作ると「コピー」に戻ります。コピーに失敗したときの扱いなどは、以前の記事「Reactで、クリップボードにコピーするボタンを作る方法」で確かめています。
CSS は次のとおりです。
.pg { display: grid; gap: 12px; max-width: 480px; font-size: 15px; color: #1f2a30; }
.pg-length { display: grid; gap: 4px; }
.pg-sets { display: grid; gap: 6px; border: 1px solid #c8dde3; border-radius: 6px; }
.pg-generate { padding: 10px; font: inherit; font-weight: bold; color: #fff; background: #067a98; border: 0; border-radius: 6px; cursor: pointer; }
.pg-generate:disabled { background: #9aa5ab; cursor: not-allowed; }
.pg-hint { margin: 0; color: #b03050; }
.pg-result { display: flex; gap: 8px; }
.pg-result input { flex: 1; min-width: 0; padding: 8px; font: 16px ui-monospace, monospace; background: #eefafd; border: 1px solid #c8dde3; border-radius: 6px; }
.pg-bits { margin: 0; color: #067a98; }
検証環境で確かめた動き
Playwright で開発用のビルドを開き、ボタンを押して確かめました。最初の3回で出たパスワードは次のとおりです。
1回目: r471TAh?aIP3fZ%+(16文字、小大数記=1111)
2回目: T8?EMMDlXE8GqKSC(16文字、小大数記=1111)
3回目: rp=lPC84q*jPf1&c(16文字、小大数記=1111)
続けて200回押したところ、200個すべてが16文字で4種類の文字を含み、同じパスワードは1つもありませんでした。長さ4にして100回押したときも、すべて4種類がそろっていました。
「見分けにくい文字を使わない」に切り替えて300回押すと、I、l、1、O、0、o を含むパスワードは0個でした。比べるために切り替えを戻して300回押すと、300個のうち224個に、これらの文字のどれかが入っていました。
組み合わせの数の表示は目安にとどめる
部品の下に出している「組み合わせの数:約 299 通り」は、使う文字の数(74)を、長さ(16)の回数だけ掛け合わせた数を、2の何乗かで表し、小数点以下を切り捨てたものです。2の何乗かで表すと、長さや文字の種類を変えたときの差が見やすくなります。GIF のように記号を外すと、74文字が62文字に減るので、299 から 295 に下がります。
ただし、この部品は4種類がそろわないパスワードを捨てているので、実際に出るパスワードの数は、すべての並びの数(長さ16なら 7416、約 299.35)より少なくなります。包除原理で数えた、4種類を含む並びの数と比べました。
| 長さ | すべての並び | 表示(切り捨て) | 4種類を含む並び |
|---|---|---|---|
| 4 | 224.84 | 224 | 220.89 |
| 8 | 249.68 | 249 | 248.54 |
| 16 | 299.35 | 299 | 299.11 |
| 32 | 2198.70 | 2198 | 2198.68 |
長さ16以上では、4種類がそろわない並びを捨てて減る分が、表示で切り捨てた小数点以下より小さいので、表示のほうがわずかに小さくなりました。短いパスワードほど減り方が大きく、長さ4では表示より約3小さくなります。入力したパスワードの強さを、よくある単語や並びまで含めて判定したいときは、以前の記事「Reactで、入力したパスワードの強さを表示する方法」で使った zxcvbn-ts のようなライブラリが向いています。





