コンテンツにスキップ

Basic 認証

Basic 認証 — RFC 7617

  • Basic 認証は HTTP 認証フレームワーク (RFC 9110 §11) の上に乗った認証スキームのひとつで、スキーム自体の仕様は RFC 7617
  • サーバーが 401 Unauthorized + WWW-Authenticate: Basic realm="..."チャレンジし、クライアントが Authorization: Basic base64(user-id ":" password)応答する。ただそれだけ。
  • Base64 は符号化であって暗号化ではない。平文と同じなので TLS が事実上の必須要件。仕様上も「安全なチャネル外での使用は非推奨」と明記されている。

HTTP 認証フレームワークの中の位置づけ

Section titled “HTTP 認証フレームワークの中の位置づけ”

「Basic 認証」という単体の仕組みがあるわけではない。HTTP には汎用の認証フレームワークがあり、そこに差し込むスキームとして Basic / Digest / Bearer / Negotiate などが定義されている、という構造を押さえるのが先。

flowchart TB
    subgraph FW["HTTP 認証フレームワーク (RFC 9110 §11)"]
        direction TB
        C["チャレンジ<br/>401 + WWW-Authenticate"]
        R["クレデンシャル送信<br/>Authorization ヘッダ"]
        C --> R
    end

    FW --> S1["Basic (RFC 7617)"]
    FW --> S2["Digest (RFC 7616)"]
    FW --> S3["Bearer (RFC 6750)"]
    FW --> S4["Negotiate / NTLM など"]

    style S1 fill:#4054b2,color:#fff

フレームワークが決めているのは「ヘッダ名」「ステータスコード」「スキーム名 パラメータ という文法」まで。中身の作り方はスキーム側の仕様が決める。Basic の場合は「ユーザー名とパスワードをコロンで連結して Base64」がその中身にあたる。

役割 オリジンサーバー向け プロキシ向け
チャレンジ 401 Unauthorized + WWW-Authenticate 407 Proxy Authentication Required + Proxy-Authenticate
クレデンシャル Authorization Proxy-Authorization
ホップ単位か エンドツーエンド ホップバイホップ

典型的な「認証情報を持たずにアクセス → 401 で弾かれる → ダイアログ入力 → 再送」の往復。

sequenceDiagram
    autonumber
    participant U as 利用者
    participant B as ブラウザ
    participant S as サーバー

    B->>S: GET /private/ (Authorization なし)
    S-->>B: 401 Unauthorized<br/>WWW-Authenticate: Basic realm="Staging"
    B->>U: 認証ダイアログを表示 (realm を提示)
    U->>B: user-id と password を入力
    Note over B: base64("demo:P@ssw0rd")<br/>= "ZGVtbzpQQHNzdzByZA=="
    B->>S: GET /private/<br/>Authorization: Basic ZGVtbzpQQHNzdzByZA==
    S->>S: デコードして照合
    alt 照合成功
        S-->>B: 200 OK (コンテンツ)
        Note over B: 同じ保護空間には以後<br/>先回りして Authorization を付ける
    else 照合失敗
        S-->>B: 401 Unauthorized (再チャレンジ)
    end

ここで重要なのは 2 往復目以降。一度成功したクライアントは、同じ保護空間(後述)に対しては 401 を待たずに Authorization を先付けする。つまり実運用でのリクエストのほとんどは、いきなり Authorization 付きで飛んでくる。

WWW-Authenticate: Basic realm="Staging Area", charset="UTF-8"
  • realm(必須): 保護空間につける名前。ブラウザのダイアログにそのまま表示されるので、利用者向けの説明文になる。逆に言うと機密情報を書いてはいけない。
  • charset(任意): RFC 7617 で追加されたパラメータ。指定できる値は UTF-8 のみ(大文字小文字は区別しない)。非 ASCII のパスワードを扱うときにクライアントへエンコードを伝える。
  • Basic スキームで定義されているパラメータはこの 2 つだけ

realm がブラウザのどこに出るかは、実際のダイアログを見ると一発で分かる。

ブラウザの認証ダイアログ。realm がサイト名の下に表示される

ここに表示されるのはサーバーが決めた文字列なので、「社内 VPN 用」「請求管理システム」のような 内部情報を書くと、認証を通していない相手にそのまま見える。逆に「Staging Area」のように、 何の資格情報を求めているかだけが伝わる文言にする。

Authorization: Basic ZGVtbzpQQHNzdzByZA==

生成規則は次のとおり。

user-pass = user-id ":" password
credentials = "Basic" SP base64(user-pass)

