Old-School Front-End / 2026 ・ No.11

いにしえのフロントエンド入門

第11回 ログインと画面の切り替え ── 「あなたは誰?」を、どの画面でも確かめる

公開

新井 太郎(申請者)でログインした申請一覧。ヘッダーの右に名前とログアウトのボタン。メニューは申請一覧とダッシュボードだけ。今月のまとめは申請5件・承認待ち 79,251 円・差し戻し1件。表のいちばん上は 0012 ニューヨーク出張 46,851 円 申請中
今回のゴール。ログインした人の名前と、その人の申請だけが並ぶ申請一覧(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。アカウントはデモ専用の架空のもの。ブラウザの枠は説明のために描き足したもの)

AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第11回はログインと画面の切り替えです。
これまでヘッダーの「新井 太郎(申請者)」は、HTML に書いてあるだけの飾りでした。
今回はログイン画面を作り、申請一覧・申請フォーム・承認の画面のどれを開いても、最初に「あなたは誰?」をサーバーに確かめます。
そして、画面で隠すことと、サーバーで断ることの違いを、実際に試して確かめます。

一

今回のゴール

新井前回、申請番号が出ました! でも先輩、あの申請はだれが出したことになってるんでしょう。

古井今はだれでもない。ヘッダーの「新井 太郎」は、HTML に書いた文字のままだ。

新井じゃあ、私が「古井 一郎」と書きかえたら、先輩の代わりに承認できちゃう……?

古井そうならないための仕組みを、今日作る。

いまここ:第11回

1 準備 2 HTML の骨組み 3 フォーム 4 CSS の基本 5 レイアウト 6 Bootstrap 7 JS の基本 8 DOM・イベント 9 入力チェック 10 通信 11 ログイン 12 承認の流れ 13 集計と印刷 14 jQuery 15 仕上げ いまここ 準備 HTML CSS JavaScript 実戦 仕上げ
  • ログイン画面 ── メールアドレスとパスワードで名乗る。間違えたら、どちらが違うかは言わずに知らせる
  • どの画面でも確かめる ── 申請一覧・申請フォーム・承認の画面は、開いたらまずサーバーに「あなたは誰?」と聞く。ログインしていなければ、ログイン画面へ。済んだら元の画面へ戻す
  • 人によって変える ── 申請一覧には自分の申請だけ。「承認」のメニューは承認者にだけ見せ、承認のデータはサーバーが承認者にしか渡さない
  • ログアウト ── 合言葉を捨てて、ログイン画面へ

相手は前回の練習用サーバーに、ログインと一覧の機能を足したものです(中身は本題ではありません)。アカウントとパスワード([email protected] / demo-arai など)は、このデモのための架空のものです。

二

画面の地図

ログイン画面と、3つの画面

login.html 入口 index.html 申請一覧 auth.js form.html 申請フォーム auth.js approve.html 承認(承認者だけ) auth.js ログインできたら、元の画面(next)へ どの画面でも、ログインしていなければ(401)ログイン画面へ 画面はファイルごとに分ける。共通の仕事は auth.js にまとめる 画面(HTML)は誰でも取れる。守るのは、/api/… が返すデータ

画面の切り替えは、いにしえのやり方で行きます。画面ごとに HTML ファイルを分け、メニューはただの <a href> のリンク。押せばブラウザが次のページを読み込みます。いまいる画面のメニューには aria-current="page" を付けて、読み上げソフトにも「ここ」と伝えます。

ここで大事なのは、画面(HTML ファイル)そのものは、だれでも取れることです。ログインしていないブラウザで試しました。

# ログインしていないブラウザから(Chromium で実測)
取りに行ったもの 状態コード
/approve.html(画面の HTML) 200   誰でも取れる
/api/me 401   ログインしていない
/api/expenses(自分の申請) 401
/api/approvals(承認待ち) 401

承認の画面の HTML は 200 で手に入ります。中身の表は空っぽで、データは /api/… から取ってきて埋めるからです。守るべきものは画面ではなく、サーバーが返すデータ。この回の考え方は、すべてここから始まります。

