このページでは、JavaとSpring Bootを使用してPush! Passkeyを実装します。最初は自社DBや既存の認証処理へ接続せず、APIキーと固定のテストユーザーだけで登録・認証を確認します。
動作確認後に、ログイン中のユーザー取得、登録完了処理、認証成功後のログイン処理の3か所を自社サイトに合わせて変更します。
JavaのWeb開発にはJakarta EE、Quarkus、Micronautなどもありますが、一般的な業務WebアプリではSpring Bootが広く利用されています。同じページに複数フレームワークの完成コードを載せるとコピー対象が分かりにくくなるため、本ページはSpring MVC方式のSpring Bootに絞ります。
既存サイトが別のJavaフレームワークで構築されている場合、変更する必要はありません。APIへの送信項目と処理順序は共通です。ルーティング、セッション、JSON応答、CSRF対策を利用中のフレームワークへ置き換えてください。
このサンプルはJava 21、Spring Boot 3系、Mavenを想定しています。Spring MVCを使用し、Push! Passkey APIへの同期通信にはSpring FrameworkのRestClientを使用します。
java -version mvn -version
Spring Initializrで次の依存関係を選択するか、既存プロジェクトのpom.xmlへ追加してください。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
Push! Passkey管理サイトで、テストに使用するWebサイトのドメインを登録し、APIキーを発行しておいてください。
src/main/
├── java/com/example/pushpasskey/
│ ├── PushPasskeyApplication.java
│ ├── PushPasskeyController.java
│ └── SecurityConfig.java
└── resources/
├── static/
│ └── pushpasskey-test.html
└── application.yml
最初の接続試験では、処理の流れを追いやすくするため、Push! Passkeyとの通信処理を1つのControllerへまとめます。本番ではプロジェクトの規約に合わせてController、Service、DTO、設定クラスへ分割できます。
pushpasskey:
api-key: ${PUSHPASSKEY_API_KEY}
test-mode: ${PUSHPASSKEY_TEST_MODE:true}
test-user-key: ${PUSHPASSKEY_TEST_USER_KEY:test-user-001}
request-timeout-seconds: ${PUSHPASSKEY_REQUEST_TIMEOUT_SECONDS:15}
server:
servlet:
session:
timeout: 30m
cookie:
http-only: true
secure: true
same-site: laxPUSHPASSKEY_API_KEY=発行されたAPIキー PUSHPASSKEY_TEST_MODE=true PUSHPASSKEY_TEST_USER_KEY=test-user-001
APIキーをapplication.ymlやJavaソースへ直接記載しないでください。IDEの実行設定、OSの環境変数、コンテナのSecretなどから渡します。
ローカルHTTP環境だけで試験する場合はCookieのsecureを一時的にfalseへ変更できます。本番は必ずHTTPSとtrueを使用してください。
パッケージ名は自社プロジェクトに合わせて変更してください。最初の接続試験では、それ以外の処理を書き換える必要はありません。
package com.example.pushpasskey;
import java.net.URI;
import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
@RequestMapping("/pushpasskey")
public class PushPasskeyController {
private static final Logger log = LoggerFactory.getLogger(
PushPasskeyController.class
);
private static final String API_BASE = "https://auth.jintec.com";
private final String apiKey;
private final boolean testMode;
private final String testUserKey;
private final RestClient restClient;
private final ObjectMapper objectMapper;
public PushPasskeyController(
@Value("${pushpasskey.api-key}") String apiKey,
@Value("${pushpasskey.test-mode:true}") boolean testMode,
@Value("${pushpasskey.test-user-key:test-user-001}") String testUserKey,
@Value("${pushpasskey.request-timeout-seconds:15}") long timeoutSeconds,
ObjectMapper objectMapper
) {
this.apiKey = apiKey.trim();
this.testMode = testMode;
this.testUserKey = testUserKey.trim();
this.objectMapper = objectMapper;
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofSeconds(timeoutSeconds));
this.restClient = RestClient.builder()
.requestFactory(factory)
.baseUrl(API_BASE)
.build();
}
@GetMapping("/status")
public Map<String, Object> status(HttpSession session) {
return Map.of(
"ok", true,
"authenticated_user_key",
stringValue(session.getAttribute("authenticated_user_key"))
);
}
@PostMapping(
value = "/endpoint",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Map<String, Object>> endpoint(
@RequestBody Map<String, Object> body,
HttpServletRequest request,
HttpSession session
) {
try {
verifySameOrigin(request);
String action = stringValue(body.get("action"));
return switch (action) {
case "regist_start" -> registStart(body, request, session);
case "auth_start" -> authStart(body, request, session);
case "regist_result" -> registResult(body, session);
case "auth_result" -> authResult(body, session);
default -> jsonError(400, "処理の指定が正しくありません。");
};
} catch (IllegalArgumentException e) {
return jsonError(400, e.getMessage());
} catch (SecurityException e) {
return jsonError(403, e.getMessage());
} catch (Exception e) {
log.error("Push! Passkey endpoint error", e);
return jsonError(500, "Push! Passkeyの処理を完了できませんでした。");
}
}
// 本番接続時に変更する箇所1
private String getLoggedInUserKey(HttpSession session) {
if (testMode) {
return testUserKey;
}
Object userId = session.getAttribute("user_id");
return userId == null ? "" : String.valueOf(userId).trim();
}
// 本番接続時に変更する箇所2
private void afterRegistSuccess(
Map<String, Object> result,
HttpSession session
) {
session.setAttribute("passkey_message", "パスキーの登録が完了しました。");
}
// 本番接続時に変更する箇所3
private void afterAuthSuccess(
Map<String, Object> result,
HttpServletRequest request
) {
String userKey = stringValue(result.get("user_key"));
if (userKey.isBlank()) {
throw new IllegalStateException("認証ユーザーを確認できません。");
}
HttpSession oldSession = request.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
HttpSession newSession = request.getSession(true);
newSession.setAttribute("authenticated_user_key", userKey);
}
private ResponseEntity<Map<String, Object>> registStart(
Map<String, Object> body,
HttpServletRequest request,
HttpSession session
) {
String userKey = getLoggedInUserKey(session);
if (userKey.isBlank()) {
return jsonError(401, "ログイン状態を確認できません。");
}
Map<String, Object> result = callApi("/regist_url", Map.of(
"api_key", apiKey,
"user_key", userKey,
"return_page_url", returnPageUrl(body, request)
));
savePending(result, "regist", userKey, session);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> authStart(
Map<String, Object> body,
HttpServletRequest request,
HttpSession session
) {
Map<String, Object> result = callApi("/auth_url", Map.of(
"api_key", apiKey,
"return_page_url", returnPageUrl(body, request)
));
savePending(result, "auth", "", session);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> registResult(
Map<String, Object> body,
HttpSession session
) {
String requestId = requiredRequestId(body);
Map<String, String> pending = pending(requestId, "regist", session);
Map<String, Object> result = callApi("/regist_result", Map.of(
"api_key", apiKey,
"request_id", requestId
));
if (booleanValue(result.get("is_success"))) {
if (!stringValue(result.get("user_key")).equals(pending.get("user_key"))) {
throw new IllegalStateException("登録ユーザーが一致しません。");
}
afterRegistSuccess(result, session);
}
if (booleanValue(result.get("is_completed"))) {
clearPending(requestId, session);
}
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> authResult(
Map<String, Object> body,
HttpServletRequest request,
HttpSession session
) {
String requestId = requiredRequestId(body);
pending(requestId, "auth", session);
Map<String, Object> result = callApi("/auth_result", Map.of(
"api_key", apiKey,
"request_id", requestId
));
String status = stringValue(result.get("status")).toLowerCase();
boolean success = booleanValue(result.get("is_success"))
|| "success".equals(status);
boolean completed = booleanValue(result.get("is_completed"))
|| java.util.Set.of("success", "failed", "expired", "cancelled")
.contains(status);
if (success) {
clearPending(requestId, session);
afterAuthSuccess(result, request);
} else if (completed) {
clearPending(requestId, session);
}
return ResponseEntity.ok(result);
}
private Map<String, Object> callApi(String path, Map<String, Object> payload) {
if (apiKey.isBlank()) {
throw new IllegalStateException("Push! Passkeyの設定を確認してください。");
}
ResponseEntity<String> response = restClient.post()
.uri(path)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.onStatus(HttpStatusCode::isError, (req, res) -> { })
.toEntity(String.class);
try {
Map<String, Object> result = objectMapper.readValue(
response.getBody(), new TypeReference<>() { }
);
if (!response.getStatusCode().is2xxSuccessful()) {
result.put("ok", false);
}
return result;
} catch (Exception e) {
throw new IllegalStateException(
"Push! PasskeyからJSON以外の応答が返されました。", e
);
}
}
@SuppressWarnings("unchecked")
private void savePending(
Map<String, Object> result,
String kind,
String userKey,
HttpSession session
) {
String requestId = stringValue(result.get("request_id"));
if (!booleanValue(result.get("ok")) || requestId.isBlank()) {
return;
}
Map<String, Map<String, String>> requests = (Map<String, Map<String, String>>)
session.getAttribute("pushpasskey_requests");
requests = requests == null ? new HashMap<>() : new HashMap<>(requests);
requests.put(requestId, Map.of("kind", kind, "user_key", userKey));
session.setAttribute("pushpasskey_requests", requests);
}
@SuppressWarnings("unchecked")
private Map<String, String> pending(
String requestId,
String kind,
HttpSession session
) {
Map<String, Map<String, String>> requests = (Map<String, Map<String, String>>)
session.getAttribute("pushpasskey_requests");
Map<String, String> value = requests == null ? null : requests.get(requestId);
if (value == null || !kind.equals(value.get("kind"))) {
throw new IllegalArgumentException(
"処理情報を確認できません。最初からやり直してください。"
);
}
return value;
}
@SuppressWarnings("unchecked")
private void clearPending(String requestId, HttpSession session) {
Map<String, Map<String, String>> requests = (Map<String, Map<String, String>>)
session.getAttribute("pushpasskey_requests");
if (requests != null) {
Map<String, Map<String, String>> copy = new HashMap<>(requests);
copy.remove(requestId);
session.setAttribute("pushpasskey_requests", copy);
}
}
private String requiredRequestId(Map<String, Object> body) {
String value = stringValue(body.get("request_id"));
if (value.isBlank()) {
throw new IllegalArgumentException("処理情報を確認できません。");
}
return value;
}
private String returnPageUrl(
Map<String, Object> body,
HttpServletRequest request
) {
String value = stringValue(body.get("return_page_url"));
try {
URI uri = URI.create(value);
return request.getServerName().equalsIgnoreCase(uri.getHost())
? value : "";
} catch (Exception e) {
return "";
}
}
private void verifySameOrigin(HttpServletRequest request) {
String origin = request.getHeader("Origin");
if (origin == null || origin.isBlank()) {
return;
}
URI uri = URI.create(origin);
if (!request.getServerName().equalsIgnoreCase(uri.getHost())) {
throw new SecurityException("このページからは処理を開始できません。");
}
}
private static String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private static boolean booleanValue(Object value) {
return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(stringValue(value));
}
private ResponseEntity<Map<String, Object>> jsonError(
int status,
String message
) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("ok", false);
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}認証開始時にはuser_keyを送信しません。Push! Passkeyは認証に成功したCredential IDから登録済みのuser_keyを特定し、認証結果として返します。
package com.example.pushpasskey;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/pushpasskey/endpoint",
"/pushpasskey/status",
"/pushpasskey-test.html"
).permitAll()
.anyRequest().permitAll()
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/pushpasskey/endpoint")
);
return http.build();
}
}Spring SecurityはPOSTに対するCSRF保護を標準で有効にします。サンプルでは外部配信のpasskey.jsからJSONを送るため、このエンドポイントだけ標準CSRF検証の対象外にし、ControllerでOriginを照合します。
既存サイトでCSRFトークンをpasskey.jsの送信へ組み込める場合は、標準CSRF保護を維持する方式が優先です。サイト全体のCSRF保護は無効にしないでください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Push! Passkey Javaテスト</title>
</head>
<body>
<h1>Push! Passkey Javaテスト</h1>
<p id="pushpasskey-message"></p>
<button type="button"
data-pushpasskey="regist"
data-endpoint="/pushpasskey/endpoint"
data-success-url="/pushpasskey-test.html">
パスキー登録
</button>
<button type="button"
data-pushpasskey="auth"
data-endpoint="/pushpasskey/endpoint"
data-success-url="/pushpasskey-test.html">
パスキー認証
</button>
<pre id="status"></pre>
<script src="https://auth.jintec.com/js/passkey.js"></script>
<script>
fetch('/pushpasskey/status')
.then(response => response.json())
.then(data => {
document.getElementById('status').textContent =
data.authenticated_user_key
? '認証したuser_key:' + data.authenticated_user_key
: '認証したuser_keyはまだありません。';
});
</script>
</body>
</html>結果照会中も、data-pushpasskey、data-endpoint、data-success-urlを持つボタンをDOMから削除しないでください。非表示にする場合はCSSまたはhidden属性を使用します。
mvn spring-boot:run
ブラウザでhttps://登録済みドメイン/pushpasskey-test.htmlを開きます。この段階で変更するのはAPIキーと、必要に応じてテスト用user_keyだけです。
test-user-001が表示されることを確認する認証成功後の移動先には、通常passkey.jsは不要です。結果照会とログインセッションの発行は移動前に完了しています。テストページでは登録と認証を同じ画面で繰り返すため、スクリプトを配置しています。
PUSHPASSKEY_TEST_MODE=false
private String getLoggedInUserKey(HttpSession session) {
Object userId = session.getAttribute("user_id");
return userId == null ? "" : String.valueOf(userId).trim();
}
user_keyは、元のDB列が数値型でもPush! Passkeyとの送受信では必ず文字列として扱います。JavaのLongやIntegerをそのままJSONへ渡さず、String.valueOf()で変換してください。DB検索時だけ自社DBの型へ戻します。
afterRegistSuccess()で登録済みフラグ、登録日時、監査ログなどを保存します。認証の正本となるCredential情報はPush! Passkey側で管理されるため、レスポンス全体を自社DBへ複製する必要はありません。
private void afterAuthSuccess(
Map<String, Object> result,
HttpServletRequest request
) {
String userKey = stringValue(result.get("user_key"));
if (userKey.isBlank()) {
throw new IllegalStateException("認証ユーザーを確認できません。");
}
// DB検索時だけ、自社DBの型へ変換します。
long userId = Long.parseLong(userKey);
User user = userService.findActiveUser(userId);
if (user == null) {
throw new IllegalStateException("有効なユーザーを確認できません。");
}
request.changeSessionId();
request.getSession().setAttribute("user_id", user.getId());
}
認証結果のuser_keyで自社DBの有効なユーザーを取得し、停止状態、退会状態、利用権限も確認してからログインを確立します。Spring Securityを利用している場合は、独自セッション値だけでなくSecurityContextを正しく作成・保存してください。
passkey.jsは応答全体をJSONとして読み取ります。ControllerからHTML、リダイレクト、デバッグ文字列が返ると、「エンドポイントの応答がJSONではありません。」と表示されます。
@RestControllerまたは@ResponseBodyを使用するパスキー認証が成功し、自社サイトのログインセッションを発行した後も、必要に応じて登録済みパスキーの件数、端末名、ブラウザ、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() のswitchへ追加します。 case "credential_status" -> credentialStatus(session); private ResponseEntity
上記は既存サンプルへ追加する部分を示しています。導入先のセッション構成に合わせてloggedInUserKey(...)の取得処理を変更してください。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、原因の判定候補、サーバーから実際に返された内容を画面へ表示します。
この表示により、Java / Spring Boot側の例外や警告、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サーバー側で例外が発生した可能性があります。 | 画面へ表示された応答内容、Spring Bootまたはリバースプロキシのログ |
| ログイン画面やHTML | 共通のログイン処理や例外ハンドラーがHTMLを返しています。 | 認証ミドルウェア、例外処理、リダイレクト設定 |
| デバッグ文字列とJSONが一緒に表示される | デバッグ用出力がJSONの前後へ混入しています。 | エンドポイントと共通処理の出力 |
| 応答本文が空 | アプリ停止、強制終了、タイムアウト、エラー非表示などが考えられます。 | Spring Bootまたはリバースプロキシのログ |
passkey.jsが表示できるのは、エンドポイントからブラウザへ実際に返された内容です。アプリケーションやWebサーバーが詳細エラーをレスポンスへ出力する設定であれば、その内容も表示されます。詳細エラーをレスポンスへ出力しない設定では、HTTP 500や空の応答だけが表示される場合があります。
エラー表示・ログ記録の設定は、導入先の開発・本番環境の運用方針に合わせて設定してください。
/pushpasskey/endpointを選択する問い合わせ時は、発生時刻、登録・認証・登録情報表示のどこで発生したか、HTTPステータス、画面へ表示された応答内容、必要に応じてDevToolsのResponseとログの該当部分をお知らせください。APIキー、Cookie、セッションID、個人情報は削除してから共有してください。
user_keyが一意な文字列であるuser_keyから有効なユーザーを取得できるrequest_idをセッションまたはDBで照合し、再利用を防いでいる