diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9be38543fb..75929033b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@
 - チャンネルをセンシティブ指定できるようになりました
 	- センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました
 - 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121
+- 二要素認証でパスキーをサポートするようになりました
 
 ### Client
 - プロフィールにその人が作ったPlayの一覧出せるように
diff --git a/locales/index.d.ts b/locales/index.d.ts
index f17ba4ca91..858736be6a 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1108,6 +1108,7 @@ export interface Locale {
     "currentAnnouncements": string;
     "pastAnnouncements": string;
     "youHaveUnreadAnnouncements": string;
+    "useSecurityKey": string;
     "_announcement": {
         "forExistingUsers": string;
         "forExistingUsersDescription": string;
@@ -1822,7 +1823,6 @@ export interface Locale {
         "securityKeyNotSupported": string;
         "registerTOTPBeforeKey": string;
         "securityKeyInfo": string;
-        "chromePasskeyNotSupported": string;
         "registerSecurityKey": string;
         "securityKeyName": string;
         "tapSecurityKey": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d0f9f0586b..7e625b9ac8 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1105,6 +1105,7 @@ forYou: "あなたへ"
 currentAnnouncements: "現在のお知らせ"
 pastAnnouncements: "過去のお知らせ"
 youHaveUnreadAnnouncements: "未読のお知らせがあります。"
+useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。"
 
 _announcement:
   forExistingUsers: "既存ユーザーのみ"
@@ -1740,7 +1741,6 @@ _2fa:
   securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
   registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
   securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
-  chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
   registerSecurityKey: "セキュリティキー・パスキーを登録する"
   securityKeyName: "キーの名前を入力"
   tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
diff --git a/packages/backend/migration/1691959191872-passkey-support.js b/packages/backend/migration/1691959191872-passkey-support.js
new file mode 100644
index 0000000000..55b571d60d
--- /dev/null
+++ b/packages/backend/migration/1691959191872-passkey-support.js
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class PasskeySupport1691959191872 {
+	name = 'PasskeySupport1691959191872'
+
+	async up(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "user_security_key" ADD "counter" bigint NOT NULL DEFAULT '0'`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialDeviceType" character varying(32)`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialBackedUp" boolean`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" ADD "transports" character varying(32) array`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'The public key of the UserSecurityKey, hex-encoded.'`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'Timestamp of the last time the UserSecurityKey was used.'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" SET DEFAULT now()`);
+			await queryRunner.query(`UPDATE "user_security_key" SET "id" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("id", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', ''), "publicKey" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("publicKey", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', '')`);
+			await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
+			await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
+			await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
+			await queryRunner.query(`DROP TABLE "attestation_challenge"`);
+	}
+
+	async down(queryRunner) {
+			await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
+			await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
+			await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
+			await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+			await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."challenge" IS 'Hex-encoded sha256 hash of the challenge.'`);
+			await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."createdAt" IS 'The date challenge was created for expiry purposes.'`);
+			await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.'`);
+			await queryRunner.query(`UPDATE "user_security_key" SET "id" = ENCODE(DECODE(REPLACE(REPLACE("id" || CASE WHEN LENGTH("id") % 4 = 2 THEN '==' WHEN LENGTH("id") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex'), "publicKey" = ENCODE(DECODE(REPLACE(REPLACE("publicKey" || CASE WHEN LENGTH("publicKey") % 4 = 2 THEN '==' WHEN LENGTH("publicKey") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex')`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" DROP DEFAULT`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'The date of the last time the UserSecurityKey was successfully validated.'`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'Variable-length public key used to verify attestations (hex-encoded).'`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "transports"`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialBackedUp"`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialDeviceType"`);
+			await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
+			await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "counter"`);
+	}
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 526d7d4678..c8ccad156c 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -75,6 +75,7 @@
 		"@nestjs/core": "10.2.4",
 		"@nestjs/testing": "10.2.4",
 		"@peertube/http-signature": "1.7.0",
+		"@simplewebauthn/server": "8.1.1",
 		"@sinonjs/fake-timers": "11.1.0",
 		"@swc/cli": "0.1.62",
 		"@swc/core": "1.3.82",
@@ -170,6 +171,7 @@
 	},
 	"devDependencies": {
 		"@jest/globals": "29.6.4",
+		"@simplewebauthn/typescript-types": "8.0.0",
 		"@swc/jest": "0.2.29",
 		"@types/accepts": "1.3.5",
 		"@types/archiver": "5.3.2",
diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index ca2b729156..4783a2b2da 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
 import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
 import { NestLogger } from '@/NestLogger.js';
 import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
-import { JanitorService } from '@/daemons/JanitorService.js';
 import { QueueStatsService } from '@/daemons/QueueStatsService.js';
 import { ServerStatsService } from '@/daemons/ServerStatsService.js';
 import { ServerService } from '@/server/ServerService.js';
@@ -25,7 +24,6 @@ export async function server() {
 
 	if (process.env.NODE_ENV !== 'test') {
 		app.get(ChartManagementService).start();
-		app.get(JanitorService).start();
 		app.get(QueueStatsService).start();
 		app.get(ServerStatsService).start();
 	}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 51d4f9cfa9..863f1a2fd5 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -43,7 +43,7 @@ import { RelayService } from './RelayService.js';
 import { RoleService } from './RoleService.js';
 import { S3Service } from './S3Service.js';
 import { SignupService } from './SignupService.js';
-import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
+import { WebAuthnService } from './WebAuthnService.js';
 import { UserBlockingService } from './UserBlockingService.js';
 import { CacheService } from './CacheService.js';
 import { UserFollowingService } from './UserFollowingService.js';
@@ -168,7 +168,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
 const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
 const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
 const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
-const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
+const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
 const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
 const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
 const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
@@ -296,7 +296,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		RoleService,
 		S3Service,
 		SignupService,
-		TwoFactorAuthenticationService,
+		WebAuthnService,
 		UserBlockingService,
 		CacheService,
 		UserFollowingService,
@@ -417,7 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$RoleService,
 		$S3Service,
 		$SignupService,
-		$TwoFactorAuthenticationService,
+		$WebAuthnService,
 		$UserBlockingService,
 		$CacheService,
 		$UserFollowingService,
@@ -539,7 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		RoleService,
 		S3Service,
 		SignupService,
-		TwoFactorAuthenticationService,
+		WebAuthnService,
 		UserBlockingService,
 		CacheService,
 		UserFollowingService,
@@ -659,7 +659,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$RoleService,
 		$S3Service,
 		$SignupService,
-		$TwoFactorAuthenticationService,
+		$WebAuthnService,
 		$UserBlockingService,
 		$CacheService,
 		$UserFollowingService,
diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts
deleted file mode 100644
index ecf7676f4b..0000000000
--- a/packages/backend/src/core/TwoFactorAuthenticationService.ts
+++ /dev/null
@@ -1,446 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as crypto from 'node:crypto';
-import { Inject, Injectable } from '@nestjs/common';
-import * as jsrsasign from 'jsrsasign';
-import { DI } from '@/di-symbols.js';
-import type { Config } from '@/config.js';
-import { bindThis } from '@/decorators.js';
-
-const ECC_PRELUDE = Buffer.from([0x04]);
-const NULL_BYTE = Buffer.from([0]);
-const PEM_PRELUDE = Buffer.from(
-	'3059301306072a8648ce3d020106082a8648ce3d030107034200',
-	'hex',
-);
-
-// Android Safetynet attestations are signed with this cert:
-const GSR2 = `-----BEGIN CERTIFICATE-----
-MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
-A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
-Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
-MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
-A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
-hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
-v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
-eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
-tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
-C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
-zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
-mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
-V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
-bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
-3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
-J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
-291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
-ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
-AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
-TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
------END CERTIFICATE-----\n`;
-
-function base64URLDecode(source: string) {
-	return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
-}
-
-function getCertSubject(certificate: string) {
-	const subjectCert = new jsrsasign.X509();
-	subjectCert.readCertPEM(certificate);
-
-	const subjectString = subjectCert.getSubjectString();
-	const subjectFields = subjectString.slice(1).split('/');
-
-	const fields = {} as Record<string, string>;
-	for (const field of subjectFields) {
-		const eqIndex = field.indexOf('=');
-		fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
-	}
-
-	return fields;
-}
-
-function verifyCertificateChain(certificates: string[]) {
-	let valid = true;
-
-	for (let i = 0; i < certificates.length; i++) {
-		const Cert = certificates[i];
-		const certificate = new jsrsasign.X509();
-		certificate.readCertPEM(Cert);
-
-		const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
-
-		const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
-		if (certStruct == null) throw new Error('certStruct is null');
-
-		const algorithm = certificate.getSignatureAlgorithmField();
-		const signatureHex = certificate.getSignatureValueHex();
-
-		// Verify against CA
-		const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
-		Signature.init(CACert);
-		Signature.updateHex(certStruct);
-		valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
-	}
-
-	return valid;
-}
-
-function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
-	if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
-		pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
-		type = 'PUBLIC KEY';
-	}
-	const cert = pemBuffer.toString('base64');
-
-	const keyParts = [];
-	const max = Math.ceil(cert.length / 64);
-	let start = 0;
-	for (let i = 0; i < max; i++) {
-		keyParts.push(cert.substring(start, start + 64));
-		start += 64;
-	}
-
-	return (
-		`-----BEGIN ${type}-----\n` +
-		keyParts.join('\n') +
-		`\n-----END ${type}-----\n`
-	);
-}
-
-@Injectable()
-export class TwoFactorAuthenticationService {
-	constructor(
-		@Inject(DI.config)
-		private config: Config,
-	) {
-	}
-
-	@bindThis
-	public hash(data: Buffer) {
-		return crypto
-			.createHash('sha256')
-			.update(data)
-			.digest();
-	}
-
-	@bindThis
-	public verifySignin({
-		publicKey,
-		authenticatorData,
-		clientDataJSON,
-		clientData,
-		signature,
-		challenge,
-	}: {
-		publicKey: Buffer,
-		authenticatorData: Buffer,
-		clientDataJSON: Buffer,
-		clientData: any,
-		signature: Buffer,
-		challenge: string
-	}) {
-		if (clientData.type !== 'webauthn.get') {
-			throw new Error('type is not webauthn.get');
-		}
-
-		if (this.hash(clientData.challenge).toString('hex') !== challenge) {
-			throw new Error('challenge mismatch');
-		}
-		if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
-			throw new Error('origin mismatch');
-		}
-
-		const verificationData = Buffer.concat(
-			[authenticatorData, this.hash(clientDataJSON)],
-			32 + authenticatorData.length,
-		);
-
-		return crypto
-			.createVerify('SHA256')
-			.update(verificationData)
-			.verify(PEMString(publicKey), signature);
-	}
-
-	@bindThis
-	public getProcedures() {
-		return {
-			none: {
-				verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
-					const negTwo = publicKey.get(-2);
-
-					if (!negTwo || negTwo.length !== 32) {
-						throw new Error('invalid or no -2 key given');
-					}
-					const negThree = publicKey.get(-3);
-					if (!negThree || negThree.length !== 32) {
-						throw new Error('invalid or no -3 key given');
-					}
-
-					const publicKeyU2F = Buffer.concat(
-						[ECC_PRELUDE, negTwo, negThree],
-						1 + 32 + 32,
-					);
-
-					return {
-						publicKey: publicKeyU2F,
-						valid: true,
-					};
-				},
-			},
-			'android-key': {
-				verify({
-					attStmt,
-					authenticatorData,
-					clientDataHash,
-					publicKey,
-					rpIdHash,
-					credentialId,
-				}: {
-					attStmt: any,
-					authenticatorData: Buffer,
-					clientDataHash: Buffer,
-					publicKey: Map<number, any>;
-					rpIdHash: Buffer,
-					credentialId: Buffer,
-				}) {
-					if (attStmt.alg !== -7) {
-						throw new Error('alg mismatch');
-					}
-
-					const verificationData = Buffer.concat([
-						authenticatorData,
-						clientDataHash,
-					]);
-
-					const attCert: Buffer = attStmt.x5c[0];
-
-					const negTwo = publicKey.get(-2);
-
-					if (!negTwo || negTwo.length !== 32) {
-						throw new Error('invalid or no -2 key given');
-					}
-					const negThree = publicKey.get(-3);
-					if (!negThree || negThree.length !== 32) {
-						throw new Error('invalid or no -3 key given');
-					}
-
-					const publicKeyData = Buffer.concat(
-						[ECC_PRELUDE, negTwo, negThree],
-						1 + 32 + 32,
-					);
-
-					if (!attCert.equals(publicKeyData)) {
-						throw new Error('public key mismatch');
-					}
-
-					const isValid = crypto
-						.createVerify('SHA256')
-						.update(verificationData)
-						.verify(PEMString(attCert), attStmt.sig);
-
-					// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
-
-					return {
-						valid: isValid,
-						publicKey: publicKeyData,
-					};
-				},
-			},
-			// what a stupid attestation
-			'android-safetynet': {
-				verify: ({
-					attStmt,
-					authenticatorData,
-					clientDataHash,
-					publicKey,
-					rpIdHash,
-					credentialId,
-				}: {
-					attStmt: any,
-					authenticatorData: Buffer,
-					clientDataHash: Buffer,
-					publicKey: Map<number, any>;
-					rpIdHash: Buffer,
-					credentialId: Buffer,
-				}) => {
-					const verificationData = this.hash(
-						Buffer.concat([authenticatorData, clientDataHash]),
-					);
-
-					const jwsParts = attStmt.response.toString('utf-8').split('.');
-
-					const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
-					const response = JSON.parse(
-						base64URLDecode(jwsParts[1]).toString('utf-8'),
-					);
-					const signature = jwsParts[2];
-
-					if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
-						throw new Error('invalid nonce');
-					}
-
-					const certificateChain = header.x5c
-						.map((key: any) => PEMString(key))
-						.concat([GSR2]);
-
-					if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
-						throw new Error('invalid common name');
-					}
-
-					if (!verifyCertificateChain(certificateChain)) {
-						throw new Error('Invalid certificate chain!');
-					}
-
-					const signatureBase = Buffer.from(
-						jwsParts[0] + '.' + jwsParts[1],
-						'utf-8',
-					);
-
-					const valid = crypto
-						.createVerify('sha256')
-						.update(signatureBase)
-						.verify(certificateChain[0], base64URLDecode(signature));
-
-					const negTwo = publicKey.get(-2);
-
-					if (!negTwo || negTwo.length !== 32) {
-						throw new Error('invalid or no -2 key given');
-					}
-					const negThree = publicKey.get(-3);
-					if (!negThree || negThree.length !== 32) {
-						throw new Error('invalid or no -3 key given');
-					}
-
-					const publicKeyData = Buffer.concat(
-						[ECC_PRELUDE, negTwo, negThree],
-						1 + 32 + 32,
-					);
-					return {
-						valid,
-						publicKey: publicKeyData,
-					};
-				},
-			},
-			packed: {
-				verify({
-					attStmt,
-					authenticatorData,
-					clientDataHash,
-					publicKey,
-					rpIdHash,
-					credentialId,
-				}: {
-					attStmt: any,
-					authenticatorData: Buffer,
-					clientDataHash: Buffer,
-					publicKey: Map<number, any>;
-					rpIdHash: Buffer,
-					credentialId: Buffer,
-				}) {
-					const verificationData = Buffer.concat([
-						authenticatorData,
-						clientDataHash,
-					]);
-
-					if (attStmt.x5c) {
-						const attCert = attStmt.x5c[0];
-
-						const validSignature = crypto
-							.createVerify('SHA256')
-							.update(verificationData)
-							.verify(PEMString(attCert), attStmt.sig);
-
-						const negTwo = publicKey.get(-2);
-
-						if (!negTwo || negTwo.length !== 32) {
-							throw new Error('invalid or no -2 key given');
-						}
-						const negThree = publicKey.get(-3);
-						if (!negThree || negThree.length !== 32) {
-							throw new Error('invalid or no -3 key given');
-						}
-
-						const publicKeyData = Buffer.concat(
-							[ECC_PRELUDE, negTwo, negThree],
-							1 + 32 + 32,
-						);
-
-						return {
-							valid: validSignature,
-							publicKey: publicKeyData,
-						};
-					} else if (attStmt.ecdaaKeyId) {
-						// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
-						throw new Error('ECDAA-Verify is not supported');
-					} else {
-						if (attStmt.alg !== -7) throw new Error('alg mismatch');
-
-						throw new Error('self attestation is not supported');
-					}
-				},
-			},
-
-			'fido-u2f': {
-				verify({
-					attStmt,
-					authenticatorData,
-					clientDataHash,
-					publicKey,
-					rpIdHash,
-					credentialId,
-				}: {
-					attStmt: any,
-					authenticatorData: Buffer,
-					clientDataHash: Buffer,
-					publicKey: Map<number, any>,
-					rpIdHash: Buffer,
-					credentialId: Buffer
-				}) {
-					const x5c: Buffer[] = attStmt.x5c;
-					if (x5c.length !== 1) {
-						throw new Error('x5c length does not match expectation');
-					}
-
-					const attCert = x5c[0];
-
-					// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
-
-					const negTwo: Buffer = publicKey.get(-2);
-
-					if (!negTwo || negTwo.length !== 32) {
-						throw new Error('invalid or no -2 key given');
-					}
-					const negThree: Buffer = publicKey.get(-3);
-					if (!negThree || negThree.length !== 32) {
-						throw new Error('invalid or no -3 key given');
-					}
-
-					const publicKeyU2F = Buffer.concat(
-						[ECC_PRELUDE, negTwo, negThree],
-						1 + 32 + 32,
-					);
-
-					const verificationData = Buffer.concat([
-						NULL_BYTE,
-						rpIdHash,
-						clientDataHash,
-						credentialId,
-						publicKeyU2F,
-					]);
-
-					const validSignature = crypto
-						.createVerify('SHA256')
-						.update(verificationData)
-						.verify(PEMString(attCert), attStmt.sig);
-
-					return {
-						valid: validSignature,
-						publicKey: publicKeyU2F,
-					};
-				},
-			},
-		};
-	}
-}
diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts
new file mode 100644
index 0000000000..1c344eabe1
--- /dev/null
+++ b/packages/backend/src/core/WebAuthnService.ts
@@ -0,0 +1,252 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import {
+	generateAuthenticationOptions,
+	generateRegistrationOptions, verifyAuthenticationResponse,
+	verifyRegistrationResponse,
+} from '@simplewebauthn/server';
+import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers';
+import { DI } from '@/di-symbols.js';
+import type { UserSecurityKeysRepository } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { MiUser } from '@/models/index.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import type {
+	AuthenticationResponseJSON,
+	AuthenticatorTransportFuture,
+	CredentialDeviceType,
+	PublicKeyCredentialCreationOptionsJSON,
+	PublicKeyCredentialDescriptorFuture,
+	PublicKeyCredentialRequestOptionsJSON,
+	RegistrationResponseJSON,
+} from '@simplewebauthn/typescript-types';
+
+@Injectable()
+export class WebAuthnService {
+	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.config)
+		private config: Config,
+
+		@Inject(DI.userSecurityKeysRepository)
+		private userSecurityKeysRepository: UserSecurityKeysRepository,
+
+		private metaService: MetaService,
+	) {
+	}
+
+	@bindThis
+	public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> {
+		const instance = await this.metaService.fetch();
+		return {
+			origin: this.config.url,
+			rpId: this.config.host,
+			rpName: instance.name ?? this.config.host,
+			rpIcon: instance.iconUrl ?? undefined,
+		};
+	}
+
+	@bindThis
+	public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
+		const relyingParty = await this.getRelyingParty();
+		const keys = await this.userSecurityKeysRepository.findBy({
+			userId: userId,
+		});
+
+		const registrationOptions = await generateRegistrationOptions({
+			rpName: relyingParty.rpName,
+			rpID: relyingParty.rpId,
+			userID: userId,
+			userName: userName,
+			userDisplayName: userDisplayName,
+			attestationType: 'indirect',
+			excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
+				id: Buffer.from(key.id, 'base64url'),
+				type: 'public-key',
+				transports: key.transports ?? undefined,
+			})),
+			authenticatorSelection: {
+				residentKey: 'required',
+				userVerification: 'preferred',
+			},
+		});
+
+		await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, registrationOptions.challenge);
+
+		return registrationOptions;
+	}
+
+	@bindThis
+	public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
+		credentialID: Uint8Array;
+		credentialPublicKey: Uint8Array;
+		attestationObject: Uint8Array;
+		fmt: AttestationFormat;
+		counter: number;
+		userVerified: boolean;
+		credentialDeviceType: CredentialDeviceType;
+		credentialBackedUp: boolean;
+		transports?: AuthenticatorTransportFuture[];
+	}> {
+		const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
+
+		if (!challenge) {
+			throw new IdentifiableError('7dbfb66c-9216-4e2b-9c27-cef2ac8efb84', 'challenge not found');
+		}
+
+		await this.redisClient.del(`webauthn:challenge:${userId}`);
+
+		const relyingParty = await this.getRelyingParty();
+
+		let verification;
+		try {
+			verification = await verifyRegistrationResponse({
+				response: response,
+				expectedChallenge: challenge,
+				expectedOrigin: relyingParty.origin,
+				expectedRPID: relyingParty.rpId,
+				requireUserVerification: true,
+			});
+		} catch (error) {
+			console.error(error);
+			throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
+		}
+
+		const { verified } = verification;
+
+		if (!verified || !verification.registrationInfo) {
+			throw new IdentifiableError('bb333667-3832-4a80-8bb5-c505be7d710d', 'verification failed');
+		}
+
+		const { registrationInfo } = verification;
+
+		return {
+			credentialID: registrationInfo.credentialID,
+			credentialPublicKey: registrationInfo.credentialPublicKey,
+			attestationObject: registrationInfo.attestationObject,
+			fmt: registrationInfo.fmt,
+			counter: registrationInfo.counter,
+			userVerified: registrationInfo.userVerified,
+			credentialDeviceType: registrationInfo.credentialDeviceType,
+			credentialBackedUp: registrationInfo.credentialBackedUp,
+			transports: response.response.transports,
+		};
+	}
+
+	@bindThis
+	public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
+		const keys = await this.userSecurityKeysRepository.findBy({
+			userId: userId,
+		});
+
+		if (keys.length === 0) {
+			throw new IdentifiableError('f27fd449-9af4-4841-9249-1f989b9fa4a4', 'no keys found');
+		}
+
+		const authenticationOptions = await generateAuthenticationOptions({
+			allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
+				id: Buffer.from(key.id, 'base64url'),
+				type: 'public-key',
+				transports: key.transports ?? undefined,
+			})),
+			userVerification: 'preferred',
+		});
+
+		await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, authenticationOptions.challenge);
+
+		return authenticationOptions;
+	}
+
+	@bindThis
+	public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
+		const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
+
+		if (!challenge) {
+			throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
+		}
+
+		await this.redisClient.del(`webauthn:challenge:${userId}`);
+
+		const key = await this.userSecurityKeysRepository.findOneBy({
+			id: response.id,
+			userId: userId,
+		});
+
+		if (!key) {
+			throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'unknown key');
+		}
+
+		// マイグレーション
+		if (key.counter === 0 && key.publicKey.length === 87) {
+			const cert = new Uint8Array(Buffer.from(key.publicKey, 'base64url'));
+			if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
+				const halfLength = (cert.length - 1) / 2;
+
+				const cborMap = new Map<number, number | ArrayBufferLike>();
+				cborMap.set(1, 2); // kty, EC2
+				cborMap.set(3, -7); // alg, ES256
+				cborMap.set(-1, 1); // crv, P256
+				cborMap.set(-2, cert.slice(1, halfLength + 1)); // x
+				cborMap.set(-3, cert.slice(halfLength + 1)); // y
+
+				const cborPubKey = Buffer.from(isoCBOR.encode(cborMap)).toString('base64url');
+				await this.userSecurityKeysRepository.update({
+					id: response.id,
+					userId: userId,
+				}, {
+					publicKey: cborPubKey,
+				});
+				key.publicKey = cborPubKey;
+			}
+		}
+
+		const relyingParty = await this.getRelyingParty();
+
+		let verification;
+		try {
+			verification = await verifyAuthenticationResponse({
+				response: response,
+				expectedChallenge: challenge,
+				expectedOrigin: relyingParty.origin,
+				expectedRPID: relyingParty.rpId,
+				authenticator: {
+					credentialID: Buffer.from(key.id, 'base64url'),
+					credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
+					counter: key.counter,
+					transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
+				},
+				requireUserVerification: true,
+			});
+		} catch (error) {
+			console.error(error);
+			throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
+		}
+
+		const { verified, authenticationInfo } = verification;
+
+		if (!verified) {
+			return false;
+		}
+
+		await this.userSecurityKeysRepository.update({
+			id: response.id,
+			userId: userId,
+		}, {
+			lastUsed: new Date(),
+			counter: authenticationInfo.newCounter,
+			credentialDeviceType: authenticationInfo.credentialDeviceType,
+			credentialBackedUp: authenticationInfo.credentialBackedUp,
+		});
+
+		return verified;
+	}
+}
diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts
index 7543a2ea3d..236985076c 100644
--- a/packages/backend/src/daemons/DaemonModule.ts
+++ b/packages/backend/src/daemons/DaemonModule.ts
@@ -6,7 +6,6 @@
 import { Module } from '@nestjs/common';
 import { CoreModule } from '@/core/CoreModule.js';
 import { GlobalModule } from '@/GlobalModule.js';
-import { JanitorService } from './JanitorService.js';
 import { QueueStatsService } from './QueueStatsService.js';
 import { ServerStatsService } from './ServerStatsService.js';
 
@@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js';
 		CoreModule,
 	],
 	providers: [
-		JanitorService,
 		QueueStatsService,
 		ServerStatsService,
 	],
 	exports: [
-		JanitorService,
 		QueueStatsService,
 		ServerStatsService,
 	],
diff --git a/packages/backend/src/daemons/JanitorService.ts b/packages/backend/src/daemons/JanitorService.ts
deleted file mode 100644
index 63c44e874f..0000000000
--- a/packages/backend/src/daemons/JanitorService.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { LessThan } from 'typeorm';
-import { DI } from '@/di-symbols.js';
-import type { AttestationChallengesRepository } from '@/models/index.js';
-import { bindThis } from '@/decorators.js';
-import type { OnApplicationShutdown } from '@nestjs/common';
-
-const interval = 30 * 60 * 1000;
-
-@Injectable()
-export class JanitorService implements OnApplicationShutdown {
-	private intervalId: NodeJS.Timeout;
-
-	constructor(
-		@Inject(DI.attestationChallengesRepository)
-		private attestationChallengesRepository: AttestationChallengesRepository,
-	) {
-	}
-
-	/**
-	 * Clean up database occasionally
-	 */
-	@bindThis
-	public start(): void {
-		const tick = async () => {
-			await this.attestationChallengesRepository.delete({
-				createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)),
-			});
-		};
-
-		tick();
-
-		this.intervalId = setInterval(tick, interval);
-	}
-
-	@bindThis
-	public dispose(): void {
-		clearInterval(this.intervalId);
-	}
-
-	@bindThis
-	public onApplicationShutdown(signal?: string | undefined): void {
-		this.dispose();
-	}
-}
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index c911f60566..72ec98cebe 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -26,7 +26,6 @@ export const DI = {
 	userProfilesRepository: Symbol('userProfilesRepository'),
 	userKeypairsRepository: Symbol('userKeypairsRepository'),
 	userPendingsRepository: Symbol('userPendingsRepository'),
-	attestationChallengesRepository: Symbol('attestationChallengesRepository'),
 	userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
 	userPublickeysRepository: Symbol('userPublickeysRepository'),
 	userListsRepository: Symbol('userListsRepository'),
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index b8372b1470..9b35996519 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -5,7 +5,7 @@
 
 import { Module } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
-import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAttestationChallenge, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
+import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
 import type { DataSource } from 'typeorm';
 import type { Provider } from '@nestjs/common';
 
@@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = {
 	inject: [DI.db],
 };
 
-const $attestationChallengesRepository: Provider = {
-	provide: DI.attestationChallengesRepository,
-	useFactory: (db: DataSource) => db.getRepository(MiAttestationChallenge),
-	inject: [DI.db],
-};
-
 const $userSecurityKeysRepository: Provider = {
 	provide: DI.userSecurityKeysRepository,
 	useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey),
@@ -423,7 +417,6 @@ const $userMemosRepository: Provider = {
 		$userProfilesRepository,
 		$userKeypairsRepository,
 		$userPendingsRepository,
-		$attestationChallengesRepository,
 		$userSecurityKeysRepository,
 		$userPublickeysRepository,
 		$userListsRepository,
@@ -491,7 +484,6 @@ const $userMemosRepository: Provider = {
 		$userProfilesRepository,
 		$userKeypairsRepository,
 		$userPendingsRepository,
-		$attestationChallengesRepository,
 		$userSecurityKeysRepository,
 		$userPublickeysRepository,
 		$userListsRepository,
diff --git a/packages/backend/src/models/entities/AttestationChallenge.ts b/packages/backend/src/models/entities/AttestationChallenge.ts
deleted file mode 100644
index dace378eff..0000000000
--- a/packages/backend/src/models/entities/AttestationChallenge.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
-import { id } from '../id.js';
-import { MiUser } from './User.js';
-
-@Entity('attestation_challenge')
-export class MiAttestationChallenge {
-	@PrimaryColumn(id())
-	public id: string;
-
-	@Index()
-	@PrimaryColumn(id())
-	public userId: MiUser['id'];
-
-	@ManyToOne(type => MiUser, {
-		onDelete: 'CASCADE',
-	})
-	@JoinColumn()
-	public user: MiUser | null;
-
-	@Index()
-	@Column('varchar', {
-		length: 64,
-		comment: 'Hex-encoded sha256 hash of the challenge.',
-	})
-	public challenge: string;
-
-	@Column('timestamp with time zone', {
-		comment: 'The date challenge was created for expiry purposes.',
-	})
-	public createdAt: Date;
-
-	@Column('boolean', {
-		comment:
-			'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
-		default: false,
-	})
-	public registrationChallenge: boolean;
-
-	constructor(data: Partial<MiAttestationChallenge>) {
-		if (data == null) return;
-
-		for (const [k, v] of Object.entries(data)) {
-			(this as any)[k] = v;
-		}
-	}
-}
diff --git a/packages/backend/src/models/entities/UserSecurityKey.ts b/packages/backend/src/models/entities/UserSecurityKey.ts
index ce1c270d46..96dd27d083 100644
--- a/packages/backend/src/models/entities/UserSecurityKey.ts
+++ b/packages/backend/src/models/entities/UserSecurityKey.ts
@@ -24,25 +24,48 @@ export class MiUserSecurityKey {
 	@JoinColumn()
 	public user: MiUser | null;
 
-	@Index()
-	@Column('varchar', {
-		comment:
-			'Variable-length public key used to verify attestations (hex-encoded).',
-	})
-	public publicKey: string;
-
-	@Column('timestamp with time zone', {
-		comment:
-			'The date of the last time the UserSecurityKey was successfully validated.',
-	})
-	public lastUsed: Date;
-
 	@Column('varchar', {
 		comment: 'User-defined name for this key',
 		length: 30,
 	})
 	public name: string;
 
+	@Index()
+	@Column('varchar', {
+		comment: 'The public key of the UserSecurityKey, hex-encoded.',
+	})
+	public publicKey: string;
+
+	@Column('bigint', {
+		comment: 'The number of times the UserSecurityKey was validated.',
+		default: 0,
+	})
+	public counter: number;
+
+	@Column('timestamp with time zone', {
+		comment: 'Timestamp of the last time the UserSecurityKey was used.',
+		default: () => 'now()',
+	})
+	public lastUsed: Date;
+
+	@Column('varchar', {
+		comment: 'The type of Backup Eligibility in authenticator data',
+		length: 32, nullable: true,
+	})
+	public credentialDeviceType: string | null;
+
+	@Column('boolean', {
+		comment: 'Whether or not the credential has been backed up',
+		nullable: true,
+	})
+	public credentialBackedUp: boolean | null;
+
+	@Column('varchar', {
+		comment: 'The type of the credential returned by the browser',
+		length: 32, array: true, nullable: true,
+	})
+	public transports: string[] | null;
+
 	constructor(data: Partial<MiUserSecurityKey>) {
 		if (data == null) return;
 
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index d14234b792..e4f4dce7d6 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -10,7 +10,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
 import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
 import { MiAntenna } from '@/models/entities/Antenna.js';
 import { MiApp } from '@/models/entities/App.js';
-import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
 import { MiAuthSession } from '@/models/entities/AuthSession.js';
 import { MiBlocking } from '@/models/entities/Blocking.js';
 import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@@ -79,7 +78,6 @@ export {
 	MiAnnouncementRead,
 	MiAntenna,
 	MiApp,
-	MiAttestationChallenge,
 	MiAuthSession,
 	MiBlocking,
 	MiChannelFollowing,
@@ -147,7 +145,6 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
 export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
 export type AntennasRepository = Repository<MiAntenna>;
 export type AppsRepository = Repository<MiApp>;
-export type AttestationChallengesRepository = Repository<MiAttestationChallenge>;
 export type AuthSessionsRepository = Repository<MiAuthSession>;
 export type BlockingsRepository = Repository<MiBlocking>;
 export type ChannelFollowingsRepository = Repository<MiChannelFollowing>;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 6c2f4b21f0..c5d9e41463 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -18,7 +18,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
 import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
 import { MiAntenna } from '@/models/entities/Antenna.js';
 import { MiApp } from '@/models/entities/App.js';
-import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
 import { MiAuthSession } from '@/models/entities/AuthSession.js';
 import { MiBlocking } from '@/models/entities/Blocking.js';
 import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@@ -143,7 +142,6 @@ export const entities = [
 	MiUserNotePining,
 	MiUserSecurityKey,
 	MiUsedUsername,
-	MiAttestationChallenge,
 	MiFollowing,
 	MiFollowRequest,
 	MiMuting,
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 58a5cca4fc..ac8371d8d0 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -3,22 +3,26 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { randomBytes } from 'node:crypto';
 import { Inject, Injectable } from '@nestjs/common';
 import bcrypt from 'bcryptjs';
 import * as OTPAuth from 'otpauth';
 import { IsNull } from 'typeorm';
 import { DI } from '@/di-symbols.js';
-import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
+import type {
+	SigninsRepository,
+	UserProfilesRepository,
+	UsersRepository,
+} from '@/models/index.js';
 import type { Config } from '@/config.js';
 import { getIpHash } from '@/misc/get-ip-hash.js';
 import type { MiLocalUser } from '@/models/entities/User.js';
 import { IdService } from '@/core/IdService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
 import { bindThis } from '@/decorators.js';
+import { WebAuthnService } from '@/core/WebAuthnService.js';
 import { RateLimiterService } from './RateLimiterService.js';
 import { SigninService } from './SigninService.js';
-import type { FastifyRequest, FastifyReply } from 'fastify';
+import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
+import type { FastifyReply, FastifyRequest } from 'fastify';
 
 @Injectable()
 export class SigninApiService {
@@ -29,22 +33,16 @@ export class SigninApiService {
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
 
-		@Inject(DI.userSecurityKeysRepository)
-		private userSecurityKeysRepository: UserSecurityKeysRepository,
-
 		@Inject(DI.userProfilesRepository)
 		private userProfilesRepository: UserProfilesRepository,
 
-		@Inject(DI.attestationChallengesRepository)
-		private attestationChallengesRepository: AttestationChallengesRepository,
-
 		@Inject(DI.signinsRepository)
 		private signinsRepository: SigninsRepository,
 
 		private idService: IdService,
 		private rateLimiterService: RateLimiterService,
 		private signinService: SigninService,
-		private twoFactorAuthenticationService: TwoFactorAuthenticationService,
+		private webAuthnService: WebAuthnService,
 	) {
 	}
 
@@ -55,11 +53,7 @@ export class SigninApiService {
 				username: string;
 				password: string;
 				token?: string;
-				signature?: string;
-				authenticatorData?: string;
-				clientDataJSON?: string;
-				credentialId?: string;
-				challengeId?: string;
+				credential?: AuthenticationResponseJSON;
 			};
 		}>,
 		reply: FastifyReply,
@@ -181,64 +175,16 @@ export class SigninApiService {
 			} else {
 				return this.signinService.signin(request, reply, user);
 			}
-		} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
+		} else if (body.credential) {
 			if (!same && !profile.usePasswordLessLogin) {
 				return await fail(403, {
 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
 				});
 			}
 
-			const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
-			const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
-			const challenge = await this.attestationChallengesRepository.findOneBy({
-				userId: user.id,
-				id: body.challengeId,
-				registrationChallenge: false,
-				challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
-			});
+			const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
 
-			if (!challenge) {
-				return await fail(403, {
-					id: '2715a88a-2125-4013-932f-aa6fe72792da',
-				});
-			}
-
-			await this.attestationChallengesRepository.delete({
-				userId: user.id,
-				id: body.challengeId,
-			});
-
-			if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
-				return await fail(403, {
-					id: '2715a88a-2125-4013-932f-aa6fe72792da',
-				});
-			}
-
-			const securityKey = await this.userSecurityKeysRepository.findOneBy({
-				id: Buffer.from(
-					body.credentialId
-						.replace(/-/g, '+')
-						.replace(/_/g, '/'),
-					'base64',
-				).toString('hex'),
-			});
-
-			if (!securityKey) {
-				return await fail(403, {
-					id: '66269679-aeaf-4474-862b-eb761197e046',
-				});
-			}
-
-			const isValid = this.twoFactorAuthenticationService.verifySignin({
-				publicKey: Buffer.from(securityKey.publicKey, 'hex'),
-				authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
-				clientDataJSON,
-				clientData,
-				signature: Buffer.from(body.signature, 'hex'),
-				challenge: challenge.challenge,
-			});
-
-			if (isValid) {
+			if (authorized) {
 				return this.signinService.signin(request, reply, user);
 			} else {
 				return await fail(403, {
@@ -252,42 +198,11 @@ export class SigninApiService {
 				});
 			}
 
-			const keys = await this.userSecurityKeysRepository.findBy({
-				userId: user.id,
-			});
-
-			if (keys.length === 0) {
-				return await fail(403, {
-					id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
-				});
-			}
-
-			// 32 byte challenge
-			const challenge = randomBytes(32).toString('base64')
-				.replace(/=/g, '')
-				.replace(/\+/g, '-')
-				.replace(/\//g, '_');
-
-			const challengeId = this.idService.genId();
-
-			await this.attestationChallengesRepository.insert({
-				userId: user.id,
-				id: challengeId,
-				challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
-				createdAt: new Date(),
-				registrationChallenge: false,
-			});
+			const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
 
 			reply.code(200);
-			return {
-				challenge,
-				challengeId,
-				securityKeys: keys.map(key => ({
-					id: key.id,
-				})),
-			};
+			return authRequest;
 		}
 	// never get here
 	}
 }
-
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
index cff51245c7..87a15da0c2 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -3,155 +3,86 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { promisify } from 'node:util';
 import bcrypt from 'bcryptjs';
-import cbor from 'cbor';
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import type { Config } from '@/config.js';
 import { DI } from '@/di-symbols.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
-import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
-
-const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
+import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
+import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { ApiError } from '@/server/api/error.js';
 
 export const meta = {
 	requireCredential: true,
 
 	secure: true,
+
+	errors: {
+		incorrectPassword: {
+			message: 'Incorrect password.',
+			code: 'INCORRECT_PASSWORD',
+			id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0',
+		},
+
+		twoFactorNotEnabled: {
+			message: '2fa not enabled.',
+			code: 'TWO_FACTOR_NOT_ENABLED',
+			id: '798d6847-b1ed-4f9c-b1f9-163c42655995',
+		},
+	},
 } as const;
 
 export const paramDef = {
 	type: 'object',
 	properties: {
-		clientDataJSON: { type: 'string' },
-		attestationObject: { type: 'string' },
 		password: { type: 'string' },
-		challengeId: { type: 'string' },
 		name: { type: 'string', minLength: 1, maxLength: 30 },
+		credential: { type: 'object' },
 	},
-	required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
+	required: ['password', 'name', 'credential'],
 } as const;
 
+// eslint-disable-next-line import/no-default-export
 @Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
 	constructor(
-		@Inject(DI.config)
-		private config: Config,
-
 		@Inject(DI.userProfilesRepository)
 		private userProfilesRepository: UserProfilesRepository,
 
 		@Inject(DI.userSecurityKeysRepository)
 		private userSecurityKeysRepository: UserSecurityKeysRepository,
 
-		@Inject(DI.attestationChallengesRepository)
-		private attestationChallengesRepository: AttestationChallengesRepository,
-
+		private webAuthnService: WebAuthnService,
 		private userEntityService: UserEntityService,
 		private globalEventService: GlobalEventService,
-		private twoFactorAuthenticationService: TwoFactorAuthenticationService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8'));
-
 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
 
 			// Compare password
-			const same = await bcrypt.compare(ps.password, profile.password!);
+			const same = await bcrypt.compare(ps.password, profile.password ?? '');
 
 			if (!same) {
-				throw new Error('incorrect password');
+				throw new ApiError(meta.errors.incorrectPassword);
 			}
 
 			if (!profile.twoFactorEnabled) {
-				throw new Error('2fa not enabled');
+				throw new ApiError(meta.errors.twoFactorNotEnabled);
 			}
 
-			const clientData = JSON.parse(ps.clientDataJSON);
-
-			if (clientData.type !== 'webauthn.create') {
-				throw new Error('not a creation attestation');
-			}
-			if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
-				throw new Error('origin mismatch');
-			}
-
-			const clientDataJSONHash = this.twoFactorAuthenticationService.hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
-
-			const attestation = await cborDecodeFirst(ps.attestationObject);
-
-			const rpIdHash = attestation.authData.slice(0, 32);
-			if (!rpIdHashReal.equals(rpIdHash)) {
-				throw new Error('rpIdHash mismatch');
-			}
-
-			const flags = attestation.authData[32];
-
-			// eslint:disable-next-line:no-bitwise
-			if (!(flags & 1)) {
-				throw new Error('user not present');
-			}
-
-			const authData = Buffer.from(attestation.authData);
-			const credentialIdLength = authData.readUInt16BE(53);
-			const credentialId = authData.slice(55, 55 + credentialIdLength);
-			const publicKeyData = authData.slice(55 + credentialIdLength);
-			const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
-			if (publicKey.get(3) !== -7) {
-				throw new Error('alg mismatch');
-			}
-
-			const procedures = this.twoFactorAuthenticationService.getProcedures();
-
-			if (!(procedures as any)[attestation.fmt]) {
-				throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
-			}
-
-			const verificationData = (procedures as any)[attestation.fmt].verify({
-				attStmt: attestation.attStmt,
-				authenticatorData: authData,
-				clientDataHash: clientDataJSONHash,
-				credentialId,
-				publicKey,
-				rpIdHash,
-			});
-			if (!verificationData.valid) throw new Error('signature invalid');
-
-			const attestationChallenge = await this.attestationChallengesRepository.findOneBy({
-				userId: me.id,
-				id: ps.challengeId,
-				registrationChallenge: true,
-				challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
-			});
-
-			if (!attestationChallenge) {
-				throw new Error('non-existent challenge');
-			}
-
-			await this.attestationChallengesRepository.delete({
-				userId: me.id,
-				id: ps.challengeId,
-			});
-
-			// Expired challenge (> 5min old)
-			if (
-				new Date().getTime() - attestationChallenge.createdAt.getTime() >=
-		5 * 60 * 1000
-			) {
-				throw new Error('expired challenge');
-			}
-
-			const credentialIdString = credentialId.toString('hex');
+			const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
 
+			const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url');
 			await this.userSecurityKeysRepository.insert({
+				id: credentialId,
 				userId: me.id,
-				id: credentialIdString,
-				lastUsed: new Date(),
 				name: ps.name,
-				publicKey: verificationData.publicKey.toString('hex'),
+				publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'),
+				counter: keyInfo.counter,
+				credentialDeviceType: keyInfo.credentialDeviceType,
+				credentialBackedUp: keyInfo.credentialBackedUp,
+				transports: keyInfo.transports,
 			});
 
 			// Publish meUpdated event
@@ -161,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			}));
 
 			return {
-				id: credentialIdString,
+				id: credentialId,
 				name: ps.name,
 			};
 		});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
index 65ae66d01e..cae4f5ab52 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
@@ -3,22 +3,38 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { promisify } from 'node:util';
-import * as crypto from 'node:crypto';
 import bcrypt from 'bcryptjs';
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js';
-import { IdService } from '@/core/IdService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
+import type { UserProfilesRepository } from '@/models/index.js';
 import { DI } from '@/di-symbols.js';
-
-const randomBytes = promisify(crypto.randomBytes);
+import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { ApiError } from '@/server/api/error.js';
 
 export const meta = {
 	requireCredential: true,
 
 	secure: true,
+
+	errors: {
+		userNotFound: {
+			message: 'User not found.',
+			code: 'USER_NOT_FOUND',
+			id: '652f899f-66d4-490e-993e-6606c8ec04c3',
+		},
+
+		incorrectPassword: {
+			message: 'Incorrect password.',
+			code: 'INCORRECT_PASSWORD',
+			id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba',
+		},
+
+		twoFactorNotEnabled: {
+			message: '2fa not enabled.',
+			code: 'TWO_FACTOR_NOT_ENABLED',
+			id: 'bf32b864-449b-47b8-974e-f9a5468546f1',
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -29,53 +45,43 @@ export const paramDef = {
 	required: ['password'],
 } as const;
 
+// eslint-disable-next-line import/no-default-export
 @Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
 	constructor(
 		@Inject(DI.userProfilesRepository)
 		private userProfilesRepository: UserProfilesRepository,
 
-		@Inject(DI.attestationChallengesRepository)
-		private attestationChallengesRepository: AttestationChallengesRepository,
-
-		private idService: IdService,
-		private twoFactorAuthenticationService: TwoFactorAuthenticationService,
+		private webAuthnService: WebAuthnService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
+			const profile = await this.userProfilesRepository.findOne({
+				where: {
+					userId: me.id,
+				},
+				relations: ['user'],
+			});
+
+			if (profile == null) {
+				throw new ApiError(meta.errors.userNotFound);
+			}
 
 			// Compare password
-			const same = await bcrypt.compare(ps.password, profile.password!);
+			const same = await bcrypt.compare(ps.password, profile.password ?? '');
 
 			if (!same) {
-				throw new Error('incorrect password');
+				throw new ApiError(meta.errors.incorrectPassword);
 			}
 
 			if (!profile.twoFactorEnabled) {
-				throw new Error('2fa not enabled');
+				throw new ApiError(meta.errors.twoFactorNotEnabled);
 			}
 
-			// 32 byte challenge
-			const entropy = await randomBytes(32);
-			const challenge = entropy.toString('base64')
-				.replace(/=/g, '')
-				.replace(/\+/g, '-')
-				.replace(/\//g, '_');
-
-			const challengeId = this.idService.genId();
-
-			await this.attestationChallengesRepository.insert({
-				userId: me.id,
-				id: challengeId,
-				challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
-				createdAt: new Date(),
-				registrationChallenge: true,
-			});
-
-			return {
-				challengeId,
-				challenge,
-			};
+			return await this.webAuthnService.initiateRegistration(
+				me.id,
+				profile.user?.username ?? me.id,
+				profile.user?.name ?? undefined,
+			);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
index 5ab1635e48..c60343d25d 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
+import { ApiError } from '@/server/api/error.js';
 
 export const meta = {
 	requireCredential: true,
 
 	secure: true,
+
+	errors: {
+		incorrectPassword: {
+			message: 'Incorrect password.',
+			code: 'INCORRECT_PASSWORD',
+			id: '78d6c839-20c9-4c66-b90a-fc0542168b48',
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -39,10 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
 
 			// Compare password
-			const same = await bcrypt.compare(ps.password, profile.password!);
+			const same = await bcrypt.compare(ps.password, profile.password ?? '');
 
 			if (!same) {
-				throw new Error('incorrect password');
+				throw new ApiError(meta.errors.incorrectPassword);
 			}
 
 			// Generate user's secret key
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
index 9cd0898d48..90d23d11f6 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -10,11 +10,20 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
 
 export const meta = {
 	requireCredential: true,
 
 	secure: true,
+
+	errors: {
+		incorrectPassword: {
+			message: 'Incorrect password.',
+			code: 'INCORRECT_PASSWORD',
+			id: '141c598d-a825-44c8-9173-cfb9d92be493',
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -42,10 +51,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
 
 			// Compare password
-			const same = await bcrypt.compare(ps.password, profile.password!);
+			const same = await bcrypt.compare(ps.password, profile.password ?? '');
 
 			if (!same) {
-				throw new Error('incorrect password');
+				throw new ApiError(meta.errors.incorrectPassword);
 			}
 
 			// Make sure we only delete the user's own creds
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
index e017e2ef53..33910f7738 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
@@ -10,11 +10,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import type { UserProfilesRepository } from '@/models/index.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
 
 export const meta = {
 	requireCredential: true,
 
 	secure: true,
+
+	errors: {
+		incorrectPassword: {
+			message: 'Incorrect password.',
+			code: 'INCORRECT_PASSWORD',
+			id: '7add0395-9901-4098-82f9-4f67af65f775',
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -38,10 +47,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
 
 			// Compare password
-			const same = await bcrypt.compare(ps.password, profile.password!);
+			const same = await bcrypt.compare(ps.password, profile.password ?? '');
 
 			if (!same) {
-				throw new Error('incorrect password');
+				throw new ApiError(meta.errors.incorrectPassword);
 			}
 
 			await this.userProfilesRepository.update(me.id, {
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
index cc837ca9f0..90640fd57a 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
@@ -25,7 +25,7 @@ export const meta = {
 		},
 
 		accessDenied: {
-			message: 'You do not have edit privilege of the channel.',
+			message: 'You do not have edit privilege of this key.',
 			code: 'ACCESS_DENIED',
 			id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
 		},
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 0aa7427da8..80d2e9d353 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -9,8 +9,16 @@ import * as assert from 'assert';
 import * as crypto from 'node:crypto';
 import cbor from 'cbor';
 import * as OTPAuth from 'otpauth';
-import { loadConfig } from '../../src/config.js';
-import { signup, api, post, react, startServer, waitFire } from '../utils.js';
+import { loadConfig } from '@/config.js';
+import { api, signup, startServer } from '../utils.js';
+import type {
+	AuthenticationResponseJSON,
+	AuthenticatorAssertionResponseJSON,
+	AuthenticatorAttestationResponseJSON,
+	PublicKeyCredentialCreationOptionsJSON,
+	PublicKeyCredentialRequestOptionsJSON,
+	RegistrationResponseJSON,
+} from '@simplewebauthn/typescript-types';
 import type { INestApplicationContext } from '@nestjs/common';
 import type * as misskey from 'misskey-js';
 
@@ -47,21 +55,18 @@ describe('2要素認証', () => {
 
 	const rpIdHash = (): Buffer => {
 		return crypto.createHash('sha256')
-			.update(Buffer.from(config.hostname, 'utf-8'))
+			.update(Buffer.from(config.host, 'utf-8'))
 			.digest();
 	};
 
 	const keyDoneParam = (param: {
 		keyName: string,
-		challengeId: string,
-		challenge: string,
 		credentialId: Buffer,
+		creationOptions: PublicKeyCredentialCreationOptionsJSON,
 	}): {
-		attestationObject: string,
-		challengeId: string,
-		clientDataJSON: string,
 		password: string,
 		name: string,
+		credential: RegistrationResponseJSON,
 	} => {
 		// A COSE encoded public key
 		const credentialPublicKey = cbor.encode(new Map<number, unknown>([
@@ -76,7 +81,7 @@ describe('2要素認証', () => {
 		// AuthenticatorAssertionResponse.authenticatorData
 		// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
 		const credentialIdLength = Buffer.allocUnsafe(2);
-		credentialIdLength.writeUInt16BE(param.credentialId.length);
+		credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
 		const authData = Buffer.concat([
 			rpIdHash(), // rpIdHash(32)
 			Buffer.from([0x45]), // flags(1)
@@ -88,20 +93,27 @@ describe('2要素認証', () => {
 		]);
 
 		return {
-			attestationObject: cbor.encode({
-				fmt: 'none',
-				attStmt: {},
-				authData,
-			}).toString('hex'),
-			challengeId: param.challengeId,
-			clientDataJSON: JSON.stringify({
-				type: 'webauthn.create',
-				challenge: param.challenge,
-				origin: config.scheme + '://' + config.host,
-				androidPackageName: 'org.mozilla.firefox',
-			}),
 			password,
 			name: param.keyName,
+			credential: <RegistrationResponseJSON>{
+				id: param.credentialId.toString('base64url'),
+				rawId: param.credentialId.toString('base64url'),
+				response: <AuthenticatorAttestationResponseJSON>{
+					clientDataJSON: Buffer.from(JSON.stringify({
+						type: 'webauthn.create',
+						challenge: param.creationOptions.challenge,
+						origin: config.scheme + '://' + config.host,
+						androidPackageName: 'org.mozilla.firefox',
+					}), 'utf-8').toString('base64url'),
+					attestationObject: cbor.encode({
+						fmt: 'none',
+						attStmt: {},
+						authData,
+					}).toString('base64url'),
+				},
+				clientExtensionResults: {},
+				type: 'public-key',
+			},
 		};
 	};
 
@@ -121,17 +133,12 @@ describe('2要素認証', () => {
 
 	const signinWithSecurityKeyParam = (param: {
 		keyName: string,
-		challengeId: string,
-		challenge: string,
 		credentialId: Buffer,
+		requestOptions: PublicKeyCredentialRequestOptionsJSON,
 	}): {
-		authenticatorData: string,
-		credentialId: string,
-		challengeId: string,
-		clientDataJSON: string,
 		username: string,
 		password: string,
-		signature: string,
+		credential: AuthenticationResponseJSON,
 		'g-recaptcha-response'?: string | null,
 		'hcaptcha-response'?: string | null,
 	} => {
@@ -144,10 +151,10 @@ describe('2要素認証', () => {
 		]);
 		const clientDataJSONBuffer = Buffer.from(JSON.stringify({
 			type: 'webauthn.get',
-			challenge: param.challenge,
+			challenge: param.requestOptions.challenge,
 			origin: config.scheme + '://' + config.host,
 			androidPackageName: 'org.mozilla.firefox',
-		}));
+		}), 'utf-8');
 		const hashedclientDataJSON = crypto.createHash('sha256')
 			.update(clientDataJSONBuffer)
 			.digest();
@@ -156,13 +163,19 @@ describe('2要素認証', () => {
 			.update(Buffer.concat([authenticatorData, hashedclientDataJSON]))
 			.sign(privateKey);
 		return {
-			authenticatorData: authenticatorData.toString('hex'),
-			credentialId: param.credentialId.toString('base64'),
-			challengeId: param.challengeId,
-			clientDataJSON: clientDataJSONBuffer.toString('hex'),
 			username,
 			password,
-			signature: signature.toString('hex'),
+			credential: <AuthenticationResponseJSON>{
+				id: param.credentialId.toString('base64url'),
+				rawId: param.credentialId.toString('base64url'),
+				response: <AuthenticatorAssertionResponseJSON>{
+					clientDataJSON: clientDataJSONBuffer.toString('base64url'),
+					authenticatorData: authenticatorData.toString('base64url'),
+					signature: signature.toString('base64url'),
+				},
+				clientExtensionResults: {},
+				type: 'public-key',
+			},
 			'g-recaptcha-response': null,
 			'hcaptcha-response': null,
 		};
@@ -222,19 +235,18 @@ describe('2要素認証', () => {
 			password,
 		}, alice);
 		assert.strictEqual(registerKeyResponse.status, 200);
-		assert.notEqual(registerKeyResponse.body.challengeId, undefined);
+		assert.notEqual(registerKeyResponse.body.rp, undefined);
 		assert.notEqual(registerKeyResponse.body.challenge, undefined);
 
 		const keyName = 'example-key';
 		const credentialId = crypto.randomBytes(0x41);
 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
 			keyName,
-			challengeId: registerKeyResponse.body.challengeId,
-			challenge: registerKeyResponse.body.challenge,
 			credentialId,
+			creationOptions: registerKeyResponse.body,
 		}), alice);
 		assert.strictEqual(keyDoneResponse.status, 200);
-		assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex'));
+		assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
 		assert.strictEqual(keyDoneResponse.body.name, keyName);
 
 		const usersShowResponse = await api('/users/show', {
@@ -248,16 +260,14 @@ describe('2要素認証', () => {
 		});
 		assert.strictEqual(signinResponse.status, 200);
 		assert.strictEqual(signinResponse.body.i, undefined);
-		assert.notEqual(signinResponse.body.challengeId, undefined);
 		assert.notEqual(signinResponse.body.challenge, undefined);
-		assert.notEqual(signinResponse.body.securityKeys, undefined);
-		assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex'));
+		assert.notEqual(signinResponse.body.allowCredentials, undefined);
+		assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url'));
 
 		const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({
 			keyName,
-			challengeId: signinResponse.body.challengeId,
-			challenge: signinResponse.body.challenge,
 			credentialId,
+			requestOptions: signinResponse.body,
 		}));
 		assert.strictEqual(signinResponse2.status, 200);
 		assert.notEqual(signinResponse2.body.i, undefined);
@@ -283,9 +293,8 @@ describe('2要素認証', () => {
 		const credentialId = crypto.randomBytes(0x41);
 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
 			keyName,
-			challengeId: registerKeyResponse.body.challengeId,
-			challenge: registerKeyResponse.body.challenge,
 			credentialId,
+			creationOptions: registerKeyResponse.body,
 		}), alice);
 		assert.strictEqual(keyDoneResponse.status, 200);
 
@@ -310,9 +319,8 @@ describe('2要素認証', () => {
 		const signinResponse2 = await api('/signin', {
 			...signinWithSecurityKeyParam({
 				keyName,
-				challengeId: signinResponse.body.challengeId,
-				challenge: signinResponse.body.challenge,
 				credentialId,
+				requestOptions: signinResponse.body,
 			}),
 			password: '',
 		});
@@ -340,23 +348,22 @@ describe('2要素認証', () => {
 		const credentialId = crypto.randomBytes(0x41);
 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
 			keyName,
-			challengeId: registerKeyResponse.body.challengeId,
-			challenge: registerKeyResponse.body.challenge,
 			credentialId,
+			creationOptions: registerKeyResponse.body,
 		}), alice);
 		assert.strictEqual(keyDoneResponse.status, 200);
 
 		const renamedKey = 'other-key';
 		const updateKeyResponse = await api('/i/2fa/update-key', {
 			name: renamedKey,
-			credentialId: credentialId.toString('hex'),
+			credentialId: credentialId.toString('base64url'),
 		}, alice);
 		assert.strictEqual(updateKeyResponse.status, 200);
 
 		const iResponse = await api('/i', {
 		}, alice);
 		assert.strictEqual(iResponse.status, 200);
-		const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex'));
+		const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url'));
 		assert.strictEqual(securityKeys.length, 1);
 		assert.strictEqual(securityKeys[0].name, renamedKey);
 		assert.notEqual(securityKeys[0].lastUsed, undefined);
@@ -382,9 +389,8 @@ describe('2要素認証', () => {
 		const credentialId = crypto.randomBytes(0x41);
 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
 			keyName,
-			challengeId: registerKeyResponse.body.challengeId,
-			challenge: registerKeyResponse.body.challenge,
 			credentialId,
+			creationOptions: registerKeyResponse.body,
 		}), alice);
 		assert.strictEqual(keyDoneResponse.status, 200);
 
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index edfe6fc28e..254985b173 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -16,6 +16,7 @@
 	},
 	"dependencies": {
 		"@discordapp/twemoji": "14.1.2",
+		"@github/webauthn-json": "2.1.1",
 		"@rollup/plugin-alias": "5.0.0",
 		"@rollup/plugin-json": "6.0.0",
 		"@rollup/plugin-replace": "5.0.2",
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 19f418b48d..247fcb4b29 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
 	<div class="_gaps_m">
-		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
+		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
 		<MkInfo v-if="message">
 			{{ message }}
 		</MkInfo>
 		<div v-if="!totpLogin" class="normal-signin _gaps_m">
-			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
+			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
 				<template #prefix>@</template>
 				<template #suffix>@{{ host }}</template>
 			</MkInput>
-			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :withPasswordToggle="true" required data-cy-signin-password>
+			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password>
 				<template #prefix><i class="ti ti-lock"></i></template>
 				<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
 			</MkInput>
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
 			<div v-if="user && user.securityKeys" class="twofa-group tap-group">
-				<p>{{ i18n.ts.tapSecurityKey }}</p>
+				<p>{{ i18n.ts.useSecurityKey }}</p>
 				<MkButton v-if="!queryingKey" @click="queryKey">
 					{{ i18n.ts.retry }}
 				</MkButton>
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<p class="or-msg">{{ i18n.ts.or }}</p>
 			</div>
 			<div class="twofa-group totp-group">
-				<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
+				<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
 				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
 					<template #label>{{ i18n.ts.password }}</template>
 					<template #prefix><i class="ti ti-lock"></i></template>
@@ -51,32 +51,29 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent } from 'vue';
 import { toUnicode } from 'punycode/';
-import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
+import { UserDetailed } from 'misskey-js/built/entities';
+import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
+import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { host as configHost } from '@/config';
-import { byteify, hexify } from '@/scripts/2fa';
 import * as os from '@/os';
 import { login } from '@/account';
-import { instance } from '@/instance';
 import { i18n } from '@/i18n';
 
 let signing = $ref(false);
-let user = $ref(null);
+let user = $ref<UserDetailed | null>(null);
 let username = $ref('');
 let password = $ref('');
 let token = $ref('');
 let host = $ref(toUnicode(configHost));
 let totpLogin = $ref(false);
-let credential = $ref(null);
-let challengeData = $ref(null);
 let queryingKey = $ref(false);
+let credentialRequest = $ref<CredentialRequestOptions | null>(null);
 let hCaptchaResponse = $ref(null);
 let reCaptchaResponse = $ref(null);
 
-const meta = $computed(() => instance);
-
 const emit = defineEmits<{
 	(ev: 'login', v: any): void;
 }>();
@@ -99,7 +96,7 @@ const props = defineProps({
 	},
 });
 
-function onUsernameChange() {
+function onUsernameChange(): void {
 	os.api('users/show', {
 		username: username,
 	}).then(userResponse => {
@@ -109,58 +106,46 @@ function onUsernameChange() {
 	});
 }
 
-function onLogin(res) {
+function onLogin(res: any): Promise<void> | void {
 	if (props.autoSet) {
 		return login(res.i);
 	}
 }
 
-function queryKey() {
+async function queryKey(): Promise<void> {
 	queryingKey = true;
-	return navigator.credentials.get({
-		publicKey: {
-			challenge: byteify(challengeData.challenge, 'base64'),
-			allowCredentials: challengeData.securityKeys.map(key => ({
-				id: byteify(key.id, 'hex'),
-				type: 'public-key',
-				transports: ['usb', 'nfc', 'ble', 'internal'],
-			})),
-			timeout: 60 * 1000,
-		},
-	}).catch(() => {
-		queryingKey = false;
-		return Promise.reject(null);
-	}).then(credential => {
-		queryingKey = false;
-		signing = true;
-		return os.api('signin', {
-			username,
-			password,
-			signature: hexify(credential.response.signature),
-			authenticatorData: hexify(credential.response.authenticatorData),
-			clientDataJSON: hexify(credential.response.clientDataJSON),
-			credentialId: credential.id,
-			challengeId: challengeData.challengeId,
-			'hcaptcha-response': hCaptchaResponse,
-			'g-recaptcha-response': reCaptchaResponse,
+	await webAuthnRequest(credentialRequest)
+		.catch(() => {
+			queryingKey = false;
+			return Promise.reject(null);
+		}).then(credential => {
+			credentialRequest = null;
+			queryingKey = false;
+			signing = true;
+			return os.api('signin', {
+				username,
+				password,
+				credential: credential.toJSON(),
+				'hcaptcha-response': hCaptchaResponse,
+				'g-recaptcha-response': reCaptchaResponse,
+			});
+		}).then(res => {
+			emit('login', res);
+			return onLogin(res);
+		}).catch(err => {
+			if (err === null) return;
+			os.alert({
+				type: 'error',
+				text: i18n.ts.signinFailed,
+			});
+			signing = false;
 		});
-	}).then(res => {
-		emit('login', res);
-		return onLogin(res);
-	}).catch(err => {
-		if (err === null) return;
-		os.alert({
-			type: 'error',
-			text: i18n.ts.signinFailed,
-		});
-		signing = false;
-	});
 }
 
-function onSubmit() {
+function onSubmit(): void {
 	signing = true;
 	if (!totpLogin && user && user.twoFactorEnabled) {
-		if (window.PublicKeyCredential && user.securityKeys) {
+		if (webAuthnSupported() && user.securityKeys) {
 			os.api('signin', {
 				username,
 				password,
@@ -169,9 +154,12 @@ function onSubmit() {
 			}).then(res => {
 				totpLogin = true;
 				signing = false;
-				challengeData = res;
-				return queryKey();
-			}).catch(loginFailed);
+				credentialRequest = parseRequestOptionsFromJSON({
+					publicKey: res,
+				});
+			})
+				.then(() => queryKey())
+				.catch(loginFailed);
 		} else {
 			totpLogin = true;
 			signing = false;
@@ -182,7 +170,7 @@ function onSubmit() {
 			password,
 			'hcaptcha-response': hCaptchaResponse,
 			'g-recaptcha-response': reCaptchaResponse,
-			token: user && user.twoFactorEnabled ? token : undefined,
+			token: user?.twoFactorEnabled ? token : undefined,
 		}).then(res => {
 			emit('login', res);
 			onLogin(res);
@@ -190,7 +178,7 @@ function onSubmit() {
 	}
 }
 
-function loginFailed(err) {
+function loginFailed(err: any): void {
 	switch (err.id) {
 		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
 			os.alert({
@@ -221,7 +209,7 @@ function loginFailed(err) {
 			break;
 		}
 		default: {
-			console.log(err);
+			console.error(err);
 			os.alert({
 				type: 'error',
 				title: i18n.ts.loginFailed,
@@ -230,12 +218,11 @@ function loginFailed(err) {
 		}
 	}
 
-	challengeData = null;
 	totpLogin = false;
 	signing = false;
 }
 
-function resetPassword() {
+function resetPassword(): void {
 	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
 	}, 'closed');
 }
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 2f923fcae3..965fd1a500 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -38,16 +38,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
 			<div class="_gaps_s">
 				<MkInfo>
-					{{ i18n.ts._2fa.securityKeyInfo }}<br>
-					<br>
-					{{ i18n.ts._2fa.chromePasskeyNotSupported }}
+					{{ i18n.ts._2fa.securityKeyInfo }}
 				</MkInfo>
 
-				<MkInfo v-if="!supportsCredentials" warn>
+				<MkInfo v-if="!webAuthnSupported()" warn>
 					{{ i18n.ts._2fa.securityKeyNotSupported }}
 				</MkInfo>
 
-				<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn>
+				<MkInfo v-else-if="webAuthnSupported() && !$i.twoFactorEnabled" warn>
 					{{ i18n.ts._2fa.registerTOTPBeforeKey }}
 				</MkInfo>
 
@@ -75,8 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, defineAsyncComponent } from 'vue';
-import { hostname } from '@/config';
-import { byteify, hexify, stringify } from '@/scripts/2fa';
+import { supported as webAuthnSupported, create as webAuthnCreate, parseCreationOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
@@ -94,10 +91,9 @@ withDefaults(defineProps<{
 	first: false,
 });
 
-const supportsCredentials = ref(!!navigator.credentials);
-const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
+const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
 
-async function registerTOTP() {
+async function registerTOTP(): Promise<void> {
 	const password = await os.inputText({
 		title: i18n.ts._2fa.registerTOTP,
 		text: i18n.ts._2fa.passwordToTOTP,
@@ -115,7 +111,7 @@ async function registerTOTP() {
 	}, {}, 'closed');
 }
 
-function unregisterTOTP() {
+function unregisterTOTP(): void {
 	os.inputText({
 		title: i18n.ts.password,
 		type: 'password',
@@ -133,7 +129,7 @@ function unregisterTOTP() {
 	});
 }
 
-function renewTOTP() {
+function renewTOTP(): void {
 	os.confirm({
 		type: 'question',
 		title: i18n.ts._2fa.renewTOTP,
@@ -192,8 +188,10 @@ async function addSecurityKey() {
 	});
 	if (password.canceled) return;
 
-	const challenge: any = await os.apiWithDialog('i/2fa/register-key', {
-		password: password.result,
+	const registrationOptions = parseCreationOptionsFromJSON({
+		publicKey: await os.apiWithDialog('i/2fa/register-key', {
+			password: password.result,
+		}),
 	});
 
 	const name = await os.inputText({
@@ -205,26 +203,8 @@ async function addSecurityKey() {
 	});
 	if (name.canceled) return;
 
-	const webAuthnCreation = navigator.credentials.create({
-		publicKey: {
-			challenge: byteify(challenge.challenge, 'base64'),
-			rp: {
-				id: hostname,
-				name: 'Misskey',
-			},
-			user: {
-				id: byteify($i!.id, 'ascii'),
-				name: $i!.username,
-				displayName: $i!.name,
-			},
-			pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
-			timeout: 60000,
-			attestation: 'direct',
-		},
-	}) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
-
 	const credential = await os.promiseDialog(
-		webAuthnCreation,
+		webAuthnCreate(registrationOptions),
 		null,
 		() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない
 		i18n.ts._2fa.tapSecurityKey,
@@ -234,10 +214,7 @@ async function addSecurityKey() {
 	await os.apiWithDialog('i/2fa/key-done', {
 		password: password.result,
 		name: name.result,
-		challengeId: challenge.challengeId,
-		// we convert each 16 bits to a string to serialise
-		clientDataJSON: stringify(credential.response.clientDataJSON),
-		attestationObject: hexify(credential.response.attestationObject),
+		credential: credential.toJSON(),
 	});
 }
 
diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts
deleted file mode 100644
index 2d0498522a..0000000000
--- a/packages/frontend/src/scripts/2fa.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
-	switch (encoding) {
-		case 'ascii':
-			return Uint8Array.from(string, c => c.charCodeAt(0));
-		case 'base64':
-			return Uint8Array.from(
-				atob(
-					string
-						.replace(/-/g, '+')
-						.replace(/_/g, '/'),
-				),
-				c => c.charCodeAt(0),
-			);
-		case 'hex':
-			return new Uint8Array(
-				string
-					.match(/.{1,2}/g)
-					.map(byte => parseInt(byte, 16)),
-			);
-	}
-}
-
-export function hexify(buffer: ArrayBuffer) {
-	return Array.from(new Uint8Array(buffer))
-		.reduce(
-			(str, byte) => str + byte.toString(16).padStart(2, '0'),
-			'',
-		);
-}
-
-export function stringify(buffer: ArrayBuffer) {
-	return String.fromCharCode(... new Uint8Array(buffer));
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 044e4d846a..e5a26b2335 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,6 +113,9 @@ importers:
       '@peertube/http-signature':
         specifier: 1.7.0
         version: 1.7.0
+      '@simplewebauthn/server':
+        specifier: 8.1.1
+        version: 8.1.1
       '@sinonjs/fake-timers':
         specifier: 11.1.0
         version: 11.1.0
@@ -481,6 +484,9 @@ importers:
       '@jest/globals':
         specifier: 29.6.4
         version: 29.6.4
+      '@simplewebauthn/typescript-types':
+        specifier: 8.0.0
+        version: 8.0.0
       '@swc/jest':
         specifier: 0.2.29
         version: 0.2.29(@swc/core@1.3.82)
@@ -637,6 +643,9 @@ importers:
       '@discordapp/twemoji':
         specifier: 14.1.2
         version: 14.1.2
+      '@github/webauthn-json':
+        specifier: 2.1.1
+        version: 2.1.1
       '@rollup/plugin-alias':
         specifier: 5.0.0
         version: 5.0.0(rollup@3.28.1)
@@ -3301,6 +3310,54 @@ packages:
     resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==}
     dev: false
 
+  /@cbor-extract/cbor-extract-darwin-arm64@2.1.1:
+    resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==}
+    cpu: [arm64]
+    os: [darwin]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-darwin-x64@2.1.1:
+    resolution: {integrity: sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==}
+    cpu: [x64]
+    os: [darwin]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-arm64@2.1.1:
+    resolution: {integrity: sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==}
+    cpu: [arm64]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-arm@2.1.1:
+    resolution: {integrity: sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==}
+    cpu: [arm]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-x64@2.1.1:
+    resolution: {integrity: sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==}
+    cpu: [x64]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-win32-x64@2.1.1:
+    resolution: {integrity: sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==}
+    cpu: [x64]
+    os: [win32]
+    requiresBuild: true
+    dev: false
+    optional: true
+
   /@colors/colors@1.5.0:
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
@@ -4066,6 +4123,11 @@ packages:
     resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==}
     dev: true
 
+  /@github/webauthn-json@2.1.1:
+    resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==}
+    hasBin: true
+    dev: false
+
   /@hapi/boom@10.0.1:
     resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
     dependencies:
@@ -4102,6 +4164,10 @@ packages:
       '@hapi/hoek': 11.0.2
     dev: true
 
+  /@hexagon/base64@1.1.27:
+    resolution: {integrity: sha512-PdUmzpvcUM3Rh39kvz9RdbPVYhMjBjdV7Suw7ZduP7urRLsZR8l5tzgSWKm7TExwBYDFwTnYrZbnE0rQ3N5NLQ==}
+    dev: false
+
   /@humanwhocodes/config-array@0.11.10:
     resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
     engines: {node: '>=10.10.0'}
@@ -4787,6 +4853,50 @@ packages:
     resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==}
     dev: true
 
+  /@peculiar/asn1-android@2.3.6:
+    resolution: {integrity: sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-ecc@2.3.6:
+    resolution: {integrity: sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-rsa@2.3.6:
+    resolution: {integrity: sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-schema@2.3.6:
+    resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==}
+    dependencies:
+      asn1js: 3.0.5
+      pvtsutils: 1.3.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-x509@2.3.6:
+    resolution: {integrity: sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      asn1js: 3.0.5
+      ipaddr.js: 2.1.0
+      pvtsutils: 1.3.5
+      tslib: 2.6.2
+    dev: false
+
   /@peertube/http-signature@1.7.0:
     resolution: {integrity: sha512-aGQIwo6/sWtyyqhVK4e1MtxYz4N1X8CNt6SOtCc+Wnczs5S5ONaLHDDR8LYaGn0MgOwvGgXyuZ5sJIfd7iyoUw==}
     engines: {node: '>=0.10'}
@@ -5423,6 +5533,26 @@ packages:
     resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
     dev: true
 
+  /@simplewebauthn/server@8.1.1:
+    resolution: {integrity: sha512-fJ0Ux9eV5oLa6gowHiUXx+oDqh6DhDK/w1oenn8p9+MhZDCXtLOIWl3Crgq5FLnwOuX9NpJzHgmgaOk2b8Tojg==}
+    engines: {node: '>=16.0.0'}
+    dependencies:
+      '@hexagon/base64': 1.1.27
+      '@peculiar/asn1-android': 2.3.6
+      '@peculiar/asn1-ecc': 2.3.6
+      '@peculiar/asn1-rsa': 2.3.6
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      '@simplewebauthn/typescript-types': 8.0.0
+      cbor-x: 1.5.4
+      cross-fetch: 4.0.0
+    transitivePeerDependencies:
+      - encoding
+    dev: false
+
+  /@simplewebauthn/typescript-types@8.0.0:
+    resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
+
   /@sinclair/typebox@0.24.51:
     resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==}
     dev: true
@@ -8909,6 +9039,15 @@ packages:
     dependencies:
       safer-buffer: 2.1.2
 
+  /asn1js@3.0.5:
+    resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
+    engines: {node: '>=12.0.0'}
+    dependencies:
+      pvtsutils: 1.3.5
+      pvutils: 1.1.3
+      tslib: 2.6.2
+    dev: false
+
   /assert-never@1.2.1:
     resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==}
 
@@ -9572,6 +9711,28 @@ packages:
   /caseless@0.12.0:
     resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
 
+  /cbor-extract@2.1.1:
+    resolution: {integrity: sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==}
+    hasBin: true
+    requiresBuild: true
+    dependencies:
+      node-gyp-build-optional-packages: 5.0.3
+    optionalDependencies:
+      '@cbor-extract/cbor-extract-darwin-arm64': 2.1.1
+      '@cbor-extract/cbor-extract-darwin-x64': 2.1.1
+      '@cbor-extract/cbor-extract-linux-arm': 2.1.1
+      '@cbor-extract/cbor-extract-linux-arm64': 2.1.1
+      '@cbor-extract/cbor-extract-linux-x64': 2.1.1
+      '@cbor-extract/cbor-extract-win32-x64': 2.1.1
+    dev: false
+    optional: true
+
+  /cbor-x@1.5.4:
+    resolution: {integrity: sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==}
+    optionalDependencies:
+      cbor-extract: 2.1.1
+    dev: false
+
   /cbor@9.0.1:
     resolution: {integrity: sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==}
     engines: {node: '>=16'}
@@ -10122,6 +10283,14 @@ packages:
       - encoding
     dev: false
 
+  /cross-fetch@4.0.0:
+    resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
+    dependencies:
+      node-fetch: 2.7.0
+    transitivePeerDependencies:
+      - encoding
+    dev: false
+
   /cross-spawn@5.1.0:
     resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
     dependencies:
@@ -15022,6 +15191,18 @@ packages:
     dependencies:
       whatwg-url: 5.0.0
 
+  /node-fetch@2.7.0:
+    resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+    engines: {node: 4.x || >=6.0.0}
+    peerDependencies:
+      encoding: ^0.1.0
+    peerDependenciesMeta:
+      encoding:
+        optional: true
+    dependencies:
+      whatwg-url: 5.0.0
+    dev: false
+
   /node-fetch@3.3.2:
     resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -15030,6 +15211,13 @@ packages:
       fetch-blob: 3.2.0
       formdata-polyfill: 4.0.10
 
+  /node-gyp-build-optional-packages@5.0.3:
+    resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==}
+    hasBin: true
+    requiresBuild: true
+    dev: false
+    optional: true
+
   /node-gyp-build-optional-packages@5.0.7:
     resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
     hasBin: true
@@ -16548,6 +16736,17 @@ packages:
       pngjs: 3.4.0
     dev: false
 
+  /pvtsutils@1.3.5:
+    resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
+    dependencies:
+      tslib: 2.6.2
+    dev: false
+
+  /pvutils@1.1.3:
+    resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
+    engines: {node: '>=6.0.0'}
+    dev: false
+
   /qrcode@1.5.3:
     resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
     engines: {node: '>=10.13.0'}