三

ログインのしくみ:合言葉

パスワードは最初の1回だけ。あとは合言葉で名乗る

ブラウザ サーバー POST /api/login メールアドレス・パスワード 200 OK Set-Cookie: sid=…; HttpOnly GET /api/me Cookie: sid=… 200 OK {"name": …, "role": …} クッキーを しまっておく 合言葉の表 sid → arai パスワードは最初の1回だけ。あとは合言葉(セッション ID)で名乗る クッキーはブラウザが自動で付けて送る。JavaScript で付ける手間はない

ログインに成功すると、サーバーは返事の見出しに Set-Cookie を付けて、合言葉(セッション ID)をブラウザに預けます。ブラウザはこれをクッキーとしてしまっておき、同じサイトへの頼みには自動で付けて送ります。サーバーは手元の表で「この合言葉は新井さん」と引き当てます。

クッキーの印意味
HttpOnlyJavaScript から読めない(八で試す)
SameSite=Laxよそのサイトから送られる POST などには付けない
Path=/このサイトのどのページへの頼みにも付ける
Max-Age=288008時間で捨てる(その後はログインし直し)

本番の https のサイトでは、さらに Secure(暗号化した通信でしか送らない)も付けます。練習用サーバーは http なので付けていません。

四

ログイン画面を作る

<form id="login-form">
  <div>
    <label for="email">メールアドレス</label>
    <input type="email" id="email" name="email" autocomplete="username" required>
  </div>
  <div>
    <label for="password">パスワード</label>
    <input type="password" id="password" name="password" autocomplete="current-password" required>
  </div>
  <p id="login-error" role="alert" hidden></p>
  <button type="submit">ログイン</button>
</form>
  • type="password" ── 打った文字を●で隠す
  • autocomplete ── username と current-password を付けると、ブラウザやパスワード管理ソフトが「ここがログインの欄だ」と分かり、保存や自動入力を手伝える
  • 入力チェックはブラウザに任せる ── 欄が2つだけなので、第9回の required の吹き出しで十分。凝るのは申請フォームのような長いフォームだけでいい
// 経費精算システム:ログイン画面(第11回)
// ログインできたら、元の画面(next)へ

const loginForm = document.querySelector('#login-form');
const loginError = document.querySelector('#login-error');