変換の各段階を実物の値で追う手順は データフローを追う にまとめてある。

生成とデコード
# 生成
printf 'demo:P@ssw0rd' | base64
# => ZGVtbzpQQHNzdzByZA==
# デコード(誰でもできる、という事実の確認)
echo 'ZGVtbzpQQHNzdzByZA==' | base64 -d
# => demo:P@ssw0rd
curl での送信
# -u を付けると curl は最初のリクエストから Authorization を付ける(先回り送信)
curl -v -u demo:'P@ssw0rd' https://example.com/private/
# ヘッダを自分で組み立てる場合
curl -v -H "Authorization: Basic $(printf 'demo:P@ssw0rd' | base64)" \
https://example.com/private/
# チャレンジの中身だけ見たいとき
curl -sI https://example.com/private/ | grep -i www-authenticate

Basic 認証で曖昧になりがちなのは「どのデータが、どの形で、どこを通り、どこで平文に戻るか」。ここだけは実物の値で完全に追えるようにしておく。

user-id = demo / password = P@ssw0rd を例に、変換の各段階を実物で示す。

# 段階 長さ
1 入力 user-id demo / password P@ssw0rd 4 / 8 文字
2 コロンで連結 demo:P@ssw0rd 13 文字
3 UTF-8 バイト列 64 65 6d 6f 3a 50 40 73 73 77 30 72 64 13 バイト
4 Base64 符号化 ZGVtbzpQQHNzdzByZA== 20 文字
5 ヘッダ行 Authorization: Basic ZGVtbzpQQHNzdzByZA==
flowchart TB
    U1["user-id<br/>demo"] --> J["連結<br/>demo:P@ssw0rd"]
    U2["password<br/>P@ssw0rd"] --> J
    J --> BY["UTF-8 バイト列<br/>64 65 6d 6f 3a 50 40 ..."]
    BY --> B64["Base64<br/>ZGVtbzpQQHNzdzByZA=="]
    B64 --> H["Authorization: Basic<br/>ZGVtbzpQQHNzdzByZA=="]
    H --> W["TLS で暗号化して送信"]

    style U2 fill:#7a2222,color:#fff
    style W fill:#1f5c33,color:#fff

Base64 は 3 バイトを 4 文字に変換する。13 バイトは 3 で割り切れないため、末尾に = が 2 つ付く。== で終わるのは暗号っぽさの証拠ではなく、元の長さが 3 の倍数でなかったことを示すだけである。

各段階を実際に見る
printf 'demo:P@ssw0rd' | xxd -g1
# 00000000: 64 65 6d 6f 3a 50 40 73 73 77 30 72 64 demo:P@ssw0rd
printf 'demo:P@ssw0rd' | base64
# ZGVtbzpQQHNzdzByZA==

2. サーバー側はまったく逆の順にほどく

Section titled “2. サーバー側はまったく逆の順にほどく”
flowchart TB
    H["Authorization ヘッダ"] --> SP["スキームと値に分割<br/>Basic / ZGVtbzpQQHNzdzByZA=="]
    SP --> DE["Base64 デコード<br/>demo:P@ssw0rd"]
    DE --> CS["最初のコロンで分割"]
    CS --> ID["user-id<br/>demo"]
    CS --> PW["password<br/>P@ssw0rd"]
    ID --> LK["利用者を検索"]
    PW --> CMP["保存済みハッシュと<br/>定数時間で照合"]
    LK --> CMP
    CMP --> OK["認証結果"]

    style DE fill:#7a2222,color:#fff

赤で示したデコード直後が最も危険な一点。ここでメモリ上に平文のパスワードが現れるので、この値をログ・例外メッセージ・トレースに載せてはいけない。エラー時に「送られてきたヘッダをそのままダンプする」実装が事故の典型例。

3. どこで暗号化され、どこで平文に戻るか

Section titled “3. どこで暗号化され、どこで平文に戻るか”
flowchart TB
    subgraph CL["クライアント"]
        A["ブラウザ<br/>平文のパスワードを保持"]
    end
    subgraph NET["インターネット"]
        B["TLS 暗号化された経路<br/>盗聴者にはヘッダも見えない"]
    end
    subgraph EDGE["TLS 終端 (CDN / ロードバランサ)"]
        C["ここで復号される<br/>Authorization が平文になる"]
    end
    subgraph ORIGIN["オリジン側"]
        D["リバースプロキシ<br/>nginx auth_basic"]
        E["アプリケーション"]
    end
    F["アクセスログ / APM / トレース"]

    A --> B --> C --> D --> E
    C -.->|"設定次第で記録される"| F
    D -.->|"設定次第で記録される"| F
    E -.->|"設定次第で記録される"| F

    style B fill:#1f5c33,color:#fff
    style C fill:#7a2222,color:#fff
    style F fill:#7a4a12,color:#fff
