Simple, Secure Magic Links

magic link user flow

What are magic links?

Magic links are a type of passwordless authentication allowing you to register users with nothing more than their email address. Popularized by Slack, they have become a common method for user registration.

Magic links have a number of benefits:

  • You don’t need to store user’s passwords
  • Doesn’t require complex OAuth flows, or dependencies on third parties (except an email provider!)
  • You can reduce chances of attackers guessing valid email addresses
  • Easy and well known workflow for onboarding new users

However, there are some disadvantages:

  1. Slightly more complex email address change workflow
  2. If a user is unable to access their email account, they are effectively locked out from your system

How do they work?

They work by generating secure random tokens that are sent to a user’s email address. Upon opening the email, they are presented with a link back to your website. After clicking the link, the user is registered (or logged in) and may continue to access your application.

How do they really work?

When a user decides to register with your application, they fill in their details along with the email address they wish to use. The application server first should check to see if the user is already registered. If it is, just return a message as if it was their first time signing up. This will prevent brute force guessing user’s email addresses. Note that there might be subtle timing differences between immediately returning the message versus sending an email. If you are really concerned about this threat, you can add in random delays in responding to the request.

A token is generated by the application server and then stored in a shared cache with a specified timeout. This can easily be done using a cache that supports time to live options, such as redis SET. The key should be the token type (login or registration) along with the token value. The cache value should be a serialized temporary user object. Depending on the size of your user account object, it should be safe to serialize and store it in the cache and not call the database at all until registration is complete.

A secondary cache key of the user’s email address and the token type should also be stored with the same timeout value. The purpose of this key is to prevent attacks that attempt to spam individual users. This way, if a user tries to sign up multiple times, they’ll only get a single email until the registration is either validated or the key / token expires. For stronger brute force prevention measures, you could also store a per-email address attempt count, and completely disable registration with that email after 3-4 attempts. While rate limiting could help here, spammers will use multiple different IP addresses in an attempt to circumvent the rate limiting. If you are under attack from a persistent malicious user, you may need to implement a CAPTCHA based system.

Note: A good timeout value is probably between 10-15 minutes, this is to handle cases where a user gets distracted, as well as assists in reducing spam attempts.

At this point, our user has recieved our email, what exactly does it contain? It will contain a link back to your web application along with the token. Note that this URL should contain the sensitive token parts in a URL fragment, as those are not supposed to be stored or logged by intermediary proxy or logging systems.

Here’s an example email from a yourl registration: yourl email registration

Once our user has clicked the link, the token is read via JavaScript and POST’d to the target API. If the token is correct, we delete the token and the secondary “in progress token” from our cache, and call the database to create the account. At this point you can consider the user logged in, and assign them sessions either via cookies or JWT.

Let’s see some Go code!

Registration

// Register a user by generating a temporary token
// and sending them an email
func (m *MagicLink) Register(c *gin.Context) {

	user := &MagicLinkUser{}
	if err := c.BindJSON(user); err != nil {
		response.Error(c, err.Error())
		return
	}

	if !validate.Data(c, user) {
		return
	}

	org, err := m.getOrg(user.Email)
	if err != nil && !store.IsOrgNotExistErr(err) {
		response.Error(c, err.Error())
		return
	}

	if org != nil || store.IsOrgExist(err) {
		// this user is already registered, but don't tell them that, just exit out
		response.Valid(c, "registration initiated")
		return
	}

	org = data.NewOrg(user.Name, user.Email)
	if err := m.sendToken(c, sender.SendTypeRegistration, org, generators.RandomKey128()); err != nil {
		response.Error(c, err.Error())
		return
	}

	response.Valid(c, "registration initiated")
}

This is the API endpoint which first validates the data is in proper form, ensuring valid lengths and the required fields are set. Next, we see if this organization (user) already exists, if it does, we just respond saying that registration is initiated so a malicious user can’t easily identify registered email addresses. We create a temporary new user object and pass it to sendToken.

Sending the Token

