パスキーを知る
パスキーの基礎
パスキーの登録方法
パスキーの削除方法
開発者向け解説
Push! Passkeyを知る
Push! Passkeyとは
システム構成・責任分界
利用を開始する
管理ツールの使い方
実装する
実装ガイド
フロントエンド実装
PHPでの実装
Rubyでの実装
Node.jsでの実装
Pythonでの実装
Javaでの実装
C#での実装
結果を連携する
認証結果の受け取り
調べる
APIリファレンス
エラーコード
対応環境
セキュリティ上の注意
トラブルシューティング
よくある質問

Node.jsでの実装

最初にPush! Passkeyだけを動作確認します

このページでは、Node.jsとExpressの新規または既存プロジェクトへサンプルコードを配置し、最初に固定のテストユーザーでパスキー登録・認証を確認します。自社サイトのユーザーDBやログイン処理へ接続するのは、その後です。

  1. 環境変数へAPIキーを設定する
  2. Expressのエンドポイントをコピーする
  3. 動作確認用HTMLをコピーする
  4. 登録と認証が成功した後に、自社サイトの処理へ接続する

Push! Passkey APIとのJSON通信、HTTPステータス確認、結果照会、処理中のrequest_id管理、タイムアウト、エラー処理は、サンプルコードに含まれています。

1. 実行環境を確認する

このページのサンプルは、次の構成を前提としています。

  • Node.js 18以降
  • Express 4.18以降
  • HTTPSで公開されているWebサイト
  • express-sessionを利用するサーバー側セッション

Node.js 18以降にはfetchが標準搭載されているため、HTTP通信用パッケージは追加しません。

node -v
npm -v

新規に動作確認用プロジェクトを作る場合は、次を実行します。

mkdir pushpasskey-sample
cd pushpasskey-sample
npm init -y
npm install express express-session dotenv

2. 基本ファイル構成

ファイル役割
.envAPIキー、セッション秘密鍵、テスト設定を保存します。Gitへ登録しません。
src/app.js登録・認証の開始、結果確認、成功後の処理を行います。
public/pushpasskey-test.html登録・認証の接続試験にだけ使用するHTMLです。
package.json必要パッケージと起動コマンドを定義します。

pushpasskey-sample/
├── .env
├── package.json
├── public/
│   └── pushpasskey-test.html
└── src/
    └── app.js

認証専用の完了ファイルは必要ありません。結果の確認とログインセッションの発行はapp.js内で行い、成功後はボタンのdata-success-urlで指定したページへ移動します。

3. 設定ファイルを作成する

.envを作成する

PUSHPASSKEY_API_KEY=ここにAPIキーを記載してください
PUSHPASSKEY_TEST_MODE=true
PUSHPASSKEY_TEST_USER_KEY=pushpasskey_test_user
PUSHPASSKEY_REQUEST_TIMEOUT_MS=15000
SESSION_SECRET=十分に長いランダムな文字列へ変更してください
PORT=3000
NODE_ENV=development

.envはGitへ登録しないでください。.gitignoreへ次を追加します。

.env
.env.*
node_modules/

SESSION_SECRETには十分に長いランダム値を設定します。本番環境では.envを公開サーバーへ置かず、利用中のホスティング、コンテナ、systemdなどの機能で環境変数を設定してください。

package.jsonへ起動コマンドを追加する

"scripts": {
  "start": "node src/app.js"
}

4. エンドポイントを作成する

src/app.js

次のコードをsrc/app.jsとして保存してください。最初の接続試験では、コード内を書き換える必要はありません。

'use strict';

require('dotenv').config();
const path = require('node:path');
const express = require('express');
const session = require('express-session');

const app = express();
const API_BASE = 'https://auth.jintec.com';
const COMPLETED_STATUSES = new Set([
  'success', 'failed', 'expired', 'cancelled'
]);

app.set('trust proxy', 1);
app.use(express.json({ limit: '32kb' }));
app.use(session({
  name: 'pushpasskey.sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 30 * 60 * 1000
  }
}));

app.get('/pushpasskey/test', (_req, res) => {
  res.sendFile(path.join(
    __dirname, '..', 'public', 'pushpasskey-test.html'
  ));
});

