diff --git a/tfweb/src/lib/auth.ts b/tfweb/src/lib/auth.ts
index 7074283..ab9a690 100644
--- a/tfweb/src/lib/auth.ts
+++ b/tfweb/src/lib/auth.ts
@@ -110,6 +110,32 @@ export async function authSession(email: string): Promise<[AuthResult, null | AP
     }
 }
 
+export async function signup(email: string): Promise<[AuthResult, null | APIError]> {
+    logger.info('sending signup');
+
+    try {
+        logger.debug(`api call: baseurl ${PUBLIC_BASE_URL}`);
+        const resp = await fetch(`${PUBLIC_BASE_URL}/v1/signup`, {
+            'method': 'POST',
+            'body': JSON.stringify({
+                email: email
+            }),
+            'headers': {
+                'Content-Type': 'application/json'
+            }
+        });
+        if (!resp.ok) {
+            const rawerror = JSON.parse(await resp.text()).errors[0];
+            logger.error(`error sending authentication: ${rawerror.message}`);
+            return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}];
+        }
+        return [AuthResult.Successful, null]
+    } catch (e) {
+        logger.error(`error making API request: ${e}`);
+        return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}]
+    }
+}
+
 export async function verifyLink(ml: string): Promise<[AuthResult, null | APIError]> {
     logger.info('checking magic link authentication');
 
@@ -164,10 +190,41 @@ export async function createTotp(token: string): Promise<[AuthResult, TOTPCreate
             logger.error(`error sending totp create: ${rawerror.message}`);
             return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}];
         }
-        logger.info(`call success, ${await resp.text()}`)
         return [AuthResult.Successful, JSON.parse(await resp.text()).data]
     } catch (e) {
         logger.error(`error making API request: ${e}`);
         return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}]
     }
 }
+
+export async function verifyTotp(token: string, totpToken: string, totpCode: string): Promise<[AuthResult, undefined | APIError]> {
+    logger.info('verifying totp authenticator');
+
+    try {
+        logger.debug(`api call: baseurl ${PUBLIC_BASE_URL}`);
+        const resp = await fetch(`${PUBLIC_BASE_URL}/v1/totp-authenticators`, {
+            'method': 'POST',
+            'body': JSON.stringify({
+                totpToken: totpToken,
+                code: totpCode
+            }),
+            'headers': {
+                'Content-Type': 'application/json',
+                'Authorization': `Bearer ${token}`
+            }
+        });
+        if (!resp.ok) {
+            logger.error('call returned error code');
+            const rawerror = JSON.parse(await resp.text()).errors[0];
+            logger.error(`error sending totp create: ${rawerror.message}`);
+            return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}];
+        }
+
+        window.localStorage.setItem("mfa", JSON.parse(await resp.text()).data.authToken);
+
+        return [AuthResult.Successful, undefined]
+    } catch (e) {
+        logger.error(`error making API request: ${e}`);
+        return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}]
+    }
+}
diff --git a/tfweb/src/lib/i18n/locales/en.json b/tfweb/src/lib/i18n/locales/en.json
index 676fd42..6a19098 100644
--- a/tfweb/src/lib/i18n/locales/en.json
+++ b/tfweb/src/lib/i18n/locales/en.json
@@ -2,8 +2,9 @@
   "itworks": {
     "header": "It works!",
     "body": "If you're seeing this page, tfweb is installed and (probably) correctly configured.",
-    "linkbody": "Perhaps you meant to visit the {link0}?",
-    "linkbody.link0": "admin panel"
+    "linkbody": "Perhaps you meant to visit the {link0} or {link1}?",
+    "linkbody.link0": "admin panel",
+    "linkbody.link1": "create an account"
   },
   "login": {
     "title": "Log in to your account",
@@ -20,6 +21,22 @@
       "usermissing": "That user does not exist."
     }
   },
