Implementing Magic Links with Amazon Cognito: A Step-by-Step Guide
Tags: aws, cognito, lambda, security
Published: 2023-03-20 10:02:26

---
title: Implementing Magic Links with Amazon Cognito: A Step-by-Step Guide
published: true
date: 2023-03-20 09:50:02 UTC
tags: AWS,Cognito,Lambda,Security
canonical_url: https://theburningmonk.com/2023/03/implementing-magic-links-with-amazon-cognito-a-step-by-step-guide/
---

Last week, we looked at [implementing passwordless authentication using one-time passwords (OTPs) using Cognito](https://dev.to/aws-heroes/passwordless-authentication-made-easy-with-cognito-a-step-by-step-guide-2pcn).

Another popular passwordless authentication method is **magic links** where:

1. The user initiates the sign-in process by entering their email in your application.
2. They receive an email with a time-limited URL.
3. The user clicks on the URL and is authenticated into the application.

Again, this is not something that Cognito supports out of the box. But we can implement this custom authentication flow using its [Lambda hooks](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html).

### How it works

Cognito has three Lambda hooks for implementing custom authentication flows:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641782aa978d7.png)

However, we can’t rely on these alone to implement magic links.

Let me explain.

#### The problems with Session

The [official documentation page](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html) explains the roles of these hooks and how they fit together. However, it doesn’t tell you that a `Session` string issued by the user pool needs to be passed back and forth between the front end client and the user pool.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641782bccc34c.png)

You can see this in the [response of the InitiateAuth](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html#CognitoUserPools-InitiateAuth-response-Session) and the [request of the RespondToAuthChallenge](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html#CognitoUserPools-RespondToAuthChallenge-request-Session) APIs. This `Session` value is how we are able to pass data from the `CreateAuthChallenge` function to the `VerifyAuthChallengeResponse` function using `privateChallengeParameters`.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641782d05fbad.png)

This is a problem when implementing magic links.

Because when the user clicks on the magic link, it would most likely open a new browser window and the previous session data is lost on the client. Of course, there are ways to work around this using various forms of local storage. But what if the magic link is opened on a different browser? The user could have also closed the previous browser tab as well.

What if we include the `Session` value in the magic link? Wouldn’t that allow the client to continue the authentication flow in the new browser window?

Sadly, that won’t work either.

The initial `Session` is generated AFTER the `CreateAuthChallenge` function was called. This is because the session contains information that was returned by the `CreateAuthChallenge` function, such as the `privateChallengeParameters` and `challengeMetadata` values above.

Instead, what we can do is introduce a custom API endpoint to kick off the authentication process and send the magic link to the user. And only when the user opens the application via the magic link do we then use Cognito’s `InitiateAuth` API to kick off a custom Cognito authentication flow.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641782e191ad4.png)

### The Solution

Here’s how the solution works:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_64178cce4b617.png)

1. The user enters their email to start the authentication flow. The front end calls a `POST /login` endpoint on our API Gateway REST API. This triggers a Lambda function to:

- Generate a secret token. The front end would later need to present this token when it responds to the custom auth challenge.
- Generate the magic URL including the secret token as a query string parameter.
- Send an email to the user including the magic URL. Here, I will use the Amazon Simple Email Service (SES) to send emails. If you wish to try it out yourself, you would need to create and verify a domain identity in SES. Please refer to the [official documentation page](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html) for more details on how to do that.
- To enforce a time limit, we can use a JSON string that contains an expiration datetime and encrypt it using the Key Management Service (KMS). This encrypted string would be our secret token.
- To make sure the magic links are invalidated when the user generates a new one, we need a way to track the CURRENT token for a user. We can do this by setting it as a custom attribute on the Cognito user. This is preferred to using a DynamoDB table because the Cognito user pool would pass it along to the `CreateAuthChallenge` function. Which saves us from having to read it out of the database ourselves.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641783175ee04.png)

2. User clicks on the magic link in the email and is directed back to the application. The link contains an `email` and a `token` query string parameter. The front end client uses the email to initiate the Cognito authentication flow.

3. The user pool calls the `DefineAuthChallenge` Lambda function to decide what it should do. The function indicates that it should present a custom auth challenge to the user.

4. To create the custom challenge, the user pool calls the `CreateAuthChallenge` Lambda function. Because we had saved the secret token as a custom attribute on the Cognito user (step 1.), the function can find the token in its invocation event.

At this point, we can “lock in” the secret token we should use for this authentication flow. So even if there’s a delay in the front end responding to this challenge and the user starts another authentication flow elsewhere, it wouldn’t affect the current flow.

