Skip to content
← All notes

Passwordless authentication in a Cognito user pool

Cognito's custom auth flow is three Lambdas and a state machine you have to write yourself. What each trigger receives, what it must return, and where the tokens come from.

Published
Reading
7 min
Tags
auth
Originally
Codemancers

Using a custom authentication flow with Cognito, several steps happen behind the scenes. Understanding the whole flow is worth the time.

It starts with a user request to initiate authentication. Cognito responds by creating a new session token, which expires after three minutes, and sends it to the defineChallenge Lambda. That function acts as a state machine for the entire flow. Since this is the first invocation in the session, we return one of the predefined challenge types AWS provides. The ChallengeNameType enum in the AWS SDK lists them all.

Next, Cognito triggers createChallenge with the challenge type. This is where the handler logic defining how the custom authentication works lives. Once the challenge parameters are set, the public ones go back to the client along with the session token.

The user enters a challenge response, which, with the username and session token, goes to the respondToAuthChallenge command. Cognito takes that response and triggers verifyChallenge, where we check it against the answer set in createChallenge.

If the response is correct, Cognito invokes defineAuth again, which responds with authentication tokens. Cognito sends those to the client and the flow is done.

Define challenge Lambda

lambdas/define-challenge/index.js
exports.handler = async (event) => {
  if (event.request.session.length === 0) {
    // First challenge
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = 'CUSTOM_CHALLENGE';
  } else if (
    event.request.session.length === 1 &&
    event.request.session[0].challengeName === 'CUSTOM_CHALLENGE' &&
    event.request.session[0].challengeResult === true
  ) {
    // User has successfully completed the challenge
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    // Challenge failed
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  }
};

defineChallenge is the most involved of the three because it is the state machine for the whole flow. Cognito provides the current session in the request: an array of every challenge answer so far.

Three properties on the response object build the reply the user pool wants: issueTokens, failAuthentication and challengeName.

If the session array is empty we issue the challenge, since this is the first trigger. Otherwise we validate. If the challenge name matches our configuration, we issue tokens when challengeResult is true and fail authentication when it is false.

Create challenge Lambda

lambdas/create-challenge/index.js
const emailClient = require('@sendgrid/mail');
const sendgridApiKey = process.env.SENDGRID_API_KEY;
emailClient.setApiKey(sendgridApiKey);
 
exports.handler = async (event) => {
  const code = Math.floor(100000 + Math.random() * 900000).toString(); // Generate a random 6-digit number
  const user = event.request.userAttributes;
  const email = {
    to: `${event.userName} <${user.email}>`,
    from: 'example.com <no-reply@example.com>',
    subject: `[${event.triggerSource}] Your login token`,
    text: `Use the link below to log in to example.com\n http://localhost:4000/verify-login?code=${code}&username=${event.userName} \n this link will expire in three minutes`,
  };
  await emailClient.send(email);
  event.response.publicChallengeParameters = { challenge: 'CUSTOM_CHALLENGE' };
  event.response.privateChallengeParameters = { code };
};

createChallenge generates the temporary password and notifies the user of it: email, SMS, push, whatever fits. Here it's an email carrying a login link, with the code and username as query parameters that the frontend uses to call the backend API and respond to the challenge.

Note the split between public and private challenge parameters. Public ones go back to the client and must never include the answer. Private ones stay inside the authentication flow.

Verify challenge Lambda

lambdas/verify-challenge/index.js
exports.handler = async (event) => {
  const expectedOtp = event.request.privateChallengeParameters.code;
  const userOtp = event.request.challengeAnswer;
  if (userOtp === expectedOtp) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }
  return event;
};

verifyChallenge is the simple one. It compares the user input against the answer stored in privateChallengeParameters back in createChallenge, and assigns the result to answerCorrect.

Login service

auth/auth.service.js
async function login({ username }) {
  const input = {
    ClientId: this.awsConfigService.userPoolClientId,
    AuthFlow: 'CUSTOM_AUTH',
    AuthParameters: {
      USERNAME: username,
    },
  };
  const command = new InitiateAuthCommand(input);
  return this.client.send(command);
}

AuthFlow is set to CUSTOM_AUTH, which tells Cognito to trigger our defineChallenge handler and not to expect a PASSWORD field in AuthParameters.

Verify login

auth/auth.service.js
async function verifyLogin({ code, username, session }) {
  const input = {
    ClientId: '<User-Pool-Client-Id>',
    ChallengeName: 'CUSTOM_CHALLENGE',
    Session: session,
    ChallengeResponses: {
      ANSWER: code,
      USERNAME: username,
    },
  };
  const command = new RespondToAuthChallengeCommand(input);
  const response = await this.client.send(command);
  return response.AuthenticationResult;
}

verifyLogin splits the authentication process, taking username and challenge answer separately. The session token from the initial auth command in login is required. The challenge name has to match the one in defineChallenge. On success the command responds with authentication tokens.

Skipping the challenge entirely

There's an alternative route to passwordless: issue tokens on the first invocation of defineChallenge and never create a challenge at all.

lambdas/define-challenge/index.js
exports.handler = async (event) => {
  event.response.issueTokens = true;
  event.response.failAuthentication = false;
  return event;
};

The login service is unchanged:

auth/auth.service.js
async function login({ username }) {
  const input = {
    ClientId: this.awsConfigService.userPoolClientId,
    AuthFlow: 'CUSTOM_AUTH',
    AuthParameters: {
      USERNAME: username,
    },
  };
  const command = new InitiateAuthCommand(input);
  return this.client.send(command);
}

Because Cognito has been told to issue tokens immediately, login now returns them directly and there is no separate verifyLogin step. Smoother, at the cost of the verification the challenge was doing.

Conclusion

That's a passwordless flow on Cognito with custom Lambda triggers. Whether you take the challenge-based route or issue directly, the shape is the same: Cognito runs the state machine, and you write what each state does.

Resources