数年前までは複雑なタスクだったアプリケーションへの認証の実装は、現在APIで簡単に実行できるように。

Next.jsで特定の認証機能を実装する方法については、すでに多数のサンプルリポジトリや解説記事が存在するため、あらためてご紹介する必要はないでしょう。

今回は、認証サービスの選択からサインインページの構築、サーバーサイドおよびクライアントサイドの選択まで、Next.jsで認証を実装する際の注意点を詳しくご紹介します。

認証方式およびサービスの選択

アプリに認証機能を組み込む方法は千通り近くあるため、今回は特定のサービスに焦点を当てるのではなく(これについてはまた別の記事で)、認証ソリューションの種類とその例をいくつか見ていきます。実装については、Next.jsアプリを複数のサービスと統合したり、シングルサインオン(SSO)を追加したりする選択肢として、next-authが一般化しつつあります。

従来のデータベースでの認証

従来のデータベースは、ユーザー名とパスワードをリレーショナルデータベースに保存します。ユーザーがアカウント登録を行うと、usersテーブルに新しい行が挿入され、ユーザーがログインすると、テーブルに保存されている情報と照合。また、ユーザーがパスワードを変更すれば、テーブルの値が更新されます。

既存のアプリケーションにおいては、この方式が最も一般的です。柔軟性に優れ、安価で、特定のサービスに縛られる必要もありません。ただし、自分で構築しなければならず、特に暗号化について懸念し、十分に安全でないパスワードが悪意のあるユーザーの手に渡らないようにする対策を講じる必要があります。

データベースソリューションでの認証

ここ数年で(Firebaseはそれ以前から)、多くのマネージドデータベースサービスが認証サービスも提供するようになっています。FirebaseSupabaseAWSは、ユーザー作成とセッション管理を簡単に抽象化するAPIを介して、マネージドデータベースとマネージド認証の両方を提供しています(詳しくは後ほど)。

Supabase認証でのログインは、以下のように非常にシンプルです。

async function signInWithEmail() {
  const { data, error } = await supabase.auth.signInWithPassword({
    email: '[email protected]',
    password: 'example-password',
  })
}

データベースソリューション以外での認証

DBaaS(Database as a Service)がアドオン的に提供する認証サービス以外にも、ログイン認証サービスに特化した企業もあり、こちらの方がおそらく一般的です。Auth0は2013年に登場した老舗サービス(現在はOkta所有)。また、開発エクスペリエンスを重視したStytchのような新入りソリューションも、徐々に注目されています。

Auth0の認証
Auth0の認証

シングルサインオン(SSO)

Oktaのようなセキュリティに特化したプラットフォームから、GoogleやGitHubなど大手まで多数の選択肢があります。SaaS業界ではGoogleのSSOがユビキタスで、開発者向けのツールの中にはGitHub経由でしか認証できないものもあります。

いずれを選択するにしても、SSOは一般に先にあげた認証ソリューションを強化するもので、外部プラットフォームとの統合に際しては独自の特殊性を持っています(SAMLはXMLベースである点に要注意)。

プロジェクトに適した選択肢

プロジェクトに何が適しているかは、何を優先するかによって異なります。セットアップはそこそこに、迅速にプロジェクトを進めたい場合は、認証をアウトソーシングするのが妥当でしょう(Auth0などにUIを含めて丸ごとアウトソーシングすることも可能)。より複雑なセットアップが予想される場合は、独自の認証バックエンドを構築することをお勧めします。また、より大規模または複雑な顧客をサポートする場合は、いずれSSOの実装も必要になります。

Next.jsは今日非常に人気が高く、ほとんどの認証ソリューションでNext.js専用のドキュメントと統合手順が用意されています。

サインアップおよびサインインページの構築と認証実装のヒント

Auth0のような認証プラットフォームは、サインアップやサインインページ全体を提供してくれます。ゼロからページを構築する場合は、認証を実装する際にリダイレクトが必要になるため、初期段階でこのページを作成しておくことをお勧めします。

つまり、まずはページの大枠を構築し、バックエンドへのリクエストを後から追加するのが効率的です。以下2つのページを作成しましょう。

  • サインアップページ
  • すでにアカウントを持つユーザー向けのサインインページ

これらの基本的なページに加えて、ユーザーがパスワードを忘れてしまった場合も考慮しなければなりません。これには、パスワードのリセットプロセスを別のページで表示する方法もあれば、通常のサインインページに動的なUI要素を追加する方法もあります。

優れたサインアップページがビジネスの成功を決める、と言うのは大袈裟ですが、ちょっとした工夫でユーザーに良い印象を与えることができ、全体としてより良いUXを提供することができます。以下、認証プロセスに気の利いた工夫が見られるサイトをいくつかご紹介します。

