Guides
Guide

How to add Magic Link to a SvelteKit application

2021-06-28
Download this example and get started in seconds:
npx make-magic --template svelte
info

🎉 Author: Sean Mullen

Shout out to Sean Mullen for being our first ever contributor to the Guest Author program!

Interested in writing for Magic? Would you mind filling out this form to join the guest author program?

note

SvelteKit is in early development at the time of writing (currently 1.0.0-next.75). Things may change, but we will try to keep this document up to date. Consult the SvelteKit documentation if running into issues.

Live Demo: https://sveltekit-magic.netlify.app.

Before we get started, it's best to understand what SvelteKit is.

From the SvelteKit documentation:

SvelteKit is a framework for building extremely high-performance web apps. ⁠ ⁠Building an app with all the modern best practices — code-splitting, offline support, server-rendered views with client-side hydration — is fiendishly complicated. SvelteKit does all the boring stuff for you so that you can get on with the creative part.

SvelteKit provides a filesystem-based router. Files in the src/routes directory represent pages and endpoints that run on the server. Pages of your application are built as Svelte components and are server-rendered when a user first visits the site. After that point any, navigation happens on the client-side.

Getting Started

Let's start by creating a new SvelteKit project.

npm init svelte@next sveltekit-magic

The above command will ask you some questions about how you'd like the template set up. Choose the 'SvelteKit demo app'. This comes with a Todo list application built-in. We'll make changes to the app, so the user needs to log in using Magic to access the todos.

The initialization step also asks some questions about other tools we'd like to use in our project. I like to use TypeScript, so I'll choose that here as well.

Now you can install the application dependencies and start up a development server.

cd sveltekit-magic
npm install
npm run dev

Open up http://localhost:3000, and you should see something that looks like this:

Creating the AuthForm

Now to start writing some code. First, we'll build the login page. Any Svelte components in the routes directory become pages in your SvelteKit app, so the file src/routes/login.svelte will create a page at http://localhost:3000/login. Let's create that file and add the following code.

<!-- src/routes/login.svelte -->
<script lang="ts">
  let email = '';

  function login() {
    console.log(email);
  }
</script>

<svelte:head>
  <title>Login</title>
</svelte:head>

<div class="content">
  <form on:submit|preventDefault="{login}">
    <label for="email">Email</label>
    <input id="email" bind:value="{email}" />
    <div class="btn-container">
      <button type="submit">Login</button>
    </div>
  </form>
</div>

<style>
  .content {
    width: 100%;
    max-width: var(--column-width);
    margin: var(--column-margin-top) auto 0 auto;
  }

  form {
    width: 28rem;
    margin: auto;
    font-size: 1.5rem;
  }

  label {
    display: block;
    font-weight: bold;
    margin-bottom: 0.25rem;
  }

  input {
    margin-bottom: 0.75rem;
    width: 100%;
    padding: 0 0.5rem;
    box-sizing: border-box;
    height: 2.5rem;
  }

  button {
    width: 100%;
    background-color: var(--accent-color);
    color: white;
    border: none;
    height: 2.5rem;
    font-weight: bold;
    transition: background-color 0.2s ease-in-out;
    cursor: pointer;
  }

  button:hover {
    background-color: var(--accent-hover);
    border: 1px solid var(--accent-color);
    color: var(--accent-color); 
  }
</style>

Navigate to http://localhost:3000/login, and you should see a simple form for the user to enter their email address. Right now, attempting to log in will just log the user's email to the console. We'll add more functionality to it, but first, let's create a link in the header so the user can navigate to this page easily.

Open the src/lib/header/Header.svelte file and add a link to the /login page.

<!-- ... -->
<ul>
  <li class:active={$page.path === '/'}><a sveltekit:prefetch href="/">Home</a></li>
  <li class:active={$page.path === '/about'}><a sveltekit:prefetch href="/about">About</a></li>
  <li class:active={$page.path === '/todos'}><a sveltekit:prefetch href="/todos">Todos</a></li>
  <!-- Add this link -->
  <li class:active={$page.path === '/login'}><a sveltekit:prefetch href="/login">Login</a></li>
</ul>
<!-- ... -->

Note: To decrease load time, SvelteKit splits your code into chunks and only loads the required chunks when navigating to a page. The sveltekit:prefetch attribute loads any code and data required for a page when the user hovers over the link. This can help speed up the perceived navigation time.

It should look something like this.

Start the Magic

Now that we have a login page that a user can navigate, we can start integrating Magic. We'll need to install the client-side and admin Magic SDKs. While we're at it, let's install a couple of other packages for encryption and environment variables.

npm install magic-sdk @magic-sdk/admin dotenv @hapi/iron cookie