位置 クレデンシャルの形 主なリスク 対策
ブラウザのメモリ 平文 共有端末での再利用 ブラウザを閉じる運用、そもそも Basic を使わない
インターネット経路 TLS 暗号文 TLS がなければ丸見え HTTPS を必須にし、HTTP は 301 で追い出す
TLS 終端(CDN / LB) 平文 事業者側のログ ヘッダのログ出力を止める
リバースプロキシ 平文 アクセスログ、error_log の debug 出力 ログフォーマットから除外
アプリケーション 平文 例外レポート、APM のヘッダ収集、リクエストダンプ 収集対象からマスクする

4. 認証済みなら、その先へ渡さない

Section titled “4. 認証済みなら、その先へ渡さない”

nginx で認証を終えているなら、アプリに Authorization を転送する必要はない。既定では転送されるので、明示的に落とす。

認証済みヘッダを上流へ渡さない
location /private/ {
auth_basic "Staging Area";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_set_header Authorization ""; # 上流には渡さない
proxy_set_header X-Auth-User $remote_user; # 必要なら利用者名だけ渡す
proxy_pass http://app_upstream;
}
アクセスログから除外する
log_format no_auth '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent';
access_log /var/log/nginx/access.log no_auth;

$http_authorization を含むカスタムログフォーマットを使っていないか、error_log ... debug; を本番で有効にしていないか(debug ではヘッダが出る)を点検する。

TLS 終端・リバースプロキシ・アプリの 3 段構成で、リクエストがどう流れて何が起きるか。どのホップから平文になるかに注目して読む。

sequenceDiagram
    autonumber
    participant B as ブラウザ
    participant L as LB / TLS 終端
    participant N as nginx
    participant A as アプリ

    B->>L: GET /private/ を TLS で送信
    Note over B,L: 経路上は暗号文<br/>ヘッダも本文も見えない
    L->>N: 復号後の HTTP<br/>Authorization: Basic<br/>ZGVtbzpQQHNzdzByZA==
    Note over L,N: ここから内部は平文<br/>ヘッダがそのまま流れる
    N->>N: Base64 デコードし<br/>.htpasswd と照合

    alt 照合成功
        N->>A: 転送<br/>Authorization は落とし<br/>X-Auth-User を付与
        A-->>N: 200 OK
        N-->>L: 200 OK
        L-->>B: 200 OK
        Note over B: 同じ保護空間へは以後先回りで送信
    else 照合失敗
        N-->>L: 401 + WWW-Authenticate<br/>Basic realm="Staging Area"
        L-->>B: 401
        Note over N,A: アプリにはリクエストが到達しない
    end

認証を前段で終えるとアプリにリクエストが届かないという点は、アプリ側のアクセス解析やレート制限を設計するときに影響する。ステージングの Basic 認証で「アプリのログに何も出ない」のは正常な挙動。

6. クレデンシャルはクライアント側でいつまで残るか

Section titled “6. クレデンシャルはクライアント側でいつまで残るか”
stateDiagram-v2
    [*] --> None
    None : クレデンシャル未保持
    Cached : 保護空間ごとに保持
    None --> Cached : 401 後に入力して成功
    Cached --> Cached : 同一保護空間へ先回り
    Cached --> None : タブやブラウザを閉じる
    Cached --> None : 再度 401 が返る
    Cached --> [*] : ログアウト手段がない

保存先はブラウザのメモリで、Cookie ストアのように利用者から見える形では管理されない。だから「ログアウトボタン」を作れず、確認も削除も利用者には難しい。この一点が Basic 認証を一般利用者向けに使えない最大の理由になる。

試験でも実務でも最初に問われるのがここ。3 つの違いは「鍵が要るか」と「元に戻せるか」で きれいに分かれる。

Base64 符号化は鍵が不要で誰でも戻せる。暗号化は鍵があれば戻せる。ハッシュは戻せない

Base64 暗号化 ハッシュ
不要 必要 不要
元に戻せるか 誰でも戻せる 鍵があれば戻せる 戻せない
目的 バイナリを ASCII で運ぶ 秘匿 同一性の検証

Authorization: Basic ...平文でパスワードを送っているのと等価。ここから次の帰結が出る。

  • TLS なしの Basic 認証は成立しない。 RFC 7617 §4 も、暗号化されていないチャネルでの利用は攻撃者にパスワードを渡すのと同じだと明言している。
  • リバースプロキシやアクセスログに残りやすい。 Authorization ヘッダをログ出力する設定にしていると、パスワードがそのままログに落ちる。
  • リクエストのたびに毎回送信される。 セッション Cookie と違い、有効期限も失効の仕組みもない。漏れた瞬間から、パスワードを変えるまで使われ続ける。

RFC 9110 は protection space(保護空間)= オリジン + realm と定義している。クライアントはこの単位でクレデンシャルをキャッシュする。

flowchart TD
    Start["リクエストを送ろうとしている"] --> Q1{"同じオリジンで<br/>認証に成功した realm の<br/>クレデンシャルを持っているか"}
    Q1 -->|"いいえ"| Send1["Authorization なしで送る"]
    Q1 -->|"はい"| Q2{"その URI は<br/>認証済み URI と<br/>同じ階層か下位か"}
    Q2 -->|"はい"| Send2["先回りして<br/>Authorization を付けて送る"]
    Q2 -->|"いいえ"| Send1
    Send1 --> R{"応答は 401 か"}
    R -->|"はい"| Prompt["realm を見て<br/>ダイアログ表示 / 再送"]
    R -->|"いいえ"| Done["完了"]
    Send2 --> Done

Basic には Digest のような nonce のやりとりがないため、先回り送信 (preemptive authentication) が仕様上許されているのが特徴。パス階層をヒントに送信範囲を推定するので、/private/ で認証したら /private/docs/ にも自動で付く、という挙動になる。

自前で実装するときに書くことになるロジック。

flowchart TD
    A["リクエスト受信"] --> B{"Authorization ヘッダあり"}
    B -->|"なし"| C["401 + WWW-Authenticate"]
    B -->|"あり"| D{"スキームが Basic か<br/>大文字小文字は区別しない"}
    D -->|"違う"| C
    D -->|"Basic"| E{"Base64 としてデコードできるか"}
    E -->|"失敗"| F["400 Bad Request"]
    E -->|"成功"| G{"最初のコロンで<br/>分割できるか"}
    G -->|"コロンなし"| F
    G -->|"できる"| H["user-id と password を取得"]
    H --> I{"利用者が存在し<br/>パスワードが一致するか"}
    I -->|"不一致"| C
    I -->|"一致"| J{"その利用者に<br/>この操作の権限があるか"}
    J -->|"ない"| K["403 Forbidden"]
    J -->|"ある"| L["200 など通常応答"]

押さえどころは 3 つ。

  1. スキーム名の比較は大文字小文字を区別しないbasic, BASIC も有効)。
  2. デコード失敗やコロン欠落は 401 ではなく 400 が素直。認証情報の誤りではなく、リクエストの文法エラーだから。
  3. 401 と 403 の分岐。認証(誰か)と認可(何をしてよいか)で層が違う。
コード 意味 必須ヘッダ 再送で解決するか
401 Unauthorized 未認証、または認証情報が誤り WWW-Authenticate必須 する(正しい情報なら通る)
403 Forbidden 認証は通ったが権限がない なし しない
407 Proxy Authentication Required プロキシに対する未認証 Proxy-Authenticate必須 する
方式 クレデンシャル 毎回送るもの 失効・ログアウト 主な用途
Basic user-id + password パスワードそのもの (Base64) 仕組みがない 内部ツール、ステージングの目隠し
Digest user-id + password nonce を使ったハッシュ 仕組みがない 平文を流せない環境の互換用途
Bearer トークン トークン 有効期限・失効リストで可能 API、OAuth 2.0 / OIDC
Cookie セッション ログイン後のセッション ID セッション ID サーバー側で破棄できる 一般的な Web アプリ
mTLS クライアント証明書 証明書(TLS 層) 失効リスト / 短命証明書 機器間通信、ゼロトラスト

Digest は「パスワードを平文で流さない」ための方式だが、MD5 前提の実装が多く、サーバー側にパスワード相当の値を保存する必要があるHA1 を保存するため、ハッシュ化保存の利点が薄い)。TLS が当たり前になった現在、TLS + Basic のほうが TLS なし Digest より安全という整理になる。