app.get('/pushpasskey/status', (req, res) => {
  res.json({
    ok: true,
    authenticated_user_key:
      req.session.pushpasskeyAuthenticatedUserKey || ''
  });
});

app.post(
  '/pushpasskey/endpoint',
  verifySameOrigin,
  async (req, res) => {
    try {
      const action = String(req.body.action || '').trim();

      switch (action) {
        case 'regist_start':
          return await registStart(req, res);
        case 'auth_start':
          return await authStart(req, res);
        case 'regist_result':
          return await registResult(req, res);
        case 'auth_result':
          return await authResult(req, res);
        default:
          return sendJson(res, 400, {
            ok: false,
            message: '処理の指定が正しくありません。'
          });
      }
    } catch (error) {
      console.error('[Push! Passkey]', error);
      return sendJson(res, 500, {
        ok: false,
        message: 'Push! Passkeyの処理を完了できませんでした。'
      });
    }
  }
);

// ============================================================
// 本番接続時に変更する箇所 1
// ログイン中のユーザーを一意に識別する文字列を返します。
// ============================================================
function getLoggedInUserKey(req) {
  // 例:既存のログインセッションを利用する場合
  // return String(req.session.userId || '').trim();

  if (process.env.PUSHPASSKEY_TEST_MODE === 'true') {
    return String(
      process.env.PUSHPASSKEY_TEST_USER_KEY || ''
    ).trim();
  }

  return '';
}

// ============================================================
// 本番接続時に変更する箇所 2
// パスキー登録成功後の処理です。
// ============================================================
async function afterRegistSuccess(req, _result) {
  req.session.passkeyMessage =
    'パスキーの登録が完了しました。';
  // 必要に応じて登録済み状態や登録日時をDBへ保存します。
}

// ============================================================
// 本番接続時に変更する箇所 3
// パスキー認証成功後のログイン処理です。
// ============================================================
async function afterAuthSuccess(req, result) {
  const userKey = String(result.user_key || '').trim();
  if (!userKey) {
    throw new Error('認証ユーザーを確認できません。');
  }

  // 実運用ではuserKeyに一致する有効なユーザーをDBから取得し、
  // regenerate後に自社サイトのログインセッションを発行します。
  await regenerateSession(req);
  req.session.pushpasskeyAuthenticatedUserKey = userKey;
  req.session.pushpasskeyAuthenticatedAt = Date.now();
}

async function registStart(req, res) {
  const userKey = getLoggedInUserKey(req);
  if (!userKey) {
    return sendJson(res, 401, {
      ok: false,
      message: 'ログイン状態を確認できません。'
    });
  }

  const result = await callPushPasskey('/regist_url', {
    api_key: apiKey(),
    user_key: userKey,
    return_page_url: returnPageUrl(req)
  });

  if (result.ok && result.request_id) {
    savePendingRequest(
      req, String(result.request_id), 'regist', userKey
    );
  }
  return res.json(result);
}

async function authStart(req, res) {
  const result = await callPushPasskey('/auth_url', {
    api_key: apiKey(),
    return_page_url: returnPageUrl(req)
  });

  if (result.ok && result.request_id) {
    savePendingRequest(req, String(result.request_id), 'auth');
  }
  return res.json(result);
}

async function registResult(req, res) {
  const requestId = requiredRequestId(req, res);
  if (!requestId) return;

  const pending = pendingRequest(req, res, requestId, 'regist');
  if (!pending) return;

  const result = await callPushPasskey('/regist_result', {
    api_key: apiKey(), request_id: requestId
  });

  if (result.is_success) {
    if (String(result.user_key) !== String(pending.userKey)) {
      throw new Error('登録ユーザーが一致しません。');
    }
    await afterRegistSuccess(req, result);
  }

  if (result.is_completed) clearPendingRequest(req, requestId);
  return res.json(result);
}

async function authResult(req, res) {
  const requestId = requiredRequestId(req, res);
  if (!requestId) return;

  if (!pendingRequest(req, res, requestId, 'auth')) return;

  const result = await callPushPasskey('/auth_result', {
    api_key: apiKey(), request_id: requestId
  });
  const status = String(result.status || '').trim().toLowerCase();
  const success = result.is_success || status === 'success';

  if (success) await afterAuthSuccess(req, result);

  const completed =
    result.is_completed || COMPLETED_STATUSES.has(status);
  if (completed) clearPendingRequest(req, requestId);

  return res.json(result);
}

