Passwordless authentication is a broad term for any authentication method that doesn't rely on passwords. Implementations typically perform proof of identity based on something that is uniquely associated with a user, such as an e-mail address, a phone, a software one-time password (OTP) generator, or a hardware authentication device like a YubiKey: the user inputs the secret that the system shares with one of those methods, proving its ownership. A passwordless flow essentially skips the "what you know" factor of two-factor authentication. We're epistemological skeptics now.
Cognito is an infamous AWS service for identity management. It can do much for us: authentication, access control, and integration with external identity providers. A big advantage over competing solutions is that it integrates well with other AWS services, like AppSync.
In this post we'll look at how we can implement passwordless authentication using Cognito to satisfy our requirements, which aren't quite as described in this AWS blog post. We assume a passing familiarity with Cognito and AWS Lambda. The code examples are Lambda functions written in Go.
"As a user, I want..."
The app we're going to work on authenticates users according to the following UX:
- A user inputs their e-mail or phone number.
- We send an OTP over e-mail or SMS.
- The user inputs the code.
- If this is their first sign-in:
- We collect additional data, including a secondary authentication method.
- We verify it using a second OTP.
This is very convenient: users can sign-in using information they know by heart, without needing to use password managers or, worse, their memory (they don't even need to remember whether they've already signed-up).
These are our requirements:
- [ ] The user must be able to sign-in with their email or phone number
- [ ] There is no separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [ ] OTPs are valid only for a brief period of time
- [ ] Users have more than one attempt to input the correct OTP
What features does Cognito offer that help us meet these requirements? Let's find out.
Cognito Custom Auth
Cognito doesn't have a built-in passwordless feature, but it supports a custom authentication flow implemented as a state machine based on three Lambda function triggers that we need to implement.
- The define auth challenge Lambda is the state machine coordinator. It returns instructions to Cognito on how the flow should progress. The options are: a challenge is required; the authentication failed; or the authentication succeeded and tokens can be emitted. It is invoked by Cognito when a client calls the
InititateAuth
API. - If a challenge is required, the create auth challenge Lambda creates it. It's invoked right after the previous Lambda1, and is responsible for delivering the challenge to the user. This can be done through public challenge parameters returned by
InitiateAuth
, or through some other means2. Private challenge parameters are shared with the verify auth challenge response Lambda, typically including the challenge answer. - The verify auth challenge response Lambda is invoked by Cognito following the
RespondToAuthChallenge
API call, containing the challenge answer provided by the user. It returns whether the answer is correct, and Cognito invokes the define auth challenge Lambda again to decide whether the authentication can terminate or should continue with more challenges (in which case the create auth challenge Lambda gets called again).
A custom authentication flow can be composed of many challenges. State is kept in an encrypted session object, which is initially returned by the InititateAuth
API call, and then threaded back to Cognito when calling RespondToAuthChallenge
. It expires after 3 minutes, which already allows us to tick off a requirement!
- [ ] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [ ] Users have more than one attempt to input the correct OTP
Configuring the Cognito user pool
Our journey first tasks us with configuring a Cognito user pool in such a way that e-mails and phone numbers can be used as aliases. Thanks to the CDK, we don't have to confront the maze of darkness that is CloudFormation YAML:
this.pool = new UserPool(stack, "UserPool", {
userPoolName: props.userPoolName,
signInAliases: { username: true, email: true, phone: true },
lambdaTriggers: {
defineAuthChallenge: props.triggers.defineAuthChallenge,
createAuthChallenge: props.triggers.createAuthChallenge,
verifyAuthChallengeResponse: props.triggers.verifyAuthChallengeResponse,
},
selfSignUpEnabled: false,
})
This translates to a user pool that's part of a CloudFormation stack, which:
- Allows users to sign-in using their e-mail address and phone-number;
- Is configured with the ARN of the Lambdas that will carry out our passwordless authentication flow;
- Doesn't allow users to sign themselves up, because the
SignUp
API can be used as an oracle for whether a user exists in a pool. This means we must use theAdminCreateUser
API.
We also configure the user pool client to only authenticate users through the custom authentication flow, thus disallowing password-based authentication. Clients are allowed to refresh user tokens (tokens have a validity and need to be refreshed for the session to be extended without users having to go through the sign-in process again).
this.pool.addClient(`UserPoolClient${clientName}`, {
userPoolClientName: clientName,
authFlows: {
custom: true,
refreshToken: true,
},
})
Verifying an email or phone number
Implementing the flow using the extension Lambda functions seems straightforward:
- The define auth challenge Lambda checks for a successfully completed challenge; if there isn't any, it either fails the authentication if there are over N attempts, or keeps the state machine running.
- The create auth challenge Lambda sends an e-mail or SMS, depending on what was used to sign-in.
- The verify auth challenge response receives the answer from the client and marks the challenge as correct or not.
Alas, it's not so simple. We need to pass a bit of information from the client to the create auth challenge Lambda, so it can decide between sending an e-mail or an SMS. Cognito, however, doesn't tell the Lambda what alias was used to start the flow, nor does it thread the ClientMetadata
parameter of InititateAuth
to the define or create auth challenge Lambdas.
How can we work around this? We could pass that information through a data store like DynamoDB, but a better solution is to leverage that ClientMetadata
parameter. InititateAuth
doesn't pass it to the Lambda, but RespondToAuthChallenge
certainly does.
An RPC by any other name
These authentication challenges actually comprise a complete request-response protocol over which the client can communicate with the state machine. Clients can answer the challenges while passing along arbitrary data to the Lambdas, which can keep the protocol going as long as needed. With this in mind, we can solve the problem by using an extra challenge to specify what the actual method we're verifying is:
The first challenge delivers no code, and expects email
or phone
to be passed in the ClientMetadata
. The second challenge is the actual OTP.
The metadata returned by the create auth challenge Lambda is kept in the encrypted session object, and is used to communicate with the define auth challenge Lambda as well as with future invocations of the create auth challenge Lambda. The private parameters are shared only with the verify auth challenge response Lambda.
This custom authentication flow now behaves as a primitive that lets us validate a user attribute, namely an e-mail or phone number.
Implementation
The verify auth challenge response Lambda has the easiest task:
func handler(ctx context.Context, event *events.CognitoEventUserPoolsVerifyAuthChallenge,
) (*events.CognitoEventUserPoolsVerifyAuthChallenge, error) {
code := event.Request.PrivateChallengeParameters["code"]
event.Response.AnswerCorrect = code == "" || code == event.Request.ChallengeAnswer
return event, nil
}
If there is no code in the private challenge parameters it's because this is the first step of the protocol, in which case the answer is always accepted. Otherwise, we match the code against the user-provided answer.
The create auth challenge Lambda calculates and delivers the challenge. We encode a challenge as:
type AttributeType string
const (
Email AttributeType = "email"
Phone AttributeType = "phone_number"
NoVerification AttributeType = "none"
)
type Challenge struct {
Attr AttributeType
Code string
}
This challenge is persisted in the metadata shared between all Lambdas in the state machine and stored in the session object. The private challenge parameters are shared with the verify auth challenge response Lambda. If our challenge had public parameters, we would specify them here and they would be sent to the client (in plaintext).
func handler(ctx context.Context, event *events.CognitoEventUserPoolsCreateAuthChallenge,
) (*events.CognitoEventUserPoolsCreateAuthChallenge, error) {
challenge := calculateAndDeliverChallenge(&event.Request, userTest, &metadata)
event.Response = triggers.CognitoEventUserPoolsCreateAuthChallengeResponse{
PrivateChallengeParameters: map[string]string{
"code": challenge.Code,
},
ChallengeMetadata: auth.Serialize(challenge),
}
return event, nil
}
We generate an empty code for the first challenge and an OTP for the second one. In the second challenge we expect the client metadata to contain the attribute type to verify (the type, not the value), which we use to select the the attribute value from the current user attributes, delivering the code by e-mail or SMS.3 The client essentially sends us a pointer to the actual attribute to verify.
func calculateAndDeliverChallenge(req *events.CognitoEventUserPoolsCreateAuthChallengeRequest,
) auth.Challenge {
sessionSize := len(request.Session)
if sessionSize == 0 {
return auth.Challenge{auth.NoVerification, ""}
}
lastSession := request.Session[sessionSize-1]
lastChallenge := auth.Deserialize(lastSession.ChallengeMetadata)
if !lastSession.ChallengeResult {
return lastChallenge
}
attr := auth.AttributeToVerify(request.ClientMetadata, request.UserAttributes)
code := auth.GenerateOTP()
deliverCode(attr, code)
return auth.Challenge{attr.Type, code}
}
If the previous challenge failed, that is, if the user introduced the wrong code, we reuse it.
The define auth challenge Lambda coordinates these two:
type ChallengeStatus struct {
Challenge auth.Challenge
AttemptCount int
Passed bool
}
func handler(ctx context.Context, event *events.CognitoEventUserPoolsDefineAuthChallenge,
) (*events.CognitoEventUserPoolsDefineAuthChallenge, error) {
status := calculateChallengeStatus(event.Request.Session)
challengeName := ""
done := status.Passed && status.Challenge.Attr != auth.NoVerification
if done {
// ...
} else {
challengeName = "CUSTOM_CHALLENGE"
}
event.Response = events.CognitoEventUserPoolsDefineAuthChallengeResponse{
IssueTokens: done,
FailAuthentication: status.AttemptCount >= 4,
ChallengeName: challengeName,
}
return event, nil
}
The state machine keeps turning until we reach the maximum allowed failed attempts or the user provides us with the correct OTP. We tick off two requirements 🎉
- [x] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP
Unfortunately, this isn't sufficient to support our authentication flow. Recall that we have no separate sign-up process, so we might not have a registered user for a run of this protocol. However, we need the attribute to verify to be present in the user attributes, which are only populated for existing users (also, Cognito doesn't issue tokens for users absent from the pool). We must ensure a user exists, even if a partial one.
Sign-In
Users aren't allowed to sign themselves up (to prevent using the SignUp
API as a user-existence oracle), so we need something that does that on their behalf, say, a Lambda resolver for a GraphQL API on AppSync.
We can call the AdminCreateUser
API to ensure the user exists, return to the client and let it handle the rest of the flow, but we'd be optimizing for the less common scenario of a user not existing in the pool.
Instead, we try to InititateAuth
and if that fails, we issue the AdminCreateUser
request. Does this mean we need to mediate all requests between the client and Cognito? No! We can take the session object returned by Cognito and return it to the client to be used in the RespondToAuthChallenge
call.
Since we're already here though, we can even go ahead and execute the first step of the protocol, telling Cognito which attribute we want to verify:
func handler(ctx context.Context, input SignInInput) (*SignInPayload, error) {
session, op := auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
if op.Error == nil {
return &SignInPayload{string(session)}, op
}
op = auth.CreateUser(cognito, input.EmailOrPhone, input.Method)
if op.Error != nil {
return nil, op.Error
}
session, op = auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
return &SignInPayload{string(session)}, op.Error
}
The CreateUser
function calls the AdminCreateUser
API, generating a random username and password and marking the email or phone number attribute as verified so it can be used as an alias. 4 The InitiateAuth
function calls Cognito's own InitiateAuth
and then the first RespondToAuthChallenge
. The rest is up to the client.
We encapsulate the AdminCreateUser
API and behave the same regardless of the user existing before the request or not. Yep, another one down:
- [x] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [x] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP
Sign-Up
Uff. The user is signed-in. However, there's a chance the JWT token our client got back from Cognito contains only a single claim for either the user's email or phone number, and we need more! In that case, our UI proceeds to collect some user data: their name and the second authentication method.
We have another attribute to verify, so we need to run another instance of our protocol. Lets add a GraphQL resolver that writes the user data into Cognito by updating the user attributes.5
func handler(ctx context.Context, input SignUpInput) (SignUpPayload, error) {
auth.SetAttributes(cognito, input.Username, map[string]string{
input.Data.Method.String(): input.Data.EmailOrPhone,
fmt.Sprintf("%s_verified", input.Data.Method.String()): "false",
"given_name": input.Data.GivenName,
"family_name": input.Data.FamilyName,
}, auth.UserPoolID()))
session, op := auth.InitiateAuth(cognito, input.Username, input.Data.Method)
return SignUpPayload{string(session)}, op.Error
}
Note that we mark that second method as not verified, because Cognito only allows one user to have a given email or phone number marked as verified and thus use it as an alias 6. We want to avoid the following scenario:
- Alice uses the verified email E;
- Bob signs-in using their phone number, and then signs-up with E;
- Bob doesn't verify E, but we already marked the attribute as verified;
- Alice signs-in using E, sees Bob's account.
Our system should consider a user as valid only when it has both attributes verified, but who marks the second method as such? That's a job for our define auth challenge Lambda:
if done {
attr := fmt.Sprintf("%s_verified", string(status.Attr))
if event.Request.UserAttributes[attr] == "false" {
auth.SetAttributes(cognito, event.UserName, map[string]string{
attr: "true",
})
}
} // ...
Finally, note that this very same logic can be used to implement an API for changing a user's e-mail or phone number.
Refreshing the token
We're almost done, but there's one missing detail: Cognito isn't prepared for us modifying a user's attributes during the authentication process, so the token the client ends up with after the sign-up doesn't have the second attribute marked as verified. There's no clever solution here: we must call the InitiateAuth
API using the REFRESH_TOKEN_AUTH
flow instead of CUSTOM_AUTH
.
We got them all.
- [x] The user must be able to sign-in with their email or phone number
- [x] There is not separate sign-up process, as it should follow directly from the sign-in
- [x] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP
Conclusion
Cognito isn't easy to work with and I find the client libraries and documentation to be somewhat lacking, but at the end of the say it's solving a huge problem on our behalf. It has enough flexibility to be subject to some unorthodox usage, and I hope it keeps on improving.
For this particular use case I missed some additional toggles, which could be added without loss of generality: it could flow the client metadata from InitiateAuth
to the authentication Lambdas, and it could allow the define auth challenge Lambda to override some token claims, much like the pre-token generation Lambda can. Another easy improvement is allowing the custom auth Lambdas to return errors, which should be returned from the API calls (even if as some generic error). By contacting AWS support I was able to turn the first of these in a feature request, but of course, without any estimate when it will be available.
Thanks to @_jwnx for reviewing this post.
-
Yes, an
InitiateAuth
call can hit 2 Lambda cold-boots. ↩ -
For example, by sending an e-mail, SMS, or push notification using some AWS service. ↩
-
Cognito doesn't support returning custom errors from the extension Lambdas. Errors must be propagated in the state machine in such a way that authentication will end up failing. ↩
-
It also calls the
AdminSetUserPassword
API to move the user from theFORCE_CHANGE_PASSWORD
state toCONFIRMED
. ↩ -
This resolver is using Cognito authorization, so only signed-in users can use it. The
Username
points to the user making the request. ↩ -
Cognito understands the
email_verified
andphone_number_verified
attributes. ↩
Top comments (12)
Thank you Duarte, this has been a fantastic post for me to get my head around OTP with Cognito - I'm porting it over to JS as I've got more experience with that but I'm stuck on the last couple of steps.
Is the auth you call for auth.SetAttributes, and auth.CreateUser from an SDK, or a private library?
Glad you enjoyed it :D
The auth module is a private library. Essentially, SetAttributes() wraps AdminUpdateUserAttributes, and CreateUser wraps AdminCreateUser and AdminSetUserPassword with a random password.
Awesome, that's exactly the nudge in the right direction I needed, thank you.
One of the things that sets your post apart (in a positive way) is that you didn't give code for every single step, so it's encouraged me to go research and really understand what's going on and write my own equivalent of the auth library. Cheers!
so is this a truly passwordless system? that is, a password is never sent over the net at any point ever to an auth/enroll server? i've been working on an example of exactly that kind of system where it uses asymmetric keys to enroll and authenticate users. i'm aware of webauthn but it is heavily focused on crypto dongles which is vast overkill for most situations (say this site, for example). using WebCrypto to generate key pairs and signing login requests allows the server to just need to remember the public keys associated with a given user and an out of band (email, sms...) way to verify their possession of that method.
it's all pretty simple honestly, and it's something of a mystery why it's not gaining traction since webcrypto has been a round for a while now.
you can check out my example and code here: out.mtcc.com/hoba-bis
Interesting approach :) However, that does require some effort from users as they have to store their private key on their devices. In our system, users are authenticated through social login or through an OTP as described in the post.
js code makes it completely transparent to the user. in my example, you join by typing in a username and an email address then click join. you login by entering your username and clicking login. all of the complexity is under the hood, with the keys (wrapped by a local password if you want), stored in localStorage or indexedDB. it's not even particularly complex and pretty much resembles existing login code. the backend just verifies the key bound to the user and verifies the sig. i patterned the exchange after digest auth (rfc 7616). i came up with this years ago and documented it in rfc 7486 well before webcrypto and webauthn.
Hi Duarte - thanks for the post. I'm a bit of a Go and Cognito noob, so apologies if this is a dumb question. When I try setup my lambda functions there are a number of dependencies that they need that I cannot figure out, for example: auth.Challenge and request.Session. Could you provide some assistance in terms of the packages (and anything else you think I should know) in order to configure and run your example?
Hi Joel, apologies for not preparing a runnable example.
auth.Challenge
is my own type, defined asrequest.Session
is a member ofCognitoEventUserPoolsCreateAuthChallengeRequest
, the type of theRequest
field inCognitoEventUserPoolsCreateAuthChallenge
. The latter is the input type of the Lambda function for the create auth challenge trigger.Hope that helps.
Thanks Duarte - much appreciated!
Hey Duarte, I've been trying to follow what you are doing here and I understand for the most part, but you seem to be using a lot of code that is not show here. Do you mind sharing the public repo for this solution? On the other hand it also seems like you are handling both login and sign up in your lambda above. How would you keep the user logged in on the frontend when the tokens expire,. maybe I am not following some of your logic. Thanks in advance.
Hi Duarte! Thanks for the excellent post. I am porting it to Clojure. I have a question:
In the deliverCode function, how do obtain the email address or phone number associated with the user?
can I get full source code for this