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

Python(FastAPI)での実装

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

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

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

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

Django・Flaskを使用している場合

PythonのWebフレームワークにはDjango、Flask、FastAPIがあります。本ページは、JSON APIを短い構成で実装できるFastAPIを標準例として採用しています。既存サイトがDjangoまたはFlaskで構築されている場合、フレームワークを変更する必要はありません。APIへの送信項目と処理順序は共通ですが、ルーティング、セッション、JSON応答、CSRF対策を各フレームワークの仕組みに置き換えてください。

1. 実行環境を確認する

  • Python 3.11以降
  • FastAPI 0.110以降
  • Uvicorn
  • httpx
  • HTTPSで公開されているWebサイト
  • サーバー側で管理するログインセッション
python --version
pip --version

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

mkdir pushpasskey-sample
cd pushpasskey-sample
python -m venv .venv

# Windows
.venv\Scripts\activate

# macOS / Linux
source .venv/bin/activate

pip install fastapi "uvicorn[standard]" httpx python-dotenv itsdangerous

2. 基本ファイル構成

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

pushpasskey-sample/
├── .env
├── requirements.txt
└── app/
    ├── __init__.py
    ├── main.py
    └── static/
        └── pushpasskey-test.html

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

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

.envを作成する

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

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

.env
.env.*
.venv/
__pycache__/

本番環境では、ホスティング、コンテナ、systemdなどの機能で環境変数を設定してください。SESSION_SECRETには十分に長いランダム値を使用します。

requirements.txtを作成する

fastapi>=0.110
uvicorn[standard]>=0.27
httpx>=0.27
python-dotenv>=1.0
itsdangerous>=2.1

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

app/main.py

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

import logging
import os
from typing import Any
from urllib.parse import urlparse

import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware

load_dotenv()

API_BASE = "https://auth.jintec.com"
COMPLETED_STATUSES = {"success", "failed", "expired", "cancelled"}
logger = logging.getLogger("pushpasskey")

app = FastAPI(docs_url=None, redoc_url=None)
app.add_middleware(
    SessionMiddleware,
    secret_key=os.environ["SESSION_SECRET"],
    session_cookie="pushpasskey.sid",
    same_site="lax",
    https_only=os.getenv("ENVIRONMENT") == "production",
    max_age=30 * 60,
)


@app.get("/pushpasskey/test", include_in_schema=False)
async def test_page() -> FileResponse:
    return FileResponse("app/static/pushpasskey-test.html")


@app.get("/pushpasskey/status", include_in_schema=False)
async def status(request: Request) -> JSONResponse:
    return JSONResponse({
        "ok": True,
        "authenticated_user_key": request.session.get(
            "pushpasskey_authenticated_user_key", ""
        ),
    })


@app.post("/pushpasskey/endpoint", include_in_schema=False)
async def endpoint(request: Request) -> JSONResponse:
    try:
        verify_same_origin(request)
        body = await request.json()
        action = str(body.get("action", "")).strip()

        if action == "regist_start":
            return await regist_start(request, body)
        if action == "auth_start":
            return await auth_start(request, body)
        if action == "regist_result":
            return await regist_result(request, body)
        if action == "auth_result":
            return await auth_result(request, body)

        return json_error(400, "処理の指定が正しくありません。")
    except ValueError as exc:
        return json_error(400, str(exc))
    except PermissionError as exc:
        return json_error(403, str(exc))
    except (httpx.TimeoutException, httpx.NetworkError):
        logger.exception("Push! Passkey API connection error")
        return json_error(502, "Push! Passkey APIへ接続できませんでした。")
    except Exception:
        logger.exception("Push! Passkey endpoint error")
        return json_error(500, "Push! Passkeyの処理を完了できませんでした。")


# ============================================================
# 本番接続時に変更する箇所 1
# ログイン中のユーザーを一意に識別する文字列を返します。
# ============================================================
def get_logged_in_user_key(request: Request) -> str:
    # 例:return str(request.session.get("user_id", "")).strip()
    if os.getenv("PUSHPASSKEY_TEST_MODE") == "true":
        return str(os.getenv("PUSHPASSKEY_TEST_USER_KEY", "")).strip()
    return ""