We can do this by saving the secret token in the `response.privateChallengeParameters` and make sure the `VerifyAuthChallengeResponse` function uses it.

5. Immediately after receiving the custom auth challenge, the front end responds with the token it received through the query string parameters. There’s no need for user input.

6. The user pool calls the `VerifyAuthChallengeResponse` function to validate the token:

- Make sure it matches what the `CreateAuthChallenge` function had saved in the `privateChallengeParameters` object.
- Make sure the token hasn’t expired.
- Make sure the user the token was issued for matches the user that’s trying to sign in.

If all the checks pass then the challenge is answered correctly.

7. The user pool calls the `DefineAuthChallenge` function again to decide what happens next. If the challenge was answered correctly then JWT tokens are issued. Otherwise, we fail the authentication attempt.

There’s no need for retries. If the token is invalid for any reason, no amounts of retries can change that.

Ok, so that’s how this solution works at a conceptual level.

Let’s see how we can implement it.

### How to implement it

**1. Set up a Cognito User Pool**

First, we need to set up a Cognito User Pool.

```
PasswordlessMagicLinksUserPool:
  Type: AWS::Cognito::UserPool
  Properties:
    UsernameConfiguration:
      CaseSensitive: false
    UsernameAttributes:
      - email
    Policies:
      # this is only to satisfy Cognito requirements
      # we won't be using passwords, but we also don't
      # want weak passwords in the system ;-)
      PasswordPolicy:
        MinimumLength: 16
        RequireLowercase: true
        RequireNumbers: true
        RequireUppercase: true
        RequireSymbols: true
    Schema:
      - AttributeDataType: String
        Mutable: false
        Required: true
        Name: email
        StringAttributeConstraints: 
          MinLength: '8'
      - AttributeDataType: String
        Mutable: true
        Required: false
        Name: authChallenge
        StringAttributeConstraints: 
          MinLength: '8'
    LambdaConfig:
      PreSignUp: !GetAtt PreSignUpLambdaFunction.Arn
      DefineAuthChallenge: !GetAtt DefineAuthChallengeLambdaFunction.Arn
      CreateAuthChallenge: !GetAtt CreateAuthChallengeLambdaFunction.Arn
      VerifyAuthChallengeResponse: !GetAtt VerifyAuthChallengeResponseLambdaFunction.Arn
```

Notice that we configured a custom attribute called `authChallenge`. When the user initiates an authentication flow, our API function would save the secret token in this attribute. And the value of this attribute would be passed along to the `DefineAuthChallenge`, `CreateAuthChallenge` and `VerifyAuthChallengeResponse` functions.

```
- AttributeDataType: String
  Mutable: true
  Required: false
  Name: authChallenge
  StringAttributeConstraints: 
    MinLength: '8'
```

Also, it’s important to note that passwords are still required even if you don’t intend to use them. I have set a fair strong password requirement here, but the passwords would be generated by the front end and they are never exposed to the user.

Our user pool is not going to verify the user’s email when they sign up. Because every time the user tries to sign in, we would send them an email with a magic link. Which would verify their ownership of the email address at that point.

**_Sidenote:_** _I made this decision for the demo to make the demo really easy to use. In practice, you should still enable auto-verification for your application. It adds minor friction to the sign-up process but ensures the user accounts in your production system are all valid emails. Additionally, you should clean up unconfirmed Cognito users after X number of days._

**2. Set up the User Pool Client for the front end**

The front end application needs a client ID to talk to the user pool. Because we don’t want the users to log in with passwords, so we will only support the custom authentication flow with `ALLOW_CUSTOM_AUTH`.

```
WebUserPoolClient:
  Type: AWS::Cognito::UserPoolClient
  Properties:
    ClientName: web
    UserPoolId: !Ref PasswordlessMagicLinksUserPool
    ExplicitAuthFlows:
      - ALLOW_CUSTOM_AUTH
      - ALLOW_REFRESH_TOKEN_AUTH
    PreventUserExistenceErrors: ENABLED
```

**3. A KMS customer-managed key (CMK)**

We will generate the secret token by encrypting a JSON payload like this:

```
{
  "email": "me@example.com",
  "expiration": "2023-03-19T02:27:45.768Z"
}
```

To do that, we will need a KMS key.

```
EncryptionKey:
  Type: AWS::KMS::Key
  Properties: 
    Enabled: true
    EnableKeyRotation: true
    KeyPolicy:
      Version: '2012-10-17'
      Statement:
        - Sid: Enable IAM User Permissions
          Effect: Allow
          Principal: 
            AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
          Action: kms:*
          Resource: '*'
        - Sid: Allow access for Key Administrators
          Effect: Allow
          Principal: 
            AWS: !Sub arn:aws:iam::${AWS::AccountId}:role/Administrator
          Action:
            - kms:Create*
            - kms:Describe*
            - kms:Enable*
            - kms:List*
            - kms:Put*
            - kms:Update*
            - kms:Revoke*
            - kms:Disable*
            - kms:Get*
            - kms:Delete*
            - kms:TagResource
            - kms:UntagResource
            - kms:ScheduleKeyDeletion
            - kms:CancelKeyDeletion
          Resource: '*'
    MultiRegion: false
    PendingWindowInDays: 7
```

**4. The Lambda function behind POST /login**

As mentioned above, we will add a custom API endpoint to initiate the authenticate flow. In the Serverless framework, I can do this by declaring a `logIn` function like this:

```
logIn:
  handler: functions/log-in.handler
  events:
    - http:
        path: login
        method: post
        cors: true
  environment:
    SES_FROM_ADDRESS: noreply@${self:custom.domain}
    KMS_KEY_ID: !Ref EncryptionKey
    BASE_URL: passwordless-cognito.theburningmonk.com
    USER_POOL_ID: !Ref PasswordlessMagicLinksUserPool
  iamRoleStatements:
    - Effect: Allow
      Action: ses:SendEmail
      Resource: 
        - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/${self:custom.domain}
        - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/*
    - Effect: Allow
      Action: kms:Encrypt
      Resource: !GetAtt EncryptionKey.Arn
    - Effect: Allow
      Action: cognito-idp:AdminUpdateUserAttributes
      Resource: !GetAtt PasswordlessMagicLinksUserPool.Arn
```

The function needs a number of environment variables, including:

- The ID of the KMS key.
- The base URL of the magic link.
- The ID of the Cognito User Pool. Because we will add the secret token as a custom attribute on the Cognito user.
- The email sender’s address. This sender must be from a verified domain in SES or it must be a verified email address in SES.

So let’s have a look at the code for this function.

```
const Cognito = require('aws-sdk/clients/cognitoidentityserviceprovider')
const cognito = new Cognito()
const SES = require('aws-sdk/clients/sesv2')
const ses = new SES()
const { TIMEOUT_MINS } = require('../lib/constants')
const { encrypt } = require('../lib/encryption')
const qs = require('querystring')
const middy = require('@middy/core')
const httpErrorHandler = require('@middy/http-error-handler')
const cors = require('@middy/http-cors')

const { SES_FROM_ADDRESS, USER_POOL_ID, BASE_URL } = process.env
const ONE_MIN = 60 * 1000

module.exports.handler = middy(async (event) => {
  const { email } = JSON.parse(event.body)
  if (!email) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        message: 'You must provide a valid email.'
      })
    }
  }

  // only send the magic link on the first attempt
  const now = new Date()
  const expiration = new Date(now.getTime() + ONE_MIN * TIMEOUT_MINS)
  const payload = {
    email,
    expiration: expiration.toJSON()
  }
  const tokenRaw = await encrypt(JSON.stringify(payload))
  const tokenB64 = Buffer.from(tokenRaw).toString('base64')
  const token = qs.escape(tokenB64)
  const magicLink = `https://${BASE_URL}/magic-link?email=${email}&token=${token}`  

  try {
    await cognito.adminUpdateUserAttributes({
      UserPoolId: USER_POOL_ID,
      Username: email,
      UserAttributes: [{
        Name: 'custom:authChallenge',
        Value: tokenB64
      }]
    }).promise()
  } catch (error) {
    return {
      statusCode: 404,
      body: JSON.stringify({
        message: 'User not found'
      })
    }
  }
  
  await sendEmail(email, magicLink)
  return {
    statusCode: 202
  }
})
.use(httpErrorHandler())
.use(cors())
```

I want to point out a few things in this function:

- The `sendEmail` function has been omitted here for brevity’s sake. It does what you’d expect and sends the magic link to the user by email.
- The request validation can be delegated to API Gateway instead. API Gateway supports request validation. In fact, it would be my preferred way to validate POST bodies because invalid requests would be rejected by API Gateway without hitting my function. So I don’t have to write custom code to validate them, and importantly, API Gateway does not charge for these invalid requests.
- If the user’s email is not found in Cognito, it will return an HTTP 404. This is fine in most cases, but it allows malicious actors to find out if an account exists in your application. If you had enabled the `PreventUserExistenceErrors` setting on the user pool client, then you should ensure this function follows the same behaviour and to not return a 404.
- This function uses the [Middy](https://github.com/middyjs/middy) middleware engine to handle unhandled errors and add CORS headers in the response.
- The encrypted token is saved in the user’s `authChallenge` attribute described earlier. However, when using custom attributes, the attribute names have to be prefixed with `custom:`. Hence why it appears as `custom:authChallenge` here.
- The encrypted token (which results in a `Buffer` object) is converted to a base64 string and then URI encoded. But it’s the base64 version that is saved in the Cognito user’s attributes. When we send this token back during sign-in, we have to first URI decode it. This happens in the front end.
- The `encrypt` function exists in another module (see below), which uses the aforementioned KMS key.

```
const KMS = require('aws-sdk/clients/kms')
const KmsClient = new KMS()