// ログインボタンが押されたとき
loginForm.addEventListener('submit', async (event) => {
  event.preventDefault();
  loginError.hidden = true;
  const button = event.submitter;
  button.disabled = true;
  try {
    const res = await fetch('/api/login', { method: 'POST', body: new FormData(loginForm) });
    if (res.status === 401) { // ID とパスワードのどちらが違うかは、あえて言わない
      loginError.textContent = 'メールアドレスかパスワードが違います。';
      loginError.hidden = false;
      loginForm.elements.password.select(); // パスワードを選んだ状態に(打ち直しやすく)
      return;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const next = new URLSearchParams(location.search).get('next');
    location.replace(safeNext(next, location.origin)); // replace:「戻る」でログイン画面に戻らないように
  } catch (error) {
    loginError.textContent = 'ログインできませんでした。通信の状態を確かめて、もう一度お試しください。';
    loginError.hidden = false;
  } finally {
    button.disabled = false;
  }
});

間違えたときのメッセージは「メールアドレスかパスワードが違います」。どちらが違うかは、あえて言いません。「パスワードが違います」と言うと、「このメールアドレスは登録されている」と、よその人に教えてしまうからです。実際に、パスワード違いとメールアドレス違いで、同じ文言になることを確かめました(九の表)。

五

戻り先の罠

申請フォームを開こうとしてログイン画面に回されたら、ログインのあとは申請フォームに戻ってほしいですね。そこで、ログイン画面の URL に戻り先を付けておきます。

/login.html?next=%2Fform.html

ところが、この next はだれでも書きかえられます。?next=https://evil.example/ と書いたリンクを送りつけられたら、ログインした直後に、本物そっくりの偽サイトへ飛ばされてしまいます(オープンリダイレクトと呼ばれる罠です)。「/ で始まっていれば同じサイト」と考えたくなりますが、試すと……

URL の部品と、同じサイトかどうか

URL の部品 http://127.0.0.1:3000 origin どのサイトか /approve.html pathname サイトの中の場所 ?tab=1 search おまけの条件 next をブラウザが読むと(実測) /form.html → origin http://127.0.0.1:3000 //evil.example/x → origin http://evil.example /\evil.example/x → origin http://evil.example 文字の形で判断せず、new URL() で読んでから origin を比べる
# safeNext の結果(Chromium で実測。いまのページは http://127.0.0.1:3000)
next の値 URL として読むと 戻る先
/form.html (同じサイト)/form.html /form.html
/approve.html?tab=1 (同じサイト)/approve.html?tab=1 /approve.html?tab=1
(空) (同じサイト)/index.html /index.html
https://evil.example/ https://evil.example/ /index.html   ← よそのサイト
//evil.example/x http://evil.example/x /index.html   ← よそのサイト
/\evil.example/x http://evil.example/x /index.html   ← よそのサイト
javascript:alert(1) javascript:alert(1) /index.html   ← よそのサイト

//evil.example/x も、/ のあとに \ を書いた /\evil.example/x も、/ で始まっているのに、ブラウザはよそのサイトとして読みました。だから safeNext は文字の形では判断せず、new URL() でブラウザと同じように読んでから、origin(どのサイトか)が同じかを比べます。違えば申請一覧へ。

もう一つ。ログインのあとの移動は location.replace() にしています。

# ログインのあと、どう移るか(Chromium で実測)
移り方 履歴の数 「戻る」を押すと
location.assign() 3 ログイン画面
location.replace() 2 ログイン画面より前(about:blank)

assign(ふつうのリンクと同じ)だと履歴にログイン画面が残り、「戻る」でまたログイン画面が出ます。replace はいまの履歴を置きかえるので、ログイン画面は履歴に残りません。

六

どの画面でも「あなたは誰?」

どの画面にも共通の仕事は、js/auth.js にまとめました。申請一覧・申請フォーム・承認の画面は、最初に requireLogin() を呼びます。

// 経費精算システム:ログインの確認と、ヘッダーの表示(第11回)
// どの画面でも最初に読み込む。ログインしていなければ、ログイン画面へ

const ROLE = { applicant: '申請者', approver: '承認者' };

/** 戻り先として安全か確かめる。同じサイトの中だけを許す */
function safeNext(next, origin) {
  try {
    const url = new URL(next || '/index.html', origin); // next が無ければ申請一覧へ
    if (url.origin !== origin) return '/index.html';     // よそのサイトは許さない
    return url.pathname + url.search;
  } catch {
    return '/index.html';
  }
}

/** ログインしている人を聞く。していなければ、ログイン画面へ送る */
async function requireLogin() {
  const res = await fetch('/api/me');
  if (res.status === 401) { // 401 = ログインしていない
    const next = location.pathname + location.search;
    location.replace(`/login.html?next=${encodeURIComponent(next)}`);
    return new Promise(() => {}); // 画面が切り替わるまで、続きを動かさない(返事の来ない引換券)
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const me = await res.json();
  showUser(me);
  return me;
}

/** ヘッダーに名前を出し、権限のないメニューを隠す */
function showUser(me) {
  document.querySelector('#user-name').textContent = `${me.name}(${ROLE[me.role]})`;
  for (const el of document.querySelectorAll('[data-role]')) {
    el.hidden = el.dataset.role !== me.role; // 隠すだけ。守るのはサーバー(第11回の七)
  }
}

// ログアウト
document.querySelector('#logout')?.addEventListener('click', async () => {
  await fetch('/api/logout', { method: 'POST' });
  location.replace('/login.html');
});
  • 401 ならログイン画面へ ── /api/me の返事が 401(ログインしていない)なら、いまの場所を next に入れてログイン画面へ
  • 返事の来ない引換券 ── new Promise(() => {}) は、いつまでも返事が来ない Promise。これを返すと、呼んだ側の await requireLogin() の続きは動かない。画面が切り替わるまでのあいだに、空のデータで表を描いたりしない
  • 名前は textContent で ── サーバーから来た名前を、第8回の約束どおり textContent で出す

申請フォームの送信(第10回)にも、401 のときの扱いを足しました。書いている途中でログインの期限が切れても、入力した内容は消さずに「もう一度ログインしてください」と知らせます。

七

隠すのと、断るのは別

「承認」のメニューは承認者にしか要りません。メニューの li に data-role="approver" と hidden を書いておき、showUser が承認者のときだけ hidden を外します。

2本のヘッダーを並べたもの。上は新井 太郎(申請者)でログインしたときで、メニューは申請一覧とダッシュボード。下は古井 一郎(承認者)のときで、間に「承認」がある
同じ HTML でも、ログインした人によってメニューが変わる(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。アカウントはデモ専用の架空のもの。ブラウザの枠は説明のために描き足したもの)

でも、これは見た目だけです。メニューが隠れていても、アドレス欄に approve.html と打てば、画面は開けてしまいます(二で見たとおり、HTML はだれでも取れる)。だから承認の画面のデータは、サーバーが渡すかどうかを決めます。

サーバーの関所:401 と 403

/api/approvals 承認待ちの一覧 ログイン中? はい 承認者? はい 200 一覧 いいえ 401 ログインしていない いいえ 403 権限がない 画面の側:メニューを隠す <li data-role="approver" hidden> … 申請者には「承認」を見せない 隠すのは「押しても無駄なボタンを見せない」ための親切。URL を打てば画面は開ける 守るのはサーバー。401 はログイン画面へ、403 は「権限がありません」と伝える
// 経費精算システム:承認の画面(第11回)
// 承認者だけが使える。そうでない人には、サーバーが 403 を返す

async function showApprovals() {
  await requireLogin();
  const res = await fetch('/api/approvals');
  if (res.status === 403) { // 403 = ログインはしているが、権限がない
    const note = document.querySelector('#forbidden');
    note.textContent = 'この画面を見る権限がありません(承認者だけが使えます)。';
    note.hidden = false;
    return;
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // …
}

401 は「あなたがだれか分からない」(ログイン画面へ)、403 は「あなたがだれかは分かったが、それはできない」(ログインし直しても無駄なので、そう伝える)。似た数字ですが、画面のするべきことが違います。

新井AI に「承認者だけ承認ボタンを表示して」と頼んだら、ボタンを隠すコードだけ書いてくれました。

古井頼んだとおりだな。「隠して」と頼んだら隠してくれる。「守って」とは頼んでいない。

八

合言葉をしまう場所

ネットの記事では、ログインの印(トークン)を localStorage にしまう書き方もよく見かけます。HttpOnly のクッキーと比べてみました。

# 置き場所の違い(Chromium で実測)
localStorage.setItem('token', …) → ほかのスクリプトからも読めた:'secret-123'
ログイン後の document.cookie → '' (HttpOnly のクッキーは JavaScript から見えない)
そのあとの fetch('/api/me') → 200(クッキーはブラウザが自動で付けて送る)

# サーバーが渡したクッキー:sid(36 文字)・HttpOnly・SameSite=Lax

HttpOnly のクッキーと localStorage

HttpOnly のクッキー Set-Cookie: sid=…; HttpOnly JavaScript からは読めない ブラウザが自動で付けて送る 期限をサーバーが決められる localStorage localStorage.setItem('token', …) どのスクリプトからも読めた 送るたびに自分で付ける 消すまで残る XSS(第8回)でよそのスクリプトが動くと、読める場所の合言葉は盗まれる 合言葉は HttpOnly のクッキーに。JavaScript では触らない

localStorage に入れたものは、そのページで動くどのスクリプトからも読めます。第8回の XSS のように、よそのスクリプトが一度でも動いてしまえば、合言葉は持ち出されます。HttpOnly のクッキーなら JavaScript からは見えず、それでもブラウザが自動で送ってくれます。合言葉は JavaScript に触らせないのが、いにしえからの手堅いやり方です。

九

動かしてみる

練習用サーバーを立て、Playwright で実際に入力・クリックしました。途中でサーバー側のログインをすべて消し、「期限切れ」も起こしています。

3つの場面。1 ログイン画面でパスワードを間違えると「メールアドレスかパスワードが違います。」と出て、パスワードの欄が選ばれた状態になる。2 古井 一郎(承認者)でログインすると、メニューに「承認」が現れ、承認待ちの3件が並ぶ。3 新井 太郎(申請者)が承認の画面の URL を開くと、「この画面を見る権限がありません」
①パスワード違い、②承認者でログイン、③申請者が URL で承認の画面を開くと(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。アカウントはデモ専用の架空のもの。ブラウザの枠は説明のために描き足したもの)
# 実際に操作して記録した値(Chromium。サーバーは練習用)
操作 着いた URL ヘッダー 画面
ログインせずに申請一覧を開く /login.html?next=%2Findex.html ― ―
パスワード違い /login.html?next=%2Findex.html ― メールアドレスかパスワードが違います。
メールアドレス違い /login.html?next=%2Findex.html ― メールアドレスかパスワードが違います。
新井でログイン /index.html 新井 太郎(申請者) 一覧 5 件・承認待ち 79,251 円・差し戻し 1 件
承認の画面を URL で開く /approve.html 新井 太郎(申請者) この画面を見る権限がありません(承認者だけが使えます)。
(ログインが切れた後)下書き保存 /form.html 新井 太郎(申請者) ログインの有効期限が切れました。もう一度ログインしてください(入力した内容は、この画面に残っています)。
(切れたまま)承認の画面を開く /login.html?next=%2Fapprove.html ― ―
古井でログイン /approve.html 古井 一郎(承認者) 承認待ち 3 件
「ログアウト」 /login.html ― ―
  • ログインせずに申請一覧を開くと、next=%2Findex.html 付きでログイン画面へ。ログインすると申請一覧に戻った
  • 新井さんの一覧は自分の5件だけ。山田さんの申請は、サーバーが最初から渡していない
  • 新井さんが承認の画面を URL で開くと、画面は開くがデータは 403。メッセージで「権限がない」と伝えた
  • 古井さんでログインすると「承認」のメニューが現れ、承認待ちの3件(新井さん2件・山田さん1件)が並んだ
POINT

ログインの本体はサーバーの仕事です。本物にするなら、パスワードをそのまま保存しない(ハッシュにする)、何度も間違えたら待たせる、https にして Secure を付ける、といった対策が要ります。画面の仕事は、だれが使っているかを見せ、行けない場所を案内し、失敗を分かる言葉で伝えること。守ってくれるのはサーバー、案内するのは画面です。

結 — 一枚まとめと次回予告

  1. 画面は HTML ファイルごとに分け、リンクで切り替える。HTML はだれでも取れる。守るのはデータ。
  2. ログインしたら、サーバーがHttpOnly のクッキーで合言葉を預ける。ブラウザが自動で送るので、JavaScript では触らない。
  3. どの画面も最初に requireLogin()。401 なら next 付きでログイン画面へ、戻りは location.replace。
  4. 戻り先は new URL() で読んで origin を比べる。「/ で始まる」では、// や /\ にだまされる。
  5. メニューを隠すのは親切、断るのはサーバー。401 は「だれか分からない」、403 は「それはできない」。

新井古井先輩の画面に、私のニューヨーク出張が並んでました! 承認してください!

古井まだボタンがない。次回は承認の流れ。承認・差し戻し、コメント、状態の移り変わりを作る。同じ申請を二人が同時に承認しようとしたらどうなるか、も試すぞ。

参考

ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・パスワード・申請は、練習用サーバーの架空のデータです。着いた URL・ヘッダーの表示・メッセージ、状態コード、戻り先の判定、クッキーの印、localStorage の実験、「戻る」の履歴は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。各ページの HTML は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。

いにしえのフロントエンド入門 第11回 — ログインと画面の切り替え / 2026.10

← 記事一覧へ