Next.js と iron-session でアクセストークンを安全に保存する
こんにちは。レンティオでエンジニアをしている小島です。
弊社のあるプロダクトではフロントエンドを Next.js で作り、バックエンドの API を Rails で作っています。
これには以下のような (よくある) ユーザーのログイン機能があります。
API がアクセストークンを発行する
- ブラウザで入力された email, password をログインのエンドポイントに送信
- ログイン成功のレスポンスでアクセストークンが渡される
- その後の API コールにはアクセストークンを含める
Authorization: Bearer <access-token>
アクセストークンをどこに保存するか
ログイン後はアクセストークンをブラウザに保存したいですが、どうすればよいでしょうか?
Cookie やローカルストレージに保存するには問題があります。
JavaScript で読み取って使うことを前提にしているため、XSS によって攻撃者にも読み取られる危険があります。
iron-session による解決
結論、Next.js と iron-session の組み合わせで以下の構成にできます。
- ブラウザは必ず BFF (Backend for Frontend) 経由で API コールする
- 生のアクセストークンを扱うのは BFF の役割
- ブラウザは BFF で暗号化されたデータを Cookie に保存する
- それには HttpOnly 属性が付き、JavaScript で読み取れない
- BFF とは同じドメインのため Cookie, Set-Cookie ヘッダのやりとりができる
以下で詳細を見ていきましょう。
iron-session の設定
iron-session で扱うデータの型と、ブラウザに保存される Cookie について設定します。
import { SessionOptions } from "iron-session"
export type SessionData = {
accessToken?: string
// 他にも機密情報があれば詰め込める
}
export const sessionOptions: SessionOptions = {
cookieName: "sample-session",
password: process.env.SAMPLE_SESSION_PASSWORD!,
}
ここでは Cookie の名前を sample-session とします。
また、iron-session がデータを暗号化・復号するのに使うパスワードを設定します。
そのほかは設定しなくても HttpOnly, Secure 属性が付くなど安全寄りになっています。
ログイン処理
BFF
API Routes にログインのエンドポイントを作成し、以下を実装します。
- ブラウザから送信された email, password を API に受け渡す
- 発行されたアクセストークンを暗号化したデータをブラウザに返す
import { getIronSession } from "iron-session"
import { NextApiRequest, NextApiResponse } from "next"
import { SessionData, sessionOptions } from "@/sample-session"
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getIronSession<SessionData>(req, res, sessionOptions)
// API にリクエスト
const { email, password } = req.body
const response = await fetch("https://api.example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
// アクセストークンを暗号化したデータを Set-Cookie ヘッダでブラウザに返す
session.accessToken = response.headers.get("Access-Token")!
await session.save()
res.status(200).json({})
}
App Router の場合
README に例があるように、Route Handlers でもほとんど同じように書けます。
import { getIronSession } from "iron-session"
import { cookies } from "next/headers"
import { SessionData, sessionOptions } from "@/sample-session"
export async function POST() {
const session = await getIronSession<SessionData>(cookies(), sessionOptions)
// API にリクエスト
const { email, password } = req.body
const response = await fetch("https://api.example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
// アクセストークンを暗号化したデータを Set-Cookie ヘッダでブラウザに返す
session.accessToken = response.headers.get("Access-Token")!
await session.save()
return Response.json({})
}
ブラウザ
ログインページから上記の API Routes を呼び出します。
// BFF にリクエスト
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
このレスポンスの Set-Cookie ヘッダによって sample-session Cookie が保存されます。
HttpOnly 属性が付いているのを確認できます。

ログイン以外の処理
BFF
ログイン以外の API コールにはアクセストークンを含める実装をしたいのですが、各エンドポイントについて API Routes を足していくのは大変です。
そこで、あらゆるパスにマッチする [...others].ts を作成し、API への汎用的なプロキシ処理を実装します。
import { createProxyMiddleware } from "http-proxy-middleware"
import { getIronSession } from "iron-session"
import { NextApiRequest, NextApiResponse } from "next"
import { SessionData, sessionOptions } from "@/sample-session"
export const config = {
api: {
bodyParser: false,
externalResolver: true,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getIronSession<SessionData>(req, res, sessionOptions)
// API にリクエストをプロキシ
const proxy = createProxyMiddleware({
target: "https://api.example.com",
changeOrigin: true,
pathRewrite: { "^/api": "" },
// 追加のヘッダでアクセストークンを送信
headers: { "Authorization": `Bearer ${session.accessToken}` },
})
proxy(req, res, (err) => {
if (err) {
res.status(500).json({ errors: ["接続に失敗しました。"] })
}
})
}
ここでは http-proxy-middleware を使ってプロキシしています。
App Router の場合
Route Handlers は Web 標準 API ベースのため http-proxy-middleware は使えないようです。
自前で fetch するコードを AI に提案してもらいましたが、実際に試したわけではないのをご了承ください。
import { cookies } from "next/headers"
import { getIronSession } from "iron-session"
import { SessionData, sessionOptions } from "@/sample-session"
export async function GET(request: Request) {
return handleProxy(request)
}
export async function POST(request: Request) {
return handleProxy(request)
}
export async function PUT(request: Request) {
return handleProxy(request)
}
export async function DELETE(request: Request) {
return handleProxy(request)
}
async function handleProxy(request: Request) {
const session = await getIronSession<SessionData>(cookies(), sessionOptions)
// URL を書き換え
const url = new URL(request.url)
const targetPath = url.pathname.replace(/^\/api/, "")
const targetUrl = `https://api.example.com${targetPath}${url.search}`
// API にリクエストをプロキシ
const response = await fetch(targetUrl, {
method: request.method,
headers: {
...Object.fromEntries(request.headers),
// 追加のヘッダでアクセストークンを送信
"Authorization": `Bearer ${session.accessToken}`,
},
body: request.body,
})
return response
}
ブラウザ
上記の API Routes を呼び出します。
// BFF にリクエスト
const response = await fetch("/api/any-endpoint")
このリクエストでは Cookie ヘッダで sample-session Cookie が送信されます。
そして BFF がアクセストークン付きの API コールをしてくれたレスポンスが返ってきます。
SSR でも
SSR でもページを組み立てる情報を得るために API コールすると思います。
その実行箇所も BFF であり、getIronSession でアクセストークンを取り出せるので同様にリクエストに含めれば大丈夫です。
おわりに
以上、準備は少し大変ですがアクセストークンを安全に扱えるようになりました。
ブラウザが必ず BFF を経由することで、API の URL が知られなくなるメリットもあるかと思います。
採用情報
レンティオではエンジニアを募集しています。もし興味ありましたらこちらもご覧ください。
Discussion