1. ログイン後にナビゲーションバーを更新する

StripeのナビゲーションバーのCTA(行動喚起)は、ユーザーがログインしているかどうかによって変化します。以下は、ログイン認証前のStripeトップページ。右上にある「Sign in」に注目してください。

認証されているかどうかに基づいてCTAが変わるStripeのホームページ
認証されているかどうかに基づいてCTAが変わるStripeのホームページ

ログイン後は、以下のようにダッシュボードへのリンクに変わります。

Stripeのホームページの変化
Stripeのホームページの変化

Stripeでの根本的なユーザー体験に差はありませんが、ユーザーを考慮したきめ細かい工夫です。余談ですが、多くの企業がサイトのナビゲーションバーを認証に「依存」させていないのにはそれなりの理由があり、ユーザーがページを読み込むたびに認証を行うAPIリクエストを削減するためです。

2. アカウント登録フォームの横にコンテンツを追加する

ここ数年、特にSaaSでは、ユーザーにアカウント登録を完了してもらうため、アカウント登録のページにコンテンツを追加する企業が増えています。わずかではありますが、コンバージョン改善に役立ちます。

Retoolのサインアップページには、横にアニメーションと複数のロゴが配置されています。

サインアップページにコンテンツを盛り込む場合は、フォントを統一すること
サインアップページにコンテンツを盛り込む場合は、フォントを統一すること

以下はKinstaのアカウント登録ページです。

Kinstaのサインアップページ
Kinstaのサインアップページ

このようなコンテンツの追加は、ユーザーにアカウントを登録する目的をリマインドし、プロセスを促進するのに貢献してくれます。

3. 強力なパスワードを提案または強制する(パスワードを使用する場合)

パスワードが本質的に安全でないことは、開発者の間ではもはや常識ですが、すべての一般ユーザーがこれを認識しているわけではありません。安全なパスワードの設定を促すことは、企業とユーザーの両方にメリットがあります。

Coinbaseは、名前よりも複雑で強力なパスワードの使用を促しています。

Coinbaseの脆弱なパスワード例
Coinbaseの脆弱なパスワード例

パスワードマネージャーを使用すれば、簡単に強力なパスワードを設定できます。

Coinbaseの強力なパスワード例
Coinbaseの強力なパスワード例

しかし、UIにはパスワードが強力でない理由は説明されず、数字の組み合わせが必要であること以上の要件は表示されません。強力なパスワードに必要な要件を提示するとユーザーが理解しやすく、パスワード再試行を避けることができ、ユーザー体験の改善につながります。

4. 適切な入力設定でパスワードマネージャーと連携させる

