diff --git a/locales/en-US.yml b/locales/en-US.yml
index fd8e965887..158b8c6490 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -601,6 +601,8 @@ common/views/components/signin.vue:
   signin-with-github: "Sign in with GitHub"
   signin-with-discord: "Sign in with Discord"
   login-failed: "Logging in has failed. Make sure you have entered the correct username and password."
+  tap-key: "Activate your security key by tapping or clicking it to login"
+  enter-2fa-code: "Enter your 2FA code below"
 common/views/components/signup.vue:
   invitation-code: "Invitation code"
   invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>."
@@ -984,7 +986,7 @@ desktop/views/components/settings.2fa.vue:
   url: "https://www.google.com/landing/2step/"
   caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!"
   register: "Register a device"
-  already-registered: "This device is already registered"
+  already-registered: "Your account is currently registered to an authenticator application"
   unregister: "Unregister"
   unregistered: "Two-factor authentication has been disabled."
   enter-password: "Enter the password"
@@ -997,6 +999,15 @@ desktop/views/components/settings.2fa.vue:
   success: "Settings saved!"
   failed: "Failed to setup. Please ensure that the token is correct."
   info: "From the next time you sign in to Misskey, the token displayed on your device will be necessary too, as well as the password."
+  totp-header: "Authenticator App"
+  security-key-header: "Security Keys"
+  security-key: "You can use a hardware security key supporting FIDO2 to log into your account for enhanced security. When you sign-in, you'll need a registered security key or your authenticator app."
+  last-used: "Last used:"
+  activate-key: "Please activate your security key by tapping or clicking it"
+  security-key-name: "Key Name"
+  register-security-key: "Finish Key Registration"
+  something-went-wrong: "Oops! Something went wrong while trying to register your key:"
+  key-unregistered: "Key Removed"
 common/views/components/media-image.vue:
   sensitive: "NSFW"
   click-to-show: "Click to show"
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 9d104456f8..5767a51b0f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -646,6 +646,8 @@ common/views/components/signin.vue:
   signin-with-github: "GitHubでログイン"
   signin-with-discord: "Discordでログイン"
   login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
+  tap-key: "セキュリティキーをクリックしてログイン"
+  enter-2fa-code: "認証コードを入力してください"
 
 common/views/components/signup.vue:
   invitation-code: "招待コード"
@@ -1100,6 +1102,15 @@ desktop/views/components/settings.2fa.vue:
   success: "設定が完了しました!"
   failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
   info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+  totp-header: "認証アプリ"
+  security-key-header: "セキュリティキー"
+  security-key: "セキュリティを強化するために、FIDO2をサポートするハードウェアセキュリティキーを使用してアカウントにログインできます。 サインインの際は、登録されたセキュリティキーまたは認証アプリが必要になります。"
+  last-used: "最後の使用:"
+  activate-key: "クリックしてセキュリティキーをアクティベートしてください"
+  security-key-name: "キー名"
+  register-security-key: "キーの登録を完了"
+  something-went-wrong: "わー! キーを登録する際に問題が発生しました:"
+  key-unregistered: "キーが削除されました"
 
 common/views/components/media-image.vue:
   sensitive: "閲覧注意"
diff --git a/migration/1561706992953-webauthn.ts b/migration/1561706992953-webauthn.ts
new file mode 100644
index 0000000000..fc1f0c042f
--- /dev/null
+++ b/migration/1561706992953-webauthn.ts
@@ -0,0 +1,29 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class webauthn1561706992953 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<any> {
+        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(`CREATE TABLE "user_security_key" ("id" character varying NOT NULL, "userId" character varying(32) NOT NULL, "publicKey" character varying NOT NULL, "lastUsed" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(30) NOT NULL, CONSTRAINT "PK_3e508571121ab39c5f85d10c166" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44" ON "user_security_key" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7" ON "user_security_key" ("publicKey") `);
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "securityKeysAvailable" boolean NOT NULL DEFAULT false`);
+        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(`ALTER TABLE "user_security_key" ADD CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "user_security_key" DROP CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447"`);
+        await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "securityKeysAvailable"`);
+        await queryRunner.query(`DROP INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7"`);
+        await queryRunner.query(`DROP INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44"`);
+        await queryRunner.query(`DROP TABLE "user_security_key"`);
+        await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
+        await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
+        await queryRunner.query(`DROP TABLE "attestation_challenge"`);
+    }
+
+}
diff --git a/package.json b/package.json
index 79009380ce..119deacaf6 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
 		"@koa/cors": "3.0.0",
 		"@types/bcryptjs": "2.4.2",
 		"@types/bull": "3.5.15",
+		"@types/cbor": "2.0.0",
 		"@types/dateformat": "3.0.0",
 		"@types/deep-equal": "1.0.1",
 		"@types/double-ended-queue": "2.1.1",
