Security
Two-Factor Authentication
Structr supports two-factor authentication (2FA) using the TOTP (Time-Based One-Time Password) standard. When enabled, users must provide a code from an authenticator app in addition to their password. This adds a second layer of security that protects accounts even if passwords are compromised.
TOTP is compatible with common authenticator apps like Google Authenticator, Microsoft Authenticator, Authy, and others.
Prerequisites
Because TOTP relies on synchronized time, ensure that both the Structr server and users’ mobile devices are synced to an NTP server. Time drift of more than 30 seconds can cause authentication failures.
Configuration
Configure two-factor authentication in structr.conf or through the Configuration Interface.
Application Settings
| Setting | Default | Description |
|---|---|---|
security.twofactorauthentication.level |
1 | Enforcement level: 0 = disabled, 1 = optional (per-user), 2 = required for all users |
security.twofactorauthentication.issuer |
Structr | The issuer name displayed in authenticator apps |
security.twofactorauthentication.algorithm |
SHA1 | Hash algorithm: SHA1, SHA256, or SHA512 |
security.twofactorauthentication.digits |
6 | Code length: 6 or 8 digits |
security.twofactorauthentication.period |
30 | Code validity period in seconds |
security.twofactorauthentication.logintimeout |
300 | Time window in seconds to enter the code after password authentication |
security.twofactorauthentication.loginpage |
/twofactor | Application page for entering the two-factor code |
security.twofactorauthentication.devicetrust.enabled |
false | Enables or disables users to trust the browser they are logging in with |
security.twofactorauthentication.devicetrust.signingsecret |
Secret key that signs device trust tokens (auto-generated if not set manually) | |
security.twofactorauthentication.devicetrust.duration |
30 | Trust period in days for trusted browsers |
security.twofactorauthentication.devicetrust.cookiename |
dt_token | Name of the cookie that stores the device trust token |
Note: Changing
algorithm,digits, orperiodafter users have already enrolled invalidates their existing authenticator setup. SettwoFactorConfirmed = falseon affected users so they receive a new QR code on their next login.
Enforcement Levels
The level setting controls how two-factor authentication applies to users:
| Level | Behavior |
|---|---|
| 0 | Two-factor authentication is completely disabled |
| 1 | Optional - users can enable 2FA individually via the isTwoFactorUser property |
| 2 | Required - all users must use two-factor authentication |
User Properties
Four properties on the User type control two-factor authentication:
| Property | Type | Description |
|---|---|---|
isTwoFactorUser |
Boolean | Enables two-factor authentication for this user. Only effective when level is set to 1 (optional). |
twoFactorConfirmed |
Boolean | Indicates whether the user has completed two-factor setup. Automatically set to true after first successful 2FA login. Set to false to force re-enrollment. |
twoFactorSecret |
String | The secret key used to generate TOTP codes. Automatically generated when the user first enrolls. |
deviceTrustSecret |
String | The secret that is used to identify the user for a stored trust token. Can be used to revoke existing trust tokens for a user by calling user.rotateDeviceTrustSecret() |
Authentication Flow
The basic two-factor login process works as follows:
- User submits username and password to
/structr/rest/login - If credentials are valid and 2FA is enabled, Structr returns HTTP status 202 (Accepted)
- The response headers contain a temporary token and, for first-time setup, QR code data
- User scans the QR code with their authenticator app (first time only)
- User enters the 6-digit code from their authenticator app
- User submits the code with the temporary token to
/structr/rest/login - If the code is valid, Structr creates a session and returns HTTP status 200
Wrong Codes
A wrong code counts against the same budget a wrong password counts against,
security.passwordpolicy.maxfailedattempts. When that budget is used up the temporary token is
discarded, so the next attempt has to start again at step 1 with the password — and that step refuses
an account whose failed attempts are over the limit. Six digits with unlimited guesses would otherwise
make the second factor a delay rather than a factor.
Clear passwordAttempts on the user to lift a lockout.
The Secret is Re-Issued on Every Enrolment
Step 3 hands out the secret, and the only thing needed to get that far is the password. So every time
a QR code is issued for a user who has not confirmed yet, the secret behind it is generated anew and
the previous one stops working. Two people who both know the password can therefore never end up with
the same working secret: whoever asked last is the only one who can complete step 5.
For the user this means a QR code has to be scanned in the same login it was shown in. Starting the
login again shows a new QR code, and an authenticator entry from an earlier attempt no longer matches.
A confirmed user’s secret is never touched — it lives in their authenticator app.
Login Paths That Cannot Ask for a Code
An OAuth return, a registration confirmation link and a password reset link all identify a user
without ever asking for a code. Where the configuration requires a second factor, none of them creates
a session: the browser is redirected to security.twofactorauthentication.loginpage with a token
parameter, and the login is finished there exactly as in step 6 above.
These paths do not enrol. They carry no QR code — it would have to travel as a URL parameter, which
does not fit in a redirect — so a user who has not confirmed a second factor yet has to log in with
their password once, which is the flow that can enrol them.
Implementation
To implement two-factor authentication in your application, you need two pages: a login page and a two-factor code entry page.
Login Page
Create a login form that detects the two-factor response. When the server returns status 202, redirect to the two-factor page with the token, deviceTrustPossible and optional QR data as URL parameters.
JavaScript:
async function login(username, password) {
const response = await fetch('/structr/rest/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: username,
password: password
})
});
if (response.status === 202) {
// Two-factor authentication required
const token = response.headers.get('token');
const qrdata = response.headers.get('qrdata') || '';
const twoFactorPage = response.headers.get('twoFactorLoginPage');
const deviceTrustPossible = response.headers.get('deviceTrustPossible');
const deviceTrustDuration = response.headers.get('deviceTrustDuration');
window.location.href = `${twoFactorPage}?token=${token}&qrdata=${qrdata}&deviceTrustPossible=${deviceTrustPossible}&deviceTrustDuration=${deviceTrustDuration}`;
} else if (response.ok) {
// Login successful, no 2FA required
window.location.href = '/';
} else {
// Login failed
const error = await response.json();
console.error('Login failed:', error);
}
}
curl:
curl -si http://localhost:8082/structr/rest/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"name": "user", "password": "password"}'
When two-factor authentication is required, the response looks like:
HTTP/1.1 202 Accepted
token: eyJhbGciOiJIUzI1NiJ9...
twoFactorLoginPage: /twofactor
deviceTrustPossible: true
deviceTrustDuration: 30
qrdata: iVBORw0KGgoAAAANSUhEUgAA...
The response headers contain:
| Header | Description |
|---|---|
token |
Temporary token for the two-factor login (valid for the configured timeout period) |
twoFactorLoginPage |
The configured page for entering the two-factor code |
deviceTrustPossible |
If device trust is possible according to the configuration |
deviceTrustDuration |
Trust duration in days |
qrdata |
Base64-encoded PNG image of the QR code (only present if twoFactorConfirmed is false for the user) |
Two-Factor Page
Create a page that displays the QR code for first-time setup and accepts the TOTP code.
Example HTML Structure
<!DOCTYPE html>
<html>
<head>
<title>Two-Factor Authentication</title>
</head>
<body>
<h1>Two-Factor Authentication</h1>
<div id="setup-instructions" style="display: none;">
<p>Scan this QR code with your authenticator app:</p>
<img id="qrcode" alt="QR Code" />
<p>Then enter the 6-digit code shown in your app.</p>
</div>
<form id="twoFactorForm">
<label for="code">Authentication Code:</label>
<input type="text" id="code" name="code"
pattern="[0-9]{6,8}" maxlength="8"
autocomplete="one-time-code" required />
<label id="trust-device-wrapper" class="flex items-center" style="display: none;">
<input type="checkbox" id="trust-device" name="trustDevice" />
<span>Trust device</span>
</label>
<button type="submit">Verify</button>
</form>
<p id="error" style="color: red;"></p>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
const qrdata = params.get('qrdata');
const deviceTrustPossible = params.get('deviceTrustPossible') === 'true';
const deviceTrustDuration = params.get('deviceTrustDuration');
// Display QR code for first-time setup
if (qrdata) {
const qrImage = document.getElementById('qrcode');
// Convert URL-safe base64 back to standard base64
const standardBase64 = qrdata.replaceAll('_', '/').replaceAll('-', '+');
qrImage.src = 'data:image/png;base64,' + standardBase64;
qrImage.style.display = 'block';
document.getElementById('setup-instructions').style.display = 'block';
}
if (deviceTrustPossible) {
document.querySelector('#trust-device-wrapper').style.display = null;
document.querySelector('#trust-device-wrapper span').textContent += ' for ' + deviceTrustDuration + ' days';
}
// Handle form submission
document.getElementById('twoFactorForm').addEventListener('submit', async (event) => {
event.preventDefault();
const code = document.getElementById('code').value;
const trustChecked = document.getElementById('trust-device').checked;
const response = await fetch('/structr/rest/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
twoFactorToken: token,
twoFactorCode: code,
trustDevice: trustChecked
})
});
if (response.ok) {
window.location.href = '/';
} else {
document.getElementById('error').textContent = 'Invalid code. Please try again.';
}
});
});
</script>
</body>
</html>
curl:
curl -si http://localhost:8082/structr/rest/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"twoFactorToken": "eyJhbGciOiJIUzI1NiJ9...", "twoFactorCode": "123456", "trustDevice": true}'
Managing User Enrollment
Enabling 2FA for a User
When the enforcement level is set to 1 (optional), enable two-factor authentication for individual users by setting isTwoFactorUser to true.
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"isTwoFactorUser": true}'
JavaScript:
await fetch('/structr/rest/User/<UUID>', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
isTwoFactorUser: true
})
});
The user will see the QR code on their next login.
Re-Enrolling a User
To force a user to set up two-factor authentication again (for example, if they lost their phone), set twoFactorConfirmed to false:
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"twoFactorConfirmed": false}'
The user will receive a new QR code on their next login. Their authenticator app will need to be updated with the new secret.
Disabling 2FA for a User
To disable two-factor authentication for a user (when level is 1):
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"isTwoFactorUser": false}'
IP Whitelisting (removed)
Earlier versions could skip the second factor for addresses listed in security.twofactorauthentication.whitelistedIPs. That setting no longer exists. The address it matched was read from the X-Forwarded-For header, which a client sets itself, so anyone who knew a listed address and a password could send that header and log in without a code. Structr logs a warning at startup if the key is still present in structr.conf, and requests from those addresses are asked for a code like any other.
Use Trusted Devices below to spare a known browser the code, or a reverse proxy in front of Structr if access really has to be decided by network address.
Trusted Devices
Device trust functionality can be enabled via the configuration setting security.twofactorauthentication.devicetrust.enabled and configured per-user via the attribute deviceTrustPossible. The login form above auto-adapts and shows a “Trust Device” checkbox stating the configured trust duration.
If the user logs in via 2FA successfully and requests device trust, a trust cookie (security.twofactorauthentication.devicetrust.cookiename) is set for the user’s browser. This browser is then fingerprinted and trusted for the configured number of days (security.twofactorauthentication.devicetrust.duration) and the login requests for that user from that browser proceed with password-authentication only.
The browser fingerprint includes browser name, browser major version, operating system name, operating system major version, and device class. If any of these fields change, the trust cookie becomes invalid.
Disabling device trust (via security.twofactorauthentication.devicetrust.enabled or the per-user attribute) does not invalidate already-issued device trust cookies. It suspends the device trust feature and requires 2FA login even if the user has a valid device trust cookie. If device trust is enabled again, previously issued trust cookies are used again.
Device trust tokens are signed with a global signing secret (security.twofactorauthentication.devicetrust.signingsecret) which is automatically created if none is set. Changing this secret revokes and invalidates all trust cookies for all users.
A user’s trust cookies can be revoked by calling user.rotateDeviceTrustSecret(), which generates a new secret and invalidates all previously issued cookies for that user only.
Troubleshooting
Invalid Code Errors
If users consistently receive “invalid code” errors:
- Check time synchronization - The most common cause is time drift between the server and the user’s device. Ensure both are synced to NTP.
- Verify the period setting - If you changed
security.twofactorauthentication.period, users need to re-enroll. - Check the algorithm - Some older authenticator apps only support SHA1.
Lost Authenticator Access
If a user loses access to their authenticator app:
- An administrator sets
twoFactorConfirmed = falseon the user - The user logs in with username and password
- The user scans the new QR code with their authenticator app
- The user completes the login with the new code
QR Code Not Displaying
If the QR code does not display:
- Check that
qrdatais present in the response headers - Verify the base64 conversion (URL-safe to standard)
- Ensure the
twoFactorConfirmedproperty is false
Superuser Login of the Configuration Interface
The superuser of the configuration interface at /structr/config can be asked for a second factor as well. It uses the algorithm, digits and period of the settings above; the enforcement level does not apply to it.
Setting It Up
Switch on superuser.twofactor.enabled, in the Superuser section of the configuration interface or in structr.conf. The next login with the right name and password then shows a QR code instead of logging in: scan it with an authenticator app, or enter the secret by hand, and confirm with the code the app shows. Only then is the secret saved, as superuser.twofactor.secret in structr.conf, and the session logged in. From then on, the login asks for a code.
Whoever logs in first after the switch is turned on sets up the authenticator, so turn it on when you are about to log in yourself. Every setup is logged as a warning and in the runtime event log, with the address it came from.
If the authenticator is lost, clear superuser.twofactor.secret in structr.conf and restart Structr: the next login sets up a new one. To turn the second factor off, switch off superuser.twofactor.enabled.
Logging In
With a second factor set up, the login form has a code field, and name, password and code are sent together. Structr accepts the current code and the one before it, so a code typed just as the app shows the next one still works. Each code is accepted once, wherever it is used: in the login form or in the X-TwoFactor-Code header.
Tools that log in by posting the form themselves send the code in the field superuserTwoFactorCode, along with action=login, superuserName and superuserPassword. The redirect after the login tells them the outcome:
| Redirect | Meaning |
|---|---|
?twoFactorRequired |
A second factor is set up and no code was sent. The password was not checked; send all three fields again |
?twoFactorSetup |
The second factor is switched on but not set up yet. The page shows the QR code; post action=twoFactorSetup with superuserTwoFactorCode in the same session to confirm it |
?twoFactorReused |
Name, password and code were right, but the code was already used. Enter the next code; this does not count as a failed attempt |
?loginFailed |
The name, the password or the code was wrong. On purpose, the answer does not say which |
?loginBlocked |
Too many failed attempts, try again later |
| none of them | Logged in |
Superuser Access over HTTP
The superuser credentials also work outside the configuration interface, with the X-User and X-Password headers and with HTTP Basic authentication. That is meant for the exception, a request sent by hand, not for scripts; scripts use an admin user.
While superuser.twofactor.enabled is on, a superuser request needs the current code in the X-TwoFactor-Code header as well. Each code is accepted once, so it is one request per code. A code that was already used is refused, but with the right password it does not count as a failed attempt. HTTP Basic authentication cannot carry a code and is refused for the superuser, and so is every request while the second factor is switched on but not set up yet. Every superuser request with a code is logged as a warning and in the runtime event log.
Failed Attempts
While the superuser has no active second factor, the password is all there is. Then 3 failed attempts within 15 minutes, counted across all addresses and all of these ways in together, block the superuser login everywhere for 15 minutes, including for the right password. With a second factor set up, wrong codes over HTTP block superuser access over HTTP the same way (a correct code that was already used does not count), and the login form locks out a single address for 30 seconds after 5 failed attempts.
| Setting | Default | Description |
|---|---|---|
configservlet.login.maxattempts |
3 | Failed attempts that block the superuser login |
configservlet.login.lockout.seconds |
900 | How long the block lasts, and the window in which failures are counted. 0 turns it off |
There is no exception for local addresses: behind a reverse proxy on the same host, every client would appear local. If an attacker keeps the login blocked, set configservlet.login.lockout.seconds to 0 in structr.conf, which leaves only the per-address limit.
Related Topics
- User Management - User properties and account security
- JWT Authentication - Token-based authentication
- OAuth - Authentication with external providers