Firebase Authentication と NextAuth.js を組み合わせて認証の実装を以前記載しました
↑ で基本となるアクセストークンは取得できるようになりました。
が、リフレッシュトークンを使った更新作業をしないため、アクセストークンの有効期限が切れると再度ログインを求められます。
Firebase Authenticationのアクセストークンは1時間で期限が切れるため、正直上記の状態だとサービスとしては使いものにならないレベルになってしまいます…🥲
厳密にはFirebase AuthenticationのIDトークンをアクセストークンとして利用します。
IDトークンの有効期限は1時間です
この記事では
Firebase Authentication と NextAuth.js を組み合わせつつ、リフレッシュトークンを使いアクセストークンを更新する方法について記載します。

検証した環境
1 | next-auth | 5.0.0-beta.20 |
2 | next | 14.1.3 |
3 | firebase-admin | 13.0.1 |
現状の確認
以前の記事のコードを元に記載していくので、簡略化しつつコードを記載します
コードの確認
ログインのUIコンポーネント
18行目でNextAuthのログイン処理のServer Actionsを呼び出します
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { login } from '@/actions/login'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" style={pending ? { opacity: '30%' } : {}} disabled={pending}>
{pending ? 'ログイン中...' : 'ログイン'}
</button>
)
}
export default function LoginPage() {
// ログイン処理をformタグのactionsから呼び出せるよう定義
const [state, formAction] = useFormState(login, { message: null })
return (
<div>
<h1>ログイン</h1>
<form action={formAction}>
<input type="email" name="email" placeholder="メールアドレス" />
<input type="password" name="password" placeholder="パスワード" />
<SubmitButton />
</form>
{state.message && <p>{state.message}</p>}
</div>
)
}
ログインを実行するServer Actions
Server ActionsではNextAuthの signIn
を呼び出します
'use server'
import { signIn } from '@/auth'
import { redirect } from 'next/navigation'
type State = {
message: string | null
}
export async function login(previousState: State, formData: FormData): Promise<State> {
const email = formData.get('email')
const password = formData.get('password')
try {
// NextAuth のログインを実行する
await signIn('credentials', { email, password, redirect: false })
} catch (error) {
console.error(error)
return { message: 'ログインに失敗しました' }
}
redirect('/dashboard')
}
NextAuth.jsの設定
認証周りを管理するNextAuth.jsの設定は src/auth.ts
に記載しています
- Credentials > authorize ・・ログイン処理
- callbacks > jwt ・・cookieに認証情報を保存する際に使用
- callbacks > session ・・Server Actions・Route Handler等で利用する
session
変数で認証情報を使えるようにする
import NextAuth, { NextAuthConfig, User } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
export const { signIn, auth } = NextAuth({
providers: [
Credentials({
authorize: async ({ email, password }) => {
// ログイン用のRoute Handlerを呼び出す
const res = await fetch(`${process.env.NEXT_API_URL}/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
})
if (!res.ok) return null
// 取得したアクセストークンを返し保存に繋げる
const json = await res.json()
return {
accessToken: json.accessToken,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
// userが存在する(ログイン後)場合、accessTokenを追加
if (user) {
token.accessToken = user.accessToken
}
// cookieに保存する
return token
},
async session({ session, token }) {
// token(cookieに保存している値のこと)内のaccessTokenを
// Server ActionsやRoute Handlerで利用する session 変数に追加し使えるようにする
session.user.accessToken = token.accessToken
return session
},
},
} satisfies NextAuthConfig)
ログイン用Route Handler
NextAuthのログイン処理で呼び出されるRoute Handlerは
email・passwordでfirebase Authenticationにログインしアクセストークンを返します
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json('parameter is missing.', { status: 400 })
}
// Firebase Auth REST APIを使ってメールアドレス/パスワードでログインするための情報の定義
const endpoint = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.FIREBASE_API_KEY}`
const headers = { 'Content-Type': 'application/json' }
const body = JSON.stringify({ email, password, returnSecureToken: 'true' })
const response = await fetch(endpoint, { method: 'post', headers, body })
const json = await response.json()
return NextResponse.json({ accessToken: json.idToken })
}
実際の動作
これでログインするとアクセストークンが保存され
認証が必要なエンドポイントにアクセスすると認証状態の確認が出来ます
トークンをリフレッシュする
認証の流れに
- リフレッシュトークンを取得・保存
- アクセストークンの有効期限が切れた際にリフレッシュトークンを使ってアクセストークンを更新
この2点を追加することでアクセストークンを永続化していきます!
リフレッシュトークンの取得・保存
googleのAPIを使ってログインする際、実はリフレッシュトークンの情報も含まれています。
{
"localId": "ZY1rJK0eYLg...",
"email": "[user@example.com]",
"displayName": "",
"idToken": "[ID_TOKEN]",
"registered": true,
"refreshToken": "[REFRESH_TOKEN]",
"expiresIn": "3600"
}
REST API の使用 | Identity Platform Documentation | Google Cloud より引用
まず
- アクセストークンの有効期限
- リフレッシュトークンの値
を取得し、保存する処理を追加します
リフレッシュトークンの取得
まずログイン時にアクセストークン以外に必要な値を呼び出し元にも返すようにします
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json('parameter is missing.', { status: 400 })
}
// Firebase Auth REST APIを使ってメールアドレス/パスワードでログインするための情報の定義
const endpoint = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.FIREBASE_API_KEY}`
const headers = { 'Content-Type': 'application/json' }
const body = JSON.stringify({ email, password, returnSecureToken: 'true' })
const response = await fetch(endpoint, { method: 'post', headers, body })
const json = await response.json()
const now = new Date()
return NextResponse.json({
// アクセストークン。引き続きidTokenを用いる
accessToken: json.idToken,
// アクセストークンの有効期限。expiresInは秒数で返ってくる
accessTokenExpiresIn: new Date(now.getTime() + Number.parseInt(json.expiresIn) * 1000),
// リフレッシュトークン
refreshToken: json.refreshToken,
// リフレッシュトークンの有効期限
// firebase authenticationのリフレッシュトークンは明確な有効期限がないため一般的な30日とする
refreshTokenExpiresIn: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
})
}
重要な点として
Firebase Authenticationのリフレッシュトークンは有効期限がないため、
リフレッシュトークンの有効期限として一般的な30日として返します。
リフレッシュトークンの保存
NextAuth.jsの型情報を更新。
NextAuth内でリフレッシュトークン周りの値を扱えるようにし、
import 'next-auth'
import 'next-auth/jwt'
declare module 'next-auth' {
interface User {
accessToken: string
accessTokenExpiresIn: string
refreshToken: string
refreshTokenExpiresIn: string
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken: string
accessTokenExpiresIn: string
refreshToken: string
refreshTokenExpiresIn: string
}
}
ログイン時に保存します
export const { signIn, auth } = NextAuth({
providers: [
Credentials({
authorize: async ({ email, password }) => {
// ログイン用のRoute Handlerを呼び出す
const res = await fetch(`${process.env.NEXT_API_URL}/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
})
if (!res.ok) return null
const json = await res.json()
// ログイン時に取得したリフレッシュトークン周りの値も返すようにする
return {
accessToken: json.accessToken,
accessTokenExpiresIn: json.accessTokenExpiresIn,
refreshToken: json.refreshToken,
refreshTokenExpiresIn: json.refreshTokenExpiresIn,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
// userが存在する(ログイン後)場合、認証情報を追加
if (user) {
token.accessToken = user.accessToken
token.accessTokenExpiresIn = user.accessTokenExpiresIn
token.refreshToken = user.refreshToken
token.refreshTokenExpiresIn = user.refreshTokenExpiresIn
}
// cookieに保存
return token
},
async session({ session, token }) {
session.user.accessToken = token.accessToken
// sessionで確認するためにaccessToken以外も追加
session.user.accessTokenExpiresIn = token.accessTokenExpiresIn
session.user.refreshToken = token.refreshToken
session.user.refreshTokenExpiresIn = token.refreshTokenExpiresIn
return session
},
},
} satisfies NextAuthConfig)
これで確認用のエンドポイントにアクセスしてみると
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
export async function GET() {
// NextAuthの内容を取得
const session = await auth()
// ブラウザ上に保存されている内容を表示
return NextResponse.json({ session })
}

