Password-based authentication has long been the norm for securing user accounts. However, it is becoming increasingly clear that password-based authentication has several drawbacks. Such as the risk of password theft, the need for users to remember complex passwords, and the time and effort required to reset forgotten passwords.
Fortunately, more and more websites have started to adopt passwordless authentication. As the name suggests, it’s a means to verify a user’s identity without using passwords.
In this blog post, we will explore how to implement passwordless authentication with Amazon Cognito.
Amazon Cognito is a fully managed service that provides user sign-up, sign-in, and access control. Its direct integration with other AWS services such as API Gateway, AppSync and Lambda makes it one of the easiest ways to add authentication and authorization to applications running in AWS. And it’s also one of the most cost-efficient products on the market, compared to the likes of Auth0 and Okta.
If you want to see a full breakdown of the case for and against Cognito, then check out my article on the topic.
Passwordless authentication with Cognito
Passwordless authentication can be implemented in many ways, such as:
- Biometrics : think Face IDs or thumbprints.
- Possession factors : something the user owns, such as an email address or phone number. If a user can open an account with you using email then you can authenticate the user by sending a one-time password (OTP) to the user’s email.
- Magic links : the user enters their email, and you send them an email with a special link (aka the “magic link”). When the user clicks the link, it takes them to the application and grants them access.
Cognito doesn’t support passwordless authentication out-of-the-box. But you can implement custom authentication flows using its Lambda hooks.
In this blog post, I will show you how to implement passwordless authentication using one-time passwords.
How it works
For this solution, I would use these three Lambda hooks to implement the custom authentication flow.
I will use the Amazon Simple Email Service (SES) to send emails to the user. 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 for more details on how to do that.
Here is our authentication flow:
The user initiates the authentication flow with their email address.
The user pool calls the
DefineAuthChallenge
Lambda function to decide what it should do. The function receives an invocation payload like the one below.
Here we can see a user with the specified email is found in the user pool because userNotFound
is false
. And we can infer that this is the start of a new authentication flow because the session
array is empty.
So the function instructs the user pool to issue a CUSTOM_CHALLENGE
to the user as the next step. As you can see in the return value from this Lambda invocation.
- To create the custom challenge, the user pool calls the
CreateAuthChallenge
Lambda function.
The function generates a one-time password and emails it to the user, using the Simple Email Service (SES).
Crucially, this function needs to save the one-time password somewhere so we can verify the user’s answer later. You can do this by saving private data in the response.privateChallengeParameters
object.
Whatever you put in here would not be returned to the user. But it would be passed along to the VerifyAuthChallengeResponse
function when the user responds to our challenge.
Any information that you wish to pass back to the frontend can be added to the response.publicChallengeParameters
object. Here I’m including the user’s email as well as information about how many attempts the user has left to answer with the right code.
In the screenshot, you can see that Lumigo has scrubbed the one-time password (in response.privateChallengeParameters.secretLoginCode
) from the trace. This is a built-in behaviour where it scrubs any data that looks like secrets or sensitive data. But I can tell you that the one-time password is XQezeO
in this case. Because it is also captured in response.challengeMetadata
, which we would circle back to later.
The user enters the one-time password on the login screen.
The user pool calls the
VerifyAuthChallengeResponse
Lambda function to check the user’s answer. As you can see from the invocation event below, we can see both:
- the user’s answer (
request.challengeAnswer
), and - the one-time password that
CreateAuthChallenge
function had generated and saved aside inrequest.privateChallengeParameters
.
We can compare the two and tell the user pool if the user has answered correctly by setting response.answerCorrect
to true
or false
.
- The user pool calls the
DefineAuthChallenge
function again to decide what happens next. In the invocation event below, you can see that thesession
array now has one element. ThechallengeResult
is whatever theVerifyAuthChallengeResponse
returned inresponse.answerCorrect
.
At this point, we have a few options:
- Fail the authentication because the user has answered incorrectly too many times.
- Succeed the authentication flow and issue the JWT tokens to the user.
- Give the user another chance to answer correctly.
The first two cases are fairly straightforward. The DefineAuthChallenge
function would need to set response.failAuthentication
or response.issueTokens
to true
respectively.
Where it gets more interesting is if we want to give the user another chance. In that case, we set both response.issueTokens
and response.failAuthentication
to false
and response.challengeName
to CUSTOM_CHALLENGE
.
The control would then flow back to the CreateAuthChallenge
function. But as you can see below, the privateChallengeParameters
we had set aside earlier is not included in its invocation event!
This is why we included the one-time password in the response.challengeMetadata
earlier in step 3!
This way, the CreateAuthChallenge
function is able to reuse the same one-time password as before. And judging by the number of items in the request.session
array, it knows how many failed attempts the user has made. So it’s also able to inform the frontend how many attempts the user has left before the user has to restart the authentication flow and get a new one-time password.
I hope this gives you a solid conceptual framework of how the authentication flow works.
Now let’s talk about implementation.
How to implement it
1. Set up a Cognito User Pool
First, we need to set up a Cognito User Pool.
PasswordlessOtpUserPool:
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'
LambdaConfig:
PreSignUp: !GetAtt PreSignUpLambdaFunction.Arn
DefineAuthChallenge: !GetAtt DefineAuthChallengeLambdaFunction.Arn
CreateAuthChallenge: !GetAtt CreateAuthChallengeLambdaFunction.Arn
VerifyAuthChallengeResponse: !GetAtt VerifyAuthChallengeResponseLambdaFunction.Arn
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 a one-time password to the email. Which would verify their ownership of the email address at that point.
2. Set up the User Pool Client for the frontend
The frontend 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 PasswordlessOtpUserPool
ExplicitAuthFlows:
- ALLOW_CUSTOM_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
3. The PreSignUp hook
Normally, a user needs to confirm their registration with a verification code (to prove that they own the email address they used). But as mentioned above, we would skip this verification step because we would verify the user’s ownership of the email every time they attempt to sign in.
So in the PreSignUp
Lambda function, we need to tell Cognito to confirm the user by setting event.response.autoConfirmUser
to true
.
module.exports.handler = async (event) => {
event.response.autoConfirmUser = true
return event
}
4. (Frontend) Signing up
When the user signs up for our application, the frontend would generate a 16-digit random password behind the scenes. This password is never shown to the user and is essentially thrown away after this point.
The aws-amplify
package has a handy Auth module, which we can use to interact with the user pool.
import { Amplify, Auth } from 'aws-amplify'
Amplify.configure({
Auth: {
region: ...,
userPoolId: ...,
userPoolWebClientId: ...,
mandatorySignIn: true
}
})
async function signUp() {
const chance = new Chance()
const password = chance.string({ length: 16 })
await Auth.signUp({
username: email.value,
password
})
}
Again, this is necessary because Cognito requires you to configure passwords even if you don’t intend to use them.
5. (Frontend) Signing in
Once registered, the user can sign in by providing only their email address.
async function signIn() {
cognitoUser = await Auth.signIn(email.value)
}
This kickstarts the custom authentication flow.
6. 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.
This is the state machine we want to implement:
And here’s what my DefineAuthChallenge
function looks like.
const _ = require('lodash')
const { MAX_ATTEMPTS } = require('../lib/constants')
module.exports.handler = async (event) => {
const attempts = _.size(event.request.session)
const lastAttempt = _.last(event.request.session)
if (event.request.session &&
event.request.session.find(attempt => attempt.challengeName !== 'CUSTOM_CHALLENGE')) {
// Should never happen, but in case we get anything other
// than a custom challenge, then something's wrong and we
// should abort
event.response.issueTokens = false
event.response.failAuthentication = true
} else if (attempts >= MAX_ATTEMPTS && lastAttempt.challengeResult === false) {
// The user given too many wrong answers in a row
event.response.issueTokens = false
event.response.failAuthentication = true
} else if (attempts >= 1 &&
lastAttempt.challengeName === 'CUSTOM_CHALLENGE' &&
lastAttempt.challengeResult === true) {
// Right answer
event.response.issueTokens = true
event.response.failAuthentication = false
} else {
// Wrong answer, try again
event.response.issueTokens = false
event.response.failAuthentication = false
event.response.challengeName = 'CUSTOM_CHALLENGE'
}
return event
}
Note that every time the user makes an attempt to respond to the challenge, the result is recorded in event.request.session
.
Sidenote: if you’re wondering how Cognito is able to collate these attempts in one place, it’s because a Session
string is passed back-and-forth as the client interacts with the user pool. You can see this in the response of the InitiateAuth API and in the request for the request of the RespondToAuthChallenge API.
7. The CreateAuthChallenge function
The CreateAuthChallenge
function is responsible for generating the one-time password and emailing it to the user.
This function can also be invoked multiple times in an authentication session if the user does not provide the right answer at first. Once again, we can use the request.session
to work out if we’re dealing with an existing authentication session.
const _ = require('lodash')
const Chance = require('chance')
const chance = new Chance()
const { MAX_ATTEMPTS } = require('../lib/constants')
module.exports.handler = async (event) => {
let otpCode
if (!event.request.session || !event.request.session.length) {
// new auth session
otpCode = chance.string({ length: 6, alpha: false, symbols: false })
await sendEmail(event.request.userAttributes.email, otpCode)
} else {
// existing session, user has provided a wrong answer, so we need to
// give them another chance
const previousChallenge = _.last(event.request.session)
const challengeMetadata = previousChallenge?.challengeMetadata
if (challengeMetadata) {
// challengeMetadata should start with "CODE-", hence index of 5
otpCode = challengeMetadata.substring(5)
}
}
const attempts = _.size(event.request.session)
const attemptsLeft = MAX_ATTEMPTS - attempts
event.response.publicChallengeParameters = {
email: event.request.userAttributes.email,
maxAttempts: MAX_ATTEMPTS,
attempts,
attemptsLeft
}
// NOTE: the private challenge parameters are passed along to the
// verify step and is not exposed to the caller
// need to pass the secret code along so we can verify the user's answer
event.response.privateChallengeParameters = {
secretLoginCode: otpCode
}
event.response.challengeMetadata = `CODE-${otpCode}`
return event
}
The sendEmail
function has been omitted here for brevity’s sake. It does what you’d expect and sends the one-time password to the user by email.
8. (Frontend) Answering the challenge
In the frontend, you should have captured the CognitoUser
object returned by Auth.signIn
. You need it to respond to the custom auth challenge because it contains the Session
data that Cognito requires.
async function answerCustomChallenge() {
// This will throw an error if it’s the 3rd wrong answer
try {
const challengeResult = await Auth.sendCustomChallengeAnswer(cognitoUser, secretCode.value)
if (challengeResult.challengeName) {
secretCode.value = ''
attemptsLeft.value = parseInt(challengeResult.challengeParam.attemptsLeft)
alert(`The code you entered is incorrect. ${attemptsLeft.value} attempts left.`)
}
} catch (error) {
alert('Too many failed attempts. Please try again.')
}
}
Note that the publicChallengeParameters
returned by the CreateAuthChallenge
function is accessible here. So we can find out how many attempts the user has left in the current session.
If the DefineAuthChallenge
function tells the user pool to fail authentication, then Auth.sendCustomChallengeAnswer
would throw an NotAuthorizedException
exception with the message Incorrect username or password
.
9. The VerifyAuthChallengeResponse function
The VerifyAuthChallengeResponse
function is responsible for checking the user’s answer. To do this, it needs to access the one-time password that the CreateAuthChallenge
function generated and stashed aside in the privateChallengeParameters
.
module.exports.handler = async (event) => {
const expectedAnswer = event.request?.privateChallengeParameters?.secretLoginCode
if (event.request.challengeAnswer === expectedAnswer) {
event.response.answerCorrect = true
} else {
event.response.answerCorrect = false
}
return event
}
And that’s it.
These are the ingredients you need to implement passwordless authentication with Cognito.
Try 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.
And you can find the source code for this demo on GitHub:
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 where I would be covering topics such as testing, security, observability and much more.
Hope to see you there.
The post Passwordless Authentication made easy with Cognito: a step-by-step guide appeared first on theburningmonk.com.
Top comments (0)