flowchart TD
    A["認証方式を選ぶ"] --> B{"ブラウザで使う<br/>一般利用者向けか"}
    B -->|"はい"| C{"ログアウトや<br/>パスワード再設定の<br/>UI が要るか"}
    C -->|"要る"| D["Cookie セッション<br/>または OIDC"]
    C -->|"不要 / 目隠し用途"| E["Basic 認証"]
    B -->|"いいえ"| F{"第三者アプリに<br/>権限を委譲するか"}
    F -->|"する"| G["OAuth 2.0 + Bearer"]
    F -->|"しない"| H{"呼び出し元は<br/>自前のサーバーか"}
    H -->|"はい"| I["Bearer / API キー<br/>または mTLS"]
    H -->|"いいえ"| E

    style E fill:#4054b2,color:#fff

使ってよいケースと使ってはいけないケース

Section titled “使ってよいケースと使ってはいけないケース”

Basic 認証は「古くて危険な方式」ではなく、成立条件が狭い方式。条件を満たすなら、追加のライブラリもテーブルもセッションストアも要らない最も安いやり方になる。逆に条件をひとつ外すと、ほかの方式で当然できることが何ひとつできない。

次の 6 つがすべて満たされているかで判断する。ひとつでも欠けたら別の方式にする。

  • HTTPS が強制されている(HTTP で 200 を返す経路が残っていない)
  • 利用者が少数で、パスワードを人手で発行・配布・変更できる規模
  • ログアウトとパスワード再設定の UI が不要
  • 認可の粒度が**「入れる / 入れない」で足りる**(ロール別の細かい権限が要らない)
  • 漏洩時の復旧が**「パスワードを変えて配り直す」で済む**
  • 総当たり対策を前段に置ける(レート制限、IP 制限、fail2ban など)

Basic 認証が成立する 6 つの条件。HTTPS が強制されている、利用者が少数で管理できる、ログアウト UI が不要、認可は入れる入れないで足りる、配り直しで復旧できる、総当たり対策を前段に置ける

判断そのものはこの順に辿ると早い。

flowchart TB
    S["Basic 認証を使いたい"] --> Q1{"HTTPS が強制されているか"}
    Q1 -->|"いいえ"| NG1["使わない<br/>まず TLS を用意する"]
    Q1 -->|"はい"| Q2{"利用者は<br/>一般のエンドユーザーか"}
    Q2 -->|"はい"| NG2["使わない<br/>Cookie セッション / OIDC"]
    Q2 -->|"いいえ"| Q3{"呼び出し元は<br/>人ではなくプログラムか"}
    Q3 -->|"はい"| Q4{"高エントロピーな鍵を<br/>環境変数などで持てるか"}
    Q4 -->|"はい"| OK1["使える<br/>API キーを Basic で運ぶ"]
    Q4 -->|"いいえ"| NG3["使わない<br/>Bearer / mTLS"]
    Q3 -->|"いいえ"| Q5{"ロールごとの権限や<br/>監査ログが必要か"}
    Q5 -->|"必要"| NG4["使わない<br/>セッション + 認可基盤"]
    Q5 -->|"不要"| OK2["使える<br/>目隠しや内部ツール"]

    style OK1 fill:#1f5c33,color:#fff
    style OK2 fill:#1f5c33,color:#fff
    style NG1 fill:#7a2222,color:#fff
    style NG2 fill:#7a2222,color:#fff
    style NG3 fill:#7a2222,color:#fff
    style NG4 fill:#7a2222,color:#fff
ケース 成立する理由 一緒にやること
ステージング / プレビュー環境の目隠し 認証というより第三者とクローラを弾く用途。利用者は開発チームだけ 本番と別のパスワード、後述の除外設定
社内の管理画面・監視ダッシュボード 利用者が少数で、退職時の対応が現実的 VPN や IP 制限との併用
サーバー間 API / API キーの運搬 呼び出し元がプログラムなので、ログアウトも再設定も不要 十分に長いランダム鍵、鍵のローテーション
CI / CD からのアーティファクト取得 シークレットストアに置いた値をヘッダに載せるだけ リポジトリのシークレット管理に任せる
短命な限定公開ページ 期間が終われば設定ごと消す 期限を決めて必ず消す
ローカルの開発ツール 外部に露出しない そのまま本番に持ち込まない

「API キーを Basic で運ぶ」パターンは現役である。たとえば Stripe の API は、シークレットキーを user-id、パスワードを空にして送る。

API キーを Basic で運ぶ例
# user-id にシークレットキー、password は空(末尾のコロンがそれを表す)
curl https://api.stripe.com/v1/charges -u sk_test_xxxxxxxxxxxx:

このパターンが成り立つのは、Basic 認証の弱点がすべて無効化されるから

  • ログアウトが要らない(相手はブラウザではない)
  • パスワード再設定の UI が要らない(鍵のローテーションで済む)
  • 総当たりが効かない(鍵のエントロピーが十分に高い)
  • 失効できる(鍵を無効化すればよい。パスワードと違い 1 対 1 で捨てられる)

