Skip to main content

MFA Setup

Last updated: June 1, 2026Latest Frontend Version: 2.16.20

Multi-factor authentication (MFA) in tiCrypt strengthens security by requiring one or more additional authentication factors beyond the user's private key. This page covers how to deploy and integrate an MFA authority. For the ticrypt-auth.conf MFA configuration parameters, see the MFA section in the Auth Service reference.

info

MFA is deployed separately from the main backend and requires its own web server and SSO integration.

How MFA Works in tiCrypt

Login Flow

  1. During login, the tiCrypt backend informs the frontend that MFA factors must be satisfied and provides URLs for each factor.
  2. The frontend opens each factor URL in a separate frame/tab.
  3. The MFA server authenticates the user (via SSO, push notification, etc.) and returns a signed certificate to the frontend via window.postMessage().
  4. The frontend submits all MFA certificates to the backend to complete the login.

Validation

The backend validates MFA factors using only the signed message received from the MFA server. There is no direct communication between the MFA servers and the tiCrypt backend. The frontend mediates all communication.

For each MFA factor, the backend needs:

  • The public key of the MFA server (configured in ticrypt-auth.conf)
  • The URL where users authenticate

Setting Up MFA with Shibboleth/SSO and PHP

The simplest approach is to host a PHP script on a web page protected by your organization's Shibboleth/SSO. The script:

  1. Reads the user's email and other properties from Shibboleth environment variables
  2. Reads the user's IP address
  3. Generates a timestamp (to prevent replay attacks)
  4. Signs the payload with the MFA private key
  5. Returns the signed message to the tiCrypt frontend

Hosting info.php

  1. Choose a host within your organization's infrastructure to serve the PHP script. This host should not be the same server as the tiCrypt backend (hosting it on the same machine weakens security).
  2. Ensure the host uses HTTPS.
  3. Place the info.php script at a route on that host. Best practice is to use a dedicated subdomain.
  4. Protect the route (or virtual host) with your organization's Shibboleth/SSO.
info

The web server used for the MFA page does not matter. Apache with mod-php is the easiest combination with Shibboleth.

Setting Up Credentials

Generate an RSA key pair for the MFA signing:

openssl genrsa -out /etc/pki/tls/certs/login_rsa_2048_priv.pem 2048
openssl rsa -in /etc/pki/tls/certs/login_rsa_2048_priv.pem -pubout -out /etc/pki/tls/certs/login_rsa_2048_pub.pem
chown nginx:nginx /etc/pki/tls/certs/login_rsa_2048_p*
tip

Adjust the chown command for your web server user (e.g., apache:apache for Apache httpd).

Place the keys as follows:

  • Private key on the server hosting info.php (protected like any other private key, readable by PHP)
  • Public key path and the info.php URL in ticrypt-auth.conf under the MFA factors section

After updating the config, restart ticrypt-auth:

systemctl restart ticrypt-auth
warning

Once MFA is active, only users who can satisfy both the MFA factor and the primary factor with the exact same email will be able to log in. Any discrepancy (e.g., case differences) will prevent session creation.

Customizing info.php

The info.php script can be customized to fit your organization's requirements or branding. The following parts are critical and must be preserved:

  • The $data array must contain at least the required fields (email, sourceIP, timestamp)
  • The digital signature mechanism must remain intact
  • The $msg value assembly must not be modified
  • The <script> section that sends the message via postMessage must remain unchanged
tip

Keep the error messages about missing keys and failed signatures. Debugging the script without them is difficult.

Optional fields for the $data array (values from SSO that auto-populate user registration):

FieldDescription
firstNameUser's first name
lastNameUser's last name
contactEmailContact email (if different from login email)
departmentUser's department
positionUser's position/title
warning

Any field populated by info.php cannot be edited by the user during registration.

tip

Extract firstName and lastName from SSO when possible. Users sometimes write their names differently, which creates confusion.

MFA Payload Schema

The window.postMessage() payload sent to the frontend must include:

FieldTypeRequiredDescription
payloadStringJSON-encoded signed payload object
authorizerStringFactor ID matching a factor in ticrypt-auth.conf
algoStringSignature algorithm. See Signature Algorithms.
signatureStringBase64-encoded signature of the payload

The payload JSON object must include:

FieldTypeRequiredDescription
emailStringtiCrypt username (normally the user's email)
sourceIPStringUser's IP address as seen by the MFA server
timestampString or IntISO-8601 datetime string or millisecond UNIX timestamp

Server-Side Validation

When the backend receives the MFA response, it:

  1. Looks up the factor configuration using the authorizer field. Rejects if no matching factor exists.
  2. Decodes the signature from Base64. Rejects on decode failure.
  3. Validates the signature on payload using the configured public key and specified algorithm. Rejects on mismatch.
  4. Decodes payload as JSON and validates required fields.
  5. Checks that timestamp is not in the future and not older than the factor's cert-ttl.
  6. Looks up the user by email.
  7. Checks for duplicate submission (replay). Rejects if previously seen.
  8. Generates an MFA token valid for the factor's token-ttl.

Duo Integration

The simplest way to integrate Duo MFA is to add it as an extra factor in the Shibboleth login flow. This way, tiCrypt is not aware of Duo and no additional configuration is needed. Alternatively, a separately hosted info.php protected by Duo can be set up as its own factor.

tip

Adding Duo to Shibboleth creates a 3-factor authentication method (private key + SSO + Duo), making compliance requirements easier to satisfy.


Multiple SSO Servers

If your organization uses different SSO servers for different user populations, you can set up a single MFA factor that supports multiple SSO providers:

  1. Host a login.html page (not protected by Shibboleth) that presents a choice of credential types.
  2. For each Shibboleth server, host an independent info.php script. All scripts must use the same private key.
  3. login.html redirects to the appropriate info.php based on user selection.
  4. The selected info.php completes authentication and returns the MFA token to the tiCrypt frontend.

Example info.php

<?php
// Second-factor authentication script for Shibboleth-based servers
$request_method = $_SERVER['REQUEST_METHOD'];
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
if ($request_method === 'OPTIONS') {
header('HTTP/1.1 204 No Content');
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Length: 0');
exit;
} else if ($request_method !== 'GET') {
header('HTTP/1.1 405 Method Not Allowed');
exit;
}

// Location of the private key used to sign the replies
$privKeyFile = "file:///etc/pki/gv-mfa/private/shibboleth_private_key.pem";

$timezone = new DateTimeZone('UTC');
$timestamp = array_key_exists('REQUEST_TIME', $_SERVER) ? $_SERVER['REQUEST_TIME'] : time();
$date = new DateTime('@' . $timestamp, $timezone);
$dateStr = $date->format(DateTime::ATOM);

$data = array(
"email" => array_key_exists("REMOTE_USER", $_SERVER) ? $_SERVER["REMOTE_USER"] : $_SERVER["eppn"],
"sourceIP" => $_SERVER["REMOTE_ADDR"],
"timestamp" => $dateStr
);

$fPort = $_GET["port"];

$pkey = openssl_pkey_get_private($privKeyFile);
if (!$pkey) {
echo "<!-- Private key file $privKeyFile not found -->";
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: red; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Private key for digital signature could not be found
</span>
</body>
</html>
<?php
exit();
}

$dataStr = json_encode($data);

if (!openssl_sign($dataStr, $signature, $pkey, "SHA256")) {
echo "<!-- Digital signature failed -->";
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: red; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Digital signature generation failed
</span>
</body>
</html>
<?php
exit();
}

$sig = base64_encode($signature);
$msg = json_encode(array(
'payload' => $dataStr,
'authorizer' => $_SERVER["AUTH_TYPE"],
'algo' => 'RSA-PKCS1-SHA256',
'signature' => $sig
));
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: blue; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Successful Authentication
</span>
</body>
<script type="text/javascript">
var msg = <?php echo $msg; ?>;
if (window.self != window.top) {
if (parent && parent.postMessage) {
try {
parent.postMessage(msg, '<?php echo $formOrigin; ?>')
} catch(e) {
// ignore
}
}
} else {
const urlParams = new URLSearchParams(window.location.search);
const port = "<?=$fPort?>";
const uri = encodeURI("http://127.0.0.1:" + port + "/mfa.html?msg=" + JSON.stringify(msg));
window.location.assign(uri);
}
</script>
</html>

Example: Shibboleth + NGINX Deployment

This section walks through a complete Shibboleth SP deployment with NGINX on Rocky Linux 8.

Build Shibboleth SP with FastCGI

On a machine with Docker:

git clone https://github.com/nginx-shib/shibboleth-fastcgi.git
cd shibboleth-fastcgi
make

Copy the resulting RPMs from build/ to the target host.

Install Shibboleth

Add the Shibboleth repository:

# /etc/yum.repos.d/shibboleth.repo
[shibboleth]
name=Shibboleth (Rocky_8)
type=rpm-md
mirrorlist=https://shibboleth.net/cgi-bin/mirrorlist.cgi/Rocky_8
gpgcheck=1
gpgkey=https://shibboleth.net/downloads/service-provider/RPMS/repomd.xml.key
https://shibboleth.net/downloads/service-provider/RPMS/cantor.repomd.xml.key
enabled=1
exclude=shibboleth

Install and start:

dnf install shibboleth-3.3.0-1.x86_64.rpm
systemctl enable --now shibd
systemctl status shibd

Set Up FastCGI Authorizer and Responder

dnf install supervisor

Create /etc/supervisord.d/shib.ini:

[fcgi-program:shibauthorizer]
command=/usr/lib64/shibboleth/shibauthorizer
socket=unix:///run/supervisor/shibauthorizer.sock
socket_owner=shibd:shibd
socket_mode=0666
user=shibd
stdout_logfile=/var/log/supervisor/shibauthorizer.log
stderr_logfile=/var/log/supervisor/shibauthorizer.error.log

[fcgi-program:shibresponder]
command=/usr/lib64/shibboleth/shibresponder
socket=unix:///run/supervisor/shibresponder.sock
socket_owner=shibd:shibd
socket_mode=0666
user=shibd
stdout_logfile=/var/log/supervisor/shibresponder.log
stderr_logfile=/var/log/supervisor/shibresponder.error.log
systemctl enable --now supervisord
systemctl status supervisord

Set Up PHP

dnf module list php
dnf module reset php
dnf -y module install php:8.0
dnf -y install php php-fpm
systemctl enable --now php-fpm

Edit /etc/php-fpm.d/www.conf to set the user/group:

user = nginx
group = nginx
systemctl reload php-fpm

Place your info.php script in the Shibboleth-protected directory (e.g., /var/www/ticrypt/dart/secure/).

Build NGINX Shibboleth Module

Build on a separate host, then copy the module to the target:

nginx -V # Note the version (e.g., 1.14.1)

# Find the matching tag at https://hg.nginx.org/pkg-oss/tags
wget https://hg.nginx.org/pkg-oss/raw-file/1.14.1-2/build_module.sh
chmod a+x build_module.sh

mkdir /root/rpmbuild
./build_module.sh -v 1.14.1 https://github.com/nginx-shib/nginx-http-shibboleth.git

When the build pauses, edit the Makefile to match your nginx -V configure arguments:

vi /tmp/build_module.sh.*/pkg-oss/rpm/SPECS/Makefile
# Remove flags not in your nginx -V output, such as:
# --with-compat \
# --with-threads \

Continue the build, then copy the module:

cp /root/rpmbuild/BUILD/nginx-module-shibboleth-*/objs/ngx_http_shibboleth_module.so /usr/lib64/nginx/modules/

Repeat for the headers-more module:

./build_module.sh -v 1.14.1 https://github.com/openresty/headers-more-nginx-module.git
# Same Makefile edit as above
cp /root/rpmbuild/BUILD/nginx-module-headersmore-*/objs/ngx_http_headers_more_filter_module.so /usr/lib64/nginx/modules/

Configure NGINX

Load the modules:

echo 'load_module /usr/lib64/nginx/modules/ngx_http_shibboleth_module.so;' > /usr/share/nginx/modules/shibboleth.conf
echo 'load_module /usr/lib64/nginx/modules/ngx_http_headers_more_filter_module.so;' >> /usr/share/nginx/modules/shibboleth.conf

Add Shibboleth locations to /etc/nginx/default.d/shibboleth.conf:

# FastCGI authorizer for Auth Request module
location = /shibauthorizer {
internal;
include fastcgi_params;
fastcgi_pass unix:/run/supervisor/shibauthorizer.sock;
}

# FastCGI responder
location /Shibboleth.sso {
include fastcgi_params;
fastcgi_pass unix:/run/supervisor/shibresponder.sock;
}

# Shibboleth error page resources
location /shibboleth-sp {
alias /usr/share/shibboleth/;
}

Add the protected location to your site config (e.g., /etc/nginx/conf.d/ticrypt.conf):

server {
server_name your-mfa-host.example.edu;
# ...
location /dart/secure {
root /var/www/ticrypt;
shib_request /shibauthorizer;
shib_request_use_headers on;
more_clear_input_headers 'remote_user' 'email' 'staticemail'
'firstname' 'lastname' 'department' 'position';
}
}

Configure Shibboleth SP

Edit /etc/shibboleth/shibboleth2.xml:

<SPConfig ...>
...
<RequestMapper type="XML">
<RequestMap>
<Host name="your-mfa-host.example.edu"
authType="shibboleth"
requireSession="true"
redirectToSSL="443" />
</RequestMap>
</RequestMapper>
<ApplicationDefaults entityID="https://your-mfa-host.example.edu"
REMOTE_USER="emailAddress"
homeURL="https://your-mfa-host.example.edu/dart/secure/"
cipherSuites="DEFAULT:!EXP:!LOW:!aNULL:!eNULL:!DES:!IDEA:!SEED:!RC4:!3DES:!kRSA:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1">
<Sessions ...>
<SSO entityID="urn:mace:incommon:your-organization">
SAML2
</SSO>
...
<Handler type="Session" Location="/Session" showAttributeValues="true"/>
...
</Sessions>
...
<MetadataProvider type="XML" validate="true" path="idp-metadata.xml" />
...
</ApplicationDefaults>
</SPConfig>

Add attribute mappings in /etc/shibboleth/attribute-map.xml:

<Attributes ...>
...
<Attribute name="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" id="emailAddress"/>
<Attribute name="Department" id="Department"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="Position" id="Position"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="Email" id="Email"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="FirstName" id="FirstName"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="LastName" id="LastName"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="StaticEmail" id="StaticEmail"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
</Attributes>

Create IdP metadata files (e.g., /etc/shibboleth/idp-metadata.xml) with your identity provider's metadata.

Register the SP with Your IdP

Provide your IdP with the SP metadata:

<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://your-mfa-host.example.edu">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://your-mfa-host.example.edu/Shibboleth.sso/SAML2/POST"
index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>

Verify

shibd -t
systemctl restart shibd
systemctl restart supervisord
systemctl restart nginx