Tags
February 02, 2024
by
Alexander VerbruggenRead more about this author

Usecase: Address Verification

A client wants to make sure the user has access to the phone numbers and email addresses he might enter as contact information for his company.

This is a very typical request but there are a number of things to keep in mind:

  • emails should be sent from the CRM so they are logged correctly and styled according to all the other email communication
  • company phone numbers are often landlines that can not receive text messages so we need to be able to call them to relay the code
  • we still want to support text messages for text-capable phones
  • we want to balance security against usability
    • e.g. if a user requests a code and it doesn't arrive for whatever reason, he can hit resend. Ideally it should be the same code just in case the older code still appears with some delay.
    • we don't want the user to be able to spam the resend button
    • we want to keep the code sequence short enough to be easy to use but prevent bruteforcing the limited complexity

Approach

In the frontend the user wants to add or change an email or phone. This might be in the first step of a multistep wizard or it could be on the account details page. Whenever he enters a new address, it needs to be verified immediately in a modal popup before it can be committed to the page.

Even when it is committed to the page, it can take a while before the dataset is fully persisted in the backend, the user might still need to fill in other steps in a wizard for example.

To verify an email address you might be tempted to add a link which can be clicked to verify a particular address. To combine this with a blocking modal popup you would need long polling or a websocket to unlock this. More importantly this approach does not work when dealing with landline verification. Even text-based verification might not happen on a phone capable of acting upon a link sent to it.

To ensure a single process for validation, we opted to send a code across whatever medium that needs to be verified which should be entered in the application to verify it.

Process

Sending the code

Whenever the frontend wants to send a new verification, they can call:

POST /verification/send

{
    "address": "test@example.com",
    "addressType": "email"
}
POST /verification/send

{
    "address": "+3235678912",
    "addressType": "phone"
    "preferredVerificationType": "call"
}

We normalize the given address to prevent multiple verifications on variations of the same address. We also check if there is already an ongoing validation.

If there is an ongoing validation we check a few details:

  • how long ago did you last request the code to be sent for this address? If it is less than 30s, we will return a 429 with a Retry-After header indicating how long you still need to wait. This can be shown in the frontend as a countdown.
  • how often have you tried to validate this particular code? If it is 5 or more, we will generate a new code to prevent brute forcing
  • how long ago was the current code created? if it is longer than 20 minutes, we will generate a new code
  • if you are beyond the 30s window, we will send either the original code or the new code and return a 200 with a Retry-After header

If there is no ongoing validation or we create a new one, send the code and return a 200 with a Retry-After header indicating how long the user has to wait before he can hit resend.

Two additional security considerations were made at this point:

  • a malicious user could for example start a lot of validations for a lot of different addresses. We add traditional rate limiting to prevent this kind of behavior
  • this call can also be performed by anonymous users, so it is theoretically possible that multiple users enter the same address. However, only those with actual access to the address we are validating will be able to check the code. This access requirement is deemed sufficient from a security perspective.

Verifying the code

The frontend can call:

POST /verification/check

{
    "address": "+3235678912",
    "addressType": "phone"
    "code": "1234"
}

We normalize the given address and check that there is an ongoing validation. There are a number of options:

  • there is no ongoing validation
  • there is an ongoing validation but the code was created longer than 20 minutes ago, it is no longer valid
  • there is an ongoing validation but you have tried to validate a code 5 or more times

All of these situations will result in an error being thrown. They all have different error codes but the codes are (by default) not whitelisted. This means the frontend can not differentiate between the different cases, only that it failed.

For the frontend it doesn't really matter why it failed, the conclusion is the same: the user should resend the code.

If we have an ongoing validation, there are still two options:

  • your code is invalid: this is either because you misunderstood the phone call or you made a typo when entering the code.
  • your code is valid, the address has been verified

When the code is invalid, we send an exception to the frontend but we whitelist the specific error code so the user knows he might have made a typo:

{
    "instance": "7af7e614bdf14e4a92fab7a2bb648a27",
    "status": 500,
    "type": "CODE-INVALID"
}

When your code is valid, we mark the address verification as having been successful, we return a validation identifier to the frontend.

{
    "verificationId": "7e2eb15b568a4b93b1ef20be223daa76"
}

Using the code

The frontend needs to hang on to that verification id until it is ready to submit the actual address. Suppose you are in the second step of five step wizard, you will probably only commit the data at the end of the wizard.

At that point you can send the address as you would normally do if there were no validation in play, but you can add a custom HTTP header X-Verification-Ids which contains a comma-separated list of all the verification ids you have built up during the wizard.

All addresses that are in the call you are making, need to match with a successful validation based on the ids you have passed in.

Note that when you are updating existing data (for example a company profile update) and you haven't changed the address, no verification id is required. Only if the actual address has been updated based on the data we already have will we check the verification ids.