このページでは、Node.jsとExpressの新規または既存プロジェクトへサンプルコードを配置し、最初に固定のテストユーザーでパスキー登録・認証を確認します。自社サイトのユーザーDBやログイン処理へ接続するのは、その後です。
Push! Passkey APIとのJSON通信、HTTPステータス確認、結果照会、処理中のrequest_id管理、タイムアウト、エラー処理は、サンプルコードに含まれています。
このページのサンプルは、次の構成を前提としています。
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
| ファイル | 役割 |
|---|---|
.env | APIキー、セッション秘密鍵、テスト設定を保存します。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で指定したページへ移動します。
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などの機能で環境変数を設定してください。
"scripts": {
"start": "node 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を渡してください。
<!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-endpointとdata-success-urlを持つボタンをDOMから削除しないでください。画面上で隠す場合はhidden属性やCSSを使用します。
.envへ管理サイトで発行したAPIキーとセッション秘密鍵を記載し、PUSHPASSKEY_TEST_MODE=trueのまま起動します。
npm start
https://自社ドメイン/pushpasskey/testを開くpushpasskey_test_userが表示されることを確認する認証開始時にはユーザー情報を送信しません。Push! Passkeyが認証に成功したCredential IDから登録時のuser_keyを特定し、認証結果として返します。
PUSHPASSKEY_TEST_MODE=false
環境変数を変更した後は、Node.jsプロセスを再起動してください。
パスキー登録は、すでにログインしているユーザーに対して行います。既存セッションにユーザーIDがある場合は、次のように変更します。
function getLoggedInUserKey(req) {
return String(req.session.userId || '').trim();
}
戻り値は必ず文字列にしてください。DBのIDが数値でもString()で文字列へ変換します。user_keyには、変更されない内部ID、会員番号、UUIDなど、自社サイトでユーザーを一意に特定できる値を使用してください。
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が利用可能かどうかの唯一の判定材料にはしないでください。
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()はセッション固定攻撃を防ぐために必要です。再生成するとそれまでのセッション値が消えるため、ログイン維持に必要な値は再生成後に設定してください。
passkey.jsは応答全体をJSONとして読み取ります。ExpressのエンドポイントからHTML、デバッグ文字列、リバースプロキシのエラーページが返ると、「エンドポイントの応答がJSONではありません。」と表示されます。
res.send()やres.redirect()ではなくres.json()を使用するconsole.error()やロガーへ記録し、レスポンスへ出力しないconsole.error('[Push! Passkey]', error);
res.status(500).json({
ok: false,
message: 'ユーザー情報を取得できません。'
});
登録ボタンの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を読み込む必要はありません。
パスキー認証が成功し、自社サイトのログインセッションを発行した後も、必要に応じて登録済みパスキーの件数、端末名、ブラウザ、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キーを送信する必要はありません。
エンドポイントは、ブラウザから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型の一意な値として扱います。
登録済みのパスキーがある場合、表示タグの内側には概ね次の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;
}
user_keyを信用しない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 403 | Origin、CSRF、認可、WAFなどで拒否されています。 | セキュリティ設定、リクエストヘッダー |
| HTTP 405 | POSTルートへ到達していません。 | ルーティング、Webサーバー・プロキシ設定 |
| HTTP 500以上 | アプリケーションまたはWebサーバー側で例外が発生した可能性があります。 | 画面へ表示された応答内容、Node.jsまたはリバースプロキシのログ |
| ログイン画面やHTML | 共通のログイン処理や例外ハンドラーがHTMLを返しています。 | 認証ミドルウェア、例外処理、リダイレクト設定 |
| デバッグ文字列とJSONが一緒に表示される | デバッグ用出力がJSONの前後へ混入しています。 | エンドポイントと共通処理の出力 |
| 応答本文が空 | アプリ停止、強制終了、タイムアウト、エラー非表示などが考えられます。 | Node.jsまたはリバースプロキシのログ |
passkey.jsが表示できるのは、エンドポイントからブラウザへ実際に返された内容です。アプリケーションやWebサーバーが詳細エラーをレスポンスへ出力する設定であれば、その内容も表示されます。詳細エラーをレスポンスへ出力しない設定では、HTTP 500や空の応答だけが表示される場合があります。
エラー表示・ログ記録の設定は、導入先の開発・本番環境の運用方針に合わせて設定してください。
/pushpasskey/endpointを選択する問い合わせ時は、発生時刻、登録・認証・登録情報表示のどこで発生したか、HTTPステータス、画面へ表示された応答内容、必要に応じてDevToolsのResponseとログの該当部分をお知らせください。APIキー、Cookie、セッションID、個人情報は削除してから共有してください。
user_keyで有効なユーザーが取得されるPUSHPASSKEY_TEST_MODE=falseへ変更したSESSION_SECRETをGitや公開ディレクトリへ保存していないgetLoggedInUserKey()が一意な文字列を返すSecure、HttpOnly、適切なSameSiteを設定しているAPIの送受信項目を詳しく確認したい場合だけ、APIリファレンスを参照してください。