米国の3人に1人が1Passwordなどのパスワードマネージャーを使用しているにもかかわらず、HTML入力のtype属性で適切な設定が行われていないフォームはいまだ多数存在します。フォームをパスワードマネージャーに対応させることも重要です。

  • input要素をform要素で囲む
  • 入力にtypeとlabelを指定する
  • 入力にオートコンプリート機能(autocomplete)を追加する
  • 動的にフィールドを追加しない(特にDelta

特にモバイル端末では、10秒で簡単にサインインできるか、面倒で手間がかかるかが顧客獲得の分かれ目になります。

セッションとJWT

ユーザーの認証後は、その後のリクエストを通してその状態を維持しなければなりません。HTTPはステートレスですが、毎回のリクエストでユーザーにパスワードを尋ねるのはもってのほか。これには、セッション(Cookie)またはJWT(JSON Web Token)を使った2つの方法が一般的です。

セッション(別名Cookie)

セッションベースの認証では、認証を維持するためのロジックと作業がサーバーによって処理されます。基本的には、以下のような流れになります。

  1. ユーザーがサインインページで認証を行う。
  2. サーバーがこの特定のブラウジングセッションを表すレコードを作成。このレコードは通常、ランダムな識別子とセッションに関する詳細(いつ開始し、終了するかなど)とともにデータベースに挿入される。
  3. このランダムな識別子(6982e583b1874abf9078e1d1dd5442f1のようなもの)がブラウザに送信され、Cookieとして保存される。
  4. その後のリクエストにはこの識別子が含まれるようになり、データベースのセッションテーブルと照合される。

セッションをいつまで維持するか、またいつ切断するかなどは簡単に調整可能です。欠点としては、規模が大きくなると、データベースの書き込みと読み込みによってかなりのレイテンシが生じる点。しかし、これはユーザー側ではあまり大きな問題にならないでしょう。

JSON Web Token(JWT)

JWTを使えば、その後のリクエストの認証をサーバー上で処理する代わりに、クライアント側で(ほぼすべてを)処理することができます。流れは以下のとおり。

  1. ユーザーがサインインページから認証を行う。
  2. サーバーがユーザーの身元、付与された権限、有効期限などが含まれるJWTを生成
  3. サーバーがそのトークンに署名し、内容を暗号化してクライアントに送信。
  4. 各リクエストに対して、クライアントがトークンを復号化し、ユーザーがリクエストを行う権限を持っているかを確認(サーバーとの通信は必要なし)。

初回の認証後の作業がすべてクライアント側で処理されるため、アプリケーションの読み込みと動作が格段に高速化されます。しかし大きな欠点として、サーバーからJWTを無効にする方法はありません。つまり、ユーザーがデバイスからログアウトしたい場合、または認証の範囲が変更された場合は、JWTの有効期限が切れるまで待つ必要があります。

サーバーサイドとクライアントサイドの認証

Next.jsは、組み込みの静的レンダリングに優れており、ページが静的である場合、つまり外部APIを呼び出す必要がない場合は、自動的にキャッシュし、CDN経由で高速配信することができます。Next.js 13以前のバージョンでは、ファイルにgetServerSidePropsやgetInitialPropsを含めなければ、ページが静的であるかどうかがわかりますが、Next.js 13以降のバージョンでは、React Server Componentsがこの確認を行います。

認証については、「読み込み中」の静的ページをレンダリングしてクライアント側で取得するか、すべてをサーバー側で処理するかの2つの選択肢があります。認証が必要なページ (1) では、静的ページの大枠をレンダリングし、クライアント側で認証リクエストを行います。理論的には、最初のコンテンツが完全に準備できていなくても、ページの読み込みが高速化されます。

以下は、ユーザーオブジェクトが準備できていない限り、ローディング画面をレンダリングする簡単な例です。

import useUser from '../lib/useUser'
 
const Profile = () => {
  // クライアントサイドでユーザーを取得
  const { user } = useUser({ redirectTo: '/login' })
 
  // サーバーレンダーの読み込み状態
  if (!user || user.isLoggedIn === false) {
    // ここで何らかのローディング画面を作成
    return <div>読み込み中...</div>
  }
 
  // ユーザーリクエストが終了したら、ユーザーに表示
  return (
    <div>
      <h1>アカウント</h1>
      <p>ユーザー名: {JSON.stringify(user.username,null)}</p>
      <p>メールアドレス: {JSON.stringify(user.email,null)}</p>
      <p>住所: {JSON.stringify(user.address,null)}</p>
    </div>
  )
}
 
export default Profile

なお、クライアントが読み込み後にリクエストを行う間、スペースを確保するため、何らかの読み込み中UIを構築する必要があることを念頭においてください。

作業を簡略化し、サーバーサイドで認証を実行したい場合は、getServerSideProps関数に認証リクエストを追加すれば、リクエストが完了するまでページのレンダリングを待つことができます。上記コードの条件ロジックの代わりに、以下、Nextドキュメントの簡易版のようなものを実行します。

import withSession from '../lib/session'
 
export const getServerSideProps = withSession(async function ({ req, res }) {
  const { user } = req.session
 
  if (!user) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    }
  }
 
  return {
    props: { user },
  }
})
 
const Profile = ({ user }) => {
  // ユーザーに示す。ローディング画面は不要
  return (
    <div>
      <h1>アカウント</h1>
      <p>ユーザー名: {JSON.stringify(user.username,null)}</p>
      <p>メールアドレス: {JSON.stringify(user.email,null)}</p>
      <p>住所: {JSON.stringify(user.address,null)}</p>
    </div>
  )
}
 
export default Profile

認証に失敗した際に処理するロジックが残っていますが、ローディング画面をレンダリングする代わりにログインページにリダイレクトします。

まとめ

プロジェクトに適した方法を選ぶには、まず認証スキームの速度がどの程度になるかを評価することから始めてみてください。リクエストにまったく時間がかからない場合は、サーバーサイドで実行し、ローディング画面の表示を省略できます。すぐにレンダリングして、リクエストを待つことを優先したい場合には、getServerSidePropsをスキップして別の場所で認証を実行してください。

1. Next.jsでは、認証を要求するページを選択することができます。すべてのページで一律に要求する方が手っ取り早いですが、Next.jpのパフォーマンス上のメリットを活かすことができません。

Justin Gage

Justin is a technical writer and author of the popular Technically newsletter. He did his B.S. in Data Science before a stint in full-stack engineering and now focuses on making complex technical concepts accessible to everyone.