Skip to content

How we launched Xiggit as a passwordless app

I’m a firm believer that passwords are dead. My family constantly gives me a hard time about it, too— I know one passphrase to my password manager, and that’s it. I don’t know any of the passwords to any of the sites or applications I have accounts with. That’s why we build our Xiggit app to be 100% passwordless from the start.

How simple is our enrollment flow?

Our flow is about as simple as it gets, regardless of it you’re enrolling or if you’re a returning user. For a new user, we ask for your name and email address when signing up. Any time you wish to sign in, you simply enter your phone number, we send a 6 digit code via SMS, and the user is authenticated once the code is confirmed in the backend.

How did we do it?

Our entire platform is 100% serverless on AWS. We use Amazon Cognito for user authentication. So, we followed this article as a blueprint. This diagram (borrowed from the Amazon article) outlines the process for the solution:

1_MM_agLZeqTA9qMvhnMl19w

  1. The user enters their mobile phone on the sign-in page. We use the Amplify Auth function to authenticate the user to Cognito.
  2. The user pool calls the “Define Auth Challenge” Lambda function. This function determines if a custom challenge needs to be created.
  3. The user pool calls the “Create Auth Challenge” Lambda function. This Lambda function generates a 6 digit code and calls the Twilio SMS Messaging service to send the code to the user.
  4. The user receives the SMS code on their phone and enters it into the sign-in page. This sends it to the user pool.
  5. The user pool calls the “Verify Auth Challenge Response” Lambda function. This function confirms the code matches the code that was sent.
  6. The user pool calls the “Define Auth Challenge” Lambda function again. This time, the Lambda function verifies that the challenge has been answered. If the challenge has been answered, it returns issueTokens: true to the user pool. The user pool then considers the user to be authenticated and sends the user valid JSON Web Tokens (JWTs) (in response to 4).

In addition to this flow, we also have a preSignUp trigger that automatically verifies the users' phone number, which is required to use this flow.

The preSignUp Trigger

Let’s start with the preSignUp trigger first. All we do is set 2 flags in the response:

  • autoConfirmUser: true
  • autoVerifyPhone: true
module.exports.handler = async (event, context) => {
  event.response.autoConfirmUser = true;
  if (
event
.request
.userAttributes
.hasOwnProperty('phone_number')

) {
    event.response.autoVerifyPhone = true;
}
  return event;
};

 

The Define Auth Challenge Trigger

Here we are checking three things

  1. Has the user answered correctly?
  2. Has the user answered incorrectly three times?
  3. Does the user need to answer the challenge?

Has the user answered correctly?

If the “Verify Auth Challenge” Lambda function has verified the code, then tell the pool to provide authentication tokens to the user

if (event.request.session &&
  event.request.session.length &&
  event.request.session.slice(-1)[0].challengeResult === true) {
  // The user provided the right answer; succeed auth
  event.response.issueTokens = true;
  event.response.failAuthentication = false;
}

 

Has the user answered incorrectly three times?

If the user has answered the challenge incorrectly 3 times, then return a failure.

if (event.request.session &&
  event.request.session.length >= 3 &&
  event.request.session.slice(-1)[0].challengeResult === false) {
  // The user provided a wrong answer 3 times; fail auth
  event.response.issueTokens = false;
  event.response.failAuthentication = true;
}

 

Does the user need to answer the challenge?

If neither of the other cases is true, then we prompt the user to respond to the custom challenge.

event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';

 

Pulling it all together

Here’s our complete DefineAuthChallenge Lambda Function:

module.exports.handler = async (event, context) => {
  if (event.request.session &&
    event.request.session.length >= 3 &&
    event.request.session.slice(-1)[0].challengeResult === false) {
    // The user provided a wrong answer 3 times; fail auth
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  } else if (event.request.session &&
    event.request.session.length &&
    event.request.session.slice(-1)[0].challengeResult === true) {
    // The user provided the right answer; succeed auth
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    // The user did not provide a correct answer yet; present challenge
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = 'CUSTOM_CHALLENGE';
  }
  return event;
};

 

The Create Auth Challenge Trigger

The “Create Auth Challenge” Trigger is the bit of code that actually creates the six-digit code that we send to the user. It determines if we need to create a code and stores the code in the session to be used later. We also sprinkle in a bit of magic to allow for a test code to be used in our lower environments and test accounts.