const { KMS_KEY_ID } = process.env

const encrypt = async (input) => {
  const resp = await KmsClient.encrypt({
    KeyId: KMS_KEY_ID,
    Plaintext: input
  }).promise()

  return resp.CiphertextBlob
}

const decrypt = async (ciphertext) => {
  const resp = await KmsClient.decrypt({
    CiphertextBlob: Buffer.from(ciphertext, 'base64')
  }).promise()

  return resp.Plaintext
}

module.exports = {
  encrypt,
  decrypt
}
```

**5. (Front end) Initiate the authentication flow**

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_6417848172817.png)

Once registered, a user can initiate the authentication flow by just entering their email. In the front end, we will make an HTTP request to the `POST /login` endpoint above.

```
async function sendMagicLink() {
  const response = await fetch('https://xxx.execute-api.eu-west-1.amazonaws.com/dev/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      email: email.value
    })
  }).catch(err => {
    alert(`Failed to send magic link: ${err.message}`)
  })

  if (response.status !== 202) {
    const responseBody = await response.json()
    alert(`Failed to send magic link: ${responseBody.message}`)
  } else {
    signInStep.value = 'SENT_MAGIC_LINK'
  }
}
```

The user would then receive an email like this:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_6417849609363.png)

**6. (Front end) Sign in with Cognito**

Upon clicking the magic link, the user would be taken back to the website. Instead of showing the user the original UI, we need to extract the email and token values from the query string and sign the user in (assuming the token is still valid).

In Vue, we can use the `onMounted` lifecycle hook to execute code when the component has been mounted to the DOM. At this point, we can check if the email and token query string parameters are present. If so, initiate Cognito’s sign-in flow and respond to the custom challenge.

```
import { onMounted } from 'vue'
import { Amplify, Auth } from 'aws-amplify'

onMounted(async () => {
  // the search string looks like "?email=xxx&token=yyy"
  if (window.location.search) {
    const qs = window.location.search.substring(1)
    const qsParams = qs.split(['&'])
    const qsEmail = qsParams.find(x => x.startsWith('email='))
    const qsToken = qsParams.find(x => x.startsWith('token='))
    if (qsToken) {
      const email = decodeURIComponent(qsEmail.substring(6))
      const cognitoUser = await Auth.signIn(email)
      
      const token = decodeURIComponent(qsToken.substring(6))
      try {
        const challengeResult = await Auth.sendCustomChallengeAnswer(cognitoUser, token)
      } catch (err) {
        console.log(err)
        alert('The token is invalid.')
      }
    }
  }  
})
```

Please note that, if we submitted a valid token then the response from `Auth.sendCustomChallengeAnswer` would contain the JWT tokens for the user. If the token is invalid or has expired, then the call would err because we only allow one attempt in the `DefineAuthChallenge` function.

**7. The DefineAuthChallenge function**

Cognito’s custom authentication flow behaves like a state machine. The `DefineAuthChallenge` function is the decision maker and instructs the user pool on what to do next every time something important happens.

As you can see from the overview of the solution, this function is engaged multiple times during an authentication session:

- when the user initiates authentication, and
- every time the user responds to an auth challenge.

With magic links, there is no point in giving users multiple attempts at responding to the auth challenge. If the link is invalid or expired, it will never become valid again.

So this is the state machine we want to implement:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641784b9f0088.png)

And here’s what my `DefineAuthChallenge` function looks like.

```
const _ = require('lodash')