Prometheus の basic_auth、Elasticsearch の組み込みユーザー、プライベートな npm レジストリの _auth、Docker レジストリのログインなど、機械同士の通信では今も広く使われている。

ケース できないこと
一般利用者向けのログイン ログアウトできない、パスワード再設定の導線を作れない、2 要素認証を挟めない、ログイン画面をブランドに合わせられない
TLS がない経路 パスワードが平文で流れる。議論の余地がない
第三者アプリへの権限委譲 相手にパスワードそのものを渡すことになる。OAuth 2.0 の領域
ロールや権限の制御が必要 Basic が運ぶのは「誰か」だけ。権限は別の層で持つ必要がある
セッション単位の監査が必要 セッションという概念がなく、いつ開始・終了したかを追えない
共有端末で使う画面 ブラウザのメモリに残り、閉じるまで次の人が使える
高頻度アクセスの公開 API リクエストごとにパスワード検証が走る。bcrypt などを正しく使うほど重くなる
モバイルアプリの利用者認証 端末にパスワードを保存することになる。失効も更新もできない
人の入れ替わりがある本番環境 退職者ひとりのために全員のパスワードを変えることになる

ステージングに置くときの実務的な注意

Section titled “ステージングに置くときの実務的な注意”

目隠し用途は最も多いケースだが、Basic 認証はリクエストを区別せず全部弾くので、機械からのアクセスが軒並み壊れる。除外設定を先に洗い出しておく。

壊れるもの 症状 対応
Webhook の受信 外部サービスからの通知が 401 で届かない 受信パスだけ auth_basic off;
ヘルスチェック・監視 常に 401 で異常判定になる /healthz を除外
OGP のクロール Slack などでリンク展開されない 仕様として受け入れるか、UA で除外
決済などのコールバック 外部からの戻りが弾かれる コールバックパスを除外
E2E テスト CI から開けない CI のシークレットに認証情報を置く
画像・フォントなどの副リソース 先回り送信が効かない経路で 401 同一オリジンに置く(クロスオリジンは CORS も要る)

逆に、クローラ避けとしては noindex より Basic 認証のほうが確実noindex は「クロールされたうえで載せない」だが、401 はそもそも中身を渡さない。

/etc/nginx/conf.d/private.conf
server {
listen 443 ssl;
server_name staging.example.com;
location /private/ {
auth_basic "Staging Area"; # realm になる
auth_basic_user_file /etc/nginx/.htpasswd;
}
# ヘルスチェックだけ認証を外す
location = /healthz {
auth_basic off;
}
}
パスワードファイルの作成
# apache2-utils / httpd-tools の htpasswd を使う
# -B で bcrypt(-c は新規作成なので 2 人目以降は付けない)
htpasswd -B -c /etc/nginx/.htpasswd demo
htpasswd -B /etc/nginx/.htpasswd another-user
  • .htpasswdドキュメントルートの外に置く。中に置くと配信されうる。
  • nginx が読める形式は crypt / apr1 (MD5) / {SHA} / bcrypt。{SHA} はソルトなしの SHA-1 なので使わない
  • 認証を外したい下位パスがある場合は auth_basic off; を個別の location に書く。
httpd.conf もしくは .htaccess
<Directory "/var/www/html/private">
AuthType Basic
AuthName "Staging Area"
AuthBasicProvider file
AuthUserFile /etc/httpd/.htpasswd
Require valid-user
</Directory>

Require valid-userRequire user demo にすれば特定ユーザーのみ、Require group staff + AuthGroupFile でグループ制御になる。

Node.js (Express) — 自前実装するなら

Section titled “Node.js (Express) — 自前実装するなら”
basic-auth.js
import { timingSafeEqual, createHash } from 'node:crypto';
const REALM = 'Staging Area';
// 長さの差から情報が漏れないよう、比較前に固定長へ潰す
function safeEqual(a, b) {
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}
export function basicAuth(users) {
return (req, res, next) => {
const header = req.get('authorization') ?? '';
const [scheme, encoded] = header.split(' ');
if (!encoded || scheme.toLowerCase() !== 'basic') {
return challenge(res);
}
let decoded;
try {
decoded = Buffer.from(encoded, 'base64').toString('utf8');
} catch {
return res.sendStatus(400);
}
const sep = decoded.indexOf(':'); // 最初のコロンで分割する
if (sep < 0) return res.sendStatus(400);
const userId = decoded.slice(0, sep);
const password = decoded.slice(sep + 1);
const expected = users[userId];
// 利用者が存在しない場合も同じ経路を通し、処理時間を揃える
if (!expected || !safeEqual(password, expected)) {
return challenge(res);
}
req.user = { id: userId };
next();
};
}
function challenge(res) {
res.set('WWW-Authenticate', `Basic realm="${REALM}", charset="UTF-8"`);
res.status(401).end();
}

