Custom Authentication with Cognito and React Native

Custom authentication (CUSTOM_AUTH) with Amazon Cognito enables you to build tailored login flows. In this example, we implement an authentication system based solely on a phone number and an OTP (One-Time Password) sent by SMS. It’s also possible to deliver the code via email. This passwordless approach is especially well-suited for modern mobile-first experiences.

Architecture Overview

We rely on four Lambda functions connected to a Cognito User Pool:

  • PreSignUp
  • DefineAuthChallenge
  • CreateAuthChallenge
  • VerifyAuthChallengeResponse

These functions form the backend logic of the custom challenge. We connect the flow to a React Native app using the low-level aws-amplify/auth module without using Amplify CLI.

Cognito User Pool Setup

  1. Create a Cognito User Pool.
  2. Enable CUSTOM_AUTH as the authentication flow.
  3. Set phone_number as a required user attribute.
  4. Attach Lambda triggers as shown below.
  5. Ensure the account is created and confirmed before signIn is called. Otherwise, the challenge Lambdas won’t be invoked.

Lambda: PreSignUp

This function auto-confirms the user and marks the phone number as verified.

⚠️ If the user is not created and confirmed before attempting a signIn, the CUSTOM_AUTH flow will not execute and the challenge Lambdas will not be triggered.

export const handler = async (event: any) => {
  event.response.autoConfirmUser = true;
  event.response.autoVerifyPhone = true;
  return event;
};

Allows signup without a password and auto-verifies phone numbers.

Lambda: DefineAuthChallenge

Controls the state machine of the login flow. This Lambda:

  • Accepts success if the last challenge passed.
  • Fails after 3 attempts.
  • Otherwise requests a new CUSTOM_CHALLENGE.
export const handler = async (event: any) => {
  if (event.request.session && event.request.session.find((s: any) => s.challengeResult)) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else if (event.request.session.length >= 3) {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  } else {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = 'CUSTOM_CHALLENGE';
  }
  return event;
};

Drives the challenge logic and limits attempts.

Lambda: CreateAuthChallenge

Generates a 6-digit OTP and sends it via SMS using SNS.

import AWS from 'aws-sdk';
const sns = new AWS.SNS();

export const handler = async (event: any) => {
  if (event.request.challengeName === 'CUSTOM_CHALLENGE') {
    const code = Math.floor(100000 + Math.random() * 900000).toString();

    await sns.publish({
      Message: `Your login code is: ${code}`,
      PhoneNumber: event.request.userAttributes.phone_number,
    }).promise();

    event.response.publicChallengeParameters = {};
    event.response.privateChallengeParameters = { answer: code };
    event.response.challengeMetadata = 'OTP Challenge';
  }

  return event;
};

Sends the code to the user’s phone.

Lambda: VerifyAuthChallengeResponse

Compares the user-submitted OTP with the one generated by CreateAuthChallenge.

export const handler = async (event: any) => {
  const expectedAnswer = event.request.privateChallengeParameters.answer;
  const userAnswer = event.request.challengeAnswer;

  event.response.answerCorrect = expectedAnswer === userAnswer;
  return event;
};

Verifies the validity of the submitted code.

React Native Integration

Install dependencies:

npm install aws-amplify @aws-amplify/auth

Registration Flow

import { signIn, confirmSignIn, signUp } from 'aws-amplify/auth';

const generatePassword = () => Math.random().toString(36).slice(-12) + 'A1!';

export const registration = async (phoneNumber) => {
  try {
    const result = await signIn({
      username: phoneNumber,
      options: { authFlowType: 'CUSTOM_WITHOUT_SRP' },
    });
    console.log("User signed in", result);
    return result;
  } catch (err) {
    if (err.name === 'UserNotFoundException') {
      await signup(phoneNumber);
      return;
    }
    throw err;
  }
};

const signup = async (phoneNumber) => {
  await signUp({
    username: phoneNumber,
    password: generatePassword(),
    options: { userAttributes: { phone_number: phoneNumber } },
  });
  console.log("User account created");
  await registration(phoneNumber);
};

OTP Verification

