SSH Multi-factor Authentication with tru.ID

In this tutorial, you'll learn how to enhance the security of your SSH server by adding tru.ID's PhoneCheck as an added security check when users attempt to log in.
Greg HolmesDeveloper Experience
Last updated: 25 April 2022
tutorial cover image

Secure Shell (SSH) is a network protocol that enables users to securely connect from one computer to another remotely. The majority of people that use SSH are system administrators, and the most common use for this protocol is to manage a server remotely.

It doesn't matter what point of your technical career you are at; if you deal with servers, you will have needed SSH as a protocol to modify or maintain remote servers on countless occasions. You may have increased security on your server by using an SSH certificate instead of a username and password. However, someone with malicious intent could still gain access to these authentication methods. That’s why it’s worth enabling multi-factor authentication (MFA) for your SSH server.

The tru.ID PhoneCheck API confirms the ownership of a mobile phone number by checking for the presence of an active SIM card with the same number. As well as creating a frictionless user experience, this method is significantly more secure than legacy methods such as SMS OTP. tru.ID APIs call the mobile network directly rather than relying on vulnerable SS7 protocols, providing a real-time SIM card check in under 2 seconds.

Before you begin

To follow along with this tutorial, you'll need:

Getting started

A repository branch has been created on GitHub containing the foundation code needed to get you started.

In your Terminal, clone the starter-files branch for this repository with the following command:

git clone -b starter-files

If you're interested in the finished code, you can find the complete example in the main branch. To get this code, run:

git clone -b main

A tru.ID Account is required to make the PhoneCheck API requests, so before proceeding, make sure you've created one.

Now navigate to the tru.ID console and create a project. Once created, before you close the window or navigate away, be sure you download the tru.json credentials file and move it to the root directory of your tru-id-ssh-auth project directory.

Within the credentials file, you'll find your project_id, project_name, the scopes available to your project, and your client_id and client_secret, which you'll use to create an auth token to make API requests.

Define core variables

Throughout your code, you'll be reusing several bits of information; these are variables such as the PhoneCheck URL and the destination directory where your project is installed, for example. Below the line VERSION=1 in the file ssh-auth, add the following:

# Base URL information
# API Check URLs
APP_ROOT=`dirname $0`
# Empty Global Variables

The only two values available for you to change are:

  • DATA_RESIDENCY, which could be either of the two supported data residencies; eu or in,
  • DEST_DIRECTORY, which is the destination for the project once installed.

Create the install

The majority of this project will be within one single bash file, with multiple commands available. The first is the install command, which copies the relevant code to the desired installation directory. You'll also apply a ForceCommand to the SSHd config file, which you will add later in the tutorial.

Start by locating the function require_curl within the ssh-auth file. Below this require_curl function, add the following code, which is your new install() function:

function install() {
set -e
echo "Making directory in ${DEST_DIRECTORY}"
mkdir "/usr/local/bin/tru-id-ssh-auth"
set +e
if [[ ! -r `dirname "${DEST_DIRECTORY}"` ]]
echo "${DEST_DIRECTORY} is not writable. Try again using sudo"
return $FAIL
echo "Copying /root/tru-id-ssh-auth/ to ${DEST_DIRECTORY}..."
cp -r "/root/tru-id-ssh-auth" "${DEST_DIRECTORY}"
echo "Setting up permissions..."
if [[ ! -f ${config_file} ]]
echo "Generating initial config on ${config_file}..."
echo "header=tru.ID SSH Auth initialised." > "${config_file}"
echo "A config file was found on ${config_file}. Edit it manually if you want to change the API key"
chmod 644 ${config_file}
echo ""
echo "To enable tru.ID authentication on a user the following command: "
echo ""
echo " ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth register-user <username> <phone-number-inc-country-code>"
echo " Example: ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth register-user test 447000000000"
echo ""
echo "To uninstall tru.ID SSH type:"
echo ""
echo " ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth uninstall"
echo ""
echo " Restart the SSH server to apply changes"
echo ""

There are several steps included in this new function. These are:

  • Make the directory for the location of the installed application, /usr/local/bin/tru-id-ssh-auth.
  • Check the directory is writeable. If not, then throw an error.
  • Copy the project files to the newly created destination directory.
  • Check if a config file exists. If not, then create one.
  • Output text to Terminal, instructing users how to use and uninstall the application.

As previously mentioned, more functionality is needed to uninstall this script. Below your newly created install() function, add the following uninstall() function:

function uninstall() {
if [[ $1 != "quiet" ]]
echo "Uninstalling tru.ID SSH from $SSHD_CONFIG..."
if [[ -w $SSHD_CONFIG ]]
sed -ie '/^ForceCommand.*tru-id-ssh-auth.*/d' $SSHD_CONFIG
if [[ $1 != "quiet" ]]
echo "tru.ID SSH was uninstalled."
echo "Now restart the ssh server to apply changes and then remove ${DEST_DIRECTORY}/tru-id-ssh-auth"

The first line within this new function is to call a function called find_sshd_config(), so let's add this:

function find_sshd_config() {
echo "Trying to find sshd_config file"
if [[ -f /etc/sshd_config ]]
elif [[ -f /etc/ssh/sshd_config ]]
echo "Cannot find sshd_config in your server. tru.ID SSH Auth will be enabled when you add your specific ForceCommand to the sshd config file."

The new function locates your server's sshd_config file and stores this file location as a global variable.

Both the install() and uninstall() functions are inaccessible until you add them as a parameter. In the list of commands for this project, find the line:

case $1 in

This section of code contains the functionality that understands various arguments in the command when running your bash script. So below the case line, add the following two new command-line arguments:


Registering users

The SIM authentication process requires users to be enabled individually by entering their phone number and linking it with their username to allow them to verify with tru.ID's PhoneCheck API.

To register a new user, create a new register_user() function by copying the below example in your ssh_auth file:

function register_user() {
if [[ $2 && $3 ]]
echo "user=$2:$3" >> ${config_file}
echo "" >> ${config_file}
echo "User was registered"
echo "Cannot register user"

Your new register_user() function finds the tru-id-ssh-auth.conf file and adds the following as a new line: user={username}:{phone number}.

Near the bottom of the file, find the line case $1 in. Below this line, enable the register_user() function as a command-line argument by adding the following:

register_user "$@"

Allow the User in

Once the PhoneCheck is successful, the function run_shell will be called, which tells the plugin the user is allowed access to their SSH session, in the ssh-auth file, add the following function:

# Once successful, allows user to proceed with access to server
function run_shell() {
if [[ "$SSH_ORIGINAL_COMMAND" != "" ]]
exec /bin/bash -c "${SSH_ORIGINAL_COMMAND}"
elif [ $SHELL ]
exec -l $SHELL
exit $?

Creating a PhoneCheck

Retrieve credentials

To make calls to the tru.ID API, you'll need to retrieve your project's credentials, which you can find in the tru.json file you moved into your project earlier. The get_credentials() function below retrieves your project's client_id and client_secret from this file, then sets them as two global variables: BASIC_AUTH_USER and BASIC_AUTH_PASSWORD. Add this new function to your ssh-auth file:

function get_credentials() {
if [ -f "$FILE" ]; then
BASIC_AUTH_USER=$(jq -r ".credentials[].client_id" ${FILE})
BASIC_AUTH_PASSWORD=$(jq -r ".credentials[].client_secret" ${FILE})
return $OK
echo "Unable to retrieve project credentials. Please make sure your project directory has a `tru.json` file."
exit $FAIL

Create access tokens

The next part to creating a PhoneCheck via API is to add functionality to generate access tokens using your tru.ID project's credentials. This new functionality will make a POST request to https://{DATA_RESIDENCY}, with a header containing your BASIC_AUTH_USER and BASIC_AUTH_PASSWORD, concatenated with a : in between, and then the entire string encoded with base64. The access token received will be what you use to make further secure API requests to tru.ID's API.

Copy the create_access_token() function below into your ssh_auth file.

# Creates an access token needed to create a PhoneCheck request.
function create_access_token() {
# Make request to get access token
response=`curl \
--header "Authorization: Basic $CREDENTIALS" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=phone_check coverage" --silent`
# Parses response to get the access token
ACCESS_TOKEN=$(jq -r .access_token <<< "${response}" )
if [ $curl_exit_code -ne 0 ]
echo $curl_exit_code
echo "Error running curl"

Create a PhoneCheck

You've created functions that allow you to retrieve your project's credentials from your tru.json file; your next function then uses the credentials from this file to generate an access token that you're going to use to make authenticated API calls.

The next step in this tutorial is to initialise the PhoneCheck, which will do the following:

  • Retrieve the current user attempting to log in.
  • Read the config file to find whether this user has their phone number registered to require the MFA step.
  • Make a POST request to https://{DATA_RESIDENCY}" with the user's phone number.

Copy the function below into your ssh_auth file to add the functionality described into your project:

function create_check() {
while read line; do
if [[ $line =~ user=$current_user: ]] ; then
phone_number=$(echo "${line}" | sed s/user=${current_user}://g);
done <${config_file}
if [ "$phone_number" == "" ]; then
echo "Phone Number is empty"
return 0
# Checking Credentials are installed
# Creating an Access Token
# Making a Phone Check request"
response=`curl \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data-raw "{\"phone_number\":\"${phone_number}\"}" --silent`
if [ $curl_exit_code -ne 0 ]
echo "Error running curl"
return 1;
# Handling Phone Check Response
check_id=$(jq -r .check_id <<< "${response}" )
status=$(jq -r .status <<< "${response}" )
check_url=$(jq -r ._links.check_url.href <<< "${response}" )

Generate a QR code

When the user has created a PhoneCheck, the next step in the MFA flow is for the user's device to open their mobile network operator's check URL.

The easiest way for the user to open the URL is with a QR code. If you check your Dockerfile within your project directory, you've already installed the library qr when you built the Docker container. So the next step is to take the check_url retrieved from the PhoneCheck response. This check_url needs to be converted into a QR code and displayed for the user.

Inside create_check(), find the line check_url=$(jq -r ._links.check_url.href <<< "${response}" ), and below it, add the following:

# Generate QR code
qr --ascii "${check_url}" > "qrcode.txt"
sleep 1
cat ~/qrcode.txt

Set up polling

The next step in this process is for your application to check tru.ID's API endpoint https://{DATA_RESIDENCY}{check_id} to determine whether there was a match between the phone number and the request to the check URL or not. There is a two-minute window when the check is created for the user to open the URL. The application will make requests to the API every five seconds for a status update.

If the status value is COMPLETED and the response body contains the value of match as true, then allow the user through; otherwise, refuse entry to the server.

Copy the new start_polling() function into your ssh_auth file:

function start_polling() {
# Check every 5 seconds for status on Check.
while true;
# Check status of phone check
response=`curl \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--request GET $GET_PHONE_CHECK_URL/${check_id} --silent`
if [ $curl_exit_code -ne 0 ]
echo "Error running curl"
return $FAIL;
status=$(jq -r .status <<< "${response}" )
match=$(jq -r .match <<< "${response}" )
# If check is complete, output
if [[ "$status" != "PENDING" && "$status" != "ACCEPTED" ]]; then
if [ "$status" == "COMPLETED" ]; then
if [ "$match" == "true" ]; then
echo "No match found!"
return $FAIL;
elif [ "$status" == "EXPIRED" ]; then
echo "Check Expired";
return $FAIL;
elif [ "$status" == "ERROR" ]; then
echo "Check Error Received";
return $FAIL;
echo "$status"
echo "404, no status was found";
return $FAIL;
# Otherwise continue
sleep $interval_in_seconds;

You need to call this new function within your create_check() function. Find the line cat ~/qrcode.txt, and below this, add the following to trigger the start_polling functionality:

# Start polling

Add the create_check as a command-line argument by finding the line: case $1 in, and adding the following:

create_check "$@"

Add ForceCommand

In your ssh-auth file, you've already called the function add_force_command, but it doesn't yet exist to add the ForceCommand to your sshd_config file. So add this function:

function add_force_command() {
echo "Trying to add force command to $SSHD_CONFIG"
if [[ -w $SSHD_CONFIG ]]
echo "Adding 'ForceCommand ${auth_ssh_command} login' to ${SSHD_CONFIG}"
uninstall "quiet" # remove previous installations
echo -e "\nForceCommand ${auth_ssh_command} login" >> ${SSHD_CONFIG}
echo ""
sleep 5

If you're following along with this tutorial using Docker, open the sshd_config file within your project directory and at the bottom of this file add the following force command:

ForceCommand /usr/local/bin/tru-id-ssh-auth/ssh-auth create-check

Once added you can skip to Complete PhoneCheck. However, if you're installing this on a server, please continue with the instructions below:

Note: If you're following along with this tutorial using the Docker container, you won't need this code snippet; however, when installing on a server other than Docker, the line is needed and needs to be uncommented. Find the line chmod 644 ${config_file} inside your install() function and add the following; you'll also need to uncomment it.

# When following this tutorial, leave the line below commented out.
# Restarting your SSH server within Docker will restart the whole Docker container.
# add_force_command "${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth create-check"

Complete PhoneCheck

The final step in the PhoneCheck process is to complete the PhoneCheck. When the mobile device opens the mobile network operator's check URL, they're eventually redirected back to your redirect_url, which on the final request, will have a response containing a code. This code needs to be submitted through the API with your credentials to complete the PhoneCheck.

In your starter-files repository, you will find a template webserver built in node. This is the webserver that will contain the code of your redirect_url that will carry out this functionality.

Install third party dependencies

In your Terminal, navigate to the webserver directory and run the following command to install the third party libraries required for this tutorial. These libraries include express for the webserver functionality, ngrok to provide a publicly accessible URL, and http-signature to verify the signature in the API request.

npm install

Create the webhook

Find the endpoint:

app.get("/", async (req, res) => {

And replace it with:

app.get("/complete-check", async (req, res) => {
if (!req.query) {
res.status(400).send("body missing");
const { code, check_id } = await req.query;
if (!code) {
res.status(400).send("code missing");
if (!check_id) {
res.status(400).send("check_id missing");
if (req.query.redirect_url) {
const verified = await tru.verifySignature(req.query.redirect_url);
if (!verified) {
res.status(400).send("signature not verified");

In the above example, you're creating a new webhook /complete-check which is accessible with a GET request. This request takes three parameters in the URL, check_id, code, and redirect_url. These parameters are used to complete the PhoneCheck. Once checks have been made to make sure they've been included, the code verifys the signature is valid for the redirect url.

Next, within the webserver directory, create a new file tru.js which is where you're going to add the tru.ID API functionality. In this file, the first thing is to add the dependencies that will be used, as well as defining the global variables and objects. Add the following:

const moment = require("moment");
const fetch = require("node-fetch");
const httpSignature = require("http-signature");
const jwksClient = require("jwks-rsa");
const config = require("../tru.json");
const tru_api_base_url = '';
const keyClient = jwksClient({
jwksUri: `${tru_api_base_url}/.well-known/jwks.json`,
// token cache in memory
const TOKEN = {
accessToken: undefined,
expiresAt: undefined,

When making a request to an endpoint in tru.ID's API, you'll need an access token:

async function getAccessToken() {
// check if existing valid token
if (TOKEN.accessToken !== undefined && TOKEN.expiresAt !== undefined) {
// we already have an access token let's check if it's not expired
// I'm removing 1 minute just in case it's about to expire better refresh it anyway
if (
.add(1, "minute")
.isBefore(moment(new Date(TOKEN.expiresAt)))
) {
// token not expired
return TOKEN.accessToken;
const url = `${tru_api_base_url}/oauth2/v1/token`;
const toEncode = `${config.credentials[0].client_id}:${config.credentials[0].client_secret}`;
const auth = Buffer.from(toEncode).toString('base64');
const requestHeaders = {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
const res = await fetch(url, {
method: "post",
headers: requestHeaders,
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "phone_check coverage",
if (!res.ok) {
return res.status(400).body("Unable to create access token")
const json = await res.json();
// update token cache in memory
TOKEN.accessToken = json.access_token;
TOKEN.expiresAt = moment().add(json.expires_in, "seconds").toString();
return json.access_token;

A new function patchPhoneCheck is needed, which will make a PATCH request to tru.ID's API: /phone_check/v0.2/checks/${checkId}, with the code contained in the body. This is a method to verify the owner of the SIM card was the one that requested the PhoneCheck. In your tru.js file add the following new function:

async function patchPhoneCheck(checkId, code) {
const url = `${tru_api_base_url}/phone_check/v0.2/checks/${checkId}`;
const body = [{ op: "add", path: "/code", value: code }];
const token = await getAccessToken();
const requestHeaders = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json-patch+json",
const res = await fetch(url, {
method: "patch",
headers: requestHeaders,
body: JSON.stringify(body),
if (!res.ok) {
return res;
const json = await res.json();
return json;

As previously explained, you need to verify the signature provided with the redirect_url, to ensure it hasn't been altered in any way. This can be carried out with the following function, so add this to your file:

async function verifySignature(originalUrl) {
try {
const url = new URL(originalUrl);
const signature = Buffer.from(
const date = Buffer.from(url.searchParams.get("date"), "base64").toString(
const originalRequest = {
url: `${url.pathname}${}`,
method: "get",
hostname: url.hostname,
headers: {
authorization: signature,
const parsedOriginalRequest = httpSignature.parseRequest(originalRequest, {
clockSkew: 300,
const jwk = await keyClient.getSigningKey(parsedOriginalRequest.keyId);
const verified = httpSignature.verifySignature(
return verified;
} catch (err) {
return false;

The two new functions patchPhoneCheck and verifySignature need to be accessed in your index.js file, so at the bottom of the tru.js file add the following exports for these two:

module.exports = {

Back in your index.js file, at the top among the requires, add the line:

const tru = require("./tru");

Find the complete-check endpoint, and at the bottom of this function, add the following:

try {
const check = await tru.patchPhoneCheck(check_id, code);
if (check.status === "COMPLETED" && check.match) {
res.status(200).send('Verification complete, please close this tab and return to your SSH session.');
} else {
// verification failed = user not authenticated
res.status(401).send("Verification failed, false match");
} catch (err) {
if (err.status) {
res.status(err.status || 500).send(err.message);
} else {
res.status(500).send("Unexpected Server error");

The above code calls the patchPhoneCheck created in your tru.js file, which makes a PATCH request to the tru.ID API with the body containing the code needed to complete the PhoneCheck process.

Now in your Terminal, inside the webserver directory, run the following command and make note of the ngrok url output:

npm start

In your ssh-auth file, find the line: # API Check URLs and below this add the following, replacing <Your NGROK URL>, with the ngrok URL you made a note of in the previous step:


To have the final redirect be your specified webhook URL, find the line below:

--data-raw "{\"phone_number\":\"${phone_number}\"}" --silent`

And replace it with:

--data-raw "{\"phone_number\":\"${phone_number}\", \"redirect_url\":\"${REDIRECT_URL}\"}" --silent`

Setting up the Docker container

This tutorial makes use of a Docker container for development purposes. To build this Docker container and have it running, you'll need to:

  • Build and run your Docker container. This process will also map the internal port 22 (ssh) to an externally accessible port 223.
  • Open the Docker container with a bash session.

So, in your Terminal, run the following two commands:

docker-compose up --build -d
docker-compose exec ssh bash

Installing the SSH Plugin

You've now added multi-factor authentication to your SSH authentication process. With your Docker container built and running, change to the project directory in the same terminal instance. For this, the default directory is /root/tru-id-ssh-auth/. Then run the command ./ssh-auth install to install your copy of your project directory over to /usr/local/bin/.

Note: This is defined in your Dockerfile at the line: ADD . /root/tru-id-ssh-auth

cd /root/tru-id-ssh-auth/
./ssh-auth install

The command ./ssh-auth install will do the following:

  • Copy your project directory from /root/tru-id-ssh-auth/ to /usr/local/bin/tru-id-ssh-auth/.
  • Create a /usr/local/bin/tru-id-ssh-auth/tru-id-ssh-auth.conf config file.

Registering a user

With the plugin installed, you now need to enable the check for the user(s). This stores the user's name and phone number into your recently created config file. The application will then compare this with the credentials entered when the user attempts to log in. Still in the same Terminal, run the following command, swapping out the placeholders for your valid details:

Note: The Docker user and password are both test.

/usr/local/bin/tru-id-ssh-auth/ssh-auth register-user <username> <phone-number-inc-country-code>
# For example: /usr/local/bin/tru-id-ssh-auth/ssh-auth register-user test 447000000000

Login attempt

You've now set everything up. To check everything is working, open a new Terminal session and run the following command to SSH into your SSH server:

ssh test@ -p 223

The Docker config example uses the username test and the password test.

Wrapping up

That's it! You've now introduced a multi-factor authentication step for your server's SSH authentication process using tru.ID's PhoneCheck API. The beauty of this is it limits the user's input, by only having to require the user to enter their SSH credentials and then scan the QR code, they don’t need to wait for a code to come through SMS or other means for example. The MFA process is all carried out in the background once the QR code has been scanned on their mobile device.


Download our Developer Console mobile app
Made withacross the 🌍
© 2024 4Auth Limited. All rights reserved. tru.ID is the trading name of 4Auth Limited.