A few days ago, I was developing an application to store my records (PRs) while practicing sports such as CrossFit and Weightlifting. Everything seemed to be working correctly in what I called my production
environment. However, at some point, a significant issue arose: someone managed to register with the same email address and create two almost identical users username !== Username
.
This was a major inconvenience, as the person could log in with both users and would not have their information consistently.
The Problem
After a bit of research (I assumed AWS Cognito
prevented this behavior automatically 😔), I discovered that a possible solution was to set the email as a sign-in alias. This required making some changes to the Cognito User Pool.
However, there was a problem: I already had registered users, and Cognito
does not allow modifying sign-in aliases in an existing User Pool
.
The Solution
Create a New User Pool
The solution was to create a new User Pool
. Thus, my User Pool
V2 was born (yes, a very original name). Once created, I tested and confirmed that:
- It was not possible to register a user with the same email.
- The system had to be
case-insensitive
: a user must always be unique, regardless of uppercase or lowercase.
To handle the latter, I discovered Pre-Signup Triggers. These triggers allow you to run a Lambda Function before completing the signup process. In this function, I implemented the following logic:
import boto3
client = boto3.client('cognito-idp')
def lambda_handler(event, context):
user_pool_id = event['userPoolId']
username = event['userName'].lower() # Normalizes the username
email = event['request']['userAttributes']['email'].lower() # Normalizes the email
# Validate username uniqueness
response = client.list_users(
UserPoolId=user_pool_id,
Filter=f'username = "{username}"'
)
if len(response['Users']) > 0:
raise Exception("The username is already registered.")
# Validate email uniqueness
response = client.list_users(
UserPoolId=user_pool_id,
Filter=f'email = "{email}"'
)
if len(response['Users']) > 0:
raise Exception("The email is already registered.")
# Normalize username and email in the event
event['userName'] = username
event['request']['userAttributes']['email'] = email
return event
- Convert data to lowercase (username and email).
- Check if the user already exists.
- Update the event and return it.
With this, we ensure data consistency and prevent duplicate users, regardless of uppercase or lowercase usage.
Deployment with AWS CDK
To deploy this solution, I used AWS CDK with TypeScript. An example configuration might look like this:
import { Construct } from "constructs";
import { addTags } from "../common/utilities/addTag";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { StringParameter } from "aws-cdk-lib/aws-ssm";
import { PolicyStatement,Effect } from "aws-cdk-lib/aws-iam";
import { CfnOutput,NestedStack,NestedStackProps} from "aws-cdk-lib";
import { PROJECT_NAME,ENVIRONMENT, COGNITO_CALLBACK_URL } from "../../constants";
import { Code, Function, ILayerVersion, LayerVersion, Runtime } from "aws-cdk-lib/aws-lambda";
import { UserPool,AccountRecovery,VerificationEmailStyle,ClientAttributes,UserPoolClient,OAuthScope } from "aws-cdk-lib/aws-cognito";
export class UserPoolStack extends NestedStack {
private projectEnv: string;
public readonly userPoolV2: UserPool;
public readonly userPoolClientV2: UserPoolClient;
constructor(scope: Construct,id: string,props?: NestedStackProps) {
super(scope,id,props);
const REGION = this.region;
this.projectEnv = `${PROJECT_NAME}-${ENVIRONMENT}`;
const callbackURl = (COGNITO_CALLBACK_URL) ? COGNITO_CALLBACK_URL : 'http://localhost:3000';
this.userPoolV2 = new UserPool(this, `${this.projectEnv}-user-pool-v2`, {
selfSignUpEnabled: ENVIRONMENT === 'prod',
accountRecovery: AccountRecovery.EMAIL_ONLY,
userVerification: {
emailStyle: VerificationEmailStyle.CODE,
},
autoVerify: {
email: true,
},
standardAttributes: {
email: {
required: true,
mutable: true,
},
},
signInAliases: {
username: true,
email: true,
}
});
const clientWritteAttributes = new ClientAttributes()
.withStandardAttributes({
fullname: true,
email: true,
nickname: true,
profilePicture: true,
});
const clientReadAttributes = new ClientAttributes()
.withStandardAttributes({
emailVerified: true,
email: true,
});
this.userPoolClientV2 = new UserPoolClient(this,`${this.projectEnv}-user-pool-client-v2`, {
userPool: this.userPoolV2,
authFlows: {
userPassword: true,
userSrp: true,
},
generateSecret: true,
preventUserExistenceErrors: true,
writeAttributes: clientWritteAttributes,
readAttributes: clientReadAttributes,
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [OAuthScope.EMAIL,OAuthScope.OPENID,OAuthScope.PROFILE],
callbackUrls: [`${callbackURl}/auth`],
logoutUrls: [`${callbackURl}/logout`],
},
});
this.userPoolV2.addDomain(`${PROJECT_NAME}-domain`,{
cognitoDomain: { domainPrefix: `${this.projectEnv}-v2` }
});
new CfnOutput(this,'CognitoUspLoginUrlV2',{
value: `https://${this.projectEnv}-v2.auth.${REGION}.amazoncognito.com/login?response_type=code&client_id=${this.userPoolClientV2.userPoolClientId}&redirect_uri=${callbackURl}/auth`
});
new CfnOutput(this,'CognitoURLV2',{
value: `https://${this.projectEnv}.auth.${REGION}.amazoncognito.com`
});
addTags(this.userPoolV2,'project',`${PROJECT_NAME}`);
this.createCognitoPreSignUpLambdaFunction();
}
private createCognitoPreSignUpLambdaFunction(): void {
const lambdaFunction = new Function(this, 'cognitoPreSignUp', {
runtime: Runtime.PYTHON_3_11,
functionName: 'cognito-pre-sign-up',
handler: 'src.handler.lambda_handler',
code: Code.fromAsset('./services/cognito_pre_sign_up'),
environment: {
LOG_LEVEL: `${ENVIRONMENT === 'prod' ? 'INFO' : 'DEBUG'}`,
},
logRetention: ENVIRONMENT === 'prod' ? RetentionDays.ONE_YEAR : RetentionDays.ONE_DAY,
});
// least privileges principle
const lambdaPolicy = new PolicyStatement({
actions: ['cognito-idp:ListUsers'],
resources: [this.userPoolV2.userPoolArn],
effect: Effect.ALLOW,
});
lambdaFunction.addToRolePolicy(lambdaPolicy);
const layerArn = StringParameter.valueForStringParameter(this, `${PROJECT_NAME}-${ENVIRONMENT}-lambda-layer-parameter`);
const newLayerVersion: ILayerVersion = LayerVersion.fromLayerVersionArn(this, 'lambda-layer-common', layerArn);
lambdaFunction.addLayers(newLayerVersion);
lambdaFunction.node.addDependency(this.userPoolV2);
addTags(lambdaFunction, 'project', `${PROJECT_NAME}`);
}
}
Migrating Existing Users
Now another question arises: what to do with the users who were already registered in the previous User Pool?
In my case, there were only 5 users, so I chose the simplest solution:
- Manually re-invite each one with their new data.
- Manually update the userIDs in the DynamoDB table.
Conclusion
With this solution, I managed to solve the user duplication issues and ensure consistent data management. I also learned that:
- Setting the email as the sign-in alias is crucial to avoid similar issues.
- Cognito’s Pre-Signup Triggers offer great flexibility to customize registration logic.
- Planning and testing are essential, especially when there are already users in production.
Now the system is more robust and ready to scale without duplication issues.
If you want to store your records you can join LiftWiz
Top comments (0)