Logo

dev-resources.site

for different kinds of informations.

Using Cloudflare Pages and Functions for email magic links

Published at
7/6/2023
Categories
cloudflare
magiclink
passwordless
sendgrid
Author
jpoehnelt
Author
9 person written this
jpoehnelt
open
Using Cloudflare Pages and Functions for email magic links

I recently migrated a site from Wordpress to Eleventy and I wanted to add a login page that would send magic link to an email address. I was able to accomplish this using Cloudflare Functions and Sendgrid.

The code here is for a hobby site and should be evaluated for security before using in production.

Overview

The flow looks like this.

  1. User enters email address and clicks “Send Magic Link” button.
  2. Cloudflare Function generates a magic link and sends it to the email address provided.
  3. User clicks the magic link and is redirected to the site with an opaque token in the URL.
  4. Cloudflare Function validates the token and sets a cookie with the user’s session id.

Requirements:

HTML Form

I created a simple HTML form that would POST to /auth/login with the email address.

<form action="/auth/login" method="post">
    <div class="mb-4">
      <label for="email">
        Email
      </label>
      <input id="email" name="email" type="text" placeholder="Email" class="input">
    </div>

    <div class="flex items-center justify-between">
      <button type="submit" class="button w-full">
        Sign In
      </button>
    </div>
  </form>
Enter fullscreen mode Exit fullscreen mode

Email magic link form

Cloudflare Functions - Login

I created a Cloudflare Function that would generate a magic link and send it to the email address provided. The function is triggered by a POST request to /auth/login.

export const onRequestPost: Func = async (context) => {
  const email = (await context.request.formData()).get("email");

  if (!email) {
    return REDIRECT_LOGIN_RESPONSE;
  }

  const token = crypto.randomUUID();
  // persist opaque token that expires in 5 minutes
  await context.env.KV.put(token, email, { expirationTtl: 60 * 5 });

  const url = `${
    new URL(context.request.url).href
  }?${TOKEN_QUERY_PARAM}=${encodeURIComponent(token)}`;

  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${context.env.SENDGRID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      personalizations: [
        {
          to: [{ email }],
          dynamic_template_data: {
            loginLink: url,
          },
        },
      ],
      from: { email: context.env.EMAIL_FROM },
      reply_to: { email: context.env.EMAIL_REPLY_TO },
      template_id: "d-1368124dc6e34f879245d3f23cb36f55",
    }),
  });

  return new Response(null, {
    headers: {
      Location: "/auth/sent", // Redirect to a page that says "Check your email"
    },
    status: 302,
  });
};
Enter fullscreen mode Exit fullscreen mode

This sends an email that looks like the following:

Email magic link email

When the user clicks the magic link, they are redirected to /auth/login with the opaque token in the query string. The function validates the token and sets a cookie with the user’s session id.

export const onRequestGet: Func = async (context) => {
  const token = new URL(context.request.url).searchParams.get(
    TOKEN_QUERY_PARAM
  );

  let email: string;

  if (token && (email = await context.env.KV.get(token))) {
    await context.env.KV.delete(token);

    const sessionId = crypto.randomUUID();

    await context.env.KV.put(sessionId, email, {
      expirationTtl: EXPIRATION_TTL,
    });

    return new Response(null, {
      headers: {
        "Content-Type": "application/json;charset=utf-8",
        "set-cookie": `${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; Secure; max-age=${EXPIRATION_TTL}; SameSite=Strict`,
        Location: "/",
      },
      status: 302,
    });
  }

  return REDIRECT_LOGIN_RESPONSE;
};
Enter fullscreen mode Exit fullscreen mode

Cloudflare Functions - UserInfo

I also created a Cloudflare Function that would return the user’s email address and possibly more data in the future. This is used by the client to determine if the user is logged in.

