このページでは、PythonとFastAPIの新規または既存プロジェクトへサンプルコードを配置し、最初に固定のテストユーザーでパスキー登録・認証を確認します。自社サイトのユーザーDBやログイン処理へ接続するのは、その後です。
Push! Passkey APIとのJSON通信、HTTPステータス確認、結果照会、処理中のrequest_id管理、タイムアウト、エラー処理はサンプルコードに含まれています。
PythonのWebフレームワークにはDjango、Flask、FastAPIがあります。本ページは、JSON APIを短い構成で実装できるFastAPIを標準例として採用しています。既存サイトがDjangoまたはFlaskで構築されている場合、フレームワークを変更する必要はありません。APIへの送信項目と処理順序は共通ですが、ルーティング、セッション、JSON応答、CSRF対策を各フレームワークの仕組みに置き換えてください。
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
| ファイル | 役割 |
|---|---|
.env | APIキー、セッション秘密鍵、テスト設定を保存します。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で指定したページへ移動します。
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には十分に長いランダム値を使用します。
fastapi>=0.110 uvicorn[standard]>=0.27 httpx>=0.27 python-dotenv>=1.0 itsdangerous>=2.1
次のコードを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ヘッダーを受け入れる設定にしてください。
<!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を使用します。
uvicorn app.main:app --reload --port 8000
https://自社ドメイン/pushpasskey/testを開くpushpasskey_test_userが表示されることを確認する認証開始時にはユーザー情報を送信しません。Push! Passkeyが認証に成功したCredential IDから、登録時のuser_keyを特定して認証結果として返します。
PUSHPASSKEY_TEST_MODE=false
環境変数を変更した後は、Uvicornのプロセスを再起動してください。
def get_logged_in_user_key(request: Request) -> str:
return str(request.session.get("user_id", "")).strip()
戻り値は必ずstr型にしてください。DBのIDが整数でもstr()で文字列へ変換します。型注釈を-> strと書くだけでは実行時に自動変換されないため、必ず値そのものを変換してください。
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処理へ置き換えてください。
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を文字列として扱ってください。退会・停止・利用権限も確認してからログインセッションを発行します。
passkey.jsは応答全体をJSONとして読み取ります。FastAPIのエンドポイントからHTML、デバッグ文字列、プロキシのエラーページが返ると、「エンドポイントの応答がJSONではありません。」と表示されます。
JSONResponseまたは辞書を返し、HTMLResponseやリダイレクトを返さないlogger.exception()へ記録し、レスポンスへ出力しない登録ボタンには会員メニューや登録完了ページ、認証ボタンにはログイン後のトップページを指定します。
<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へ送信します。
# 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型の一意な値として扱います。
登録済みのパスキーがある場合、表示タグの内側には概ね次の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、原因の判定候補、サーバーから実際に返された内容を画面へ表示します。
この表示により、Python / FastAPI側の例外や警告、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サーバー側で例外が発生した可能性があります。 | 画面へ表示された応答内容、Uvicorn・FastAPIまたはリバースプロキシのログ |
| ログイン画面やHTML | 共通のログイン処理や例外ハンドラーがHTMLを返しています。 | 認証ミドルウェア、例外処理、リダイレクト設定 |
| デバッグ文字列とJSONが一緒に表示される | デバッグ用出力がJSONの前後へ混入しています。 | エンドポイントと共通処理の出力 |
| 応答本文が空 | アプリ停止、強制終了、タイムアウト、エラー非表示などが考えられます。 | Uvicorn・FastAPIまたはリバースプロキシのログ |
passkey.jsが表示できるのは、エンドポイントからブラウザへ実際に返された内容です。アプリケーションやWebサーバーが詳細エラーをレスポンスへ出力する設定であれば、その内容も表示されます。詳細エラーをレスポンスへ出力しない設定では、HTTP 500や空の応答だけが表示される場合があります。
エラー表示・ログ記録の設定は、導入先の開発・本番環境の運用方針に合わせて設定してください。
/pushpasskey/endpointを選択する問い合わせ時は、発生時刻、登録・認証・登録情報表示のどこで発生したか、HTTPステータス、画面へ表示された応答内容、必要に応じてDevToolsのResponseとログの該当部分をお知らせください。APIキー、Cookie、セッションID、個人情報は削除してから共有してください。
user_keyで有効なユーザーが取得されるPUSHPASSKEY_TEST_MODE=falseへ変更したSESSION_SECRETをGitや公開ディレクトリへ保存していないget_logged_in_user_key()が一意なstr型を返すSecure、HttpOnly、適切なSameSiteを設定しているAPIの送受信項目を詳しく確認したい場合だけ、APIリファレンスを参照してください。