If you haven't already signed up for a Magic account, you can do so here for free.

Create a .env file at the root of the project and grab Magic publishable and secret keys. SvelteKit uses Vite as the bundler, to access the publishable key on the client-side, we need to prefix the variable with VITE_. Environment variables that do not start with VITE_ will not be written to the bundle that gets shipped to the client. The .env file should now look something like this.

VITE_MAGIC_PUBLIC_KEY=pk_live_3*FE****222**7F4
MAGIC_SECRET_KEY=sk_live_5*A****66**6**6
ENCRYPTION_SECRET=some_random_string_that_is_at_least_32_characters_long

While we're at it, we should also protect our secret key by adding the .env file to our .gitignore.

Note: The dev server may need to be restarted after installing packages and editing the .env file.

Creating the endpoints.

In SvelteKit, endpoints are JavaScript or TypeScript files that live in the routes directory and export methods that map to the HTTP methods. We will have three endpoints for handling authentication, login, logout, and user. Let's create those files now. I like to keep my endpoints in an api directory. They will be called login.ts, logout.ts, and user.ts.

We'll also create a file for instantiating the Magic admin SDK and a file for utilities. Files or folders prefixed with an underscore (_) character are hidden from the SvelteKit router. They will be called _magic.ts and _utils.ts, and they will live in the src/routes/api directory.

After you've created the files, your directory structure should look something like this:

In the _utils.ts file, we'll keep the functions for encrypting and managing the session cookies. Session cookies are pieces of data passed around in request and response headers to identify a user’s session. We’ll use the @hapi/iron package for encryption and create encrypt and decrypt convenience functions to simplify it’s usage. The cookie package is for creating the cookie headers.

Let's add those now.

// src/routes/api/_utils.ts
import { serialize } from 'cookie';
import Iron from '@hapi/iron';
import dotenv from 'dotenv';

dotenv.config();

const ENCRYPTION_SECRET = process.env['ENCRYPTION_SECRET'];
const SESSION_LENGTH_MS = 604800000;
export const SESSION_NAME = 'session';

async function encrypt(data): Promise<string> {
  return data && Iron.seal(data, ENCRYPTION_SECRET, Iron.defaults);
}

async function decrypt<T>(data: string): Promise<T> {
  return data && Iron.unseal(data, ENCRYPTION_SECRET, Iron.defaults);
}

export async function createSessionCookie(data: any): Promise<string> {
  const encrypted_data = await encrypt(data);

  return serialize(SESSION_NAME, encrypted_data, {
    maxAge: SESSION_LENGTH_MS / 1000,
    expires: new Date(Date.now() + SESSION_LENGTH_MS),
    httpOnly: true,
    secure: process.env['NODE_ENV'] === 'production',
    path: '/',
    sameSite: 'lax',
  });
}

export async function getSession<T>(cookie: string): Promise<T> {
  return await decrypt(cookie);
}

export function removeSessionCookie(): string {
  return serialize(SESSION_NAME, '', {
    maxAge: -1,
    path: '/',
  });
}

Instantiate the Magic Admin SDK

SvelteKit endpoints always run on the server, so we'll use the Magic admin SDK here. The dotenv package is used to load the environment variables to be read from process.env. Once we have the magic secret key, we can initialize the SDK and export it for the endpoints to use.

// src/routes/api/_magic.ts
import { Magic } from '@magic-sdk/admin';
import dotenv from 'dotenv';

dotenv.config();

const MAGIC_SECRET_KEY = process.env['MAGIC_SECRET_KEY'];
export const magic = new Magic(MAGIC_SECRET_KEY);

Logging into the application

In the src/routes/api/login.ts file, we'll export a post function responsible for logging the user in by validating a DID (Decentralized Identifier) token that will be generated on the client side and then returning a cookie to the client to track the session.

Let's see the whole endpoint, and then we’ll break down the most important parts.

// src/routes/api/login.ts
import type { Request, Response } from '@sveltejs/kit';
import { magic } from './_magic';
import { createSessionCookie } from './_utils';

export async function post(req: Request): Promise<Response> {
  try {
    // Parse and validate the DID token
    const didToken = magic.utils.parseAuthorizationHeader(req.headers['authorization']);
    magic.token.validate(didToken);

    // Token is valid, so get the user metadata and set it in a cookie.
    const metadata = await magic.users.getMetadataByToken(didToken);
    const cookie = await createSessionCookie(metadata);

    return {
      status: 200,
      headers: {
        'set-cookie': cookie,
      },
      body: {
        user: metadata,
      },
    };
  } catch (err) {
    return {
      status: 500,
      body: {
        error: {
          message: 'Internal Server Error',
        },
      },
    };
  }
}