@@ -104,9 +105,11 @@
 		"autosize": "4.0.2",
 		"autwh": "0.1.0",
 		"bcryptjs": "2.4.3",
+		"bootstrap": "4.3.1",
 		"bootstrap-vue": "2.0.0-rc.13",
 		"bull": "3.10.0",
 		"cafy": "15.1.1",
+		"cbor": "4.1.5",
 		"chai": "4.2.0",
 		"chalk": "2.4.2",
 		"cli-highlight": "2.1.1",
@@ -148,6 +151,7 @@
 		"jsdom": "15.1.1",
 		"json5": "2.1.0",
 		"json5-loader": "3.0.0",
+    "jsrsasign": "8.0.12",
 		"katex": "0.10.2",
 		"koa": "2.7.0",
 		"koa-bodyparser": "4.2.1",
diff --git a/src/boot/master.ts b/src/boot/master.ts
index 6c23a528fa..b698548d47 100644
--- a/src/boot/master.ts
+++ b/src/boot/master.ts
@@ -79,6 +79,7 @@ export async function masterMain() {
 		require('../daemons/server-stats').default();
 		require('../daemons/notes-stats').default();
 		require('../daemons/queue-stats').default();
+		require('../daemons/janitor').default();
 	}
 
 	bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
diff --git a/src/client/app/common/scripts/2fa.ts b/src/client/app/common/scripts/2fa.ts
new file mode 100644
index 0000000000..f638cce156
--- /dev/null
+++ b/src/client/app/common/scripts/2fa.ts
@@ -0,0 +1,5 @@
+export function hexifyAB(buffer) {
+	return Array.from(new Uint8Array(buffer))
+		.map(item => item.toString(16).padStart(2, 0))
+		.join('');
+}
diff --git a/src/client/app/common/views/components/settings/2fa.vue b/src/client/app/common/views/components/settings/2fa.vue
index 6e8d19d83a..eb645898e2 100644
--- a/src/client/app/common/views/components/settings/2fa.vue
+++ b/src/client/app/common/views/components/settings/2fa.vue
@@ -1,11 +1,54 @@
 <template>
-<div class="2fa">
+<div class="2fa totp-section">
 	<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
 	<ui-info warn>{{ $t('caution') }}</ui-info>
 	<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
 	<template v-if="$store.state.i.twoFactorEnabled">
+		<h2 class="heading">{{ $t('totp-header') }}</h2>
 		<p>{{ $t('already-registered') }}</p>
 		<ui-button @click="unregister">{{ $t('unregister') }}</ui-button>
+
+		<template v-if="supportsCredentials">
+			<hr class="totp-method-sep">
+
+			<h2 class="heading">{{ $t('security-key-header') }}</h2>
+			<p>{{ $t('security-key') }}</p>
+			<div class="key-list">
+				<div class="key" v-for="key in $store.state.i.securityKeysList">
+					<h3>
+						{{ key.name }}
+					</h3>
+					<div class="last-used">
+						{{ $t('last-used') }}
+						<mk-time :time="key.lastUsed"/>
+					</div>
+					<ui-button @click="unregisterKey(key)">
+						{{ $t('unregister') }}
+					</ui-button>
+				</div>
+			</div>
+
+			<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info>
+			<ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
+
+			<ol v-if="registration && !registration.error">
+				<li v-if="registration.stage >= 0">
+					{{ $t('activate-key') }}
+					<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
+				</li>
+				<li v-if="registration.stage >= 1">
+					<ui-form :disabled="registration.stage != 1 || registration.saving">
+						<ui-input v-model="keyName" :max="30">
+							<span>{{ $t('security-key-name') }}</span>
+						</ui-input>
+						<ui-button @click="registerKey" :disabled="this.keyName.length == 0">
+							{{ $t('register-security-key') }}
+						</ui-button>
+						<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
+					</ui-form>
+				</li>
+			</ol>
+		</template>
 	</template>
 	<div v-if="data && !$store.state.i.twoFactorEnabled">
 		<ol>