module.exports.handler = async (event, context) => {
  try {
    let secretLoginCode;
    if (!event.request.session || !event.request.session.length) {
      // This is a new auth session
      // Generate a new secret login code and mail it to the user
      if (process.env.ENVIRONMENT !== 'production') {
        secretLoginCode = '123123';
      } else {
        secretLoginCode = randomDigits(6).join('');
        await sendCodeViaSMS(event.request.userAttributes.phone_number, secretLoginCode);
      }
    } else {
      // There's an existing session. Don't generate new digits but
      // re-use the code from the current session. This allows the user to
      // make a mistake when keying in the code and to then retry, rather
      // the needing to e-mail the user an all new code again.
      const previousChallenge = event.request.session.slice(-1)[0];
      secretLoginCode = previousChallenge.challengeMetadata.match(/CODE-(\d*)/)[1];
    }

    // This is sent back to the client app
    event.response.publicChallengeParameters = {
        phoneNumber: event.request.userAttributes.phone_number
    };

    // Add the secret login code to the private challenge parameters
    // so it can be verified by the "Verify Auth Challenge Response" trigger
    event.response.privateChallengeParameters = { secretLoginCode };

    // Add the secret login code to the session so it is available
    // in a next invocation of the "Create Auth Challenge" trigger
    event.response.challengeMetadata = `CODE-${secretLoginCode}`;

    return event;
  } catch (error) {
    return null;
  }
}

 

The function sendCodeViaSMS uses the Twilio API to send the user the code via their Programmable Messaging API (although we will be moving this to their Verification API in the near future).

The Verify Auth Challenge Response Trigger

The “Verify Auth Challenge Response” trigger code is simple — it checks if the passed code matches the code created in the “Create Auth Challenge” trigger. It returns the result in the answerCorrect value of the response object.

module.exports.handler = async (event, context) => {

  const expectedAnswer = event.request.privateChallengeParameters.secretLoginCode;
  if (event.request.challengeAnswer === expectedAnswer) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }

  return event;
});

 

Deploying the code

We deploy all of our code using the Serverless framework and Cloudformation (including creating our Cognito User Pool!). I’ve included the snippet of that configuration below. The definitions for PreSignUpLambdaFunction, CreateAuthChallengeLambdaFunction, DefineAuthChallengeLambdaFunction, and VerifyAuthChallengeFunction are defined elsewhere in the file.

UserPool:
  Type: AWS::Cognito::UserPool
  Properties:
    AdminCreateUserConfig:
      UnusedAccountValidityDays: 7
    AliasAttributes:
      - 'phone_number'
      - 'email'
      - 'preferred_username'
    UserPoolName: 'AppUserPool'
    Policies:
      PasswordPolicy:
        MinimumLength: 12
        RequireLowercase: false
        RequireNumbers: false
        RequireSymbols: false
        RequireUppercase: false
    Schema:
      - AttributeDataType: 'String'
        Name: 'family_name'
        Required: true
        Mutable: true
      - AttributeDataType: 'String'
        Name: 'given_name'
        Required: true
        Mutable: true
      - AttributeDataType: 'String'
        Name: 'email'
        Required: false
        Mutable: true
      - AttributeDataType: 'String'
        Name: 'phone_number'
        Required: true
        Mutable: false
    LambdaConfig:
      PreSignUp:
        Fn::GetAtt:
          - PreSignUpLambdaFunction
          - Arn
      CreateAuthChallenge:
        Fn::GetAtt:
          - CreateAuthChallengeLambdaFunction
          - Arn
      DefineAuthChallenge:
        Fn::GetAtt:
          - DefineAuthChallengeLambdaFunction
          - Arn
      VerifyAuthChallengeResponse:
        Fn::GetAtt:
          - VerifyAuthChallengeLambdaFunction
          - Arn

 

Before we go and some parting thoughts

One thing I didn’t mention earlier — when a user registers for our app, we the Amplify signUp function to create a user account. Each user account is created with a completely random password and the username is a hash of the users' phone number to ensure uniqueness. Once the user has signed up (their Cognito account has been created), we use the Amplify signIn function to trigger the flow we’ve described here.

As we’ve shown, it’s easy to use Cognito triggers to provide for custom authentication processes. You can find all of the source code referenced here in the GitHub repo. I hope this article helps you customize your Cognito authentication!

Leave a Comment