refreshToken周りの値が保存されていることを確認できます!
アクセストークンの更新
アクセストークンの有効期限が切れた場合、リフレッシュトークンを使ってアクセストークンを更新します。
実装は大きく2つです
- トークンのリフレッシュを行えるようにする
- アクセストークンの有効期限が切れた際に上記リフレッシュ処理を実施
トークンのリフレッシュ処理
リフレッシュトークンを元にアクセストークンを更新するためのエンドポイントを作ります
※ エラーハンドリングはせず最小限の構成になってますimport { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { refreshToken } = await request.json()
if (!refreshToken) {
return NextResponse.json('parameter is missing.', { status: 400 })
}
const endpoint = `https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_API_KEY}`
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }
const parameters = new URLSearchParams()
parameters.append('grant_type', 'refresh_token')
parameters.append('refresh_token', refreshToken)
const response = await fetch(endpoint, { method: 'post', headers, body: parameters })
const json = await response.json()
const now = new Date()
return NextResponse.json({
accessToken: json.id_token,
accessTokenExpiresIn: new Date(now.getTime() + Number.parseInt(json.expires_in) * 1000),
refreshToken: json.refresh_token,
refreshTokenExpiresIn: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
})
}
注意点は以下です
- エンドポイントのURLがログイン・新規登録等で使う
identitytoolkit.googleapis.com
ではなくsecuretoken.googleapis.com
になっている - リクエストのbodyは
application/x-www-form-urlencoded
で送信- そのため
URLSearchParams
を使ってリクエストのbodyを作成
- そのため
- レスポンスがログイン時の
idToken
ではなくid_token
のようにスネークケースになっている
特に最後のキャメルケースからスネークケースへの変更は気付きづらいため注意が必要です
参考: REST API の使用 | Identity Platform Documentation | Google Cloud
サンプルのレスポンスは以下のようになります
{
access_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImEwODA2N2Q4M2YwY2Y5YzcxNjQyNjUwYzUyMWQ0ZWZhNWI2YTNlMDkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbmV4dC1hdXRoLXNhbXBsZS0zY2E0YiIsImF1ZCI6Im5leHQtYXV0aC1zYW1wbGUtM2NhNGIiLCJhdXRoX3RpbWUiOjE3NDIyMjM5NzcsInVzZXJfaWQiOiJjS251TkJEYmFEVGZCRlVwVG51cWZwWVkwbjcyIiwic3ViIjoiY0tudU5CRGJhRFRmQkZVcFRudXFmcFlZMG43MiIsImlhdCI6MTc0MjMxMTE0NSwiZXhwIjoxNzQyMzE0NzQ1LCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEB0ZXN0LmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.fFv6bOabc_BnMijLqywqVcfq7TYjmgvFR757SFCq9_eR9SCXNzZB6g8MzU0Kbf3HbBh9wy52BLbsslyW7O9l2pCF4mvaEjNU7QfHWeF2SjlYms4UYysP1UAx9vWEwI8w9J6qL43L2Y2EjMYJWxFxqHLrX_uuF3l75R58dT9EasVNeA7nyBUOoGKzZzDuOQbxY5Df-rfNa75AoLXrM1qZDFr5utQeaGURuB_va7EKvMpu86x6ztMgeYS8aYwCR6Ya15C5KiJPBr-6HnWfQMr6bOe183HGo_TZssM1sWqZyfiBAUxxx3zoPaIFnO_yq5RM8X2bPk1m6CqHLqidAgXg',
expires_in: '3600',
token_type: 'Bearer',
refresh_token: 'AMf-vBzAVJYCE8w-rKPXLnEQhvl-IfvWvaTmjlUwhF7tVuoP244MBOxR3CjGqAPvGtRH3pFZCJNau98EJWi8KCiOGxhodvRygV3r9cr0HGrfe0PrWronFLRdo2KwckxHC03lwO2MHPzHLxG4WA9LwW3uNCXnSSq0TRCWSlzWsqG-d6lwWLc6XT0VJdm4l2uVHV_Y4vur4sxxxxx1jNyn1N5iNcL-z7o3g',
id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImEwODA2N2Q4M2YwY2Y5YzcxNjQyNjUwYzUyMWQ0ZWZhNWI2YTNlMDkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbmV4dC1hdXRoLXNhbXBsZS0zY2E0YiIsImF1ZCI6Im5leHQtYXV0aC1zYW1wbGUtM2NhNGIiLCJhdXRoX3RpbWUiOjE3NDIyMjM5NzcsInVzZXJfaWQiOiJjS251TkJEYmFEVGZCRlVwVG51cWZwWVkwbjcyIiwic3ViIjoiY0tudU5CRGJhRFRmQkZVcFRudXFmcFlZMG43MiIsImlhdCI6MTc0MjMxMTE0NSwiZXhwIjoxNzQyMzE0NzQ1LCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEB0ZXN0LmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.fFv6bOabc_BnMijLqywqVcfq7TYjmgvFR757SFCq9_eR9SCXNzZB6g8MzU0Kbf3HbBh9wy52BLbsslyW7O9l2pCF4mvaEjNU7QfHWeF2SjlYms4UYysP1UAx9vWEwI8w9J6qL43L2Y2EjMYJWxFxqHLrX_uuF3l75R58dT9EasVNeA7nyBUOoGKzZzDuOQbxY5Df-rfNa75AoLXrM1qZDFr5utQeaGURuB_va7EKvMpu86x6ztMgeYS8aYwCR6Ya15C5KiJPBr-6HnWfQMr6bOe183HGo_TZssM1sWqZyfiBAUxxx3zoPaIFnO_yq5RM8X2bPk1m6CqHLqidAgXg',
user_id: 'cKnuNBDbaDTfBFUpTnuqfpxx0n72',
project_id: '534803655629'
}
トークンの有効期限に応じた処理
最後にアクセストークンを読み込む時に有効期限を確認し、期限が切れていた場合はリフレッシュ処理を行います。
セッション情報の保存・更新を行う callbacks > jwt で実装します
export const { signIn, auth } = NextAuth({
providers: [
// ・・省略・・
],
callbacks: {
async jwt({ token, user }) {
// ログイン後などのuserが存在する場合はtokenに追加する
if (user) {
token.accessToken = user.accessToken
token.accessTokenExpiresIn = user.accessTokenExpiresIn
token.refreshToken = user.refreshToken
token.refreshTokenExpiresIn = user.refreshTokenExpiresIn
return token
}
const now = new Date()
const accessTokenExpiresIn = new Date(token.accessTokenExpiresIn)
const refreshTokenExpiresIn = new Date(token.refreshTokenExpiresIn)
const refreshToken = token.refreshToken
// リフレッシュトークンの有効期限が切れている場合は問答無用でログアウト
if (refreshTokenExpiresIn.getTime() < now.getTime()) {
// console.log('リフレッシュトークンの期限が切れています')
return null
}
// アクセストークンの有効期限が切れている場合はリフレッシュトークンを使って更新
if (accessTokenExpiresIn.getTime() < now.getTime()) {
console.log('アクセストークンの期限が切れています')
// リフレッシュトークンが存在しない場合は更新できないためログアウト
if (!refreshToken) return null
console.log('トークンを更新します')
// リフレッシュトークンを使ってアクセストークンを更新するためのエンドポイントを呼び出す
const res = await fetch(`${process.env.NEXT_API_URL}/refresh-token`, {
method: 'POST',
body: JSON.stringify({ refreshToken }),
})
if (!res.ok) return null
// 更新したアクセストークンを保存出来るようtokenに追加
const json = await res.json()
console.log('更新したアクセストークン', json)
token.accessToken = json.accessToken
token.accessTokenExpiresIn = json.accessTokenExpiresIn
token.refreshToken = json.refreshToken
token.refreshTokenExpiresIn = json.refreshTokenExpiresIn
}
return token
},
async session({ session, token }) {
// ・・省略・・
},
},
} satisfies NextAuthConfig)
アクセストークンの有効期限が切れている場合にアクセスすると

アクセストークンが更新されデータが取得出来ました!
分かりづらいですが流れとしては
- アクセストークンの有効期限が切れている場合はリフレッシュトークンを使ってアクセストークンを更新
- 「アクセストークンの期限が切れています」がログに表示
- 「トークンを更新します」がログに表示
- 更新に成功した場合はアクセストークンを保存出来るようtokenに追加
- 「更新したアクセストークン」がログに表示
- returnしてcookieに保存
- (cookieに保存した内容を使いAPIを叩く)
となります
リフレッシュ処理が問題なく行えているかを検証する場合は
// アクセストークンの有効期限より後の時間を指定
const now = new Date("2025-03-19T00:00:00.000Z")
のように時間を先送りすることで検証可能です