First, we'll use the Magic SDK to read and validate the token.

const didToken = magic.utils.parseAuthorizationHeader(req.headers['authorization']);
magic.token.validate(didToken);

The DID token will be sent as an authorization header, which is parsed using the magic.utils.parseAuthorizationHeader function. It is then validated using magic.token.validate.

If the token is not valid, it will throw an error. If the token is valid, we can use it to get information about the user. We'll put that information in the session cookie, so when the user makes subsequent requests, we'll know who they're.

const metadata = await magic.users.getMetadataByToken(didToken);
const cookie = await createSessionCookie(metadata); 
// NOTE: createSessionCookie is implemented in _utils.ts

When the user successfully logs in, we return the metadata and set the cookie with the set-cookie header. In this example, we're sending a status 500 error code if anything goes wrong. In a real-world application, you may want to be more specific about the issue so the client can handle the type of error appropriately.

Client-side login

Now that we have the login endpoint let's call it from the client. Create a file called auth.ts in src/lib. Here we'll put the functions that will call the authentication endpoints, and we'll also create a Svelte store for keeping information about the logged-in user. First, let's import the dependencies. We'll need the client-side Magic SDK and a writable store.

The store will either contain user information or be null. Based on the store’s state, we can change the display to reflect that a user is logged in or logged out.

// src/lib/auth.ts
import { Magic } from 'magic-sdk';
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';

export const store = writable(null);

The Magic SDK also needs to be instantiated here, but it’s slightly more complex due to the nature of SvelteKit. The Magic SDK can only be used in browser environments, but pages in SvelteKit can be rendered either server-side or client-side, so we need to make sure that Magic is only run on the client. To do that, we'll create a createMagic function that will only be called when the user does actions, such as attempting to log in or log out. The first time createMagic is called, it will instantiate the client-side SDK. If it is called again, it will return the previously created instance.

// src/lib/auth.ts
let magic;

function createMagic() {
  magic = magic || new Magic(import.meta.env.VITE_MAGIC_PUBLIC_KEY as string);
  return magic;
}

Now let's create the login function. Instead of just logging the user's email to the console, we'll pass it to this function which will call the magic.auth.loginWithMagicLink method to log the user in and generate a DID token. We'll send that token using a fetch request to the login endpoint we just created. If everything goes to plan, the fetch should return the user metadata, which we will set in the store.

// src/lib/auth.ts
export async function login(email: string): Promise<void> {
  const magic = createMagic();

  const didToken = await magic.auth.loginWithMagicLink({ email });

  // Validate the did token on the server
  const res = await fetch('/api/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${didToken}`,
    },
  });

  if (res.ok) {
    const data = await res.json();
    store.set(data.user);
  }
}

Now in the login.svelte page component, import the auth function and call it with the user's email when they submit the form. If the login is successful, we'll navigate to the 'todos' page. Update the <script> tag in the src/routes/login.svelte to look as follows.

<!-- src/routes/login.svelte -->
<script lang="ts">
  import * as auth from '$lib/auth';
  import { goto } from '$app/navigation';

  let email = '';

  async function login() {
    try {
      await auth.login(email);
      goto('/todos');
    } catch (err) {
      console.log(err);
    }
  }
</script>

Protecting the todos

At this point, you should be able to log into the application successfully. But there's a problem. Users that haven't logged in can also access the 'todos' page. It's time to fix that.

To do that, we'll use SvelteKit's load function to call the user endpoint. The load function runs before the component is rendered and can run on either the client- or server-side, so SvelteKit passes it a fetch function that is agnostic of the environment. In the src/routes/__layout.svelte component, add the following code. It will call the endpoint to see if the user is logged in and set their metadata in the store.

<!-- src/routes/__layout.svelte -->
<script lang="ts" context="module">
  import { store as authStore } from '$lib/auth';

  export async function load({ fetch }) {
    const res = await fetch('/api/user');
    const { user } = await res.json();
    authStore.set(user);
    return {
      status: 200,
    };
  }
</script>

Note that this is a context="module" script tag. This only runs when the module first evaluates and before any rendering happens.

Every request in SvelteKit runs through the handle function defined in src/hooks.ts. It is here that we'll check to see if the user has a session cookie. If they do, we'll get the user metadata from the cookie and set it on the request to be available to the endpoints. Update the src/hooks.ts file, so it is as follows.

// src/hooks.ts
import cookie from 'cookie';
import type { Handle } from '@sveltejs/kit';
import { getSession, SESSION_NAME } from './routes/api/_utils';