# ============================================================
# 本番接続時に変更する箇所 2
# パスキー登録成功後の処理です。
# ============================================================
async def after_regist_success(
    request: Request, result: dict[str, Any]
) -> None:
    request.session["passkey_message"] = "パスキーの登録が完了しました。"
    # 必要に応じて登録済み状態や登録日時をDBへ保存します。


# ============================================================
# 本番接続時に変更する箇所 3
# パスキー認証成功後のログイン処理です。
# ============================================================
async def after_auth_success(
    request: Request, result: dict[str, Any]
) -> None:
    user_key = str(result.get("user_key", "")).strip()
    if not user_key:
        raise RuntimeError("認証ユーザーを確認できません。")

    # 実運用ではuser_keyに一致する有効なユーザーをDBから取得し、
    # 自社サイトのログインセッションを発行します。
    request.session.clear()
    request.session["pushpasskey_authenticated_user_key"] = user_key


async def regist_start(
    request: Request, body: dict[str, Any]
) -> JSONResponse:
    user_key = get_logged_in_user_key(request)
    if not user_key:
        return json_error(401, "ログイン状態を確認できません。")

    result = await call_pushpasskey("/regist_url", {
        "api_key": api_key(),
        "user_key": user_key,
        "return_page_url": return_page_url(request, body),
    })
    if result.get("ok") and result.get("request_id"):
        save_pending_request(
            request, str(result["request_id"]), "regist", user_key
        )
    return JSONResponse(result)


async def auth_start(
    request: Request, body: dict[str, Any]
) -> JSONResponse:
    result = await call_pushpasskey("/auth_url", {
        "api_key": api_key(),
        "return_page_url": return_page_url(request, body),
    })
    if result.get("ok") and result.get("request_id"):
        save_pending_request(request, str(result["request_id"]), "auth")
    return JSONResponse(result)


async def regist_result(
    request: Request, body: dict[str, Any]
) -> JSONResponse:
    request_id = required_request_id(body)
    pending = pending_request(request, request_id, "regist")
    result = await call_pushpasskey("/regist_result", {
        "api_key": api_key(), "request_id": request_id
    })
    if result.get("is_success"):
        if str(result.get("user_key", "")) != str(pending["user_key"]):
            raise RuntimeError("登録ユーザーが一致しません。")
        await after_regist_success(request, result)
    if result.get("is_completed"):
        clear_pending_request(request, request_id)
    return JSONResponse(result)


async def auth_result(
    request: Request, body: dict[str, Any]
) -> JSONResponse:
    request_id = required_request_id(body)
    pending_request(request, request_id, "auth")
    result = await call_pushpasskey("/auth_result", {
        "api_key": api_key(), "request_id": request_id
    })
    status_value = str(result.get("status", "")).strip().lower()
    success = bool(result.get("is_success")) or status_value == "success"
    if success:
        await after_auth_success(request, result)
    if result.get("is_completed") or status_value in COMPLETED_STATUSES:
        clear_pending_request(request, request_id)
    return JSONResponse(result)


async def call_pushpasskey(
    api_path: str, payload: dict[str, Any]
) -> dict[str, Any]:
    if not api_key():
        raise RuntimeError("Push! Passkeyの設定を確認してください。")
    timeout = float(os.getenv("PUSHPASSKEY_REQUEST_TIMEOUT_SECONDS", "15"))
    async with httpx.AsyncClient(
        timeout=timeout, follow_redirects=False
    ) as client:
        response = await client.post(
            f"{API_BASE}{api_path}",
            json=payload,
            headers={"Accept": "application/json"},
        )
    content_type = response.headers.get("content-type", "")
    if "application/json" not in content_type.lower():
        raise RuntimeError("Push! PasskeyからJSON以外の応答が返されました。")
    result = response.json()
    if not isinstance(result, dict):
        raise RuntimeError("Push! Passkeyの応答形式が正しくありません。")
    if not response.is_success:
        result["ok"] = False
    return result


def save_pending_request(
    request: Request, request_id: str, kind: str, user_key: str = ""
) -> None:
    requests = dict(request.session.get("pushpasskey_requests", {}))
    requests[request_id] = {"kind": kind, "user_key": user_key}
    request.session["pushpasskey_requests"] = requests


def pending_request(
    request: Request, request_id: str, kind: str
) -> dict[str, Any]:
    data = request.session.get("pushpasskey_requests", {}).get(request_id)
    if not data or data.get("kind") != kind:
        raise ValueError("処理情報を確認できません。最初からやり直してください。")
    return data