@@ -24,12 +67,21 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../../i18n';
+import { hostname } from '../../../../config';
+import { hexifyAB } from '../../../scripts/2fa';
+
+function stringifyAB(buffer) {
+	return String.fromCharCode.apply(null, new Uint8Array(buffer));
+}
 
 export default Vue.extend({
 	i18n: i18n('desktop/views/components/settings.2fa.vue'),
 	data() {
 		return {
 			data: null,
+			supportsCredentials: !!navigator.credentials,
+			registration: null,
+			keyName: '',
 			token: null
 		};
 	},
@@ -76,7 +128,116 @@ export default Vue.extend({
 			}).catch(() => {
 				this.$notify(this.$t('failed'));
 			});
+		},
+
+		registerKey() {
+			this.registration.saving = true;
+			this.$root.api('i/2fa/key-done', {
+				password: this.registration.password,
+				name: this.keyName,
+				challengeId: this.registration.challengeId,
+				// we convert each 16 bits to a string to serialise
+				clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
+				attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
+			}).then(key => {
+				this.registration = null;
+				key.lastUsed = new Date();
+				this.$notify(this.$t('success'));
+			})
+		},
+
+		unregisterKey(key) {
+			this.$root.dialog({
+				title: this.$t('enter-password'),
+				input: {
+					type: 'password'
+				}
+			}).then(({ canceled, result: password }) => {
+				if (canceled) return;
+				return this.$root.api('i/2fa/remove-key', {
+					password,
+					credentialId: key.id
+				}).then(() => {
+					this.$notify(this.$t('key-unregistered'));
+				});
+			});
+		},
+
+		addSecurityKey() {
+			this.$root.dialog({
+				title: this.$t('enter-password'),
+				input: {
+					type: 'password'
+				}
+			}).then(({ canceled, result: password }) => {
+				if (canceled) return;
+				this.$root.api('i/2fa/register-key', {
+					password
+				}).then(registration => {
+					this.registration = {
+						password,
+						challengeId: registration.challengeId,
+						stage: 0,
+						publicKeyOptions: {
+							challenge: Buffer.from(
+								registration.challenge
+									.replace(/\-/g, "+")
+									.replace(/_/g, "/"),
+								'base64'
+							),
+							rp: {
+								id: hostname,
+								name: 'Misskey'
+							},
+							user: {
+								id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
+								name: this.$store.state.i.username,
+								displayName: this.$store.state.i.name,
+							},
+							pubKeyCredParams: [{alg: -7, type: 'public-key'}],
+							timeout: 60000,
+							attestation: 'direct'
+						},
+						saving: true
+					};
+					return navigator.credentials.create({
+						publicKey: this.registration.publicKeyOptions
+					});
+				}).then(credential => {
+					this.registration.credential = credential;
+					this.registration.saving = false;
+					this.registration.stage = 1;
+				}).catch(err => {
+					console.warn('Error while registering?', err);
+					this.registration.error = err.message;
+					this.registration.stage = -1;
+				});
+			});
 		}
 	}
 });
 </script>
+
+<style lang="stylus" scoped>
+.totp-section
+	.totp-method-sep
+		margin 1.5em 0 1em
+		border none
+		border-top solid var(--lineWidth) var(--faceDivider)
+
+	h2.heading
+		margin 0
+
+	.key
+		padding 1em
+		margin 0.5em 0
+		background #161616
+		border-radius 6px
+
+		h3
+			margin-top 0
+			margin-bottom .3em
+
+		.last-used
+			margin-bottom .5em
+</style>
diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue
index 03ee51d06e..53cc62c333 100644
--- a/src/client/app/common/views/components/signin.vue
+++ b/src/client/app/common/views/components/signin.vue
@@ -1,23 +1,40 @@
 <template>
-<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit">
+<form class="mk-signin" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
 	<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
-	<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
-		<span>{{ $t('username') }}</span>
-		<template #prefix>@</template>
-		<template #suffix>@{{ host }}</template>
-	</ui-input>
-	<ui-input v-model="password" type="password" :with-password-toggle="true" required>
-		<span>{{ $t('password') }}</span>
-		<template #prefix><fa icon="lock"/></template>
-	</ui-input>
-	<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
-		<span>{{ $t('@.2fa') }}</span>
-		<template #prefix><fa icon="gavel"/></template>
-	</ui-input>
-	<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
-	<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
-	<p v-if="meta && meta.enableGithubIntegration"  style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
-	<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
+	<div class="normal-signin" v-if="!totpLogin">
+		<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
+			<span>{{ $t('username') }}</span>
+			<template #prefix>@</template>
+			<template #suffix>@{{ host }}</template>
+		</ui-input>
+		<ui-input v-model="password" type="password" :with-password-toggle="true" required>
+			<span>{{ $t('password') }}</span>
+			<template #prefix><fa icon="lock"/></template>
+		</ui-input>
+		<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
+		<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
+		<p v-if="meta && meta.enableGithubIntegration"  style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
+		<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
+	</div>
+	<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
+		<div v-if="user && user.securityKeys" class="twofa-group tap-group">
+			<p>{{ $t('tap-key') }}</p>
+			<ui-button @click="queryKey" v-if="!queryingKey">
+				{{ $t('@.error.retry') }}
+			</ui-button>
+		</div>
+		<div class="or-hr" v-if="user && user.securityKeys">
+			<p class="or-msg">{{ $t('or') }}</p>
+		</div>
+		<div class="twofa-group totp-group">
+			<p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p>
+			<ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
+				<span>{{ $t('@.2fa') }}</span>
+				<template #prefix><fa icon="gavel"/></template>
+			</ui-input>
+			<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
+		</div>
+	</div>
 </form>
 </template>
 
