Next.js + Firebase Authentication + Auth.js(NextAuth.js) を組み合わせて認証・セッション管理をしていたところ、
「画面遷移やNextAuthのセッション自体は有効」なのに、「Firebaseからデータ取得」が失敗することがありました。
Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングのずれ(Firebase AuthのIDトークンの期限切れ)が原因だったようなので、その際の対応についてまとめます。
目次
原因:Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングのずれ
FirebaseのJWT(idToken)とAuth.jsのJWTについてまとめた表です。
Firebase | Auth.js | |
---|---|---|
有効期限 | 1時間 | 30日(デフォルト) |
用途 | Firebase API呼び出し時の認証 | アプリ内のセッション管理 |
署名 | Google秘密鍵 | NEXTAUTH_SECRET |
内容 | uid, email, firebase情報など | uid, email, name, カスタムデータなど |
こちらの表の通り、2つのJWTは有効期限が異なります。この「切れるタイミングの差」が原因で不具合が発生。
つまり、ログインから1時間経った後、「Firebaseのトークンは切れているのにAuth.jsのセッションは有効のまま」という現象が起きていたもよう。
この2つのJWTがどう認証に関わっているかについては下記参照。
Firebase Authのトークン自動更新について
👉クライアントサイド(ブラウザ)では自動更新される
Firebase SDKでは自動的にトークンの更新が行われるため、開発者が手動でリフレッシュする必要はありません。
ブラウザで.getIdToken(true)
を呼び出すと、自動的にトークンがリフレッシュされます。
// 自動リフレッシュ
firebase.auth().currentUser.getIdToken(true) // forceRefreshがtrue
👉サーバーサイドでは自動更新されない
長時間実行されるクライアントセッションでは、サーバーサイドのトークンが期限切れになる可能性があり、通常約1時間後にユーザーがタブを再度開いた場合にページの更新が必要。
つまり、今回のようにFirebase Authentication + Auth.js(NextAuth.js) を組み合わせて認証・セッション管理をしている場合は、トークンの自動更新を実装する必要があるらしい。
解決策:リフレッシュトークンを使ってトークンを自動更新する
Firebaseのリフレッシュトークンを使うと、新しいトークンを取得することができます。
ユーザーがログインするたびに、ユーザー認証情報が Firebase Authentication バックエンドに送信され、Firebase ID トークン(JWT)および更新トークンと交換されます。Firebase ID トークンの有効期間は短く、1 時間で期限切れとなります。新しい ID トークンは、更新トークンを使用して取得できます。 更新トークンは、次のいずれかが発生した場合にのみ有効期限が切れます。
https://firebase.google.com/docs/auth/admin/manage-sessions?hl=ja
ということで、トークンの有効期限をチェックし、期限切れの場合はリフレッシュトークンを使って新しいトークンを取得する処理を実装します。
FIREBASE_TOKEN_API_KEYを取得
Google Cloud コンソール の「API とサービス」>「認証情報」にアクセス。
下記より取得した値を、環境変数FIREBASE_TOKEN_API_KEY
に設定します。

続いてコードを書いていきます。
※すでにログイン機能は実装してあるものとし、必要な箇所だけ抜粋して載せます。
ログイン機能の実装は下記のサンプルコードを参照。
フロント側(ログイン処理)
// src/components/LoginForm.tsx
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const idToken = await userCredential.user.getIdToken();
const refreshToken = userCredential.user.refreshToken; // リフレッシュトークンを取得
await signIn('credentials', {
idToken,
refreshToken, // authjsにリフレッシュトークンを渡す
redirect: true,
callbackUrl: '/user/home',
});
}
サーバー側
有効期限のチェック、リフレッシュトークンを使って新しいトークンを取得する部分を実装します。
// src/app/api/auth/[...nextauth]/options.ts
// 定数の定義
const TOKEN_REFRESH_BUFFER = 5 * 60; // 5分前にリフレッシュ
const SESSION_MAX_AGE = 30 * 24 * 60 * 60; // 30日
/**
* Firebase Refresh Tokenを使用して新しいID Tokenを取得
*/
const fetchNewIdToken = async (refreshToken: string) => {
const res = await fetch(
`https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_TOKEN_API_KEY}`,
{
method: 'POST',
body: JSON.stringify({
grant_type: 'refresh_token',
refreshToken,
}),
},
)
const { id_token } = await res.json();
return id_token
}
/**
* Firebase ID Tokenを検証してユーザー情報を取得
*/
const verifyFirebaseToken = async (idToken: string): Promise<User | null> => {
try {
const decoded = await adminAuth.verifyIdToken(idToken);
return {
id: decoded.uid,
uid: decoded.uid,
email: decoded.email || '',
tokenExpiryTime: decoded.exp || 0,
idToken,
};
} catch (error) {
console.error('Firebase ID token verification failed:', error);
return null;
}
};
export const authConfigs: NextAuthOptions = {
debug: process.env.NEXTAUTH_DEBUG === 'true',
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE, // 30日間
},
jwt: {
maxAge: SESSION_MAX_AGE, // 30日間
},
providers: [
CredentialsProvider({
name: "Firebase Auth",
credentials: {
idToken: { label: "ID Token", type: "text" },
refreshToken: { label: "Refresh Token", type: "text" },
},
async authorize(credentials) {
if (!credentials?.idToken) {
console.error('No ID token provided');
return null;
}
const user = await verifyFirebaseToken(credentials.idToken);
if (!user) return null;
// Refresh tokenがあれば追加
if (credentials.refreshToken) {
user.refreshToken = credentials.refreshToken;
}
return user;
}
}
),
],
callbacks: {
async jwt({ token, user }) {
// 初回ログイン時:Firebase情報をNextAuth JWTに保存
if (user) {
token.uid = user.id;
token.email = user.email ?? "";
token.tokenExpiryTime = user.tokenExpiryTime;
token.refreshToken = user.refreshToken;
token.idToken = user.idToken
}
// トークンの有効期限チェック
const currentTime = Math.floor(Date.now() / 1000)
const tokenExpiryTime = token.tokenExpiryTime as number
const isExpired = currentTime > tokenExpiryTime - TOKEN_REFRESH_BUFFER; // 5分前にリフレッシュ
// 有効期限が迫っていたらリフレッシュ
if (isExpired) {
try {
const newIdToken = await fetchNewIdToken(token.refreshToken as string);
// 新しいトークンを検証して情報を更新
const verifiedUser = await verifyFirebaseToken(newIdToken);
if (verifiedUser) {
token.idToken = newIdToken;
token.tokenExpiryTime = verifiedUser.tokenExpiryTime;
}
} catch (error) {
console.error('Error refreshing token:', error)
}
}
return token;
},
async session({ session, token }) {
// Authjs JWTからセッション情報を構築
if (token.uid && token.email) {
session.user.uid = token.uid;
session.user.email = token.email;
session.user.idToken = token.idToken
}
return session;
},
},
}
参考
ありがとうございます!!!