export const handle: Handle = async ({ request, resolve }) => {
  const cookies = cookie.parse(request.headers.cookie || '');

  const user = await getSession(cookies[SESSION_NAME]);
  request.locals.user = user;
  request.locals.userid = user?.publicAddress;

  // TODO https://github.com/sveltejs/kit/issues/1046
  if (request.query.has('_method')) {
    request.method = request.query.get('_method').toUpperCase();
  }

  const response = await resolve(request);

  return response;
};

Now in the user endpoint, we'll export a get function. If there is no user on the req.locals object, the user is not logged in, so we send back null. Otherwise, we'll refresh the cookie to extend their session and return the user metadata in the body.

// src/routes/api/user.ts
import type { Request, Response } from '@sveltejs/kit';
import { createSessionCookie } from './_utils';

export async function get(req: Request): Promise<Response> {
  try {
    if (!req.locals.user) {
      return {
        status: 200,
        body: {
          user: null,
        },
      };
    }

    const user = req.locals.user;

    // Refresh session
    const cookie = await createSessionCookie(user);

    return {
      status: 200,
      headers: {
        'set-cookie': cookie,
      },
      body: {
        user,
      },
    };
  } catch (err) {
    return {
      status: 500,
      body: {
        error: {
          message: 'Internal Server Error',
        },
      },
    };
  }
}

Now let's create a src/routes/todos/__layout.svelte component. We'll again use the load function, this time to check that a user is in the authentication store. Because this layout is nested in a deeper path than the src/routes/__layout.ts file, we know that this will run after the call to the /api/user endpoint. If there is no user in the store, we'll redirect the user to the login page. Otherwise, we can show them their todos. Here is the entirety of the src/routes/todos/__layout.svelte file.

<!-- src/routes/todos/__layout.svelte -->
<script lang="ts" context="module">
  import { get } from 'svelte/store';
  import { store as authStore } from '$lib/auth';

  export function load() {
    const user = get(authStore);
    if (!user) {
      return {
        status: 302,
        redirect: '/auth',
      };
    }
    return {
      status: 200,
    };
  }
</script>

<slot />

Logging out

Finally, we need to allow the user to log out. Let's start by changing the Header component, so it displays 'LOGOUT' rather than 'LOGIN' for the logged-in client. In src/lib/header/Header.svelte import the authentication store and subscribe to it.

<!-- src/lib/header/Header.svelte -->
<script lang="ts">
  // ...
  import { store as authStore, logout } from '$lib/auth';

  $: user = $authStore;
</script>

We'll use an #if block to change the display based on the user. If there is no user in the store, we'll display the LOGIN link we previously created. Otherwise, we'll call a function to log the user out.

{#if user}
  <li class:active={$page.path === '/auth'}>
    <a href="javascript:void(0)" on:click|preventDefault={logout}>Logout</a>
  </li>
{:else}
  <li class:active={$page.path === '/auth'}><a href="/auth">Login</a></li>
{/if}

The logout function will live in the src/lib/auth.ts file. It will call the /api/logout endpoint and clear the current user from the store. After that's complete, you can redirect the user to wherever makes sense. Here we'll send them back to the login page.

// src/lib/auth.ts
export async function logout(): Promise<void> {
  await fetch('/api/logout');
  store.set(null);
  goto('/auth');
}

The last piece of the puzzle is to implement the logout endpoint. We can use the magic.users.logoutByIssuer method to log out the user with Magic. First, import our Magic SDK instance. Remember that user is set on request.locals in the handle hook. The issuer property is part of the user metadata, and we'll pass that to the logoutByIssuer method. This method needs to be wrapped in a try/catch block because it will throw an error if the user has already logged out or their magic session expired.

Whenever a user requests our application, we're not reaching out to Magic to see if they are logged in. We’re relying on our session cookie, so we need to clear that cookie as well. Import the removeSessionCookie method from the utils.ts file. That will create a cookie that clears the previous cookie and send the cookie along in the headers.

// src/routes/api/logout.ts
import type { Request, Response } from '@sveltejs/kit';
import { magic } from './_magic';
import { removeSessionCookie } from './_utils';

export async function get(req: Request): Promise<Response> {
  const cookie = removeSessionCookie();

  try {
    await magic.users.logoutByIssuer(req.locals.user.issuer);
  } catch (err) {
    console.log('Magic session already expired');
  }

  return {
    status: 200,
    headers: {
      'set-cookie': cookie,
    },
    body: {},
  };
}

And that's it! We now have Magic auth fully integrated into the SvelteKit todos app.

Check out the full example on Github: https://github.com/srmullen/sveltekit-magic or see it in action here: https://sveltekit-magic.netlify.app.

Let's make some magic!