@@ -26,6 +43,7 @@ import Vue from 'vue';
 import i18n from '../../../i18n';
 import { apiUrl, host } from '../../../config';
 import { toUnicode } from 'punycode';
+import { hexifyAB } from '../../scripts/2fa';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/signin.vue'),
@@ -47,7 +65,11 @@ export default Vue.extend({
 			token: '',
 			apiUrl,
 			host: toUnicode(host),
-			meta: null
+			meta: null,
+			totpLogin: false,
+			credential: null,
+			challengeData: null,
+			queryingKey: false,
 		};
 	},
 
@@ -68,23 +90,87 @@ export default Vue.extend({
 			});
 		},
 
-		onSubmit() {
-			this.signing = true;
-
-			this.$root.api('signin', {
-				username: this.username,
-				password: this.password,
-				token: this.user && this.user.twoFactorEnabled ? this.token : undefined
+		queryKey() {
+			this.queryingKey = true;
+			return navigator.credentials.get({
+				publicKey: {
+					challenge: Buffer.from(
+						this.challengeData.challenge
+							.replace(/\-/g, '+')
+							.replace(/_/g, '/'),
+							'base64'
+					),
+					allowCredentials: this.challengeData.securityKeys.map(key => ({
+						id: Buffer.from(key.id, 'hex'),
+						type: 'public-key',
+						transports: ['usb', 'ble', 'nfc']
+					})),
+					timeout: 60 * 1000
+				}
+			}).catch(err => {
+				this.queryingKey = false;
+				console.warn(err);
+				return Promise.reject(null);
+			}).then(credential => {
+				this.queryingKey = false;
+				this.signing = true;
+				return this.$root.api('signin', {
+					username: this.username,
+					password: this.password,
+					signature: hexifyAB(credential.response.signature),
+					authenticatorData: hexifyAB(credential.response.authenticatorData),
+					clientDataJSON: hexifyAB(credential.response.clientDataJSON),
+					credentialId: credential.id,
+					challengeId: this.challengeData.challengeId
+				});
 			}).then(res => {
 				localStorage.setItem('i', res.i);
 				location.reload();
-			}).catch(() => {
+			}).catch(err => {
+				if(err === null) return;
+				console.error(err);
 				this.$root.dialog({
 					type: 'error',
 					text: this.$t('login-failed')
 				});
 				this.signing = false;
 			});
+		},
+
+		onSubmit() {
+			this.signing = true;
+
+			if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
+				if (window.PublicKeyCredential && this.user.securityKeys) {
+					this.$root.api('i/2fa/getkeys', {
+						username: this.username,
+						password: this.password
+					}).then(res => {
+						this.totpLogin = true;
+						this.signing = false;
+						this.challengeData = res;
+						return this.queryKey();
+					});
+				} else {
+					this.totpLogin = true;
+					this.signing = false;
+				}
+			} else {
+				this.$root.api('signin', {
+					username: this.username,
+					password: this.password,
+					token: this.user && this.user.twoFactorEnabled ? this.token : undefined
+				}).then(res => {
+					localStorage.setItem('i', res.i);
+					location.reload();
+				}).catch(() => {
+					this.$root.dialog({
+						type: 'error',
+						text: this.$t('login-failed')
+					});
+					this.signing = false;
+				});
+			}
 		}
 	}
 });
@@ -94,6 +180,48 @@ export default Vue.extend({
 .mk-signin
 	color #555
 
+	.or-hr,
+	.or-hr .or-msg,
+	.twofa-group,
+	.twofa-group p
+		color var(--text)
+
+	.tap-group > button
+		margin-bottom 1em
+
+	.securityKeys .or-hr
+		&
+			position relative
+
+		.or-msg
+			&:before
+				right 100%
+				margin-right 0.125em
+
+			&:after
+				left 100%
+				margin-left 0.125em
+
+			&:before, &:after
+				content ""
+				position absolute
+				top 50%
+				width 100%
+				height 2px
+				background #555
+
+			&
+				position relative
+				margin auto
+				left 0
+				right 0
+				top 0
+				bottom 0
+				font-size 1.5em
+				height 1.5em
+				width 3em
+				text-align center
+
 	&.signing
 		&, *
 			cursor wait !important
diff --git a/src/daemons/janitor.ts b/src/daemons/janitor.ts
new file mode 100644
index 0000000000..462ebf915c
--- /dev/null
+++ b/src/daemons/janitor.ts
@@ -0,0 +1,18 @@
+const interval = 30 * 60 * 1000;
+import { AttestationChallenges } from '../models';
+import { LessThan } from 'typeorm';
+
+/**
+ * Clean up database occasionally
+ */
+export default function() {
+	async function tick() {
+		await AttestationChallenges.delete({
+			createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000))
+		});
+	}
+
+	tick();
+
+	setInterval(tick, interval);
+}
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 925e3fcbfc..94a19b06be 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -43,6 +43,8 @@ import { Poll } from '../models/entities/poll';
 import { UserKeypair } from '../models/entities/user-keypair';
 import { UserPublickey } from '../models/entities/user-publickey';
 import { UserProfile } from '../models/entities/user-profile';