export const verifyOTP = async (otpCode) => {
  const result = await confirmSignIn({ challengeResponse: otpCode });
  return result;
};

CDK Deployment Example (without Amplify)

import { Stack, StackProps, CfnOutput, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';

export class AuthStack extends Stack {
  public readonly userPoolId: string;
  public readonly userPoolClientId: string;
  public readonly identityPoolId: string;

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    // Define Auth Challenge Lambda
    const defineAuthChallengeFunction = new lambda.Function(this, 'DefineAuthChallengeFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-functions/define-auth-challenge')),
      timeout: Duration.seconds(30),
    });

    // Create Auth Challenge Lambda
    const createAuthChallengeFunction = new lambda.Function(this, 'CreateAuthChallengeFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-functions/create-auth-challenge')),
      timeout: Duration.seconds(30),
    });

    // Verify Auth Challenge Lambda
    const verifyAuthChallengeFunction = new lambda.Function(this, 'VerifyAuthChallengeFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-functions/verify-auth-challenge')),
      timeout: Duration.seconds(30),
    });

    // PreSignUp Lambda
    const preSignUpFunction = new lambda.Function(this, 'PreSignUpFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-functions/pre-signup')),
      timeout: Duration.seconds(10),
    });

    // Permissions for SNS (sending SMS) and Cognito (user management)
    createAuthChallengeFunction.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          'sns:Publish',
        ],
        resources: ['*'],
      })
    );

    createAuthChallengeFunction.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          'cognito-idp:AdminGetUser',
          'cognito-idp:AdminConfirmSignUp',
        ],
        resources: ['*'],
      })
    );

    // Create the User Pool with Custom Auth Flow
    const userPool = new cognito.UserPool(this, 'MyUserPool', {
      selfSignUpEnabled: true,
      autoVerify: {
        email: false,
        phone: true,  
      },
      signInAliases: {
        phone: true,
        email: false,
      },
      standardAttributes: {
        phoneNumber: {
          required: true,
          mutable: true,
        },
      },
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
        requireSymbols: true,
      },
      // Custom Auth Flow Configuration
      lambdaTriggers: {
        preSignUp: preSignUpFunction,
        defineAuthChallenge: defineAuthChallengeFunction,
        createAuthChallenge: createAuthChallengeFunction,
        verifyAuthChallengeResponse: verifyAuthChallengeFunction,
      },
    });

    // Donner à Cognito la permission d'invoquer les Lambdas
    createAuthChallengeFunction.addPermission('CognitoInvoke', {
      principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      sourceArn: userPool.userPoolArn,
      action: 'lambda:InvokeFunction',
    });

    defineAuthChallengeFunction.addPermission('CognitoInvoke', {
      principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      sourceArn: userPool.userPoolArn,
      action: 'lambda:InvokeFunction',
    });

    verifyAuthChallengeFunction.addPermission('CognitoInvoke', {
      principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      sourceArn: userPool.userPoolArn,
      action: 'lambda:InvokeFunction',
    });

    preSignUpFunction.addPermission('CognitoInvoke', {
      principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      sourceArn: userPool.userPoolArn,
      action: 'lambda:InvokeFunction',
    });

    const userPoolClient = new cognito.UserPoolClient(this, 'MyUserPoolClient', {
      userPool,
      authFlows: {
        adminUserPassword: false, // Deactivated with Custom Auth Flow
        custom: true, // Activated with Custom Auth Flow
        userPassword: false, // Deactivated with Custom Auth Flow
        userSrp: false, // Disabled for compatibility with SRP clients
      },
      oAuth: {
        flows: {
          authorizationCodeGrant: true,
          implicitCodeGrant: true,
        },
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
          cognito.OAuthScope.PHONE,
        ],
        callbackUrls: ['myapp://'],
      },
      // Add this configuration to enforce the Custom Auth Flow
      generateSecret: false,
      preventUserExistenceErrors: false,
    });

    // Creation of the Identity Pool for mobile authentication
    const identityPool = new cognito.CfnIdentityPool(this, 'MyIdentityPool', {
      identityPoolName: 'MyIdentityPool',
      allowUnauthenticatedIdentities: false,
      allowClassicFlow: false,
      cognitoIdentityProviders: [
        {
          clientId: userPoolClient.userPoolClientId,
          providerName: userPool.userPoolProviderName,
          serverSideTokenCheck: false,
        },
      ],
    });

    // IAM Role for authenticated users
    const authenticatedRole = new iam.Role(this, 'CognitoAuthenticatedRole', {
      assumedBy: new iam.FederatedPrincipal(
        'cognito-identity.amazonaws.com',
        {
          StringEquals: {
            'cognito-identity.amazonaws.com:aud': identityPool.ref,
          },
          'ForAnyValue:StringLike': {
            'cognito-identity.amazonaws.com:amr': 'authenticated',
          },
        },
        'sts:AssumeRoleWithWebIdentity'
      ),
    });

    // IAM Role for unauthenticated users (optional)
    const unauthenticatedRole = new iam.Role(this, 'CognitoUnauthenticatedRole', {
      assumedBy: new iam.FederatedPrincipal(
        'cognito-identity.amazonaws.com',
        {
          StringEquals: {
            'cognito-identity.amazonaws.com:aud': identityPool.ref,
          },
          'ForAnyValue:StringLike': {
            'cognito-identity.amazonaws.com:amr': 'unauthenticated',
          },
        },
        'sts:AssumeRoleWithWebIdentity'
      ),
    });

    // Attaching roles to the Identity Pool
    new cognito.CfnIdentityPoolRoleAttachment(this, 'IdentityPoolRoleAttachment', {
      identityPoolId: identityPool.ref,
      roles: {
        authenticated: authenticatedRole.roleArn,
        unauthenticated: unauthenticatedRole.roleArn,
      },
    });

    // Policy for authenticated users (example: S3 access)
    authenticatedRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          's3:GetObject',
          's3:PutObject',
          's3:DeleteObject',
        ],
        resources: ['arn:aws:s3:::my-user-data/*'],
      })
    );

    // Policy for unauthenticated users (limited access)
    unauthenticatedRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          's3:GetObject',
        ],
        resources: ['arn:aws:s3:::my-public-data/*'],
      })
    );

    // Store IDs for reference
    this.userPoolId = userPool.userPoolId;
    this.userPoolClientId = userPoolClient.userPoolClientId;
    this.identityPoolId = identityPool.ref;

    // Outputs
    new CfnOutput(this, 'UserPoolId', {
      value: userPool.userPoolId,
      description: 'Cognito User Pool ID',
    });

    new CfnOutput(this, 'UserPoolClientId', {
      value: userPoolClient.userPoolClientId,
      description: 'Cognito User Pool Client ID',
    });

    new CfnOutput(this, 'IdentityPoolId', {
      value: identityPool.ref,
      description: 'Identity Pool ID for mobile authentication',
    });

    new CfnOutput(this, 'AuthenticatedRoleArn', {
      value: authenticatedRole.roleArn,
      description: 'ARN of the role for authenticated users',
    });

    new CfnOutput(this, 'UnauthenticatedRoleArn', {
      value: unauthenticatedRole.roleArn,
      description: 'ARN of the role for unauthenticated users',
    });

    new CfnOutput(this, 'DefineAuthChallengeLambdaArn', {
      value: defineAuthChallengeFunction.functionArn,
      description: 'ARN of the Define Auth Challenge Lambda',
    });

    new CfnOutput(this, 'CreateAuthChallengeLambdaArn', {
      value: createAuthChallengeFunction.functionArn,
      description: 'ARN of the Create Auth Challenge Lambda',
    });

    new CfnOutput(this, 'VerifyAuthChallengeLambdaArn', {
      value: verifyAuthChallengeFunction.functionArn,
      description: 'ARN of the Verify Auth Challenge Lambda',
    });
  }
}

Deploys Cognito and all Lambda triggers using CDK only.

This setup enables a frictionless, secure, and passwordless login experience for your users. With full control via Lambda, you can evolve your authentication logic freely and securely.

Have fun !