Verify email and phone for new accounts
by Ryan Bethel
@ryanbethel
@ryanbethel@indieweb.social
on
Having a verified way to communicate with account owners is critical for most applications. It is the best way to recover account access if a password is lost or forgotten. In our previous authentication posts, we covered Why you should roll your own auth and Authentication for a username and password flow. This third installment of the series focuses on account verification with an email or phone number.
TL&DR
- An email or phone number is given at registration.
- Using SendGrid, an email is sent with a verification link.
- Using Twilio, a one time code is sent as an SMS message.
- The account is verified as soon as one of those links/codes is used.
- At login, if the user forgets their password, they can click “forgot password”.
- They will receive a password reset link to recover the account.
Now, let’s deep-dive into each of these components.
Lego not Ikea
Think of this authentication series as more like Lego, less like Ikea. Ikea furniture has detailed instructions for only one outcome, Lego gives flexibility to choose the parts you want for different outcomes. Although we include both phone and email options, often, email is enough. SMS has some vulnerabilities that make it less secure than email. These are building blocks; use only what you need.
Twilio and SendGrid
Software projects require constant build versus buy decisions. This series started by making the case that you should build your own authentication rather than buy it from a third party. But here, we use SendGrid and Twilio for email and phone messages. Email deliverability is a significant challenge. The likelihood of your messages being seen by users is much higher with a reputable email service. And the only practical way to send SMS messages is to connect to the network through a third party.
Both these services have a limited free tier so you can try them more easily. Note that for Twilio this demo uses their Verify API. It is built for this purpose and it handles the logic of sending and verifying the one time codes.
Your mileage may vary. But in general, the cost and complexity of adding third party dependencies only grows with time, so it is still best to avoid them when possible.
Environment variables
Configuring the services requires five environment variables. For SendGrid, an API key is needed as well as the email address that will be used as sender for messages. Twilio has an API token and an account service ID. The SMS messages come from a Twilio verified number. The test phone number is where the SMS messages will be sent during local development. That means you can register accounts with fake phone numbers and the SMS code will all be sent to this phone number.
SENDGRID_API_KEY=key
TRANSACTION_SEND_EMAIL=me@example.com
TWILIO_API_ACCOUNT_SID=key
TWILIO_API_TOKEN=key
SMS_TEST_PHONE=+15555551111
Local development without API keys
Local development is valuable for testing business logic and iterating quickly. This demo is written so that the code functions in local dev even with no API keys. Without them, the app will print the email validation link and SMS one time codes to the console. This makes it easier to add authentication to an app during the earliest stages of development. Then when the app is ready to be deployed, real API keys can be added.
Email verification
When a user registers with an email address, a unique verification link is sent. When the link is clicked a success message confirms that their account is verified. The database entry for that account is updated to reflect that the account has been verified.
The handler below is on the /verify
route.
After submitting registration, the unverified user info is added to the session and redirected here.
This endpoint initiates the verification of email, phone, or both.
import {sendCode} from '../../auth-shared/sms-code-verify.mjs'
import sendLink from '../../auth-shared/send-email-link.mjs'
export async function get(req){
const session = req.session
const { unverified, authorized, redirectAfterAuth = '/', ...restSession } = session
let phone,email,phoneVerified,emailVerified
if (unverified) {
phone = unverified.phone
email = unverified.email
phoneVerified = unverified.verified?.phone
emailVerified = unverified.verified?.email
const verifyPhone = phone && !phoneVerified
const verifyEmail = email && !emailVerified
if (verifyEmail){
// send verification link
await sendLink({
email,
redirectAfterAuth,
subject:'Enhance Auth Verify Email Link',
linkPath:'/verify/email'
})
if (!verifyPhone){
return {
location: '/verify/waiting-email'
}
}
}
if (verifyPhone){
// send SMS code
const { smsVerify, unverified, authorized} = req.session
const serviceSid = await sendCode({
phone,
friendlyName:'Enhance Auth Verify Phone'
})
const newSession = { ...req.session }
newSession.smsVerify = {otp:{ serviceSid }}
return {
session: newSession,
location: '/verify/phone'
}
}
} else if (authorized) {
phone = authorized.phone
email = authorized.email
phoneVerified = authorized.verified?.phone
emailVerified = authorized.verified?.email
return {
json:{
verifyPhone:phone && !phoneVerified,
verifyEmail:email && !emailVerified
}
}
}
}
The function below sends the verification link using the SendGrid API. It sets a reference to the token being sent with some meta data so that when the link is clicked the server can continue verifying the account. A key difference with the email link verification vs. the phone is that when the email verification link is clicked, it opens in a new browser window, and therefore starts with a new session.
import crypto from 'crypto'
import db from '@begin/data'
import sgMail from '@sendgrid/mail'
export default async function sendLink({ email, subject='', text, html, linkPath, redirectAfterAuth = '/', newRegistration = false }){
const isLocal = process.env.ARC_ENV === 'testing'
const requiredEnvs = process.env.TRANSACTION_SEND_EMAIL && process.env.SENDGRID_API_KEY
const domain = process.env.DOMAIN_NAME || 'http://localhost:3333'
const verifyToken = crypto.randomBytes(32).toString('base64')
const link = `${domain}${linkPath}?token=${encodeURIComponent(verifyToken)}`
const ttl = Date.now() + 60*60*1000
await db.set({ table: 'session', key: verifyToken, verifyToken,
email, redirectAfterAuth, newRegistration, linkUsed:false, ttl})
// Local Development Testing Setup
if (isLocal) {
console.log(`${subject}: ${link}`)
}
if (requiredEnvs) {
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
let toEmail = email
if(isLocal) toEmail = process.env.TRANSACTION_SEND_EMAIL;
const msg = {
to: toEmail,
from: `${process.env.TRANSACTION_SEND_EMAIL}`,
subject,
}
if (text) {
msg.text = text(link)
} else if (html) {
msg.html = html(link)
} else {
msg.text = `${subject}: ${link}`
}
try {
await sgMail.send(msg)
} catch (e) {
console.error(e)
console.error(e.response.body.errors)
}
} else {
console.log('TRANSACTION_SEND_EMAIL and SENDGRID_API_KEY needed to send')
}
return verifyToken
}
Once email verification is initiated, the server redirects to the /verify/email
route.
The handler for that route is below.
It checks the status of the verification and shows a success or waiting message.
import db from '@begin/data'
import { getAccounts, upsertAccount } from '../../models/accounts.mjs'
/**
* @type {import('@enhance/types').EnhanceApiFn}
*/
export async function get(req) {
const token = req.query?.token
const { authorized, unverified, ...newSession } = req.session
if (token) {
const verifySession = await db.get({ table: 'session', key: token })
const { linkUsed } = verifySession
const linkExpired = verifySession?.ttl < Date.now()
if (!verifySession || linkUsed || linkExpired) return { location: '/verify/expired' };
await db.set({ ...verifySession, table: 'session', key: token, linkUsed: true })
let accounts = await getAccounts()
let account = accounts.find(acct => verifySession.email === acct.email)
if (!account) return { location: '/login' };
account.verified = account.verified ? {...account.verified , email:true} : {email:true}
account = await upsertAccount({ ...account })
return {
session: {},
location: '/verify/success-email'
}
} else if (unverified) {
let accounts = await getAccounts()
let account = accounts.find(acct => unverified.email === acct.email )
const accountVerified = account.verified?.email
if (!account) return {location: '/login'};
if (accountVerified) {
return {
session: {},
location: '/verify/success-email'
}
}
const { redirectAfterAuth = '/' } = req.session
await sendLink({ email: account.email, subject:'Enhance Auth Verify Email Link', linkPath:'/verify/email', redirectAfterAuth })
return {
session: {},
location: '/verify/waiting-email'
}
} else if (authorized) {
if (authorized.verified?.email===true){
return {
location: '/verify/success-email'
}
} else {
return { location: '/verify/success-email' }
}
} else {
return {
location: '/login'
}
}
}
Phone verification
When an account is registered with a phone number, the unverified account info is added to the session and the browser is redirected to /verify
.
A one time code is sent to the phone number, and the user is then prompted to enter this code.
The function below uses the Twilio Verify API to send the one time code. Note that if you are in a local development environment and the environment variables are not present, the API is bypassed and a code is sent to the console.
import twilio from "twilio"
const accountSid = process.env.TWILIO_API_ACCOUNT_SID
const authToken = process.env.TWILIO_API_TOKEN
const isLocal = process.env.ARC_ENV === 'testing'
const requiredEnvs = (process.env.TWILIO_API_ACCOUNT_SID && process.env.TWILIO_API_TOKEN)
export async function sendCode({phone,friendlyName}){
let service
if (requiredEnvs){
const toPhone = isLocal ? process.env.SMS_TEST_PHONE : '+1'+phone.replace('-','')
const client = twilio(accountSid, authToken)
service = await client.verify.v2.services.create({
friendlyName,
});
await client.verify.v2.services(service.sid).verifications.create({
to: toPhone,
channel: 'sms',
});
if (!process.env.SMS_TEST_PHONE) console.log('Warning: SMS messages will be sent to phone numbers unless SMS_TEST_PHONE is set');
} else {
console.log('Missing required environment variables')
if (isLocal){
console.log('Use simulated One Time Password "123456" for testing')
service = {sid:'simulated-testing'}
}
}
return service.sid
}
export async function verifyCode({phone, serviceSid, smsCode }){
let verification, status
if (requiredEnvs){
const toPhone = isLocal ? process.env.SMS_TEST_PHONE : '+1'+ phone.replace('-','')
const client = twilio(accountSid, authToken)
verification= await client.verify.v2
.services(serviceSid)
.verificationChecks.create({ to: toPhone, code: smsCode })
status = verification.status
} else {
console.log('Missing required environment variables')
if (isLocal){ status = smsCode === '123456' ? 'approved' : false }
}
return status
}
The following is the handler for /verify/phone
.
This handles the forms for entering the code and success messages once verified.
import { sendCode, verifyCode } from "../../auth-shared/sms-code-verify.mjs"
import { getAccount, upsertAccount } from "../../models/accounts.mjs"
export async function get(req) {
const { redirectAfterAuth = '/' } = req.session
const { smsVerify, unverified, authorized } = req.session
const {otp } = smsVerify || {}
const phoneVerified = authorized?.verified?.phone || unverified?.verified?.phone
if (!authorized && !unverified) {
return {
location: '/login'
}
}
if (phoneVerified) {
return {
location:'/verify/success-phone'
}
}
return {
json: { otpSent: !!(otp?.serviceSid) },
}
}
export async function post(req) {
const { otpCode, request } = req.body
const { smsVerify, unverified, authorized} = req.session
const { otp } = smsVerify || {}
const phoneVerified = authorized?.verified?.phone || unverified?.verified?.phone
const phone= authorized?.phone || unverified?.phone
if (request && phone) {
const serviceSid = await sendCode({phone, friendlyName:'Enhance Auth Verify Phone'})
const newSession = { ...req.session }
newSession.smsVerify = {otp:{ serviceSid }}
return {
session: newSession,
location: '/verify/phone'
}
}
if (otpCode) {
const { serviceSid } = otp
const status = await verifyCode({phone, serviceSid, smsCode:otpCode})
if (status === 'approved') {
let { smsVerify, unverified, authorized, ...newSession } = req.session
let key = authorized?.key || unverified?.key
let account = await getAccount(key)
let match = account?.phone === phone
let verified = account?.verified
if (!match) {
return {
session: {},
location: '/login'
}
}
if (verified) {
account.verified.phone = true
} else {
account.verified = {phone:true}
}
if (match) {
let result = await upsertAccount(account)
let {password:removePassword, ...newAccount} = result
const redirectAfterAuth = req.session.redirectAfterAuth || '/'
return {
session: { ...newSession, authorized:newAccount },
location: redirectAfterAuth
}
}
}
}
return {
location: '/verify/phone'
}
}
Forgot Password
With a verified account, users can recover if a password is lost or forgotten. On the login screen there is a “Forgot password” link that initiates this process. After entering their phone or email address, a link or one time code is generated.
Next steps
Now that we have a fully verified account we have the option to allow users to login directly with just their email or phone number. We will cover using these magic links in the next post. To try out this code here is an example deployed on Begin. You can register an account to try resetting your password. Note that in this deployed example accounts are automatically deleted from the database after a day. The code for the full example can be found on GitHub at https://github.com/enhance-dev/enhance-auth.