+import { UserSecurityKey } from '../models/entities/user-security-key';
+import { AttestationChallenge } from '../models/entities/attestation-challenge';
 import { Page } from '../models/entities/page';
 import { PageLike } from '../models/entities/page-like';
 
@@ -96,6 +98,8 @@ export const entities = [
 	UserGroupJoining,
 	UserGroupInvite,
 	UserNotePining,
+	UserSecurityKey,
+	AttestationChallenge,
 	Following,
 	FollowRequest,
 	Muting,
@@ -146,7 +150,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
 			options: {
 				host: config.redis.host,
 				port: config.redis.port,
-				options:{
+				options: {
 					password: config.redis.pass,
 					prefix: config.redis.prefix,
 					db: config.redis.db || 0
diff --git a/src/models/entities/attestation-challenge.ts b/src/models/entities/attestation-challenge.ts
new file mode 100644
index 0000000000..942747c02f
--- /dev/null
+++ b/src/models/entities/attestation-challenge.ts
@@ -0,0 +1,46 @@
+import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+
+@Entity()
+export class AttestationChallenge {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@PrimaryColumn(id())
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | 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<AttestationChallenge>) {
+		if (data == null) return;
+
+		for (const [k, v] of Object.entries(data)) {
+			(this as any)[k] = v;
+		}
+	}
+}
diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts
index 7d990b961f..6f960f1b7b 100644
--- a/src/models/entities/user-profile.ts
+++ b/src/models/entities/user-profile.ts
@@ -76,6 +76,11 @@ export class UserProfile {
 	})
 	public twoFactorEnabled: boolean;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public securityKeysAvailable: boolean;
+
 	@Column('varchar', {
 		length: 128, nullable: true,
 		comment: 'The password hash of the User. It will be null if the origin of the user is local.'
diff --git a/src/models/entities/user-security-key.ts b/src/models/entities/user-security-key.ts
new file mode 100644
index 0000000000..d54c728e53
--- /dev/null
+++ b/src/models/entities/user-security-key.ts
@@ -0,0 +1,48 @@
+import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+
+@Entity()
+export class UserSecurityKey {
+	@PrimaryColumn('varchar', {
+		comment: 'Variable-length id given to navigator.credentials.get()'
+	})
+	public id: string;
+
+	@Index()
+	@Column(id())
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | 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;
+
+	constructor(data: Partial<UserSecurityKey>) {
+		if (data == null) return;
+
+		for (const [k, v] of Object.entries(data)) {
+			(this as any)[k] = v;
+		}
+	}
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index a60cd10ef9..888fd53f36 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -37,6 +37,8 @@ import { FollowingRepository } from './repositories/following';
 import { AbuseUserReportRepository } from './repositories/abuse-user-report';
 import { AuthSessionRepository } from './repositories/auth-session';
 import { UserProfile } from './entities/user-profile';
+import { AttestationChallenge } from './entities/attestation-challenge';
+import { UserSecurityKey } from './entities/user-security-key';
 import { HashtagRepository } from './repositories/hashtag';
 import { PageRepository } from './repositories/page';
 import { PageLikeRepository } from './repositories/page-like';
@@ -52,6 +54,8 @@ export const PollVotes = getRepository(PollVote);
 export const Users = getCustomRepository(UserRepository);
 export const UserProfiles = getRepository(UserProfile);
 export const UserKeypairs = getRepository(UserKeypair);
+export const AttestationChallenges = getRepository(AttestationChallenge);
+export const UserSecurityKeys = getRepository(UserSecurityKey);
 export const UserPublickeys = getRepository(UserPublickey);
 export const UserLists = getCustomRepository(UserListRepository);
 export const UserListJoinings = getRepository(UserListJoining);
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 5da7ee7832..cc89b674c5 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy';
 import { EntityRepository, Repository, In } from 'typeorm';
 import { User, ILocalUser, IRemoteUser } from '../entities/user';
-import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..';
+import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings } from '..';
 import { ensure } from '../../prelude/ensure';
 import config from '../../config';
 import { SchemaType } from '../../misc/schema';
@@ -156,6 +156,11 @@ export class UserRepository extends Repository<User> {
 					detail: true
 				}),
 				twoFactorEnabled: profile!.twoFactorEnabled,
+				securityKeys: profile!.twoFactorEnabled
+					? UserSecurityKeys.count({
+						userId: user.id
+					}).then(result => result >= 1)
+					: false,
 				twitter: profile!.twitter ? {
 					id: profile!.twitterUserId,
 					screenName: profile!.twitterScreenName
@@ -195,6 +200,15 @@ export class UserRepository extends Repository<User> {
 				clientData: profile!.clientData,
 				email: profile!.email,
 				emailVerified: profile!.emailVerified,
+				securityKeysList: profile!.twoFactorEnabled
+					? UserSecurityKeys.find({
+						where: {
+							userId: user.id
+						},
+						select: ['id', 'name', 'lastUsed']
+					})
+					: []
+
 			} : {}),
 
 			...(relation ? {
diff --git a/src/server/api/2fa.ts b/src/server/api/2fa.ts
new file mode 100644
index 0000000000..bc5f6e6d7d
--- /dev/null
+++ b/src/server/api/2fa.ts
@@ -0,0 +1,422 @@
+import * as crypto from 'crypto';
+import config from '../../config';
+import * as jsrsasign from 'jsrsasign';
+
+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]);
+		const algorithm = certificate.getSignatureAlgorithmField();
+		const signatureHex = certificate.getSignatureValueHex();
+
+		// Verify against CA
+		const Signature = new jsrsasign.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`
+	);
+}
+
+export function hash(data: Buffer) {
+	return crypto
+		.createHash('sha256')
+		.update(data)
+		.digest();
+}
+
+export function verifyLogin({
+	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 (hash(clientData.challenge).toString('hex') != challenge) {
+		throw new Error('challenge mismatch');
+	}
+	if (clientData.origin != config.scheme + '://' + config.host) {
+		throw new Error('origin mismatch');
+	}
+
+	const verificationData = Buffer.concat(
+		[authenticatorData, hash(clientDataJSON)],
+		32 + authenticatorData.length
+	);
+
+	return crypto
+		.createVerify('SHA256')
+		.update(verificationData)
+		.verify(PEMString(publicKey), signature);
+}
+
+export const procedures = {
+	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 = 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 => 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/src/server/api/endpoints/i/2fa/getkeys.ts b/src/server/api/endpoints/i/2fa/getkeys.ts
new file mode 100644
index 0000000000..bb1585d795
--- /dev/null
+++ b/src/server/api/endpoints/i/2fa/getkeys.ts
@@ -0,0 +1,67 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import * as crypto from 'crypto';
+import define from '../../../define';
+import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models';
+import { ensure } from '../../../../../prelude/ensure';
+import { promisify } from 'util';
+import { hash } from '../../../2fa';
+import { genId } from '../../../../../misc/gen-id';
+
+export const meta = {
+	requireCredential: true,
+
+	secure: true,
+
+	params: {
+		password: {
+			validator: $.str
+		}
+	}
+};
+
+const randomBytes = promisify(crypto.randomBytes);
+
+export default define(meta, async (ps, user) => {
+	const profile = await UserProfiles.findOne(user.id).then(ensure);
+
+	// Compare password
+	const same = await bcrypt.compare(ps.password, profile.password!);
+
+	if (!same) {
+		throw new Error('incorrect password');
+	}
+
+	const keys = await UserSecurityKeys.find({
+		userId: user.id
+	});
+
+	if (keys.length === 0) {
+		throw new Error('no keys found');
+	}
+
+	// 32 byte challenge
+	const entropy = await randomBytes(32);
+	const challenge = entropy.toString('base64')
+		.replace(/=/g, '')
+		.replace(/\+/g, '-')
+		.replace(/\//g, '_');
+
+	const challengeId = genId();
+
+	await AttestationChallenges.save({
+		userId: user.id,
+		id: challengeId,
+		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
+		createdAt: new Date(),
+		registrationChallenge: false
+	});
+
+	return {
+		challenge,
+		challengeId,
+		securityKeys: keys.map(key => ({
+			id: key.id
+		}))
+	};
+});
diff --git a/src/server/api/endpoints/i/2fa/key-done.ts b/src/server/api/endpoints/i/2fa/key-done.ts
new file mode 100644
index 0000000000..074ab22bf0
--- /dev/null
+++ b/src/server/api/endpoints/i/2fa/key-done.ts
@@ -0,0 +1,151 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import { promisify } from 'util';
+import * as cbor from 'cbor';
+import define from '../../../define';
+import {
+	UserProfiles,
+	UserSecurityKeys,
+	AttestationChallenges,
+	Users
+} from '../../../../../models';
+import { ensure } from '../../../../../prelude/ensure';
+import config from '../../../../../config';
+import { procedures, hash } from '../../../2fa';
+import { publishMainStream } from '../../../../../services/stream';
+
+const cborDecodeFirst = promisify(cbor.decodeFirst);
+
+export const meta = {
+	requireCredential: true,
+
+	secure: true,
+
+	params: {
+		clientDataJSON: {
+			validator: $.str
+		},
+		attestationObject: {
+			validator: $.str
+		},
+		password: {
+			validator: $.str
+		},
+		challengeId: {
+			validator: $.str
+		},
+		name: {
+			validator: $.str
+		}
+	}
+};
+
+const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8'));
+
+export default define(meta, async (ps, user) => {
+	const profile = await UserProfiles.findOne(user.id).then(ensure);
+
+	// Compare password
+	const same = await bcrypt.compare(ps.password, profile.password!);
+
+	if (!same) {
+		throw new Error('incorrect password');
+	}
+
+	if (!profile.twoFactorEnabled) {
+		throw new Error('2fa not enabled');
+	}
+
+	const clientData = JSON.parse(ps.clientDataJSON);
+
+	if (clientData.type != 'webauthn.create') {
+		throw new Error('not a creation attestation');
+	}
+	if (clientData.origin != config.scheme + '://' + config.host) {
+		throw new Error('origin mismatch');
+	}
+
+	const clientDataJSONHash = 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];
+
+	// tslint: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');
+	}
+
+	if (!procedures[attestation.fmt]) {
+		throw new Error('unsupported fmt');
+	}
+
+	const verificationData = procedures[attestation.fmt].verify({
+		attStmt: attestation.attStmt,
+		authenticatorData: authData,
+		clientDataHash: clientDataJSONHash,
+		credentialId,
+		publicKey,
+		rpIdHash
+	});
+	if (!verificationData.valid) throw new Error('signature invalid');
+
+	const attestationChallenge = await AttestationChallenges.findOne({
+		userId: user.id,
+		id: ps.challengeId,
+		registrationChallenge: true,
+		challenge: hash(clientData.challenge).toString('hex')
+	});
+
+	if (!attestationChallenge) {
+		throw new Error('non-existent challenge');
+	}
+
+	await AttestationChallenges.delete({
+		userId: user.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');
+
+	await UserSecurityKeys.save({
+		userId: user.id,
+		id: credentialIdString,
+		lastUsed: new Date(),
+		name: ps.name,
+		publicKey: verificationData.publicKey.toString('hex')
+	});
+
+	// Publish meUpdated event
+	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
+		detail: true,
+		includeSecrets: true
+	}));
+
+	return {
+		id: credentialIdString,
+		name: ps.name
+	};
+});
diff --git a/src/server/api/endpoints/i/2fa/register-key.ts b/src/server/api/endpoints/i/2fa/register-key.ts
new file mode 100644
index 0000000000..1c2cc32e37
--- /dev/null
+++ b/src/server/api/endpoints/i/2fa/register-key.ts
@@ -0,0 +1,60 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import define from '../../../define';
+import { UserProfiles, AttestationChallenges } from '../../../../../models';
+import { ensure } from '../../../../../prelude/ensure';
+import { promisify } from 'util';
+import * as crypto from 'crypto';
+import { genId } from '../../../../../misc/gen-id';
+import { hash } from '../../../2fa';
+
+const randomBytes = promisify(crypto.randomBytes);
+
+export const meta = {
+	requireCredential: true,
+
+	secure: true,
+
+	params: {
+		password: {
+			validator: $.str
+		}
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const profile = await UserProfiles.findOne(user.id).then(ensure);
+
+	// Compare password
+	const same = await bcrypt.compare(ps.password, profile.password!);
+
+	if (!same) {
+		throw new Error('incorrect password');
+	}
+
+	if (!profile.twoFactorEnabled) {
+		throw new Error('2fa not enabled');
+	}
+
+	// 32 byte challenge
+	const entropy = await randomBytes(32);
+	const challenge = entropy.toString('base64')
+		.replace(/=/g, '')
+		.replace(/\+/g, '-')
+		.replace(/\//g, '_');
+
+	const challengeId = genId();
+
+	await AttestationChallenges.save({
+		userId: user.id,
+		id: challengeId,
+		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
+		createdAt: new Date(),
+		registrationChallenge: true
+	});
+
+	return {
+		challengeId,
+		challenge
+	};
+});
diff --git a/src/server/api/endpoints/i/2fa/remove-key.ts b/src/server/api/endpoints/i/2fa/remove-key.ts
new file mode 100644
index 0000000000..cb28c8fbfb
--- /dev/null
+++ b/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -0,0 +1,46 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import define from '../../../define';
+import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models';
+import { ensure } from '../../../../../prelude/ensure';
+import { publishMainStream } from '../../../../../services/stream';
+
+export const meta = {
+	requireCredential: true,
+
+	secure: true,
+
+	params: {
+		password: {
+			validator: $.str
+		},
+		credentialId: {
+			validator: $.str
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const profile = await UserProfiles.findOne(user.id).then(ensure);
+
+	// Compare password
+	const same = await bcrypt.compare(ps.password, profile.password!);
+
+	if (!same) {
+		throw new Error('incorrect password');
+	}
+
+	// Make sure we only delete the user's own creds
+	await UserSecurityKeys.delete({
+		userId: user.id,
+		id: ps.credentialId
+	});
+
+	// Publish meUpdated event
+	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
+		detail: true,
+		includeSecrets: true
+	}));
+
+	return {};
+});
diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts
index 02361a139d..cd9fe5bb9d 100644
--- a/src/server/api/private/signin.ts
+++ b/src/server/api/private/signin.ts
@@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
 import { publishMainStream } from '../../../services/stream';
 import signin from '../common/signin';
 import config from '../../../config';
-import { Users, Signins, UserProfiles } from '../../../models';
+import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models';
 import { ILocalUser } from '../../../models/entities/user';
 import { genId } from '../../../misc/gen-id';
 import { ensure } from '../../../prelude/ensure';
+import { verifyLogin, hash } from '../2fa';
 
 export default async (ctx: Koa.BaseContext) => {
 	ctx.set('Access-Control-Allow-Origin', config.url);
@@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
 	// Compare password
 	const same = await bcrypt.compare(password, profile.password!);
 
-	if (same) {
-		if (profile.twoFactorEnabled) {
-			const verified = (speakeasy as any).totp.verify({
-				secret: profile.twoFactorSecret,
-				encoding: 'base32',
-				token: token
-			});
-
-			if (verified) {
-				signin(ctx, user);
-			} else {
-				ctx.throw(403, {
-					error: 'invalid token'
-				});
-			}
-		} else {
-			signin(ctx, user);
-		}
-	} else {
-		ctx.throw(403, {
-			error: 'incorrect password'
+	async function fail(status?: number, failure?: {error: string}) {
+		// Append signin history
+		const record = await Signins.save({
+			id: genId(),
+			createdAt: new Date(),
+			userId: user.id,
+			ip: ctx.ip,
+			headers: ctx.headers,
+			success: !!(status || failure)
 		});
+
+		// Publish signin event
+		publishMainStream(user.id, 'signin', await Signins.pack(record));
+
+		if (status && failure) {
+			ctx.throw(status, failure);
+		}
 	}
 
-	// Append signin history
-	const record = await Signins.save({
-		id: genId(),
-		createdAt: new Date(),
-		userId: user.id,
-		ip: ctx.ip,
-		headers: ctx.headers,
-		success: same
-	});
+	if (!same) {
+		await fail(403, {
+			error: 'incorrect password'
+		});
+		return;
+	}
 
-	// Publish signin event
-	publishMainStream(user.id, 'signin', await Signins.pack(record));
+	if (!profile.twoFactorEnabled) {
+		signin(ctx, user);
+		return;
+	}
+
+	if (token) {
+		const verified = (speakeasy as any).totp.verify({
+			secret: profile.twoFactorSecret,
+			encoding: 'base32',
+			token: token
+		});
+
+		if (verified) {
+			signin(ctx, user);
+			return;
+		} else {
+			await fail(403, {
+				error: 'invalid token'
+			});
+			return;
+		}
+	} else {
+		const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
+		const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
+		const challenge = await AttestationChallenges.findOne({
+			userId: user.id,
+			id: body.challengeId,
+			registrationChallenge: false,
+			challenge: hash(clientData.challenge).toString('hex')
+		});
+
+		if (!challenge) {
+			await fail(403, {
+				error: 'non-existent challenge'
+			});
+			return;
+		}
+
+		await AttestationChallenges.delete({
+			userId: user.id,
+			id: body.challengeId
+		});
+
+		if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
+			await fail(403, {
+				error: 'non-existent challenge'
+			});
+			return;
+		}
+
+		const securityKey = await UserSecurityKeys.findOne({
+			id: Buffer.from(
+				body.credentialId
+					.replace(/\-/g, '+')
+					.replace(/_/g, '/'),
+					'base64'
+			).toString('hex')
+		});
+
+		if (!securityKey) {
+			await fail(403, {
+				error: 'invalid credentialId'
+			});
+			return;
+		}
+
+		const isValid = verifyLogin({
+			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) {
+			signin(ctx, user);
+		} else {
+			await fail(403, {
+				error: 'invalid challenge data'
+			});
+			return;
+		}
+	}
+
+	await fail();
 };