3-Strike Auth Gate for Your Lex Bot
Add identity verification with a 3-strike lockout to your Amazon Lex banking bot.
Introduction
30 Second Summary
When you call your bank and answer a security question wrong, the system does not give you unlimited guesses. After a few failed attempts, it locks you out and connects you to a human. That same principle applies to chatbots handling sensitive financial data.
In this project, you will add a 3-strike authentication gate to your Amazon Lex banking bot. The bot verifies a customer's date of birth before granting access, tracks failed attempts, and escalates to a human agent when those attempts run out.
What You'll Build
You will type "verify my identity" into your bot's test window, provide the wrong date of birth three times in a row, and watch the bot lock you out with an escalation message. Then you will start a fresh conversation, provide the correct date, and sail through to a verified session.
By the end of this project, you'll have:
- A VerifyIdentity intent that asks for the customer's date of birth and validates it against a known record using your Lambda function.
- A session-attribute-driven attempt counter that tracks how many times the user has failed authentication across conversation turns.
- A 3-strike escalation flow that closes the conversation and signals a human agent handoff when the user cannot prove their identity.
- Secret Mission: A session-wide lockout that blocks ALL banking intents after a failed authentication, preventing attackers from bypassing the gate entirely.
Are there any prerequisites?
This project builds on an existing Amazon Lex V2 banking bot with a Python Lambda function already attached. If you have not set that up yet, complete the Amazon Lex Chatbot project series first.
Before We Start
Before we start building, let's commit to what you're about to add to your banking bot and why it matters.
Create the VerifyIdentity Intent and DateOfBirth Slot
Your banking bot can handle balance checks and transfers, but right now anyone can walk right in. Before you can lock down sensitive operations, the bot needs a way to recognize when a user wants to verify their identity.
In this step, you'll create a dedicated VerifyIdentity intent with a date-of-birth slot. You'll also enable the dialog code hook so your Lambda function fires on every turn of this intent.
In this step, get ready to:
- Add the VerifyIdentity intent with sample utterances and a DateOfBirth slot.
- Enable the dialog code hook to connect the intent to your Lambda function.
- Build and test the bare intent in the Lex test window.
Add the VerifyIdentity intent to your bot
An intent represents something a user wants to accomplish. Your bot needs a specific intent to handle authentication requests, separate from balance checks or transfers.
- In the Amazon Lex V2 console, open your banking bot and click Intents in the left sidebar.
- Click Add intent.
- Select Add empty intent.
- Name it VerifyIdentity and click Add.
You'll land on the intent editor page. Now add sample utterances so the bot knows when a user is trying to authenticate.
- In the Sample utterances section, add each of the following phrases one at a time:
- verify my identity
- I need to verify myself
- authenticate me
- verify me
- I want to check my identity
Next, add the slot that collects the user's date of birth. A slot is a piece of information the bot needs to collect before it can fulfill the intent.
- Scroll down to the Slots section.
- Click Add slot.
- Set the Name to DateOfBirth.
- For Slot type, select the built-in type AMAZON.Date.
- In the prompt field, enter: For security purposes, please provide your date of birth.
- Click Add.
What is AMAZON.Date?
AMAZON.Date is a built-in slot type that automatically converts spoken or typed dates into ISO 8601 format. When a user says "March 15 1990", Lex resolves it to 1990-03-15 in the interpretedValue field.
This standardized format makes it easy for your Lambda to compare the user's input against a stored date.
Enable the dialog code hook
By default, your Lambda function only fires after all slots are filled. Enabling the dialog code hook makes Lambda fire on every conversation turn, giving you control over validation logic.
- Scroll down to the Code hooks section at the bottom of the intent editor.
- Check the box for Use a Lambda function for initialization and validation.
What does the dialog code hook do?
Without the code hook, Lambda only runs after Lex collects all required slots. With it enabled, Lambda fires between each retry for eliciting a slot. This lets your function inspect, validate, and reject slot values mid-conversation.
You need this because in Step 2, your Lambda will check whether the date of birth is correct and decide whether to re-ask or escalate.
Build and test the bare intent
- Click Save intent at the bottom of the page.
- Click Build at the top of the bot.
- Wait for the build to complete. This usually takes 30-60 seconds.
Before you test, think about what you expect to happen. The intent is set up and the code hook is enabled, but your Lambda has no handler for VerifyIdentity yet. What do you think the bot will do when you type a date?
- Click Test to open the test window.
- Type verify my identity and press Enter.
The bot responds with "For security purposes, please provide your date of birth." That confirms your intent and slot are wired up correctly.
- Type any date, such as March 15 1990.
The bot either closes the conversation or says nothing useful. This is expected. Your Lambda currently delegates back to Lex without doing any validation, so it has no logic to check the date or respond meaningfully.
Bot not asking for your date of birth?
- Make sure you clicked Save intent before building. Unsaved changes are not included in the build.
- Check that the DateOfBirth slot is marked as Required, not Optional. Optional slots are not prompted automatically.
- Confirm that your sample utterances are saved. Try typing one of them exactly as listed.
Help me troubleshoot why my Lex bot is not asking for the DateOfBirth slot.
Your intent is live and collecting dates. The problem is obvious. The bot happily accepts any date without checking it. Next up, you'll write the Lambda logic that actually validates the date of birth and controls what happens when a user gets it wrong.
Add DOB Validation to Your Lambda
Your bot now asks for a date of birth, but it does nothing useful with the answer. It collects the slot and moves on without checking whether the user is who they claim to be.
In this step, you will add validation logic to your Lambda function that compares the user's date of birth against a known record. If it matches, access is granted. If it does not, the bot asks again.
In this step, get ready to:
- Add a mock customer database and DOB validation handler to your Lambda function.
- Deploy and test both the success and failure paths in the Lex test window.
- Observe the security gap when there is no attempt limit.
Add the VerifyIdentity handler to your Lambda function
Your Lambda needs a handler that intercepts the VerifyIdentity intent, extracts the date of birth the user provided, and decides whether to let them through or ask again.
- Return to your Lambda function in the AWS Console.
- Click the Code tab to open the inline code editor.
- Select all existing code in lambda_function.py and delete it.
- Add the mock customer database at the top of the file by pasting this code:
import json
# Mock customer database
# In production, replace with a real database lookup
MOCK_CUSTOMERS = {
"12345": {
"date_of_birth": "1990-03-15"
},
"67890": {
"date_of_birth": "1985-07-22"
}
}
MAX_ATTEMPTS = 3
What does this code do?
MOCK_CUSTOMERS simulates a database of customer records. In a real banking app, you would look up the customer's DOB from a secure database. The hardcoded entries let you test without setting up a real data source.
MAX_ATTEMPTS sets the maximum allowed authentication tries. You will wire this up in the next step.
- Below the constants, paste the first part of the verify_identity_handler function. This section extracts the slot value and delegates back to Lex if the slot has not been filled yet:
def verify_identity_handler(event):
session_state = event['sessionState']
intent = session_state['intent']
slots = intent.get('slots', {})
session_attributes = session_state.get('sessionAttributes', {})
# Get the date of birth slot value
dob_slot = slots.get('DateOfBirth')
if dob_slot is None or dob_slot.get('value') is None:
# Slot not yet filled, delegate to Lex to elicit it
return {
'sessionState': {
'dialogAction': {
'type': 'Delegate'
},
'intent': intent,
'sessionAttributes': session_attributes
}
}
dob_value = dob_slot['value']['interpretedValue']
# Mock validation - check against account "12345"
expected_dob = MOCK_CUSTOMERS["12345"]["date_of_birth"]
What's happening here?
The handler reads the DateOfBirth slot from the incoming event. If Lex has not collected the value yet (slot is None), the function returns a Delegate action to let Lex prompt the user with the slot's configured message.
Once a date is provided, interpretedValue holds it in ISO 8601 format (e.g. 1990-03-15). The code then looks up the expected DOB from the mock database.
- Continuing inside the same function (right after the expected_dob line), paste the success branch that grants access when the DOB matches:
if dob_value == expected_dob:
# Authentication successful
session_attributes['authenticated'] = 'true'
session_attributes['authAttempts'] = '0'
return {
'sessionState': {
'dialogAction': {
'type': 'Close'
},
'intent': {
'name': 'VerifyIdentity',
'state': 'Fulfilled'
},
'sessionAttributes': session_attributes
},
'messages': [
{
'contentType': 'PlainText',
'content': 'Your identity has been verified. How can I help you today?'
}
]
}
What does the success branch do?
When the date matches, the handler returns a Close dialog action with intent state Fulfilled. This tells Lex the conversation for this intent is complete. The session attributes authenticated and authAttempts are stored for later use.
- Directly below the success branch (still inside verify_identity_handler), paste the failure branch that re-asks when the DOB does not match:
else:
# Authentication failed - re-elicit slot
slots['DateOfBirth'] = None
return {
'sessionState': {
'dialogAction': {
'type': 'ElicitSlot',
'slotToElicit': 'DateOfBirth'
},
'intent': {
'name': 'VerifyIdentity',
'slots': slots,
'state': 'InProgress'
},
'sessionAttributes': session_attributes
},
'messages': [
{
'contentType': 'PlainText',
'content': 'That does not match our records. Please try again. What is your date of birth?'
}
]
}
Why clear the slot before re-eliciting?
Setting slots['DateOfBirth'] = None forces Lex to treat the slot as unfilled. Without this, Lex would see the old (wrong) value still present and skip the prompt entirely.
The ElicitSlot dialog action tells Lex to ask for that specific slot again, using the custom message you provide in the response.
- Below verify_identity_handler, replace the existing lambda_handler with this updated version that routes the VerifyIdentity intent to your new handler:
def lambda_handler(event, context):
session_state = event['sessionState']
session_attributes = session_state.get('sessionAttributes', {})
intent_name = session_state['intent']['name']
# Route to the appropriate handler
if intent_name == 'VerifyIdentity':
return verify_identity_handler(event)
# Default: delegate to Lex
return {
'sessionState': {
'dialogAction': {
'type': 'Delegate'
},
'intent': session_state['intent'],
'sessionAttributes': session_attributes
}
}
What does the routing change?
Previously, lambda_handler delegated every intent back to Lex without doing anything. Now it checks the intent name first. If it is VerifyIdentity, the event goes to your validation handler. All other intents still delegate as before.
✔️ Awesome, I've got everything!
Great. Confirm you have saved no extra whitespace or blank lines between the function chunks. Your file should flow from the imports and constants at the top, through verify_identity_handler, to lambda_handler at the bottom.
ⓧ I'd like to double check the full code
Scroll through your file and confirm these sections appear in order:
- import json at the top.
- MOCK_CUSTOMERS dictionary with accounts 12345 and 67890.
- MAX_ATTEMPTS = 3 constant.
- verify_identity_handler(event) function containing: delegation check, DOB comparison, success branch (Close/Fulfilled), and failure branch (ElicitSlot).
- lambda_handler(event, context) that routes VerifyIdentity to the handler and delegates everything else.
Deploy and test both paths
Your code is ready in the editor but has not been deployed yet. The Lambda runtime is still running the old version until you deploy.
- Click Deploy in the Code source section of the Lambda console.
- Wait for the green banner confirming the function was updated successfully.
Seeing a syntax error on deploy?
Check that the indentation inside verify_identity_handler is consistent. The success branch (if dob_value == expected_dob) and failure branch (else) must both be indented at the same level (4 spaces in from the function body).
Also confirm there is no gap between the success branch's closing brace and the else: line. They must be siblings at the same indentation.
Help me fix a syntax error in my Lambda function for the VerifyIdentity handler.
- Switch back to the Lex V2 console and open the Test window for your bot.
- Type verify my identity and press Enter.
- The bot responds: "For security purposes, please provide your date of birth."
- Type March 15 1990 (the correct mock DOB).
You should see: "Your identity has been verified. How can I help you today?"
- Start a new test conversation by clicking the refresh icon in the test panel.
- Type verify my identity again.
- This time, type a wrong date like January 1 2000.
The bot responds: "That does not match our records. Please try again. What is your date of birth?"
Bot not responding with the expected message?
Make sure you clicked Deploy in the Lambda console before testing. The green confirmation banner must appear.
Also confirm the dialog code hook is still enabled on the VerifyIdentity intent. Without it, Lambda only fires at fulfillment and never intercepts the slot validation.
Help me figure out why my Lex bot isn't calling my Lambda for DOB validation.
Observe the security gap
Both paths work. A correct date grants access and a wrong date gets rejected. But what happens if someone keeps guessing?
- In the same test conversation (after your first wrong date), type another wrong date.
- Keep entering wrong dates. Try at least 5 different wrong values.
Before you continue, predict: what do you think will happen after 5 or 10 wrong attempts? Will the bot ever stop asking?
The bot patiently re-asks every single time. There is no counter, no lockout, and no escalation. An attacker could try every possible date until they guess correctly.
Why is this a problem?
Date of birth has a limited keyspace. A determined attacker could automate thousands of attempts and eventually land on the right value. Without an attempt limit, your verification is security theater.
Production authentication systems cap retries and escalate to a human or lock the account. That is exactly what you will build in the next step.
Your validation logic works, but it has an unlimited-retry vulnerability. In the next step, you will add a 3-strike counter that tracks failed attempts and escalates to a human agent when the limit is reached.
Add the 3-Strike Counter and Escalation
In Step 2, you saw the security gap firsthand. Without attempt tracking, a bad actor could try unlimited date combinations until they guess correctly.
In this step, you'll cap attempts at 3 and escalate to a human agent when those attempts are exhausted. This prevents brute-force attacks while giving legitimate users a secure fallback.
In this step, get ready to:
- Add session attribute tracking to count failed authentication attempts.
- Add an escalation branch that locks the conversation after 3 failures.
- Test the full error matrix: success, retry, and escalation paths.
Add session attribute tracking to the handler
Session attributes in Amazon Lex V2 are a string-to-string map that persists for the entire session duration. They're passed to every Lambda invocation automatically, which makes them perfect for tracking state across conversation turns.
You'll use a session attribute called authAttempts to count how many times the user has entered the wrong date of birth.
- Return to your Lambda function in the AWS Console.
- In verify_identity_handler, find the line that reads:
session_attributes = session_state.get('sessionAttributes', {})
- Directly below that line, add the following line to read the current attempt count:
# Get the current attempt count
auth_attempts = int(session_attributes.get('authAttempts', '0'))
What does this line do?
Since session attributes are always strings, you read authAttempts as a string and convert it to an integer with int(). The default of '0' handles brand new sessions where no attempts have been made yet.
Add the escalation branch
Now you need to replace the existing failure logic with code that increments the counter and checks whether the user has exceeded their limit.
- In verify_identity_handler, find the existing else branch that handles a failed DOB match. It currently looks like this:
else:
# Authentication failed - re-elicit slot
slots['DateOfBirth'] = None
return {
'sessionState': {
'dialogAction': {
'type': 'ElicitSlot',
'slotToElicit': 'DateOfBirth'
},
'intent': {
'name': 'VerifyIdentity',
'slots': slots,
'state': 'InProgress'
},
'sessionAttributes': session_attributes
},
'messages': [
{
'contentType': 'PlainText',
'content': 'That does not match our records. Please try again. What is your date of birth?'
}
]
}
- Delete that entire else block and replace it with the following code that increments the counter and checks for escalation:
else:
# Authentication failed - increment counter
auth_attempts += 1
session_attributes['authAttempts'] = str(auth_attempts)
if auth_attempts >= MAX_ATTEMPTS:
# Escalate to human agent
session_attributes['escalationReason'] = 'max_auth_attempts_exceeded'
session_attributes['sessionLocked'] = 'true'
return {
'sessionState': {
'dialogAction': {
'type': 'Close'
},
'intent': {
'name': 'VerifyIdentity',
'state': 'Failed'
},
'sessionAttributes': session_attributes
},
'messages': [
{
'contentType': 'PlainText',
'content': 'For your security, I am unable to verify your identity. I am transferring you to a human agent who can assist you further. Please stay on the line.'
}
]
}
What does this code do?
- auth_attempts += 1 increments the counter and str(auth_attempts) writes it back as a string (session attributes only store strings).
- When attempts reach MAX_ATTEMPTS (3), it returns a Close action with the intent state set to Failed. This ends the conversation entirely.
- escalationReason and sessionLocked are session attributes that a production system could use to route the user to a human agent queue.
- Immediately after the escalation if block (still inside the outer else), add the remaining-attempts path that re-asks the user:
else:
# Re-elicit the slot with remaining attempts info
remaining = MAX_ATTEMPTS - auth_attempts
slots['DateOfBirth'] = None
return {
'sessionState': {
'dialogAction': {
'type': 'ElicitSlot',
'slotToElicit': 'DateOfBirth'
},
'intent': {
'name': 'VerifyIdentity',
'slots': slots,
'state': 'InProgress'
},
'sessionAttributes': session_attributes
},
'messages': [
{
'contentType': 'PlainText',
'content': f'That does not match our records. You have {remaining} attempt{"s" if remaining > 1 else ""} remaining. Please try again. What is your date of birth?'
}
]
}
What does this code do?
- When the user still has attempts remaining, it clears the DateOfBirth slot (set to None) and returns ElicitSlot to force Lex to ask again.
- The message includes the number of remaining attempts so the user knows they're running out of tries.
- Setting the slot to None is essential. Without it, Lex would see the slot as already filled and skip re-asking.
Why session attributes instead of Lex's built-in MaxRetries?
Lex's built-in MaxRetries (0-5) only fires when Lex cannot understand a slot value, like a speech recognition failure. In this project, Lex successfully recognizes the date. The Lambda is rejecting it based on business logic.
The built-in retry mechanism would never trigger because Lex considers the slot "filled" once it resolves to an AMAZON.Date value. You must manage the retry loop manually.
Deploy and test the full error matrix
- Click Deploy in the Lambda console to save your updated function.
- Switch to the Lex V2 console and click Build on your bot.
- Wait for the build to complete before testing.
Now test all three paths through the authentication flow.
Test the success path first.
- Open the Test window and type verify my identity.
- When the bot asks for your date of birth, type March 15 1990 (the correct mock DOB).
You should see: "Your identity has been verified. How can I help you today?"
Now test the retry path.
- Start a new conversation in the Test window.
- Type verify my identity.
- Provide a wrong date (e.g. January 1 2000). You should see "That does not match our records. You have 2 attempts remaining."
- Provide another wrong date. You should see "You have 1 attempt remaining."
- Now provide the correct date: March 15 1990. The bot should verify you successfully. The counter resets on success.
Finally, test the escalation path.
- Start a new conversation.
- Type verify my identity.
- Provide 2 wrong dates and watch the remaining-attempts counter decrement each time.
Before you enter the 3rd wrong date, what do you think will happen? Will the bot ask again or close the conversation?
- Enter a 3rd wrong date.
The bot responds: "For your security, I am unable to verify your identity. I am transferring you to a human agent who can assist you further. Please stay on the line." The conversation ends.
Bot still re-asking forever?
- Make sure you clicked Deploy in the Lambda console after saving your code changes.
- Make sure you clicked Build in the Lex console after deploying the Lambda. The bot needs to be rebuilt to pick up Lambda changes.
- Confirm that the auth_attempts = int(session_attributes.get('authAttempts', '0')) line appears BEFORE the DOB slot check, not inside the else branch.
- Start a brand new test session. Old sessions may carry stale state.
Why isn't my 3-strike escalation triggering after 3 wrong dates?
✔️ Awesome, I've got everything!
Great. Make sure you've saved and deployed your Lambda function.
ⓧ I'd like to double check the full code
Scroll through your lambda_function.py and confirm the following sections are present in order:
- The MOCK_CUSTOMERS dictionary and MAX_ATTEMPTS = 3 constant at the top.
- verify_identity_handler reads auth_attempts from session attributes right after extracting session_attributes.
- The Delegate return when the slot is not yet filled.
- The success path that sets authenticated to 'true' and resets authAttempts to '0'.
- The else branch increments auth_attempts and writes it back to session attributes.
- The escalation if auth_attempts >= MAX_ATTEMPTS returns Close with state Failed and sets sessionLocked and escalationReason.
- The remaining-attempts else returns ElicitSlot with the slot cleared and remaining count in the message.
- lambda_handler routes VerifyIdentity to the handler and delegates everything else.
Your banking bot now has real security teeth. It gives users three chances, warns them each time, and locks them out when attempts are exhausted. Ready for a challenge? The Secret Mission takes this security model even further.
Secret mission
Add Session-Wide Lockout Across All Intents
Your 3-strike system locks out the VerifyIdentity intent, but what stops an attacker from simply trying other banking intents after escalation? In this extension, you'll add a session-wide lockout that blocks ALL intents after a failed authentication. This closes the last remaining security gap in your bot.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Amazon Lex V2 and AWS Lambda within the free tier, so there are no ongoing costs at this scale.
Resources you used:
- VerifyIdentity intent with DateOfBirth slot (in your Lex V2 banking bot).
- Lambda function code updates (verify_identity_handler and session lockout logic).
Keep everything running
No action needed. Choose this if you're still actively building on your banking bot or plan to extend the authentication flow further.
- Your VerifyIdentity intent stays in the bot and is ready to use anytime.
- Your Lambda function retains the authentication handler and lockout logic.
- Lex V2 and Lambda only charge per request, so an idle bot costs nothing.
Pause - I'll come back to this later
Since Lex V2 and Lambda are pay-per-request services, an idle bot incurs no charges. There is nothing to pause.
- Close your test window in the Lex V2 console to end any active session.
- Your bot, intent, and Lambda code remain exactly as you left them when you return.
Delete - I don't want to use this again
Remove the VerifyIdentity intent and revert your Lambda to its pre-project state.
- In the Lex V2 console, open your banking bot and select Intents from the left navigation.
- Select the VerifyIdentity intent.
- Click Delete and confirm the deletion.
- Click Build to rebuild the bot without the deleted intent.
- In the Lambda console, open your function and remove the verify_identity_handler function, the MOCK_CUSTOMERS dictionary, and the MAX_ATTEMPTS constant.
- In lambda_handler, remove the session lockout check (the if session_attributes.get('sessionLocked') == 'true' block) and the VerifyIdentity routing branch.
- Click Deploy to save the reverted Lambda function.
Nice Work!
Nice Work!
You just built a production-grade authentication gate for your banking chatbot. Your bot now refuses brute-force attempts and escalates to a human agent when a user cannot prove their identity.
You've learned how to:
- Built a VerifyIdentity intent with a DateOfBirth slot that gates access to your banking bot.
- Implemented a session-attribute-driven attempt counter in your Python Lambda that tracks failed authentication across conversation turns.
- Added a 3-strike escalation that closes the conversation and signals a human agent handoff when the user cannot verify their identity.
- Secret Mission: Extended the security model with a session-wide lockout that blocks all banking intents after a failed authentication.
Ready to quiz yourself?