これは、自分のためのふり返りメモです。
「Express と PostgreSQL を使って、ログインできる API サーバーを自力で作った」ときの道のりを、あとから読み返しても分かるように、ゆっくり言葉にしてみます。
未来の自分(ちょっと忘れた自分)が読んでも、「ああ、こうやって作ったんだったな」と思い出せるように。
はじめに
このメモでやりたいことは、ざっくりいうとこうです。
- Express という「Web サーバーのフレームワーク」を使う
- PostgreSQL(ポスグレ)という「データベース」につなぐ
- ユーザー登録(signup)とログイン(login)ができる
- ログインした人だけが使える
/usersの API を作る
つまり、
「ログイン機能付きのユーザー管理 API を、ゼロからちゃんと自分で組んでみる」
という練習です。
なぜ「Express + DB + 認証」を最初にやるのか
自分に向けて、まずこう言っておきたいです。
フロントエンドだけじゃなくて、「Web サービスの裏側」が分かるようになりたいなら、Express + DB + 認証 は最初に通っておくと後がラク。
理由をシンプルにまとめると:
- どんな Web サービスも、だいたい
- HTTP でリクエストが来る(Express の役目)
- どこかの DB とかストレージに保存する(PostgreSQL の役目)
- 誰がアクセスしているかを確かめる(認証 の役目)
- この 3 つが分かると、「フロントから見えてない世界」が一気につながって見える
「裏側がどう動いているか」をイメージできるかどうかが大事。
だから、最初にここをちゃんと通っておく価値は大きい、という話です。
ゴール:/auth と /users がちゃんと動く API サーバー
このメモのゴールは、とてもはっきりしています。
-
/auth/signup- 名前・メール・パスワードを送ると、ユーザーが作られる
-
/auth/login- メール・パスワードを送ると、トークン(JWT)が返ってくる
-
/users//users/:id- ヘッダーにトークンをつけてアクセスすると、ユーザー一覧や詳細が見られる
- 更新・削除もできる
つまり、**「ログインしていない人は見られない /users API」**を Express と PostgreSQL で自分の手で作るのがゴールです。
全体像:このバックエンドは何でできているのか
ざっくり、こういう世界を作ります。
- クライアント(ブラウザや curl)がリクエストを送る
- Express がそのリクエストを受け取る
- Express から PostgreSQL に SQL を投げる
- 結果を JSON にして返す
- 認証が必要なものは、JWT トークンをチェックしてから進ませる
図:クライアント → Express → PostgreSQL の流れ
ファイル構成(index / app / routes / middleware / db)
ここで作ったときのファイル構成を、あとから見て分かるように整理しておきます。
-
src/index.ts- アプリの「入り口」。
app.listen(PORT)とかを書く場所。
- アプリの「入り口」。
-
src/app.ts- Express の本体を組み立てる場所。
app.use(express.json())などの設定や、ルーターをつなぐ場所。
- Express の本体を組み立てる場所。
-
src/routes/auth.ts-
/auth/signupや/auth/loginをまとめる。
-
-
src/routes/users.ts-
/usersや/users/:idをまとめる。
-
-
src/middleware/authMiddleware.ts- JWT トークンをチェックする「門番」。
-
src/db.ts- PostgreSQL との接続設定(pg のプールなど)。
-
src/types/express.d.ts-
req.userを型として追加するならここ。
-
こうやって役割ごとにファイルを分けることで、「どこに何が書いてあったか」をあとから思い出しやすくなります。
環境構築(Node / TypeScript / Docker / .env)
ざっくり、自分がやったことを順番に書くと:
- Node.js と npm をインストール
-
npm init -yで Node プロジェクトを作る - TypeScript を入れる
npm install --save-dev typescript ts-node-dev @types/node
- Express と関連パッケージを入れる
npm install express pg bcrypt jsonwebtokennpm install --save-dev @types/express @types/bcrypt @types/jsonwebtoken
- PostgreSQL は Docker で立てる(あとで書く)
-
.envを作って、DB の接続情報やJWT_SECRETをそこに書く
package.json と tsconfig.json の役割
ここも、説明すると:
-
package.json
- → このプロジェクトで使う「部品(ライブラリ)一覧」と、「便利コマンド(scripts)」が書いてあるメモ帳。
-
tsconfig.json
- → TypeScript に対して「こういうルールで書くからね」「出力はここに出してね」と伝えるための設定ファイル。
Docker で Postgres を立てる
PostgreSQL は、自分の PC に直接入れることもできるけど、今回は「Docker を使って箱の中に立てる」やり方でやりました。
docker-compose.yml にサービスを作っておけば、docker-compose up -d でポスグレが立ち上がります。
.env で接続情報と JWT_SECRET を管理する
DB のパスワードや JWT の秘密鍵などは、GitHub に上げたくないので .env に書いて、dotenv ライブラリで読みこむようにします。
DATABASE_URL=postgres://postgres:password@localhost:5432/myapp
JWT_SECRET=supersecretjwt
PORT=3000
DB 設計:users テーブルをどう設計するか
今回つくる API はユーザーを管理するので、まずは users テーブルをどうするか考えます。
必要なカラム
最低限、こんなカラムがほしいです。
-
id(主キー)… 自動でふられる番号 -
name… ユーザー名 -
email… ログインに使うメールアドレス(ユニーク) -
password… ハッシュ化したパスワード -
created_at/updated_at… 作成日時 / 更新日時
CREATE TABLE の SQL 例
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
「アプリの仕様」からテーブルを逆算するという考え方
ここで自分に言い聞かせたいポイントは、テーブル設計は、アプリの仕様から逆算するということ。
- ログインさせたい →
emailとpasswordが必要 - ユーザー一覧を出したい →
nameはもちろん欲しい - あとから「いつ作ったユーザーか」を見たくなるかも →
created_atを持っておく
というように、「このアプリで何をしたいか」から決めていくイメージです。
認証の設計:パスワードハッシュと JWT
なぜ平文保存してはいけないのか(bcrypt)
パスワードをそのまま保存すると、DB が漏洩したときに大事故になります。
なので、必ず**「ハッシュ化」**して保存します。今回は bcrypt を使いました。
-
bcrypt.hash(平文, ラウンド数)で、パスワードがわけの分からない文字列に変わる - ログインのときは
bcrypt.compare(入力された平文, 保存されているハッシュ)で照合する
なぜ JWT を使うのか(ステートレス vs セッション)
ログイン処理のあと、「この人はログイン済みですよ」という証拠をどう扱うか。
- セッション方式: サーバー側に「ログインした人のリスト」を覚えておく
- JWT 方式: 「この人は userId=1 です」という情報をトークンの中に持たせる
今回は、サーバー側が状態を持たなくていい(ステートレス)JWT 方式を採用しました。
実装① authRouter:サインアップとログイン
POST /auth/signup の処理の流れ
- body から
name,email,passwordを取り出す - どれかが足りなければ 400 を返す
- 同じ email がすでにないか DB でチェックする
-
bcrypt.hashでパスワードをハッシュ化する - INSERT して、
RETURNING id, name, emailで新しいユーザー情報を返す
authRouter.post("/signup", async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: "name, email, password は必須です" });
}
// email 重複チェック
const existing = await pool.query(
"SELECT id FROM users WHERE email = $1",
[email]
);
if (existing.rows.length > 0) {
return res.status(409).json({ error: "このメールアドレスはすでに使われています" });
}
const hashedPassword = await bcrypt.hash(password, 10);
const result = await pool.query(
"INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email",
[name, email, hashedPassword]
);
return res.status(201).json(result.rows[0]);
});
POST /auth/login の処理の流れ
-
email,passwordを受け取る - email でユーザーを検索する(見つからなければ 401)
-
bcrypt.compareでパスワードをチェック - OK なら
jwt.signでトークンを作る(有効期限は 24h にした) -
{ token }を返す
authRouter.post("/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "email, password は必須です" });
}
const result = await pool.query(
"SELECT id, password FROM users WHERE email = $1",
[email]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: "メールアドレスまたはパスワードが違います" });
}
const user = result.rows[0];
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return res.status(401).json({ error: "メールアドレスまたはパスワードが違います" });
}
const token = jwt.sign(
{ userId: user.id, email },
process.env.JWT_SECRET!,
{ expiresIn: "24h" }
);
return res.json({ token });
});
【覚えるパターン】
- バリデーション
- DB 検索
- 比較(パスワードや重複)
- 成功したら最小限の情報を返す
この 4 ステップは、いろんな API 実装でも同じパターンで出てきます。
実装② authMiddleware:JWT を検証する門番
Authorization: Bearer <token> のヘッダーをチェックする middleware です。
やっていることはシンプルです。
- ヘッダーからトークンを取り出す
-
jwt.verifyでチェックする - OK なら
req.userに詰める - ダメなら 401 で返す
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
interface JwtPayload {
userId: number;
email: string;
}
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: "トークンが必要です" });
}
const [scheme, token] = authHeader.split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ error: "トークンの形式が正しくありません" });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
// 型定義してあればこんな感じで保存
(req as any).user = { id: decoded.userId, email: decoded.email };
next();
} catch (err) {
return res.status(401).json({ error: "トークンが無効です" });
}
}
実装③ usersRouter:認証付き CRUD
authMiddleware を通したあとでだけアクセスできる /users を作ります。
usersRouter.use(authMiddleware);
// ユーザー一覧
usersRouter.get("/", async (req, res) => {
const result = await pool.query(
"SELECT id, name, email, created_at, updated_at FROM users ORDER BY id"
);
res.json(result.rows);
});
// 特定ユーザーの詳細
usersRouter.get("/:id", async (req, res) => {
const id = Number(req.params.id);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "id は数値で指定してください" });
}
const result = await pool.query(
"SELECT id, name, email, created_at, updated_at FROM users WHERE id = $1",
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "ユーザーが見つかりません" });
}
res.json(result.rows[0]);
});
// ユーザーの更新
usersRouter.put("/:id", async (req, res) => {
const id = Number(req.params.id);
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: "name は必須です" });
}
const result = await pool.query(
"UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, name, email, created_at, updated_at",
[name, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "ユーザーが見つかりません" });
}
res.json(result.rows[0]);
});
// ユーザーの削除
usersRouter.delete("/:id", async (req, res) => {
const id = Number(req.params.id);
const result = await pool.query("DELETE FROM users WHERE id = $1", [id]);
if (result.rowCount === 0) {
return res.status(404).json({ error: "ユーザーが見つかりません" });
}
// 204 No Content: 「ちゃんと消したけど、返す中身はないよ」
res.status(204).send();
});
curl を使った実戦テスト例
フロントがなくても、curl で API をたたいて動作確認ができます。
1. signup
curl -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{"name":"kohei","email":"kohei@example.com","password":"password123"}'
2. login(トークンをもらう)
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"kohei@example.com","password":"password123"}'
レスポンス例:
{ "token": "xxxxx.yyyyy.zzzzz" }
3. 認証付き /users
curl http://localhost:3000/users \
-H "Authorization: Bearer xxxxx.yyyyy.zzzzz"
ここで 401 が返ってきたら、「トークンのつけ方」や「JWT_SECRET が合っているか」を疑います。
エラーを味方にする:実際に踏んだエラーと、どう直したか
ここが一番「自分に向けて言い聞かせたい」ところです。
password authentication failed for user "postgres"
DB の接続情報(ユーザー名・パスワード・DB 名)が .env と Docker 側でズレていると出るエラー。
対処: docker-compose.yml と .env を見比べる。
relation "users" does not exist
users テーブルをまだ作っていないのにクエリを投げると出るエラー。
対処: CREATE TABLE を流したか確認する。
トークンなし・ヘッダ typo での 401
Authorization ヘッダーを入れ忘れたり、Bearer のスペルを間違えたりして 401 が出るやつ。
対処: console.log(req.headers) で、何が届いているか見る。
「エラー文を読む癖」が どれだけ重要か
エラーが出たときに、「うわ、エラーだ、どうしよう」ではなく、**「エラーはヒントだから、ちゃんと読む」**という反応ができるかどうか。
そのためには、まず自分がエラー文をちゃんと読む人になっておく必要があると感じました。
まとめ:次に何を足せば「プロダクション寄り」になるか
このメモのゴールは、「Express × PostgreSQL で、認証付きの CRUD API をとりあえず自分の手で組んで、全体像をつかむこと」でした。
ここから先、「もっとぽくする」ために足せるものを最後にリストアップしておきます。
-
入力バリデーションの強化:
zodなどで、「どんな形の JSON なら OK か」を型として書く -
ログ設計:
winstonなどで、「いつ」「誰が」「どの API を叩いたか」をきちんと残す -
マイグレーションツール:
PrismaやKnexで、テーブル変更をコードで管理する -
テストと CI:
Jestでテストを書き、GitHub Actions で回す
今の自分は、まず**「全体の流れをつかむ」「どこに何を書くか体で覚える」「エラーを怖がらない」**。
この 3 つができるようになるのが第一目標です。