ポイントは 4 つ。

  • === で比較しない。 文字列比較は先頭から一致を見るため、応答時間からパスワードを 1 文字ずつ推測されうる。timingSafeEqual を使い、長さの差も出さないようハッシュしてから比較する。
  • 利用者が存在しない場合も同じ分岐を通す。 早期 return すると「そのユーザーは存在しない」が応答時間から分かる。
  • 最初のコロンで分割する。 split(':') の結果を素朴に使うとコロン入りパスワードが壊れる。
  • 401 には必ず WWW-Authenticate を付ける。 付け忘れると仕様違反であり、クライアントは再試行の方法を知りようがない。

運用とセキュリティで押さえる点

Section titled “運用とセキュリティで押さえる点”

Basic 認証はサーバーに平文と照合できる形が必要……ではない。受け取るのが平文なので、サーバー側はハッシュ化して保存できる(Digest と違う利点)。htpasswd -B の bcrypt や、アプリ側なら Argon2id を使う。

失効の仕組みがなく毎回パスワードが飛んでくるため、総当たりが素直に効く。

  • レート制限(nginx なら limit_req
  • fail2ban などによる送信元単位の遮断
  • そもそも十分に長いランダムパスワードを使う

Authorization ヘッダ付きリクエストへの応答は、共有キャッシュに保存してはならないのが原則(RFC 9111 §3.5)。publics-maxage などを明示した場合のみ許される。CDN の前段に Basic 認証を置くときは、この扱いを取り違えると別の利用者に認証済みコンテンツが配られる

AuthorizationCORS のセーフリスト対象外のリクエストヘッダなので、クロスオリジンで付けるとプリフライトが飛ぶ

サーバーが返すべきヘッダ
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Credentials: true

Access-Control-Allow-Origin: *Allow-Credentials: true併用できない点にも注意。

Basic 認証にはログアウトの標準的な手段がない。ブラウザはクレデンシャルをキャッシュし、タブを閉じるまで送り続ける。

flowchart TD
    A["ログアウトさせたい"] --> B{"取れる手段"}
    B --> C["再度 401 を返して<br/>キャッシュを無効化させる"]
    B --> D["ブラウザを閉じてもらう"]
    B --> E["そもそも Basic を使わず<br/>セッション方式にする"]
    C --> F["ブラウザ実装依存で<br/>確実ではない"]
    D --> F
    E --> G["確実"]

    style G fill:#4054b2,color:#fff

URL に https://user:pass@example.com/ の形で埋め込む手法は、フィッシングに悪用されたため現代のブラウザでは制限・非推奨(サブリソースでは無視され、トップレベル遷移でも挙動がばらつく)。回避策として当てにしない。

API に Basic を使うときのダイアログ抑止

Section titled “API に Basic を使うときのダイアログ抑止”

WWW-Authenticate: Basic を返すと、ブラウザからの XHR / fetch でもネイティブの認証ダイアログが出てしまうことがある。API では

  • 401 を返すが WWW-Authenticate を省略する(仕様違反にはなるが実務でよく使われる)
  • スキーム名を Basic 以外にする
  • そもそも Bearer を使う

のいずれかを選ぶ。「ダイアログを出さないために WWW-Authenticate を消す」は仕様違反である、という点は認識したうえでやる。

  • 401 は「未認証」、403 は「認可失敗」。名前と意味が逆に見える。
  • 401 には WWW-Authenticate必須407 には Proxy-Authenticate が必須。
  • クレデンシャルは base64(user-id ":" password)Base64 は暗号化ではない
  • user-id にコロンは使えない。password には使える。
  • charset パラメータに指定できるのは UTF-8 のみ
  • realm はダイアログに表示される。保護空間の識別子であり、機密を書かない
  • 保護空間 = オリジン + realm。パスではない。
  • クライアントは 401 を待たず先回りで送ってよい(Basic の特徴)。
  • プロキシ版は 407 / Proxy-Authenticate / Proxy-Authorization の 3 点セット。
  • サーバー側の照合はタイミングセーフに。文字列の === は使わない。
  • Authorization 付き応答は共有キャッシュに保存しないのが既定。
  • Authorization は CORS セーフリスト外 → プリフライトが飛ぶ
  • ログアウトの標準手段がない
  • Basic が成立する条件は TLS 必須・利用者が少数・ログアウト不要・認可が粗い・失効が配り直しで済む・総当たり対策が前段にあるの 6 点。
  • API キーを Basic で運ぶのは現役のパターン(相手がプログラムなら弱点が無効化される)。
  • ステージングの目隠しに使うと Webhook・ヘルスチェック・OGP クロールが 401 で壊れる
Basic 認証のクレデンシャルはどう組み立てるか。

user-idpassword をコロンで連結した文字列を UTF-8 のバイト列にし、Base64 で符号化する。Authorization: Basic <その値> として送る。

ユーザー名にコロンを含められるか。パスワードはどうか。

ユーザー名は不可。パスワードは可。デコード後の文字列を最初のコロンで分割するため、以降のコロンはすべてパスワード側に含まれる。

401 と 403 の違いは。

401 は「認証されていない、または認証情報が誤っている」状態で、WWW-Authenticate を伴い、正しい情報を送れば通る。403 は「認証は済んでいるが権限がない」状態で、同じ情報を送り直しても通らない。

realm は何のためにあるか。

保護空間に付ける名前。クライアントは「オリジン + realm」の単位でクレデンシャルをキャッシュし、ブラウザは認証ダイアログにこの文字列を表示する。同一サーバー内で認証範囲を分けるために使う。

Base64 を使っているのに安全でないと言われるのはなぜか。

Base64 は鍵を必要としない可逆な符号化で、誰でも復号できる。パスワードを平文で送っているのと等価であり、通信路の暗号化(TLS)がなければそのまま盗聴される。

Digest 認証は Basic より安全か。

平文を流さない点では優れるが、MD5 前提の実装が多く、サーバー側にパスワード相当の値(HA1)を保存する必要があるためハッシュ保存の利点が薄い。TLS 上で使うなら Basic のほうが実装も運用も素直で、実質的な安全性でも劣らない。

プロキシ認証で使うステータスコードとヘッダは。

407 Proxy Authentication RequiredProxy-Authenticate(チャレンジ)、Proxy-Authorization(クレデンシャル)。これらはホップバイホップで、次のプロキシには転送されない。

Basic 認証でログアウトを実装するには。

標準的な手段はない。再度 401 を返してブラウザのキャッシュを無効化させる、ブラウザを閉じてもらう、といった実装依存の回避策しかない。確実にログアウトが必要ならセッション方式や Bearer トークンを使う。

サーバー側でパスワードを照合するときの注意点は。

タイミング攻撃を避けるため、定数時間比較(timingSafeEqual など)を使う。長さの差が漏れないよう事前にハッシュ化して固定長にし、ユーザーが存在しない場合も同じ経路を通して処理時間を揃える。

Basic 認証を使ってよいのはどんなときか。

HTTPS が強制されていて、利用者が人手で管理できる少数に限られ、ログアウトとパスワード再設定の UI が不要で、認可の粒度が「入れる / 入れない」で足り、漏洩時はパスワードを配り直せば復旧でき、総当たり対策を前段に置ける場合。ステージングの目隠し、社内の管理画面、サーバー間 API がこれに当たる。

一般利用者向けのログインに Basic 認証を使えないのはなぜか。

ログアウトできず、パスワード再設定の導線を作れず、2 要素認証を挟めず、ログイン画面の見た目も制御できない。クレデンシャルはブラウザのメモリにあり、利用者からは確認も削除もできない。

API キーを Basic 認証で送るのが実務で許容されるのはなぜか。

相手がブラウザではなくプログラムなので、Basic の弱点がすべて無効化される。ログアウトもパスワード再設定の UI も不要で、鍵のエントロピーが十分に高いため総当たりが効かず、鍵を無効化すれば失効できる。Stripe の API はシークレットキーを user-id、パスワードを空にして送る形をとっている。

ステージング環境に Basic 認証を置くと壊れるものは。

機械からのアクセスが軒並み 401 になる。Webhook の受信、ヘルスチェックと監視、OGP のクロール、外部サービスからのコールバック、E2E テストが代表例。必要なパスは個別に認証を外す。

クロスオリジンの fetch で Authorization を付けると何が起きるか。

Authorization は CORS のセーフリストヘッダではないため、プリフライト (OPTIONS) が発生する。サーバーは Access-Control-Allow-Headers: Authorization を返す必要がある。資格情報付きにする場合は Access-Control-Allow-Credentials: true が必要で、Access-Control-Allow-Origin: * は使えない。