module.exports.handler = async (event) => {
  if (event.request.userNotFound) {
    event.response.issueTokens = false
    event.response.failAuthentication = true
    return event
  }

  if (_.isEmpty(event.request.session)) {
    // Issue new challenge
    event.response.issueTokens = false
    event.response.failAuthentication = false
    event.response.challengeName = 'CUSTOM_CHALLENGE'
  } else {
    const lastAttempt = _.last(event.request.session)
    if (lastAttempt.challengeResult === true) {
      // User gave right answer
      event.response.issueTokens = true
      event.response.failAuthentication = false
    } else {
      // User gave wrong answer
      event.response.issueTokens = false
      event.response.failAuthentication = true
    }
  }

  return event
}
```

When the client initiates the Cognito authentication flow, the user pool calls the `DefineAuthChallenge` function. At this point in the flow, the `request.session` array would be empty. So we set `response.challengeName` to `CUSTOM_CHALLENGE` and this instructs the user pool to invoke the `CreateAuthChallenge` function next.

When the client responds to the auth challenge, the user pool would call the `VerifyAuthChallengeResponse` function to check the user’s answer. The user pool then calls the `DefineAuthChallenge` function again to decide what to do next.

As mentioned before, we don’t need to give the user multiple attempts to enter the right code ([as we did in the OTP example](https://dev.to/aws-heroes/passwordless-authentication-made-easy-with-cognito-a-step-by-step-guide-2pcn)). So we can make an on-the-spot decision to issue tokens or fail the authentication attempt.

**8. The CreateAuthChallenge function**

The `CreateAuthChallenge` function only needs to do one thing – to pass the encrypted token to the `VerifyAuthChallengeResponse` function.

```
module.exports.handler = async (event) => {
  event.response.publicChallengeParameters = {
    email: event.request.userAttributes.email
  }

  // the verify step would decrypt this and check the user's answer
  event.response.privateChallengeParameters = {
    challenge: event.request.userAttributes['custom:authChallenge']
  }

  return event
}
```

**_Sidenote:_** _you might be wondering why this step is necessary since the user attributes are also available to the_ `VerifyAuthChallengeResponse`_ function. This is a design choice I made to “lock in” the secret token an ongoing authentication flow would use. It shouldn’t matter in this case because the front-end calls_ `Auth.signIn` _and then_ `Auth.sendCustomChallengeAnswer` _immediately after_. _There’s a very small time window where the user can initiate another authentication flow (in another window perhaps) between these two calls._

**9. The VerifyAuthChallengeResponse function**

We need to do several things when verifying a user’s answer:

- The most obvious is, does the secret token they submitted match what we had locked in at the `CreateAuthChallenge` step?
- Decrypt the token with our KMS key and extract the `email` and `expiration` datetime from the decrypted JSON string.
- Make sure the token hasn’t expired.
- Make sure the current user’s email matches what’s in the token.

If all these checks pass, then we can say the auth challenge has been answered correctly.

So, here’s my `VerifyAuthChallengeResponse` function.

```
const { decrypt } = require('../lib/encryption')

module.exports.handler = async (event) => {
  const email = event.request.userAttributes.email

  const expected = event.request.privateChallengeParameters.challenge
  if (event.request.challengeAnswer !== expected) {
    console.log("answer doesn't match current challenge token")
    event.response.answerCorrect = false
    return event
  }

  const json = await decrypt(event.request.challengeAnswer)
  const payload = JSON.parse(json)
  console.log(payload)
  
  const isExpired = new Date().toJSON() > payload.expiration
  console.log('isExpired:', isExpired)

  if (payload.email === email && !isExpired) {    
    event.response.answerCorrect = true
  } else {
    console.log("email doesn't match or token is expired")
    event.response.answerCorrect = false
  }
  
  return event
}
```

And that’s it.

These are the ingredients you need to implement passwordless authentication with magic links with Cognito.

### Trying it out for yourself

To get a sense of how this passwordless authentication mechanism works, please feel free to try out the demo application [here](https://passwordless-cognito.theburningmonk.com/magic-link).

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_641784ea59df4.png)

And you can find the source code for this demo on GitHub:

- [Back end (Serverless framework with Node)](https://github.com/theburningmonk/passwordless-magic-links-cognito-demo)
- [Front end (Vue3 with Vite)](https://github.com/theburningmonk/passwordless-cognito-ui-demo)

### Wrap up

I hope you have found this article useful and helps you get more out of Cognito, a somewhat underloved service.

If you want to learn more about building serverless architecture, then check out my [upcoming workshop](https://productionreadyserverless.com/?utm_campaign=cognito-magic-link&utm_source=blog) where I would be covering topics such as testing, security, observability and much more.

Hope to see you there.

![](https://theburningmonk.com/wp-content/uploads/2022/07/img_62d34443958b3.png)

The post [Implementing Magic Links with Amazon Cognito: A Step-by-Step Guide](https://theburningmonk.com/2023/03/implementing-magic-links-with-amazon-cognito-a-step-by-step-guide/) appeared first on [theburningmonk.com](https://theburningmonk.com).