export const onRequestGet: Func = async (context) => {
  return new Response(JSON.stringify({ email: context.data.email }), {
    headers: {
      "Content-Type": "application/json;charset=utf-8",
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Although this is a mostly static site, I am using AlpineJS.

import Alpine from "alpinejs";

window.Alpine = Alpine;

window.fetchUserInfo = async () => {
  return (await fetch("/auth/userinfo")).json().catch(() => ({}));
};

Alpine.start();
Enter fullscreen mode Exit fullscreen mode

Here is the simplified HTML snippet for the navbar which switches on user state.

<div x-data="{ open: false, userInfo: {} }" x-init="userInfo = (await fetchUserInfo())">
    <a x-show="!userInfo?.email" href="/auth/" class="button">Login</a>
    <div x-show="userInfo?.email">...</div>
</div>
Enter fullscreen mode Exit fullscreen mode

Cloudflare Pages - Middleware

I didn’t want all pages to be guarded by the login page, so I created a middleware function that would redirect to the login page if the user was not logged in. The following snippet guards all pages/functions under /app and /api.

import { parse } from "cookie";
import { COOKIE_NAME, Func, REDIRECT_LOGIN_RESPONSE } from "./_common";
import sentryPlugin from "@cloudflare/pages-plugin-sentry";

const session: Func = async (context) => {
  const cookie = parse(context.request.headers.get("Cookie") || "");

  let sessionId: string;

  if (cookie && (sessionId = cookie[COOKIE_NAME])) {
    context.data.sessionId = sessionId;
    context.data.email = await context.env.KV.get(sessionId);
    context.data.sentry.setUser({ email: context.data.email });
  }

  return await context.next();
};

const authorize: Func = async (context) => {
  const pathname = new URL(context.request.url).pathname;

  if (/^\/app/gi.test(pathname) && !context.data.email) {
    return REDIRECT_LOGIN_RESPONSE;
  }

  if (/^\/api/gi.test(pathname) && !context.data.email) {
    return new Response(JSON.stringify({ error: "Not authorized" }), {
      status: 401,
      headers: {
        "Content-Type": "application/json;charset=utf-8",
      },
    });
  }

  return await context.next();
};

const sentry: Func = (context) => {
  return sentryPlugin({ dsn: context.env.SENTRY_DSN })(context);
};

export const onRequest: Func[] = [sentry, session, authorize];
Enter fullscreen mode Exit fullscreen mode

Configuration and Constants

In the above code, I used some shared configuration and constants. Here is the code for those.

import { PluginData } from "@cloudflare/pages-plugin-sentry";

export interface Env {
  SENDGRID_API_KEY: string;
  SENTRY_DSN: string;
  EMAIL_REPLY_TO: string;
  EMAIL_FROM: string;
  KV: KVNamespace;
}

export const TOKEN_QUERY_PARAM = "token";
export const EXPIRATION_TTL = 86400;
export const COOKIE_NAME = "sessionId";

export const REDIRECT_LOGIN_RESPONSE = new Response(null, {
  status: 302,
  headers: {
    Location: "/auth",
  },
});

export type Data = {
  sessionId?: string;
  email?: string;
} & PluginData;

export type Func = PagesFunction<Env, any, Data>;
Enter fullscreen mode Exit fullscreen mode

Metrics

Here are the metrics for this tiny site for Cloudlfare Functions and Sendgrid.

![Cloudflare Function metrics]((https://justin.poehnelt.com/images/cloudflare-function-metrics-1D4heGTPUo-600.jpeg)

![Sendgrid email metrics]((https://justin.poehnelt.com/images/email-magiclink-sendgrid-metrics-g6i2sA9jhA-600.jpeg)

And it’s all working! 🎉

Learnings

  • I didn’t get around to figuring out how to test/debug functions locally. This slowed down dev cycles. Typescript was helpful in catching errors though.
  • It took me a bit to find the right Cloudflare docs and kept running into worker specific information. I was looking for Cloudflare Functions docs.
  • Cloudflare KV was super convenient to use. I didn’t have to worry about setting up a database or anything. I just used the API. The integration into the context was also nice.
  • Sendgrid was easy to use. I just had to set up an API key and I was good to go. The dynamic templates were also nice to use.
passwordless Article's
30 articles in total
Favicon
Understanding Passkeys: The Behind-the-Scenes Magic of Passwordless Authentication
Favicon
Implementing passwordless sign-in flow with text messages in Cognito
Favicon
AI Password Generators: Bridging the Gap to a Passwordless Future
Favicon
Javascript: Implementing Passwordless Login with Salesforce
Favicon
Busting Common Passwordless Authentication Myths: A Technical Analysis
Favicon
React: Implementing Passwordless Login with AWS Cognito
Favicon
Passwordless Authentication
Favicon
Apple's New Passwords App: A Game-Changer for User Security and the Cybersecurity Landscape
Favicon
[CYBERSECURITY] TendĂŞncia Passwordless
Favicon
Getting started with Passwordless Authentication
Favicon
Mastering Magic Link Security: A Deep Dive for Developers
Favicon
Passkeys: The Future of Passwordless Authentication
Favicon
Passkeys F.A.Q.
Favicon
The Unseen Powerhouse: Demystifying Authentication Infrastructure for Tech Leaders
Favicon
Easiest Passwordless Login in Laravel without external packages
Favicon
The Road to Passkey Adoption: A Developer’s Perspective
Favicon
What Are Passkeys and How Can They Securely Replace Passwords?
Favicon
Ditch the Passwords: Discover the Magic of WebAuthn and Passkeys
Favicon
The Road to Adoption: A Product and Strategy Perspective
Favicon
What is FIDO? — The Future of Secure and Passwordless Authentication
Favicon
Nintendo Passkey Implementation Analyzed
Favicon
How to login your users without a password (but with a magic.link)
Favicon
Microsoft 365 passkeys – Analysis of sign-ups and logins with passkeys
Favicon
Using Cloudflare Pages and Functions for email magic links
Favicon
Experience Effortless Security with Affirm's Passwordless Login System
Favicon
Secure Authentication in Next.js with Email Magic Links
Favicon
Top 5 Passwordless Providers for Retail and E-commerce
Favicon
Passkeys Explained For Product Managers
Favicon
New Study Shows Consumers Desire a World Beyond Passwords and Biometrics
Favicon
Multi Factor Authentication

Featured ones: