I’m a huge fan of passwordless login. You will notice I do not have any articles on creating, resetting, or changing a password. I hate passwords. We do not need them in 2024, and we should not design an application that still uses them. Not only are they insecure, they require more work to implement. Let’s simplify, shall we?
TL;DR#
Passwordless login is the future. You can log in with only an email using a Magic Link. While you can attach the email address to the link, Firebase recommends saving the email address in the local storage for retrieval. Passwords are insecure.
Magic Link Login#
Our application will request an email from the user. The email is saved to the local storage. The user will receive an email confirming their email address. When the user clicks on the link, the login page obtains the email address from the local storage and logs in. They must confirm their email address if they click the link from a different device.
Note on SvelteKit#
This demo uses SvelteKit, but all premises are the same for any framework. See the SvelteKit Setup for Firebase configuration.
Login Functions#
First, we need to send the magic link.
export const sendMagicLink = async (
email: string,
originURL: string
) => {
try {
await sendSignInLinkToEmail(auth, email, {
handleCodeInApp: true,
url: originURL + '/auth/callback'
});
} catch (e) {
if (e instanceof FirebaseError) {
console.error(e);
return e;
}
}
};
We also need to sign in using the magic link.
export const signInWithMagic = async (
email: string,
url: string
) => {
try {
await signInWithEmailLink(auth, email, url);
} catch (e) {
if (e instanceof FirebaseError) {
console.error(e);
return e;
}
}
};
Finally, we should be able to test if the sign in URL is valid.
export const isMagicLinkURL = (url: string) => {
return isSignInWithEmailLink(auth, url);
};
Firebase Helper#
This is optional, but it allows you to check for a user immediately in the loader function. This can only run on the browser. If you need server authentication, see Server App or Firebase Admin.
export const getUser = async () =>
new Promise<User | null>((resolve, reject) => {
const unsubscribe = onIdTokenChanged(auth, (user) => {
unsubscribe();
resolve(user);
}, (error) => {
unsubscribe();
reject(error);
});
});
📝 You should not use Svelte Stores in a load function, but you can use promises.
📝 You must wait for onIdTokenChanged
or onAuthStateChanged
as auth.currentUser
does not WAIT for the latest value.
Login Page#
The login page at /login
works as expected.
Route Guard#
We create a route guard in the loader function at page.ts
using the getUser()
method.
import { getUser } from '$lib/use-user';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { browser } from '$app/environment';
export const load: PageLoad = async () => {
if (!browser) {
return {
loading: true
};
}
const loggedIn = !!(await getUser());
if (loggedIn) {
return redirect(302, '/');
}
};
You should not see a login page if a user is already logged in.
Login#
When not loading, the login page sends the user an email with a valid login link.
<script lang="ts">
import { page } from '$app/stores';
import LoginForm from '$lib/login-form.svelte';
import { getEmail } from '$lib/tools';
import { sendMagicLink } from '$lib/use-user';
import { error } from '@sveltejs/kit';
import type { PageData } from './$types';
import Loading from '$lib/loading.svelte';
export let data: PageData;
let emailSent = false;
const sendLink = async (event: SubmitEvent) => {
const email = getEmail(event);
const originURL = $page.url.origin;
const sendError = await sendMagicLink(email, originURL);
if (sendError) {
error(500, sendError.message);
}
localStorage.setItem('emailForSignIn', email);
emailSent = true;
setTimeout(() => (emailSent = false), 5000);
};
</script>
{#if data.loading}
<Loading />
<p class="my-5 text-center text-xl font-bold">Loading...</p>
{:else}
<main class="mt-10 flex flex-col items-center justify-center gap-5">
<LoginForm isConfirmPage={false} on:submit={sendLink} />
{#if emailSent}
<p class="text-blue-500">
Email Sent! Check your mailbox. If you don't see it look under junk or spam!
</p>
{/if}
</main>
{/if}
It then saves the email as emailForSignIn
in local storage.
Login Form#
I share the login form with the confirm email form since they share almost identical characteristics.
<script lang="ts">
export let isConfirmPage: boolean;
</script>
<form on:submit>
<div class="flex min-w-72 flex-col gap-5 rounded border p-5">
<h1 class="text-3xl">Login with Magic Link</h1>
<p class="my-5">
{#if isConfirmPage}
Please confirm your email address to login!
{:else}
Enter your email address to receive a magic link to login!
{/if}
</p>
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
Email Address
</label>
<input
type="email"
id="email"
name="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
/>
</div>
<button
type="submit"
class="rounded-lg bg-stone-600 p-5 font-semibold text-white hover:opacity-75"
>
{isConfirmPage ? 'Login' : 'Send Link'}
</button>
</div>
</form>
The form inputs an email and does something.
Form Helper#
I also have a form helper that gets the email from a form event.
export const getEmail = (event: SubmitEvent) => {
event.preventDefault();
const { email } = Object.fromEntries(
new FormData(event.target as HTMLFormElement)
);
if (!email || typeof email !== 'string') {
throw "No email!";
}
return email;
};
Callback#
I created a callback at the url auth/callback
, but this could be anywhere or even on the same login page.
Loader#
The callback load function at +page.ts
does a few things.
import { error, redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getUser, isMagicLinkURL, signInWithMagic } from '$lib/use-user';
import { browser } from '$app/environment';
export const load: PageLoad = async ({ url }) => {
const urlString = url.toString();
// you can get the email this way as well
// const _email = url.searchParams.get('email');
// console.log(_email);
if (!isMagicLinkURL(urlString)) {
error(400, 'Bad request');
}
// just load on server
if (!browser) {
return {
loading: true
};
}
const loggedIn = !!(await getUser());
// redirect if already logged in
if (loggedIn) {
return redirect(302, '/');
}
// ask for email if no email
const email = localStorage.getItem('emailForSignIn');
if (!email) {
return {
loading: false
};
}
// otherwise login
const signInError = await signInWithMagic(email, urlString);
if (signInError) {
error(500, signInError.message);
}
localStorage.removeItem('emailForSignIn');
return redirect(302, '/');
};
- It displays an error if it is not a valid callback URL.
- It will redirect you home if you have already logged in.
- It checks for an email.
- Logs in if one exists
- Displays the confirm email page otherwise
Confirm Email Page#
You should only need to confirm an email address if the user is logging in from a different device or if local storage is disabled.
<script lang="ts">
import { error } from '@sveltejs/kit';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import LoginForm from '$lib/login-form.svelte';
import { getEmail } from '$lib/tools';
import { signInWithMagic } from '$lib/use-user';
import Loading from '$lib/loading.svelte';
import type { PageData } from './$types';
export let data: PageData;
const confirmMagicLink = async (event: SubmitEvent) => {
const email = getEmail(event);
const urlString = $page.url.toString();
const signInError = await signInWithMagic(email, urlString);
if (signInError) {
error(500, signInError.message);
}
localStorage.removeItem('emailForSignIn');
goto('/');
};
</script>
{#if data.loading}
<Loading />
<p class="my-5 text-center text-xl font-bold">Logging you in...</p>
{:else}
<main class="mt-10 flex flex-col items-center justify-center gap-5">
<LoginForm isConfirmPage={true} on:submit={confirmMagicLink} />
</main>
{/if}
Add email to callback URL?#
Yes, you can.
// sendMagicLink()
...
await sendSignInLinkToEmail(auth, email, {
handleCodeInApp: true,
url: originURL + '/auth/callback' + `?email=${email}`
});
...
And you can receive it from the URL.
// auth/callback/+page.ts
export const load: PageLoad = async ({ url }) => {
const _email = url.searchParams.get('email');
...
☢️ The problem is that this is theoretically not secure. A user could hack your email and easily log in to a website with a script. A user could technically do this anyway, as they already have your email address from the email itself, but Firebase recommends against this. However, other login systems see no problem with this method. It is up to you.
Repo: GitHub
Demo: Vercel Serverless
J