async function callPushPasskey(apiPath, payload) {
  if (!apiKey()) {
    throw new Error('Push! Passkeyの設定を確認してください。');
  }

  const controller = new AbortController();
  const timeout = setTimeout(
    () => controller.abort(),
    Number(process.env.PUSHPASSKEY_REQUEST_TIMEOUT_MS || 15000)
  );

  try {
    const response = await fetch(`${API_BASE}${apiPath}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json'
      },
      body: JSON.stringify(payload),
      signal: controller.signal,
      redirect: 'error'
    });

    const contentType = response.headers.get('content-type') || '';
    if (!contentType.includes('application/json')) {
      throw new Error(
        'Push! PasskeyからJSON以外の応答が返されました。'
      );
    }

    const result = await response.json();
    if (!response.ok) result.ok = false;
    return result;
  } finally {
    clearTimeout(timeout);
  }
}

function savePendingRequest(req, requestId, kind, userKey = '') {
  const requests = req.session.pushpasskeyRequests || {};
  requests[requestId] = {
    kind,
    userKey,
    createdAt: Date.now()
  };
  req.session.pushpasskeyRequests = requests;
}

function pendingRequest(req, res, requestId, kind) {
  const requests = req.session.pushpasskeyRequests || {};
  const data = requests[requestId];
  if (!data || data.kind !== kind) {
    sendJson(res, 400, {
      ok: false,
      message:
        '処理情報を確認できません。最初からやり直してください。'
    });
    return null;
  }
  return data;
}

function clearPendingRequest(req, requestId) {
  const requests = req.session.pushpasskeyRequests || {};
  delete requests[requestId];
  req.session.pushpasskeyRequests = requests;
}

function requiredRequestId(req, res) {
  const requestId = String(req.body.request_id || '').trim();
  if (!requestId) {
    sendJson(res, 400, {
      ok: false, message: '処理情報を確認できません。'
    });
    return '';
  }
  return requestId;
}

function returnPageUrl(req) {
  const value = String(req.body.return_page_url || '').trim();
  try {
    const url = new URL(value);
    return url.host === req.get('host') ? value : '';
  } catch {
    return '';
  }
}

function verifySameOrigin(req, res, next) {
  const origin = req.get('origin');
  if (!origin) return next();

  try {
    if (new URL(origin).host === req.get('host')) return next();
  } catch {
    // 不正なOriginは拒否します。
  }

  return sendJson(res, 403, {
    ok: false,
    message: 'このページからは処理を開始できません。'
  });
}

function regenerateSession(req) {
  return new Promise((resolve, reject) => {
    req.session.regenerate((error) => {
      if (error) reject(error);
      else resolve();
    });
  });
}

function apiKey() {
  return String(process.env.PUSHPASSKEY_API_KEY || '').trim();
}

function sendJson(res, status, data) {
  return res.status(status).json(data);
}

app.listen(Number(process.env.PORT || 3000), () => {
  console.log(`Server started on port ${process.env.PORT || 3000}`);
});

開発中は標準のMemoryStoreで動作確認できますが、本番運用には適していません。本番ではRedis、データベースなど、複数プロセス間で共有できるセッションストアを設定してください。

リバースプロキシ配下でHTTPS運用する場合、app.set('trust proxy', 1)cookie.secure=trueが正しく機能するよう、プロキシからX-Forwarded-Protoを渡してください。

5. 動作確認用ファイルを作成する

public/pushpasskey-test.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Push! Passkey動作確認</title>
  <script src="https://auth.jintec.com/js/passkey.js" defer></script>
</head>
<body>
  <h1>Push! Passkey動作確認</h1>
  <p>最初にパスキー登録を行い、続いて認証してください。</p>

  <button type="button"
    data-pushpasskey="regist"
    data-endpoint="/pushpasskey/endpoint"
    data-success-url="/pushpasskey/test">
    パスキーを登録する
  </button>

  <button type="button"
    data-pushpasskey="auth"
    data-endpoint="/pushpasskey/endpoint"
    data-success-url="/pushpasskey/test">
    パスキーで認証する
  </button>

  <div id="resultMessage" role="alert" aria-live="polite"></div>
  <p id="sessionStatus">認証状態を確認しています。</p>

  <script>
  fetch('/pushpasskey/status', {
    credentials: 'same-origin'
  })
    .then((response) => response.json())
    .then((data) => {
      document.getElementById('sessionStatus').textContent =
        data.authenticated_user_key
          ? `認証したuser_key:${data.authenticated_user_key}`
          : '認証したuser_keyはまだありません。';
    })
    .catch(() => {
      document.getElementById('sessionStatus').textContent =
        '認証状態を取得できませんでした。';
    });
  </script>
</body>
</html>

結果照会中も、data-endpointdata-success-urlを持つボタンをDOMから削除しないでください。画面上で隠す場合はhidden属性やCSSを使用します。

6. 登録と認証を動作確認する

この段階で変更するのはAPIキーとSESSION_SECRETだけです

.envへ管理サイトで発行したAPIキーとセッション秘密鍵を記載し、PUSHPASSKEY_TEST_MODE=trueのまま起動します。

npm start
  1. https://自社ドメイン/pushpasskey/testを開く
  2. 「パスキーを登録する」を押し、登録完了を確認する
  3. 動作確認画面へ戻り、「パスキーで認証する」を押す
  4. 認証後にpushpasskey_test_userが表示されることを確認する

認証開始時にはユーザー情報を送信しません。Push! Passkeyが認証に成功したCredential IDから登録時のuser_keyを特定し、認証結果として返します。

7. 自社サイトに合わせて変更する

最初にテストモードを終了する

PUSHPASSKEY_TEST_MODE=false

環境変数を変更した後は、Node.jsプロセスを再起動してください。

変更箇所1:ログイン中のユーザーを取得する

パスキー登録は、すでにログインしているユーザーに対して行います。既存セッションにユーザーIDがある場合は、次のように変更します。

function getLoggedInUserKey(req) {
  return String(req.session.userId || '').trim();
}

戻り値は必ず文字列にしてください。DBのIDが数値でもString()で文字列へ変換します。user_keyには、変更されない内部ID、会員番号、UUIDなど、自社サイトでユーザーを一意に特定できる値を使用してください。

変更箇所2:登録成功後の処理

async function afterRegistSuccess(req, _result) {
  await db.users.update({
    where: { id: req.session.userId },
    data: { passkeyRegisteredAt: new Date() }
  });
}

上記のdbは説明用です。Prisma、Sequelize、TypeORM、SQLクライアントなど、自社サイトのDB処理へ置き換えてください。登録済みフラグは、現在もCredentialが利用可能かどうかの唯一の判定材料にはしないでください。

変更箇所3:認証成功後のログイン処理

async function afterAuthSuccess(req, result) {
  const userKey = String(result.user_key || '').trim();
  if (!userKey) {
    throw new Error('認証ユーザーを確認できません。');
  }

  const user = await db.users.findUnique({
    where: { id: Number(userKey) }
  });
  if (!user || user.status !== 'active') {
    throw new Error('ログイン可能なユーザーではありません。');
  }

  await regenerateSession(req);
  req.session.userId = user.id;
}

DB検索時だけ、自社DBの型に合わせてNumber(userKey)などへ変換します。Push! Passkeyとの送受信ではuser_keyを文字列として扱ってください。退会・停止・利用権限も確認してからログインセッションを発行します。

req.session.regenerate()はセッション固定攻撃を防ぐために必要です。再生成するとそれまでのセッション値が消えるため、ログイン維持に必要な値は再生成後に設定してください。

重要事項:エンドポイントはJSONだけを返します

passkey.jsは応答全体をJSONとして読み取ります。ExpressのエンドポイントからHTML、デバッグ文字列、リバースプロキシのエラーページが返ると、「エンドポイントの応答がJSONではありません。」と表示されます。

  • res.send()res.redirect()ではなくres.json()を使用する
  • エラー調査はconsole.error()やロガーへ記録し、レスポンスへ出力しない
  • 例外を握りつぶして空の応答を返さない
  • HTMLログイン画面へ移動させる認証ミドルウェアを適用しない
console.error('[Push! Passkey]', error);

res.status(500).json({
  ok: false,
  message: 'ユーザー情報を取得できません。'
});

8. 登録・認証成功後の画面

登録ボタンのdata-success-urlには、既存の会員メニューや登録完了ページを指定できます。認証ボタンには、ログイン後のトップページを指定します。

<button type="button"
  data-pushpasskey="regist"
  data-endpoint="/pushpasskey/endpoint"
  data-success-url="/mypage/passkey">
  パスキーを登録する
</button>

<button type="button"
  data-pushpasskey="auth"
  data-endpoint="/pushpasskey/endpoint"
  data-success-url="/mypage">
  パスキーでログイン
</button>

認証成功後の移動先では、エンドポイント内ですでにログインセッションが発行されています。移動先ページにpasskey.jsを読み込む必要はありません。

9. ログイン中ユーザーのパスキー登録情報を表示する

パスキー認証が成功し、自社サイトのログインセッションを発行した後も、必要に応じて登録済みパスキーの件数、端末名、ブラウザ、OS、登録日時、最終利用日時を表示できます。この機能を使用しない場合、認証完了後にPush! Passkey APIを呼び出す必要はありません。

表示タグを設置する

登録情報を表示したいログイン後のページへ、次のタグとpasskey.jsを設置します。

<div class="my-passkey-list"
     data-pushpasskey-credentials
     data-endpoint="/pushpasskey/endpoint">
</div>
<script src="https://auth.jintec.com/js/passkey.js"></script>

表示タグを検出すると、passkey.jsはエンドポイントへcredential_statusアクションを送信します。ブラウザからuser_keyやAPIキーを送信する必要はありません。

エンドポイントへcredential_status処理を追加する

エンドポイントは、ブラウザからuser_keyを受け取らず、既存のログインセッションから対象ユーザーを特定します。そのuser_keyとサーバー側のAPIキーをPush! Passkeyの/credential_statusへ送信します。

// actionのswitchへ追加します。
case 'credential_status':
  return credentialStatus(req, res);

async function credentialStatus(req, res) {
  const userKey = getLoggedInUserKey(req);
  if (!userKey) {
    return res.status(401).json({
      ok: false, message: 'ログイン状態を確認できません。'
    });
  }

  const result = await callPushPasskey('/credential_status', {
    api_key: apiKey(),
    user_key: String(userKey)
  });
  return res.status(result.ok === false ? 400 : 200).json(result);
}

上記は既存サンプルへ追加する部分を示しています。導入先のセッション構成に合わせてgetLoggedInUserKey(req)の取得処理を変更してください。user_keyはstring型の一意な値として扱います。

passkey.jsが生成するHTML

登録済みのパスキーがある場合、表示タグの内側には概ね次のHTMLが生成されます。表示内容や件数はAPIの応答によって変わります。

<p class="pushpasskey-credentials-summary">登録済みパスキー:1件</p>
<ul class="pushpasskey-credentials-list">
  <li class="pushpasskey-credential-item">
    <strong class="pushpasskey-credential-device">端末名</strong>
    <span class="pushpasskey-credential-environment">Chrome / Windows</span>
    <span class="pushpasskey-credential-created">登録:2026/08/20 10:00</span>
    <span class="pushpasskey-credential-last-used">最終利用:2026/08/20 11:00</span>
  </li>
</ul>

表示デザインを調整する

passkey.jsが生成するclassへCSSを指定できます。導入先の他画面へ影響させないため、表示タグへ独自classを追加し、その内側だけへスタイルを適用する方法を推奨します。

.my-passkey-list .pushpasskey-credentials-summary {
  font-weight: 700;
  margin-bottom: 12px;
}
.my-passkey-list .pushpasskey-credentials-list {
  list-style: none;
  padding: 0;
}
.my-passkey-list .pushpasskey-credential-item {
  margin-bottom: 12px;
}
.my-passkey-list .pushpasskey-credential-item span {
  display: block;
}

セキュリティ上の注意

  • APIキーをHTMLやJavaScriptへ埋め込まない
  • ブラウザから送信されたuser_keyを信用しない
  • ログインセッションから対象ユーザーを特定する
  • 公開鍵本体、Credential ID、内部管理用データをブラウザへ返さない

10. エンドポイントからJSON以外の応答が返された場合

passkey.jsは、エンドポイントから返された応答をJSONとして解析します。JSONとして解析できない場合は、単に「JSONではありません」と表示するのではなく、HTTPステータス、Content-Type、原因の判定候補、サーバーから実際に返された内容を画面へ表示します。

この表示により、Node.js / Express側の例外や警告、Webサーバーのエラーページ、ログイン画面へのリダイレクト、デバッグ出力などを確認しやすくなります。

表示例

エンドポイントの応答をJSONとして解析できませんでした。

【HTTPステータス】
500 Internal Server Error

【Content-Type】
text/html; charset=UTF-8

【判定】
エンドポイントまたはWebサーバー側でエラーが発生した可能性があります。

【サーバーから返された内容】
実際にブラウザへ返されたエラー内容

主な表示内容と確認箇所

表示内容主な原因確認する場所
HTTP 404エンドポイントURLや公開パスが正しくありません。data-endpoint、ルーティング、プロキシ設定
HTTP 403Origin、CSRF、認可、WAFなどで拒否されています。セキュリティ設定、リクエストヘッダー
HTTP 405POSTルートへ到達していません。ルーティング、Webサーバー・プロキシ設定
HTTP 500以上アプリケーションまたはWebサーバー側で例外が発生した可能性があります。画面へ表示された応答内容、Node.jsまたはリバースプロキシのログ
ログイン画面やHTML共通のログイン処理や例外ハンドラーがHTMLを返しています。認証ミドルウェア、例外処理、リダイレクト設定
デバッグ文字列とJSONが一緒に表示されるデバッグ用出力がJSONの前後へ混入しています。エンドポイントと共通処理の出力
応答本文が空アプリ停止、強制終了、タイムアウト、エラー非表示などが考えられます。Node.jsまたはリバースプロキシのログ

画面へ表示される内容について

passkey.jsが表示できるのは、エンドポイントからブラウザへ実際に返された内容です。アプリケーションやWebサーバーが詳細エラーをレスポンスへ出力する設定であれば、その内容も表示されます。詳細エラーをレスポンスへ出力しない設定では、HTTP 500や空の応答だけが表示される場合があります。

エラー表示・ログ記録の設定は、導入先の開発・本番環境の運用方針に合わせて設定してください。

さらに詳しく確認する場合

  1. ブラウザのDevToolsで「Network」を開く
  2. /pushpasskey/endpointを選択する
  3. HTTPステータス、Response、Content-Typeを確認する
  4. 同じ時刻のNode.jsまたはリバースプロキシのログを確認する

問い合わせ時は、発生時刻、登録・認証・登録情報表示のどこで発生したか、HTTPステータス、画面へ表示された応答内容、必要に応じてDevToolsのResponseとログの該当部分をお知らせください。APIキー、Cookie、セッションID、個人情報は削除してから共有してください。

11. 自社サイトへの接続を確認する

  1. 自社サイトへログインし、対象ユーザーへパスキーを登録する
  2. 登録成功後に指定した会員ページへ戻る
  3. 自社サイトからログアウトし、パスキー認証を行う
  4. 認証結果のuser_keyで有効なユーザーが取得される
  5. ログインセッションが発行され、会員ページが表示される

本番公開前のチェックリスト

  • PUSHPASSKEY_TEST_MODE=falseへ変更した
  • APIキーとSESSION_SECRETをGitや公開ディレクトリへ保存していない
  • getLoggedInUserKey()が一意な文字列を返す
  • 認証結果から有効なユーザーだけをログインさせている
  • 認証成功時にセッションIDを再生成している
  • エンドポイントが常にJSONだけを返す
  • 同一オリジン検証を削除していない
  • 本番用の共有セッションストアを使用している
  • CookieにSecureHttpOnly、適切なSameSiteを設定している
  • 動作確認用ルート、HTML、状態確認APIを公開環境から削除した

APIの送受信項目を詳しく確認したい場合だけ、APIリファレンスを参照してください。