+  "signup": {
+    "title": "Create an account",
+    "subtitle": "We'll send you an email with a \"magic link\"",
+    "label": "What is your email?",
+    "button": "Create account",
+    "email": "Check your email",
+    "emailbody": "We sent you an email with a link to complete signing up.",
+    "emailbody2": "Didn't work? Check your junk inbox or click {link0} to try again.",
+    "emailbody2.link0": "here",
+    "error": {
+      "invalidEmail": "That email address isn't valid. Try again.",
+      "generic": "There was an error logging you in. Try again or contact support with the error code {err}",
+      "userexists": "That user already exists. Try {link0}?",
+      "userexists.link0": "logging in"
+    }
+  },
   "ml": {
     "header": "Authenticated!",
     "body": "Redirecting to admin page...",
diff --git a/tfweb/src/routes/+page.svelte b/tfweb/src/routes/+page.svelte
index 2bf313c..27f53fb 100644
--- a/tfweb/src/routes/+page.svelte
+++ b/tfweb/src/routes/+page.svelte
@@ -21,5 +21,5 @@
     <h1>{$t('itworks.header')}</h1>
     <p>{$t('itworks.body')}</p>
     <!-- eslint-disable-next-line svelte/no-at-html-tags -->
-    <p>{@html $t('itworks.linkbody', {values:{link0:'<a href="/admin">'+$t('itworks.linkbody.link0')+'</a>'}})}</p>
+    <p>{@html $t('itworks.linkbody', {values:{link0:'<a href="/admin">'+$t('itworks.linkbody.link0')+'</a>',link1:'<a href="/signup">'+$t('itworks.linkbody.link1')+'</a>'}})}</p>
 </LoadingWrapper>
diff --git a/tfweb/src/routes/2fasetup/+page.svelte b/tfweb/src/routes/2fasetup/+page.svelte
index 32d72cf..15dfa59 100644
--- a/tfweb/src/routes/2fasetup/+page.svelte
+++ b/tfweb/src/routes/2fasetup/+page.svelte
@@ -2,7 +2,8 @@
     import {isLoading, t} from "svelte-i18n";
     import LoadingWrapper from "$components/LoadingWrapper.svelte";
     import {onMount} from "svelte";
-    import {AuthResult, authSession, createTotp, isAuthedSession} from "$lib/auth.ts";
+    import {AuthResult, authSession, createTotp, isAuthedSession, verifyTotp} from "$lib/auth.ts";
+    import type {TOTPCreateInfo} from "$lib/auth.ts";
     import type {SessionInfo} from "$lib/auth.ts";
     import type {APIError} from "$lib/auth.ts";
     import {Logger, logSetup} from "$lib/logger";
@@ -18,6 +19,10 @@
     let logger = new Logger("2fasetup/+page.svelte");
 
     let totp_secret = '';
+    let otp_url = '';
+    let totp_create_token = '';
+
+    let code = '';
 
     onMount(async () => {
         let session_load_info = await isAuthedSession();
@@ -54,10 +59,41 @@
             return;
         }
 
+        logger.info('setting secret and otpurl');
+
+        totp_secret = (create_res[1] as TOTPCreateInfo).secret;
+        otp_url = (create_res[1] as TOTPCreateInfo).url;
+        totp_create_token = (create_res[1] as TOTPCreateInfo).totpToken;
+
+        logger.info(totp_secret);
+        logger.info(otp_url);
+
         loading = false;
     })
 
     async function onSubmit() {
+        let create_res = await verifyTotp(window.localStorage.getItem("session"), totp_create_token, code);
+
+        if (create_res[0] == AuthResult.Failed) {
+            logger.error(`totp auth fail`);
+            isError = true;
+            let err = create_res[1] as APIError;
+
+            let etext = err.code;
+
+            if (etext === "api_call_failed") {
+                etext = $t('2fasetup.error.api');
+            } else if (etext === "ERR_ALREADY_HAS_TOTP") {
+                window.location.href = '/2fa';
+                return;
+            }
+
+            error = $t('2fasetup.error.generic', {values:{err:etext}});
+            loading = false;
+            return;
+        }
+
+        window.location.href = '/admin';
 
     }
 </script>
@@ -71,13 +107,13 @@
     <p>{$t('2fasetup.body')}</p>
     <h4>{$t('2fasetup.scan')}</h4>
     <div>
-        <QrCode value="abcd1234"/>
+        <QrCode bind:value={otp_url}/>
     </div>
     <h4>{$t('2fasetup.code')}</h4>
-    <div><span>totp secret hete</span></div>
+    <div><span>{totp_secret}</span></div>
     <form on:submit|preventDefault={onSubmit}>
         <label for="code">{$t('2fasetup.verify')}</label>
-        <input type="number" min="0" max="999999" id="code" />
+        <input bind:value={code} type="number" min="0" max="999999" id="code" />
         <button>{$t('2fasetup.button')}</button>
     </form>
 </LoadingWrapper>