def clear_pending_request(request: Request, request_id: str) -> None:
    requests = dict(request.session.get("pushpasskey_requests", {}))
    requests.pop(request_id, None)
    request.session["pushpasskey_requests"] = requests


def required_request_id(body: dict[str, Any]) -> str:
    request_id = str(body.get("request_id", "")).strip()
    if not request_id:
        raise ValueError("処理情報を確認できません。")
    return request_id


def return_page_url(request: Request, body: dict[str, Any]) -> str:
    value = str(body.get("return_page_url", "")).strip()
    parsed = urlparse(value)
    if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
        return ""
    return value


def verify_same_origin(request: Request) -> None:
    origin = request.headers.get("origin")
    if origin and urlparse(origin).netloc != request.url.netloc:
        raise PermissionError("このページからは処理を開始できません。")


def api_key() -> str:
    return str(os.getenv("PUSHPASSKEY_API_KEY", "")).strip()


def json_error(status_code: int, message: str) -> JSONResponse:
    return JSONResponse(
        {"ok": False, "message": message}, status_code=status_code
    )

このサンプルはStarletteのCookieセッションを使用します。セッション内容は署名されますが暗号化されないため、APIキーや個人情報を保存しないでください。本番でセッションに多くの情報を保持する場合や、即時失効・複数台構成が必要な場合は、RedisやDBを使用するサーバー側セッションへ置き換えてください。

リバースプロキシ配下では、公開HostとスキームがFastAPIへ正しく伝わるよう、信頼できるプロキシだけからForwardedヘッダーを受け入れる設定にしてください。

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

app/static/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だけです

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

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

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

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

PUSHPASSKEY_TEST_MODE=false

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

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

def get_logged_in_user_key(request: Request) -> str:
    return str(request.session.get("user_id", "")).strip()

戻り値は必ずstr型にしてください。DBのIDが整数でもstr()で文字列へ変換します。型注釈を-> strと書くだけでは実行時に自動変換されないため、必ず値そのものを変換してください。

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

async def after_regist_success(request: Request, result: dict) -> None:
    user_id = request.session.get("user_id")
    await users.update_passkey_registered_at(user_id)

usersは説明用です。SQLAlchemy、Django ORM、SQLModelなど、自社サイトのDB処理へ置き換えてください。

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

async def after_auth_success(request: Request, result: dict) -> None:
    user_key = str(result.get("user_key", "")).strip()
    if not user_key:
        raise RuntimeError("認証ユーザーを確認できません。")

    user = await users.find_active_by_id(int(user_key))
    if user is None:
        raise RuntimeError("ログイン可能なユーザーではありません。")

    request.session.clear()
    request.session["user_id"] = user.id

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

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

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

  • JSONResponseまたは辞書を返し、HTMLResponseやリダイレクトを返さない
  • 調査情報はlogger.exception()へ記録し、レスポンスへ出力しない
  • 例外ハンドラーもJSONを返す
  • HTMLログイン画面へ移動させる認証処理をエンドポイントへ適用しない

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

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

<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へ送信します。

# endpoint() のaction分岐へ追加します。
if action == "credential_status":
    return await credential_status(request)

async def credential_status(request: Request) -> JSONResponse:
    user_key = get_logged_in_user_key(request)
    if not user_key:
        return JSONResponse(
            {"ok": False, "message": "ログイン状態を確認できません。"},
            status_code=401,
        )

    result = await call_pushpasskey("/credential_status", {
        "api_key": api_key(),
        "user_key": str(user_key),
    })
    return JSONResponse(result)

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

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、原因の判定候補、サーバーから実際に返された内容を画面へ表示します。

この表示により、Python / FastAPI側の例外や警告、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サーバー側で例外が発生した可能性があります。画面へ表示された応答内容、Uvicorn・FastAPIまたはリバースプロキシのログ
ログイン画面やHTML共通のログイン処理や例外ハンドラーがHTMLを返しています。認証ミドルウェア、例外処理、リダイレクト設定
デバッグ文字列とJSONが一緒に表示されるデバッグ用出力がJSONの前後へ混入しています。エンドポイントと共通処理の出力
応答本文が空アプリ停止、強制終了、タイムアウト、エラー非表示などが考えられます。Uvicorn・FastAPIまたはリバースプロキシのログ

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

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

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

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

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

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

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

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

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

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

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