func (m *MagicLink) sendToken(c *gin.Context, messageType sender.SendType, org *data.Org, token string) error {

	cacheKey := messageType.CacheKey(token)

	inProgressKey := messageType.ProgressKey(org.Email)
    // see if we already have a token send attempt here and return early
    // if one already exists, this prevents spamming the end-user.
	if err := m.checkInProgress(inProgressKey); err != nil {
		return err
	}

	err := m.cache.SetOrgWithTTL(cacheKey, org, m.tokenTimeout)

	if err != nil {
		return fmt.Errorf("failed setting %s token", messageType)
	}

	if err := m.sender.Send(messageType, org.Email, org.Name, token); err != nil {
		m.cache.Delete(cacheKey)
		m.cache.Delete(inProgressKey)
		return errors.Wrap(err, "failed to send registration email")
	}

	return nil
}

Here we have two keys being created, one with the messageType (registration) and the random token, such as “registration$(random bytes)”. The second “in progress key” is our messageType and the email address the user supplied, such as “registration$user@email.com”. If we’ve already sent out a token (i.e. this user is already in progress of registering), we exit out and do not continue to send an email.

Adding our in progress key

func (m *MagicLink) checkInProgress(key string) error {
	var inProgress string
	var err error

	if inProgress, err = m.cache.GetString(key, false); err != nil {
		return err
	}

	if inProgress != "" {
		return fmt.Errorf("token already in progress")
	}

	return m.cache.SetStringWithTTL(key, "inprogress", m.tokenTimeout)
}

Our in progress check is relatively simple, we see if it’s in progress for the message type (either registration or login) and if it isn’t, we set it in our cache with the same timeout value.

Login

// Login for users who already exist but just need a new session
func (m *MagicLink) Login(c *gin.Context) {
	user := &MagicLinkEmail{}
	if err := c.BindJSON(user); err != nil {
		response.Error(c, err.Error())
		return
	}

	if !validate.Data(c, user) {
		return
	}

	org, err := m.getOrg(user.Email)
	if err != nil || org == nil {
		response.Valid(c, "sent")
		return
	}

	if err := m.sendToken(c, sender.SendTypeLogin, org, generators.RandomKey128()); err != nil {
		response.Error(c, err.Error())
	}

	response.Valid(c, "sent")
}

The login process is almost exactly the same, regardless of whether the user exists or not, we send a success message. If it is successful, we actually send the token, doing the same check to see if there’s already an “in progress” token.

Verification

// Verify for a user provided they have the correct token
// in the POST body
func (m *MagicLink) Verify(c *gin.Context) {
	token := &MagicLinkToken{}
	if err := c.BindJSON(token); err != nil {
		response.Error(c, err.Error())
		return
	}

	if !validate.Data(c, token) {
		return
	}

	tokenType := sender.SendTypeLogin
	if token.Type == "registration" {
		tokenType = sender.SendTypeRegistration
	}

	key := tokenType.CacheKey(token.Token)

	org, err := m.cache.GetOrg(key) // extract deserialized account
	m.cache.Delete(key)

	if err != nil {
		response.Error(c, "invalid or expired token")
		return
	}

    err = m.cache.Delete(tokenType.ProgressKey(org.Email))

	if err != nil {
		log.Printf("failed to delete in progress token: %s\n", err)
		response.Error(c, "internal error occurred")
		return
	}

	switch tokenType {
	case sender.SendTypeRegistration:
		if err := m.orgService.AddOrg(org); err != nil {
			response.Error(c, "error registrating user")
			return
		}
		m.setSessions(c, org)
		response.Valid(c, "registration complete")
		return
	case sender.SendTypeLogin:
		m.setSessions(c, org)
		response.Valid(c, "login successful")
		return
	}

	response.Error(c, "internal error occurred")
}

Finally, we verify the token. If the token is valid, we extract the deserialized account out of our cache. If it’s invalid, we say the token is incorrect and return an error. Next, we destroy the in progress key so they will be able to try again if they close their browser. Note that the timeout will kick in regardless after 10-15 minutes.

Depending on login or registration type, we either register the account, or return a successful login and assign them sessions. Otherwise we exit out with an error.

Email address change

While I have not personally implemented this feature yet, it could be implemented as follows:

  1. Generate e-mail address change token and in progress token and send a link to the user via email
  2. User is directed to a temporary page which allows them to set a new email address
  3. User fills in form field of new email address, at which point another token is generated and a link to the new email address is sent sent
  4. User clicks secondary change email email to confirm the new email address

Conclusion

Magic Links are relatively easy to implement and present a far better user experience than passwords. Just make sure you consider the implications to your system design and think about how it could be abused by spammers or other malicious actors.