diff --git a/tfweb/src/routes/admin/+page.svelte b/tfweb/src/routes/admin/+page.svelte
index 4d914cb..ac02147 100644
--- a/tfweb/src/routes/admin/+page.svelte
+++ b/tfweb/src/routes/admin/+page.svelte
@@ -2,7 +2,7 @@
     import {isLoading, t} from "svelte-i18n";
     import LoadingWrapper from "$components/LoadingWrapper.svelte";
     import {onMount} from "svelte";
-    import {AuthResult, isAuthedSession} from "$lib/auth.ts";
+    import {AuthResult, isAuthedMFA, isAuthedSession} from "$lib/auth.ts";
     import {Logger, logSetup} from "$lib/logger";
     import type {APIError} from "$lib/auth.ts";
 
@@ -23,6 +23,14 @@
             return;
         }
 
+        let mfa_load_info = await isAuthedMFA();
+        if (mfa_load_info[0] == AuthResult.Failed) {
+            let err = mfa_load_info[1] as APIError;
+            logger.error(`mfa load failed: ${err.code} ${err.message}`);
+            window.location.href = '/2fa';
+            return;
+        }
+
         loading = false;
     })
 </script>
diff --git a/tfweb/src/routes/signup/+page.svelte b/tfweb/src/routes/signup/+page.svelte
new file mode 100644
index 0000000..32f52fb
--- /dev/null
+++ b/tfweb/src/routes/signup/+page.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+    import {isLoading, t} from "svelte-i18n";
+    import LoadingWrapper from "$components/LoadingWrapper.svelte";
+    import {onMount} from "svelte";
+    import {AuthResult, authSession, isAuthedSession, signup} from "$lib/auth.ts";
+    import type {APIError} from "$lib/auth.ts";
+    import {Logger, logSetup} from "$lib/logger";
+
+    let loading = true;
+    let isError = false;
+    let error = '';
+    $: currentlyLoading = $isLoading || loading;
+
+    logSetup();
+    let logger = new Logger("signup/+page.svelte");
+
+    onMount(async () => {
+        let session_load_info = await isAuthedSession();
+        if (session_load_info[0] != AuthResult.Failed) {
+            logger.error(`session load success, the user is already logged in`);
+            window.location.href = '/2fa';
+            return;
+        }
+
+        loading = false;
+    })
+
+    let email = '';
+    let email_regexp = new RegExp(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/);
+
+    let hasErrForm = false;
+    let errForm = '';
+
+    let isDone = false;
+
+    async function onSubmit() {
+        loading = true;
+
+        if (!email_regexp.test(email)) {
+            errForm = $t('signup.error.invalidEmail');
+            hasErrForm = true;
+            loading = false;
+            return;
+        }
+
+        let auth_result = await signup(email);
+
+        if (auth_result[0] === AuthResult.Failed) {
+
+            hasErrForm = true;
+
+            // parse the error and filter out common ones for more specific error messages
+            let err = auth_result[1] as APIError;
+
+            if (err.code == "ERR_USER_EXISTS") {
+                errForm = $t('signup.error.userexists', {values:{link0:'<a href="/login">'+$t('signup.error.userexists.link0')+'</a>'}});
+            } else {
+                errForm = $t('signup.error.generic', {values: {err: (auth_result[1] as APIError).code}});
+            }
+
+            loading = false;
+            return;
+        }
+
+        isDone = true;
+        loading = false;
+    }
+</script>
+
+<svelte:head>
+    <title>{$t("common.title", {values: {title: $t("common.page.signup")}})}</title>
+</svelte:head>
+
+<LoadingWrapper isLoading={currentlyLoading} isError={isError} error={error}>
+    {#if isDone}
+        <h1>{$t('signup.email')}</h1>
+        <p>{$t('signup.emailbody')}</p>
+        <!-- eslint-disable-next-line svelte/no-at-html-tags -->
+        <p>{@html $t('signup.emailbody2', {values:{link0:'<a href="/signup">'+$t('signup.emailbody2.link0')+'</a>'}})}</p>
+    {:else}
+        <h1>{$t('signup.title')}</h1>
+        <h3>{$t('signup.subtitle')}</h3>
+        <form on:submit|preventDefault={onSubmit}>
+            <label for="email">{$t('signup.label')}</label>
+            <input type="email" bind:value={email} placeholder="john.doe@google.com" id="email"/>
+            <button>{$t('signup.button')}</button>
+            {#if hasErrForm}
+                <!-- eslint-disable-next-line svelte/no-at-html-tags -->
+                <p>{@html errForm}</p>
+            {/if}
+        </form>
+    {/if}
+</LoadingWrapper>