Structr
Security
Overview
Structr provides a security system that controls who can access your application and what they can do. This chapter covers authentication (verifying identity), authorization (granting permissions), and the tools to manage both.
Core Concepts
Structr’s security model is built on four pillars:
| Concept | Question it answers | Key mechanism |
|---|---|---|
| Users & Groups | Who are the actors? | User accounts, group membership, inheritance |
| Authentication | How do we verify identity? | Sessions, JWT, OAuth, two-factor |
| Permissions | What can each actor do? | Ownership, grants, visibility flags |
| Access Control | Which endpoints are accessible? | Resource Access Permissions |
These concepts work together: a request arrives, Structr authenticates the user (or treats them as anonymous), then checks permissions for the requested operation.
Authentication
When a request reaches Structr, the authentication system determines the user context. Structr first checks whether the request belongs to an OAuth login flow. It then looks for a session cookie, unless the request carries an Authorization header, in which case it skips the session check and validates the JWT in that header instead. Finally it evaluates the X-StructrSessionToken header and the X-User and X-Password headers. If none of these yield a user, Structr treats the request as anonymous.
Structr supports multiple authentication methods that you can combine based on your needs:
| Scenario | Recommended method |
|---|---|
| Web application with login form | Sessions |
| Single-page application (SPA) | JWT |
| Mobile app | JWT |
| Login via external provider (Google, Azure, GitHub) | OAuth |
| Your server calling Structr API | JWT or authentication headers |
| External system with its own identity provider | JWKS validation |
| High-security requirements | Any method combined with two-factor authentication |
The distinction between the last two server scenarios: when your own backend calls Structr, you control the credentials and can use JWT tokens that Structr issues or simple authentication headers. When an external system (like an Azure service principal) calls Structr with tokens from its own identity provider, Structr validates those tokens against the provider’s JWKS endpoint.
Permission Resolution
Once Structr knows who is making the request, it evaluates permissions for every operation the user attempts. Structr checks permissions in a specific order and stops at the first match:
- Admin users bypass all permission checks
- Schema permissions check type-level grants for the user or their groups
- Ownership grants full access to the owner of the object
- Direct grants check SECURITY relationships from the user to the object
- The same checks are repeated for every group the user belongs to, including nested groups
- Graph resolution follows permission propagation paths through relationships
Visibility flags are evaluated separately: visibleToPublicUsers and visibleToAuthenticatedUsers grant read access before this chain is consulted. For details on each level, see the User Management article.
Getting Started
Basic Web Application
A basic security setup for a typical web application involves creating users and groups in the Security area of the Admin UI, creating a Resource Access Permission with signature _login that allows POST for public users, implementing a login form that posts to /structr/rest/login, and configuring permissions on your data types.
Adding OAuth
To add OAuth login, register your application with the OAuth provider, configure the provider settings in structr.conf, add login links pointing to /oauth/<provider>/login, and optionally implement onOAuthLogin to customize user creation.
Adding Two-Factor Authentication
To add two-factor authentication, set security.twofactorauthentication.level to 1 (optional) or 2 (required), create a two-factor code entry page, and update your login flow to handle the 202 response.
Securing an API
To secure a REST API for external consumers, create Resource Access Permissions for each endpoint you want to expose, configure JWT settings in structr.conf, implement token request and refresh logic in your API clients, and optionally configure CORS if clients run in browsers.
Related Topics
- REST Interface / Authentication - Resource Access Permissions and CORS configuration
- SSL Configuration - Installing SSL certificates for HTTPS
- Configuration Interface - Security-related settings in structr.conf
- Admin UI / Security - Managing users and groups through the graphical interface
User Management
Structr provides a multi-layered security system that combines user and group management with flexible permission resolution. This chapter covers how to manage users and groups, how authentication works, and how permissions are resolved.
Users
The User type is a built-in type in Structr that represents user accounts in your application. Users can authenticate, own objects, receive permissions, and belong to groups. Every request to Structr is evaluated in the context of a user - either an authenticated user or an anonymous user.
You can use the User type directly, extend it with additional properties, or create subtypes for specialized user categories in your application.
Creating Users
You can create users through the Admin UI or programmatically.
Via Admin UI:
- Navigate to the Security area
- Select the type (User or one of its subtypes) in the dropdown next to the “Create” button and click “Create”
- Structr creates a new user with a random default name
- Rename the user and configure properties through the Edit dialog, which has the tabs General, Advanced, Custom Properties (when the type defines custom properties) and Security
Via REST API (curl):
curl -X POST http://localhost:8082/structr/rest/User \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{
"name": "john.doe",
"eMail": "john.doe@example.com",
"password": "securePassword123"
}'
Via REST API (JavaScript):
const response = await fetch('/structr/rest/User', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'john.doe',
eMail: 'john.doe@example.com',
password: 'securePassword123'
})
});
const result = await response.json();
console.log('Created user:', result.result.id);
User Properties
The following properties are available on user objects:
| Property | Type | Description |
|---|---|---|
name | String | Username for authentication |
eMail | String | Email address, often used as an alternative login identifier |
password | String | User password (stored as a secure hash, never in cleartext) |
isAdmin | Boolean | Administrator flag that grants full system access, bypassing all permission checks |
blocked | Boolean | When true, completely disables the account and prevents any action |
passwordAttempts | Integer | Counter for failed login attempts; triggers account lockout when threshold is exceeded |
locale | String | Preferred locale for localization (e.g., de_DE, en_US). Structr uses this value in the $.locale() function and for locale-aware formatting. |
publicKey | String | SSH public key for filesystem access via the SSH service |
skipSecurityRelationships | Boolean | Disables automatic creation of OWNS and SECURITY relationships when this user creates objects. Useful for admin users creating many objects where individual ownership tracking is not needed. |
confirmationKey | String | Temporary authentication key used during self-registration. Replaces the password until the user confirms their account via the confirmation link. |
twoFactorSecret | String | Secret key for TOTP two-factor authentication (see Two-Factor Authentication chapter) |
twoFactorConfirmed | Boolean | Indicates whether the user has completed two-factor setup |
isTwoFactorUser | Boolean | Enables two-factor authentication for this user (when 2FA level is set to optional) |
Setting Passwords
Structr never stores cleartext passwords - only secure hash values. To set or change a password:
Via Admin UI:
- Open the user’s Edit dialog
- Go to the General tab
- Enter the new password in the password field and click “Set Password”
Via REST API (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 '{"password": "newSecurePassword456"}'
Via REST API (JavaScript):
await fetch('/structr/rest/User/<UUID>', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: 'newSecurePassword456'
})
});
You cannot display or recover existing passwords. If a user forgets their password, use the password reset flow or set a new password directly.
Extending the User Type
You can customize the User type to fit your application’s needs.
Adding Properties
To add properties to the User type, open the Schema area, locate the User type, and add new properties. For example, you might add a phoneNumber property or a department property. These properties then become available on all user objects.
Creating Subtypes
For more complex scenarios, you can create subtypes of User. This is useful when your application has different kinds of users with different properties or behaviors - for example, an Employee type and a Customer type, both inheriting from User.
To create a subtype, create a new type in the Schema and select User as its base class. The subtype inherits all User functionality (authentication, permissions, group membership) and can add its own properties and methods.
Groups
The Group type organizes users and simplifies permission management. Instead of granting permissions to individual users, you grant them to groups and add users to those groups. When a user belongs to a group, they inherit all permissions granted to that group.
Groups also serve as the integration point for external directory services like LDAP. When you connect Structr to an LDAP server, directory groups can map to Structr groups, enabling centralized user management.
Creating Groups
Via Admin UI:
- Navigate to the Security area
- Select the type (Group or one of its subtypes) in the dropdown next to the “Create” button and click “Create”
- Rename the group as appropriate
Via REST API (curl):
curl -X POST http://localhost:8082/structr/rest/Group \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"name": "Editors"}'
Via REST API (JavaScript):
await fetch('/structr/rest/Group', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Editors'
})
});
Managing Membership
In the Admin UI, drag and drop users into groups in the Security area. Groups can contain both users and other groups, allowing hierarchical structures.
Via REST API (curl):
# Add user to group
curl -X PUT http://localhost:8082/structr/rest/User/<USER_UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"groups": ["<GROUP_UUID>"]}'
Via REST API (JavaScript):
await fetch('/structr/rest/User/<USER_UUID>', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
groups: ['<GROUP_UUID>']
})
});
Group Inheritance
All members inherit access rights granted to a group. This includes direct group members, users in nested subgroups, and permissions that flow down the group hierarchy.
Schema-Based Permissions
In addition to object-level permissions, Structr supports schema-based permissions that apply to all instances of a type. This feature allows you to grant a group access to all objects of a specific type without creating individual permission grants.
To configure schema-based permissions:
- Open the Schema area
- Select the type you want to configure
- In the General tab, find the schema grants table, which lists the existing groups
- Check read, write, delete, or accessControl for each group that should have the permission on all instances of this type
Schema-based permissions are evaluated efficiently and improve performance compared to individual object permissions, especially when you have many objects of the same type.
User Categories
Structr distinguishes several categories of users based on their authentication status and privileges.
Anonymous Users
Requests without authentication credentials are anonymous requests. The corresponding user is called the anonymous user or public user. Anonymous users are at the lowest level of the access control hierarchy and can only access objects explicitly marked as public.
Authenticated Users
A request that includes valid credentials is an authenticated request. Authenticated users are at a higher level in the access control hierarchy and can access objects based on their permissions, group memberships, and ownership.
Admin Users
Admin users have the isAdmin flag set to true. They can create, read, modify, and delete all nodes and relationships in the database. They can access all endpoints, modify the schema, and execute maintenance tasks. Admin users bypass all permission checks.
Note: The
isAdminflag is required for users to log into the Structr Admin UI.
Superuser
The superuser is a special account defined in structr.conf with the superuser.username and superuser.password setting. This account exists separately from regular admin users and serves specific purposes:
- Logging into the Configuration Interface
- Performing system-level operations that require elevated privileges beyond normal admin access
The superuser account is not stored in the database. It exists only through the configuration file setting.
Note: The default value for
superuser.usernameissuperadminand can be changed at any time to suit hardening needs. When set to empty string, superuser access is prevented completely. The same applies whilesuperuser.passwordis empty: without a configured password, nobody can log in as the superuser.Important: During authentication, Structr first compares the login name and the password with the configured superuser credentials. Only when both match does it authenticate the request as the superuser. In every other case the normal user lookup continues, so a regular user account that has the same name as the superuser can still log in with its own password.
Authentication Methods
Authentication determines who is making a request. Structr supports multiple authentication methods that you can use depending on your application’s needs.
| Method | Use Case |
|---|---|
| HTTP Headers | Simple API access, scripting |
| Session Cookies | Web applications with login forms |
| JSON Web Tokens | Stateless APIs, single-page applications |
| OAuth | Login via external providers (Google, GitHub, etc.) |
For details on JWT authentication, including token creation, refresh tokens, and external JWKS providers, see the JWT Authentication chapter.
For details on OAuth authentication with providers like Google, GitHub, or Auth0, see the OAuth chapter.
Authentication Headers
You can provide username and password via the HTTP headers X-User and X-Password. When you secure the connection with TLS, the headers are encrypted and your credentials are protected.
Note: Do not use authentication headers over unencrypted connections (http://…) except for localhost. Always use HTTPS for remote servers.
curl:
curl -s http://localhost:8082/structr/rest/Project \
-H "X-User: admin" \
-H "X-Password: admin"
JavaScript:
const response = await fetch('/structr/rest/Project', {
headers: {
'X-User': 'admin',
'X-Password': 'admin'
}
});
const data = await response.json();
console.log(data.result);
Response:
{
"result": [
{
"id": "362cc05768044c7db886f0bec0061a0a",
"type": "Project",
"name": "Project #1"
}
],
"query_time": "0.000035672",
"result_count": 1,
"page_count": 1,
"result_count_time": "0.000114435",
"serialization_time": "0.001253579"
}
You must send the authentication headers with every request. For applications where this is impractical, use session-based authentication.
Sessions
Session-based authentication lets you log in once and use a session cookie for subsequent requests. The server maintains session state and the cookie authenticates each request.
Prerequisites
Create a Resource Access Permission with the signature _login that allows POST for non-authenticated users. For details on Resource Access Permissions, see the REST Interface chapter.
Login
curl:
curl -si http://localhost:8082/structr/rest/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"name": "user", "password": "password"}'
JavaScript:
const response = await fetch('/structr/rest/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // Important: include cookies
body: JSON.stringify({
name: 'user',
password: 'password'
})
});
if (response.ok) {
const data = await response.json();
console.log('Logged in as:', data.result.name);
}
Response:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=f49d1dbb60be23612b0820453d996e41...;Path=/
Content-Type: application/json;charset=utf-8
{
"result": {
"id": "0490bebcbc2f4018857a492c532334c2",
"type": "User",
"isUser": true,
"name": "user"
}
}
The Set-Cookie header contains the session ID. Most HTTP clients handle session cookies automatically.
Logout
To end a session, send a POST request to the logout endpoint. Create a Resource Access Permission with the signature _logout that allows POST for authenticated users.
curl:
curl -si http://localhost:8082/structr/rest/logout \
-X POST \
-b "JSESSIONID=your-session-id"
JavaScript:
await fetch('/structr/rest/logout', {
method: 'POST',
credentials: 'include'
});
Permission System
Structr’s permission system operates on multiple levels, checked in the following order until one of them grants the permission:
- Administrator Check - Users with
isAdmin=truebypass all other checks - Schema-Based Permissions - Type-level permissions granted to the user or one of their groups
- Ownership - The owner has all permissions on the object
- Permission Grants - A SECURITY relationship from the user to the object
- Group Membership - The same checks, repeated for every group the user belongs to, including nested groups
- Graph-Based Resolution - Permission propagation through relationships
Visibility flags are not part of this chain. Structr evaluates visibleToPublicUsers and visibleToAuthenticatedUsers separately in the security context when it decides whether a node is readable, before it consults the checks above.
Permission Types
Four basic permissions control access to objects:
| Permission | Description |
|---|---|
| Read | View object properties and relationships |
| Write | Modify object properties |
| Delete | Remove objects from the database |
| AccessControl | Modify security settings and permissions on the object |
Visibility Flags
Every object has two visibility flags:
| Flag | Description |
|---|---|
visibleToPublicUsers | Grants read access to anonymous users |
visibleToAuthenticatedUsers | Grants read access to logged-in users |
These flags provide simple access control without explicit permission grants. Visibility grants read permission - the object appears in results and you can read its properties.
Note that these flags are independent: visibleToPublicUsers does not imply visibility for authenticated users, and visibleToAuthenticatedUsers does not imply visibility for anonymous users.
Ownership
When a non-admin user creates an object, Structr automatically grants full permissions (Read, Write, Delete, AccessControl) through an OWNS relationship.
When an anonymous user creates an object (if a Resource Access Permission allows the request), the object becomes ownerless. Such an object is accessible to non-admin users only through its visibility flags, schema-based permissions, or grants that you add afterwards.
Note: An object must first be visible to a user before they can modify it.
For admin users, you can disable automatic ownership creation by setting skipSecurityRelationships = true. This improves performance when creating many objects that do not need individual ownership tracking.
To prevent ownership for non-admin users, add an onCreate lifecycle method:
{
$.set($.this, 'owner', null);
}
Permission Grants
You can grant specific permissions to users or groups on individual objects through SECURITY relationships.
Granting permissions:
$.grant(user, node, 'read, write');
Revoking permissions:
$.revoke(user, node, 'write');
Structr creates SECURITY relationships automatically when users create objects. To skip this for admin users, set skipSecurityRelationships = true. For non-admin users, use a lifecycle method:
{
$.revoke($.me, $.this, 'read, write, delete, accessControl');
}
Note: If you skip both OWNS and SECURITY relationships, the creating user may lose access to the object. Use
grant()orcopy_permissions()to assign appropriate access.
The grantees Property
Every node exposes a read-only grantees property that returns the principals (users and groups) connected to it via an incoming SECURITY relationship. It is the lightweight counterpart to owner: where owner gives you the single principal at the end of the OWNS relationship, grantees gives you every principal at the end of a SECURITY relationship.
const principals = $.this.grantees;
for (const p of principals) {
$.log(p.name + ' (' + p.type + ')');
}
The property returns only the principals, not the permissions attached to each SECURITY edge. Use it when you need a quick list of “who has any direct grant on this node” - for example, to populate a dropdown of existing grantees in a permission-editing UI, or to decide whether a node has any access control at all. When you also need the permissions and the full provenance, use getDirectAccessEntries() or getEffectiveAccessEntries() (see next subsection).
To modify who has access, use grant() and revoke(). Writing to grantees directly is rejected with a read-only property error, because a bulk write would replace the whole set of SECURITY relationships and discard the permission flags in the process.
Because grantees is defined on NodeInterface, the base trait of every node, it is available on all types without any configuration. It is not included in the default public or ui views, so you will not see it in REST output unless you add it to a custom view or request it explicitly.
Inspecting Permissions
To answer the question “who has access to this object, and how?”, Structr exposes two read-only scripting functions that project the current permission state into a list of entries. Unlike isAllowed(), which checks whether a specific principal has a specific permission, these functions enumerate all principals and the permissions each one has.
$.getDirectAccessEntries(node) returns the entries that come from direct sources: the owner (if any) and principals connected to the node via an incoming SECURITY relationship. $.getEffectiveAccessEntries(node) extends that result with entries derived from transitive group membership (a user appearing because a group they belong to has a direct grant) and from schema-based permissions on the node’s type.
Each entry is an object with the following fields:
| Field | Description |
|---|---|
grantee | UUID of the principal |
granteeName | Display name of the principal |
granteeType | Type of the principal, typically User or Group |
permissions | Array of permission names: any combination of read, write, delete, accessControl |
via | Provenance string describing which paths contributed to this entry |
The via field is a +-joined composite of path tokens. A single direct grant produces direct; the owner produces owner; a schema grant produces schema; and a permission inherited through a group produces group:<uuid>:<name>, naming the group that has the direct SECURITY edge (not intermediate groups on the membership chain). When a principal receives permissions through more than one path, the tokens are concatenated in the order owner, direct, schema, group:…. For example, a user who is both the owner and a member of a group called Editors that was granted write access produces a single entry with via equal to owner+group:abc123:Editors and the union of all permissions.
Example:
const entries = $.getEffectiveAccessEntries($.this);
for (const entry of entries) {
$.log(entry.granteeName + ' (' + entry.granteeType + '): '
+ entry.permissions.join(', ') + ' via ' + entry.via);
}
The result is always sorted by granteeName for stable, diff-friendly output. Permission propagation along domain relationships (see Graph-Based Permission Resolution below) is intentionally not included in either function, because propagation is unbounded in the general case. Use isAllowed() when you need a propagation-aware decision for a specific principal.
These functions are particularly useful for audit UIs, permission-inspection dialogs in custom admin pages, and migration scripts that need to reason about existing access before modifying it.
Graph-Based Permission Resolution
For complex scenarios, Structr can propagate permissions through relationships. This enables domain-specific security models where access to one object grants access to related objects.
How It Works
When you configure a relationship for permission propagation, Structr follows that relationship when resolving access. For example, if a user has READ permission on a ProductGroup, and you configure the relationship from ProductGroup to Product to propagate READ, the user automatically gets READ access to all Products in that group.
Relationships configured for permission propagation are called active relationships and appear in orange in the schema editor.
Propagation Direction
| Direction | Effect |
|---|---|
| None | Permission resolution not active |
| Source to Target | Permissions propagate in the direction of the relationship |
| Target to Source | Permissions propagate against the direction of the relationship |
| Both | Permissions propagate in both directions |
Permission Actions
For each permission type (read, write, delete, accessControl), you can configure what happens when traversing the relationship:
| Action | Effect |
|---|---|
| Add | Grants this permission to users traversing the relationship |
| Keep | Maintains this permission if the user already has it |
| Remove | Revokes this permission when traversing |
Hidden Properties
When users gain access through permission propagation, you can hide sensitive properties from them. Configure hidden properties on the relationship, and Structr excludes those properties from JSON output for users accessing objects via that path.
Resolution Process
When a non-admin user accesses an object:
- Structr checks for direct permissions
- If none exist, Structr searches for connected paths through active relationships
- Structr traverses relationships applying ADD, KEEP, or REMOVE rules
- If a valid path with sufficient permissions is found, access is granted
- If no path exists, access is denied
Permission resolution only follows active relationships. If your schema has a chain like ProductGroup → SubGroup → Product, but only ProductGroup → SubGroup is active, users with access to ProductGroup do not automatically access Products in SubGroups.
Account Security
Password Policy
Configure password requirements in structr.conf:
# Maximum failed login attempts before lockout
security.passwordpolicy.maxfailedattempts = 4
# Complexity requirements (only checked while enforce is true)
security.passwordpolicy.complexity.enforce = true
security.passwordpolicy.complexity.minlength = 8
security.passwordpolicy.complexity.requiredigits = true
security.passwordpolicy.complexity.requirelowercase = true
security.passwordpolicy.complexity.requireuppercase = true
security.passwordpolicy.complexity.requirenonalphanumeric = true
# Clear all sessions when password changes
security.passwordpolicy.onchange.clearsessions = true
Complexity enforcement is off by default. When you enable it, Structr rejects passwords shorter than security.passwordpolicy.complexity.minlength (default 8) and passwords that lack a character from a required category. Without enforcement, no length or character rule applies.
The following settings complete the account security configuration:
| Setting | Default | Description |
|---|---|---|
security.passwordpolicy.forcechange | false | Forces users to change their password after maxage days |
security.passwordpolicy.maxage | 90 | Number of days after which a user has to change the password |
security.passwordpolicy.remindtime | 14 | Number of days before the forced change in which the application should warn the user; the warning has to be implemented in application code |
security.passwordpolicy.resetFailedAttemptsOnPasswordReset | true | Resets the failed login counter when the password is reset |
security.passwordhash.memory | 65536 | Memory in KB that Argon2id uses for each password verification |
security.passwordhash.iterations | 3 | Number of Argon2id passes |
security.passwordhash.parallelism | 1 | Number of parallel threads for Argon2id |
security.passwordhash.hashlength | 32 | Length of the Argon2id hash in bytes |
security.passwordhash.saltlength | 16 | Length of the salt in bytes |
security.authentication.propertykeys | empty | Space-separated list of additional property keys in the form <Type>.<key> that Structr accepts as login name besides name and eMail. Only string properties are used; a key of another type is ignored with a warning in the log. All keys are checked in a single lookup, so a wrong password counts as one failed attempt however many keys are configured. |
registration.allowloginbeforeconfirmation | false | Allows self-registered users to log in before they click the confirmation link |
Account Lockout
When a user exceeds the maximum failed login attempts configured in security.passwordpolicy.maxfailedattempts, Structr locks the account. The passwordAttempts property tracks failures.
To unlock an account, reset passwordAttempts to 0:
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 '{"passwordAttempts": 0}'
JavaScript:
await fetch('/structr/rest/User/<UUID>', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
passwordAttempts: 0
})
});
Blocking Users
To manually disable a user account, set blocked to true. A blocked user cannot perform any action in Structr, regardless of their permissions or admin status. This is useful for temporarily suspending accounts without deleting them.
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 '{"blocked": true}'
To unblock a user, set blocked to false.
Two-Factor Authentication
Structr supports TOTP-based two-factor authentication. For configuration and implementation details, see the Two-Factor Authentication chapter.
User Self-Registration
You can allow users to sign up themselves instead of creating accounts manually. The registration process uses double opt-in: users enter their email address, receive a confirmation email, and click a link to complete registration.
Prerequisites
- Configure SMTP settings so Structr can send emails (see Email)
- Create a Resource Access Permission with signature
_registrationallowing POST for public users - Enable
jsonrestservlet.user.autocreateinstructr.conf
How It Works
- User submits their email to the registration endpoint
- Structr creates a user with a
confirmationKeyinstead of a password - Structr sends a confirmation email with a unique link
- User clicks the link, which validates the
confirmationKey - Structr confirms the account and redirects to the target page
- User can now set their password and log in normally
Mail Templates
Structr uses the following mail templates for registration emails. Create these as MailTemplate objects to overwrite the defaults:
| Template Name | Purpose | Default Value |
|---|---|---|
CONFIRM_REGISTRATION_SENDER_ADDRESS | Sender email address | smtp.user from structr.conf (if it contains a valid email address); otherwise structr-mail-daemon@localhost |
CONFIRM_REGISTRATION_SENDER_NAME | Sender name | Structr Mail Daemon |
CONFIRM_REGISTRATION_SUBJECT | Email subject | Welcome to Structr, please finalize registration |
CONFIRM_REGISTRATION_TEXT_BODY | Plain text body | Go to ${link} to finalize registration. |
CONFIRM_REGISTRATION_HTML_BODY | HTML body | <div>Click <a href='${link}'>here</a> to finalize registration.</div> |
CONFIRM_REGISTRATION_BASE_URL | Base URL for the link | ${base_url} |
CONFIRM_REGISTRATION_TARGET_PAGE | Redirect page after confirmation | register_thanks |
CONFIRM_REGISTRATION_ERROR_PAGE | Redirect page on error | register_error |
The ${link} variable in the body templates contains the confirmation URL.
Note: You can use scripting in the TEXT_BODY and HTML_BODY templates. The script runs in the context of the user (the
mekeyword refers to the user being registered).
Registration Endpoint
curl:
curl -X POST http://localhost:8082/structr/rest/registration \
-H "Content-Type: application/json" \
-d '{"eMail": "user.name@example.com"}'
JavaScript:
await fetch('/structr/rest/registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
eMail: 'user.name@example.com'
})
});
The accepted attributes are configured in registration.customuserattributes. The eMail attribute is always supported.
Password Reset
To allow users to regain access when they forget their password, Structr provides a password reset flow.
Prerequisites
- Configure SMTP settings so Structr can send emails (see Email)
- Create a Resource Access Permission with signature
_resetPasswordallowing POST for public users - Enable
jsonrestservlet.user.autologininstructr.confto allow auto-login via the reset link
Mail Templates
Structr uses the following mail templates for password reset emails. Create these as MailTemplate objects to overwrite the defaults:
| Template Name | Purpose | Default Value |
|---|---|---|
RESET_PASSWORD_SENDER_ADDRESS | Sender email address | smtp.user from structr.conf (if it contains a valid email address); otherwise structr-mail-daemon@localhost |
RESET_PASSWORD_SENDER_NAME | Sender name | Structr Mail Daemon |
RESET_PASSWORD_SUBJECT | Email subject | Request to reset your Structr password |
RESET_PASSWORD_TEXT_BODY | Plain text body | Go to ${link} to reset your password. |
RESET_PASSWORD_HTML_BODY | HTML body | <div>Click <a href='${link}'>here</a> to reset your password.</div> |
RESET_PASSWORD_BASE_URL | Base URL for the link | ${base_url} |
RESET_PASSWORD_TARGET_PAGE | Redirect page for password entry | /reset-password |
The ${link} variable contains the password reset URL. This link is valid only once.
Password Reset Endpoint
curl:
curl -X POST http://localhost:8082/structr/rest/reset-password \
-H "Content-Type: application/json" \
-d '{"eMail": "user.name@example.com"}'
JavaScript:
await fetch('/structr/rest/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
eMail: 'user.name@example.com'
})
});
Structr sends an email with a link to the configured target page. When the user clicks the link, they are automatically logged in and can set a new password.
Best Practices
- Grant minimal permissions - Follow the principle of least privilege
- Use groups effectively - Manage permissions through groups rather than individual grants
- Use schema-based permissions for performance - When all instances of a type should have the same permissions, configure them at the schema level
- Test with non-admin users - Admin users bypass all permission checks, so always test your permission design with regular users
- Design clear permission flows - When using graph-based resolution, document how permissions propagate through your data model
- Monitor failed logins - Watch for brute-force attempts through the
passwordAttemptsproperty - Enable two-factor authentication - Require 2FA for admin users and sensitive operations
Related Topics
- Two-Factor Authentication - TOTP-based second factor for login security
- JWT Authentication - Token-based authentication with JSON Web Tokens
- OAuth - Authentication with external providers like Google, GitHub, or Auth0
- Email - Configuring email for self-registration and password reset
- SSH Access - Configuring SSH access to the Structr filesystem
- REST Interface/Authentication - Resource Access Permissions and endpoint security
- Security (Admin UI) - Managing users, groups, and resource access permissions in the Admin UI
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
Related Topics
- User Management - User properties and account security
- JWT Authentication - Token-based authentication
- OAuth - Authentication with external providers
JWT Authentication
Structr supports authentication and authorization with JSON Web Tokens (JWTs). JWTs enable stateless authentication where the server does not need to maintain session state. This approach is particularly useful for APIs, single-page applications, and mobile apps.
You can learn more about JWT at https://jwt.io/.
Configuration
Structr supports three methods for signing and verifying JWTs:
- Secret Key – a shared secret for signing and verification
- Java KeyStore – a private/public keypair stored in a JKS file
- External JWKS – validation against an external identity provider like Microsoft Entra ID
Secret Key
To use JWTs with a secret key, configure the following settings in structr.conf or through the Configuration Interface:
| Setting | Value |
|---|---|
security.jwt.secrettype | secret |
security.jwt.secret | Your secret key (at least 32 characters) |
Java KeyStore
When you want to sign and verify JWTs with a private/public keypair, you first need to create a Java KeyStore file containing your keys.
Create a new keypair in a new KeyStore file with the following keytool command:
keytool -genkey -alias jwtkey -keyalg RSA -keystore server.jks -storepass jkspassword
Store the KeyStore file in the same directory as your structr.conf file.
Configure the following settings:
| Setting | Value |
|---|---|
security.jwt.secrettype | keypair |
security.jwt.keystore | The name of your KeyStore file |
security.jwt.keystore.password | The password to your KeyStore file |
security.jwt.key.alias | The alias of the key in the KeyStore file |
Token Settings
You can adjust token expiration and issuer in the configuration:
| Setting | Default | Description |
|---|---|---|
security.jwt.jwtissuer | structr | The issuer field in the JWT |
security.jwt.audience | empty | Comma-separated list of values written into the aud claim of every token this instance issues. When set, verification rejects tokens whose audience does not intersect this list; when empty, no audience claim is emitted or verified. Enabling it invalidates all existing access and refresh tokens. |
security.jwt.expirationtime | 60 | Access token expiration in minutes |
security.jwt.refreshtoken.expirationtime | 1440 | Refresh token expiration in minutes (default: 24 hours) |
Besides the REST resource /structr/rest/token used in the examples below, Structr also serves a dedicated token servlet and a login servlet. Their paths are configured with tokenservlet.path (default /structr/token) and loginservlet.path (default /structr/login). The rate limiter for authentication endpoints uses these paths, see Rate Limiting.
Creating Tokens
Structr creates JWT access tokens through a request to the token resource. With each access token, Structr also creates a refresh token that you can use to obtain further access tokens without sending user credentials again.
Structr provides the tokens in the response body and stores them as HttpOnly cookies in the browser.
Prerequisites
Create a Resource Access Permission with the signature _token that allows POST for public users.
Requesting a Token
curl:
curl -X POST http://localhost:8082/structr/rest/token \
-H "Content-Type: application/json" \
-d '{
"name": "admin",
"password": "admin"
}'
JavaScript:
const response = await fetch('/structr/rest/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'admin',
password: 'admin'
})
});
const data = await response.json();
const accessToken = data.result.access_token;
const refreshToken = data.result.refresh_token;
Response:
{
"result": {
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdHJ1Y3RyIiwic3ViIjoiYWRtaW4iLCJleHAiOjE1OTc5MjMzNjh9...",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdHJ1Y3RyIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE1OTgwMDYxNjh9...",
"expiration_date": "1597923368582",
"token_type": "Bearer"
},
"result_count": 1,
"page_count": 1,
"result_count_time": "0.000041704",
"serialization_time": "0.000166971"
}
Refreshing a Token
To obtain a new access token without sending user credentials again, send the refresh token in the Refresh-Token request header or as the refresh_token key in the JSON request body:
curl:
curl -X POST http://localhost:8082/structr/rest/token \
-H "Refresh-Token: eyJhbGciOiJIUzI1NiJ9..."
JavaScript:
const response = await fetch('/structr/rest/token', {
method: 'POST',
headers: {
'Refresh-Token': refreshToken
}
});
const data = await response.json();
const newAccessToken = data.result.access_token;
Token Lifetime
The access token remains valid until:
- The expiration time is exceeded
- The refresh token that Structr created with it is revoked or used
A refresh token can be used exactly once. When you exchange it for a new access token, Structr removes it from the user and issues a new pair of tokens, which also invalidates the access token that was issued with it. Requesting a token with user credentials does not affect tokens issued earlier; a user can hold several valid token pairs at the same time.
The refresh token remains valid until:
- The expiration time is exceeded
- It is used to obtain a new access token
- You revoke it (see Revoking Tokens below)
Authenticating Requests
To authenticate a request with a JWT, you have two options.
Cookie-Based Authentication
When you request a token from a browser, Structr stores the access token as an HttpOnly cookie. The browser automatically sends this cookie with subsequent requests, so you do not need additional configuration.
JavaScript:
// After obtaining a token, subsequent requests are automatically authenticated
const response = await fetch('/structr/rest/User', {
credentials: 'include' // Include cookies
});
const data = await response.json();
Bearer Token Authentication
For API access or when cookies are not available, send the access token in the HTTP Authorization header:
curl:
curl http://localhost:8082/structr/rest/User \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
JavaScript:
const response = await fetch('/structr/rest/User', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const data = await response.json();
Revoking Tokens
Structr does not store tokens as separate objects. For each token pair it issues, it records an identifier in the refreshTokens property of the user, and an access token is only accepted while its identifier is still present there. Removing identifiers from that property revokes the corresponding tokens before they expire. The property is read-only for REST clients, so revocation happens through the following means.
Logout
A POST request to /structr/rest/logout, authenticated with the access token, clears all refresh token identifiers of the current user and thereby revokes all of the user’s access and refresh tokens. This requires a Resource Access Permission with the signature _logout that allows POST for authenticated users.
curl:
curl -X POST http://localhost:8082/structr/rest/logout \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
JavaScript:
await fetch('/structr/rest/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
Revoking Tokens From Code
Internally, the Principal interface provides the methods clearTokens(), which removes all refresh token identifiers of a user, and removeRefreshToken(tokenId), which removes a single identifier. Structr calls clearTokens() on logout and when a session times out, and removeRefreshToken() when a refresh token is exchanged. These methods are not exposed to scripts, and the refreshTokens property is read-only for admin users as well, so a PUT request or a plain $.set() is rejected with a 422 error. A script that runs with superuser privileges may write the property directly, for example to log out a user from all devices:
{
$.doPrivileged(() => {
const user = $.find('User', { name: 'john.doe' })[0];
$.set(user, 'refreshTokens', []);
});
}
Removing single identifiers from the array revokes the corresponding token pairs only. Structr also removes expired refresh token identifiers automatically whenever it issues new tokens for a user.
External JWKS Providers
Structr can validate JWTs issued by external authentication systems like Microsoft Entra ID, Keycloak, Auth0, or other OIDC-compliant identity providers. This enables machine-to-machine authentication where external systems send requests to Structr with pre-issued tokens.
When an external system (such as an Entra ID service principal) sends a request to Structr with a JWT in the Authorization header, Structr validates the token by fetching the public key from the configured JWKS endpoint. Structr does not manage these external identities - it only validates the tokens they produce.
This capability is particularly useful for:
- Integrating with enterprise identity providers
- Machine-to-machine authentication using service principals
- Centralizing authentication across multiple applications
Note: JWKS validation handles incoming requests with externally-issued tokens. For interactive user login through external providers, see the OAuth chapter.
Configuration
To enable external token validation, configure the JWKS provider settings:
| Setting | Description |
|---|---|
security.jwt.secrettype | Set to jwks for external JWKS validation |
security.jwks.provider | The JWKS endpoint URL of the external service |
security.jwks.audience | Required. Comma-separated list of accepted aud values, usually the client id this application is registered under at the provider |
security.jwt.jwtissuer | The expected issuer claim in the JWT |
security.jwks.admin.claim.key | Token claim to check for admin privileges (optional) |
security.jwks.admin.claim.value | Value that grants admin privileges (optional) |
security.jwks.group.claim.key | Token claim containing group memberships (optional) |
Note:
security.jwks.audienceis not optional, and tokens are refused while it is empty. Signature and issuer together say only that the provider issued the token, not that it was issued for this installation - the same provider issues tokens to every other application in the tenant, and to anybody who can sign up there. Theaudclaim is what distinguishes them.
Microsoft Entra ID
To validate tokens issued by Microsoft Entra ID (formerly Azure Active Directory), configure the JWKS endpoint and issuer for your Azure tenant:
security.jwt.secrettype = jwks
security.jwks.provider = https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
security.jwks.audience = <application-client-id>
security.jwt.jwtissuer = https://login.microsoftonline.com/<tenant-id>/v2.0
security.jwks.admin.claim.key = roles
security.jwks.admin.claim.value = <your-admin-role-name>
security.jwks.group.claim.key = roles
Replace <tenant-id> with your Azure tenant ID, <application-client-id> with the application (client) ID of the app registration the tokens are issued for - whatever the tokens carry in aud, which may also be an App ID URI - and <your-admin-role-name> with the role value that should grant admin privileges in Structr.
In Azure Portal, configure your App Registration to include role claims in the token under “Token configuration”.
After you configure these settings, Structr validates tokens in the Authorization header against the configured service.
How It Works
When Structr receives a request with a JWT in the Authorization header:
- Structr extracts the token and reads its header to identify the signing key (via the
kidclaim) - Structr fetches the public keys from the configured JWKS endpoint
- Structr verifies the token signature using the appropriate public key
- If validation succeeds, Structr processes the request in the context of the authenticated identity
Structr contacts the JWKS endpoint for every request that carries an externally issued token; it does not cache the public keys. If the endpoint cannot be reached, the token cannot be verified and the request is treated like one with an invalid token.
The identity Structr creates for a validated external token is temporary and not stored in the database. Its id and name are taken from the token claims named by security.jwks.id.claim.key and security.jwks.name.claim.key, both of which default to oid.
Error Handling
If token validation fails, Structr returns an appropriate HTTP error:
| Status | Reason |
|---|---|
| 401 Unauthorized | Token is missing, expired, has an invalid signature, or could not be verified because the JWKS endpoint was unreachable |
Best Practices
- Use short expiration times for access tokens - 15-60 minutes is typical. Use refresh tokens to obtain new access tokens.
- Store refresh tokens securely - Refresh tokens have longer lifetimes and should be protected.
- Use HTTPS - Always transmit tokens over encrypted connections.
- Implement token refresh logic - Check for 401 responses and automatically refresh tokens when they expire.
- Revoke tokens on logout - Call the logout endpoint when users log out so that Structr clears their refresh tokens and prevents token reuse.
Related Topics
- User Management - Users, groups, and the permission system
- OAuth - Interactive authentication with external identity providers
- Two-Factor Authentication - Adding a second factor to login security
- REST Interface/Authentication - Resource Access Permissions and endpoint security
OAuth
Structr supports OAuth authentication through various external identity providers. OAuth allows users to authenticate using their existing accounts from services like Google, GitHub, or Microsoft Entra ID, eliminating the need for separate credentials in your application.
OAuth implements an interactive login flow where users authenticate through a provider’s login page. For machine-to-machine authentication using pre-issued tokens, see the JWKS section in the JWT Authentication chapter.
For more information about how OAuth works, see the Authorization Code Flow documentation.
Supported Providers
Structr includes built-in support for the following OAuth providers:
- OpenID Connect (Auth0) – works with any OIDC-compliant provider
- Microsoft Entra ID (Azure AD) – enterprise Single Sign-On with Azure Active Directory
- Keycloak – open-source identity and access management
- GitHub
You can also configure custom OAuth providers by specifying the required endpoints.
Configuration
Configure OAuth settings in structr.conf or through the Configuration Interface.
Enabling Providers
Control which OAuth providers are available using the oauth.servers setting:
| Setting | Description |
|---|---|
oauth.servers | Space-separated list of enabled OAuth providers (e.g., google github azure). Defaults to all available providers: auth0 azure facebook github google linkedin keycloak |
Provider Settings
Each provider requires a client ID and client secret. Most providers also support simplified tenant-based configuration where endpoints are constructed automatically.
Recommended Approach: Tenant-Based Configuration
For providers that support it, use the tenant/server settings and Structr will automatically construct the authorization, token, and userinfo endpoints:
Auth0
oauth.auth0.tenant = your-tenant.auth0.com
oauth.auth0.client_id = <your-client-id>
oauth.auth0.client_secret = <your-client-secret>
Microsoft Entra ID (Azure AD)
oauth.azure.tenant_id = <your-tenant-id>
oauth.azure.client_id = <your-client-id>
oauth.azure.client_secret = <your-client-secret>
Keycloak
oauth.keycloak.server_url = https://keycloak.example.com
oauth.keycloak.realm = master
oauth.keycloak.client_id = <your-client-id>
oauth.keycloak.client_secret = <your-client-secret>
Other Providers (Google, GitHub, Facebook, LinkedIn)
These providers use default endpoints and only require credentials:
oauth.google.client_id = <your-client-id>
oauth.google.client_secret = <your-client-secret>
Complete Provider Settings Reference
The following table shows all available settings. Replace <provider> with the provider name (auth0, azure, google, facebook, github, linkedin, keycloak).
General Settings (All Providers)
| Setting | Required | Description |
|---|---|---|
oauth.<provider>.client_id | Yes | Client ID from the OAuth provider |
oauth.<provider>.client_secret | Yes | Client secret from the OAuth provider |
oauth.<provider>.redirect_uri | No | Callback URL that the provider calls after successful authentication. Defaults to /oauth/<provider>/auth |
oauth.<provider>.error_uri | No | Page to redirect to when authentication fails. Defaults to /error |
oauth.<provider>.return_uri | No | Page to redirect to after successful login. Defaults to / |
oauth.<provider>.logout_uri | No | Logout URI. Defaults to /logout |
oauth.<provider>.scope | No | OAuth scope. Defaults vary by provider |
Tenant/Server-Based Configuration (Recommended)
Markdown Rendering Hint: MarkdownTopic(Auth0) not rendered because level 5 >= maxLevels (5)
Markdown Rendering Hint: MarkdownTopic(Azure AD) not rendered because level 5 >= maxLevels (5)
Markdown Rendering Hint: MarkdownTopic(Keycloak) not rendered because level 5 >= maxLevels (5)
Manual Endpoint Configuration (Advanced)
If you don’t use tenant-based configuration or need to override endpoints:
| Setting | Description |
|---|---|
oauth.<provider>.authorization_location | Full URL of the authorization endpoint |
oauth.<provider>.token_location | Full URL of the token endpoint |
oauth.<provider>.user_details_resource_uri | Full URL where Structr retrieves user details |
Required Global Setting
Enable automatic user creation so Structr can create user nodes for new OAuth users:
| Setting | Value |
|---|---|
jsonrestservlet.user.autocreate | true |
Provider-Specific Examples
Microsoft Entra ID (Azure AD)
oauth.servers = azure
oauth.azure.tenant_id = <your-tenant-id>
oauth.azure.client_id = <your-client-id>
oauth.azure.client_secret = <your-client-secret>
oauth.azure.return_uri = /
jsonrestservlet.user.autocreate = true
oauth.servers = google
oauth.google.client_id = <your-client-id>
oauth.google.client_secret = <your-client-secret>
jsonrestservlet.user.autocreate = true
GitHub
oauth.servers = github
oauth.github.client_id = <your-client-id>
oauth.github.client_secret = <your-client-secret>
jsonrestservlet.user.autocreate = true
Keycloak
oauth.servers = keycloak
oauth.keycloak.server_url = https://keycloak.example.com
oauth.keycloak.realm = production
oauth.keycloak.client_id = <your-client-id>
oauth.keycloak.client_secret = <your-client-secret>
jsonrestservlet.user.autocreate = true
Admin UI Integration
When you configure an OAuth provider, Structr automatically adds a login button for that provider to the Admin UI login form. Clicking this button redirects to the provider’s login page and returns to the Structr backend after successful authentication. This enables Single Sign-On for administrators without additional configuration.
Setting the isAdmin flag
Please note that in order to log into the Admin User Interface, the new user must be created with the isAdmin flag set to true. That means you need to implement a custom onOAuthLogin lifecycle method as described below, and select an “Admin Group” in Azure AD that Structr can use to identify administrators.
Triggering Authentication
For your own application pages, trigger OAuth authentication by redirecting users to /oauth/<provider>/login.
HTML
<a href="/oauth/auth0/login">Login with Auth0</a>
<a href="/oauth/azure/login">Login with Microsoft</a>
<a href="/oauth/google/login">Login with Google</a>
<a href="/oauth/github/login">Login with GitHub</a>
<a href="/oauth/facebook/login">Login with Facebook</a>
<a href="/oauth/linkedin/login">Login with LinkedIn</a>
<a href="/oauth/keycloak/login">Login with Keycloak</a>
Authentication Flow
When a user clicks the login link in your page or in the Admin UI login form, the following process is executed:
- Structr redirects the user to the provider’s authorization URL
- The user authenticates with the provider (enters credentials, approves permissions)
- The provider redirects back to Structr’s callback URL with an authorization code
- Structr exchanges the authorization code for an access token
- Structr retrieves user details from the provider
- Structr creates or updates the local User node
- If configured, Structr calls the
onOAuthLoginmethod on the User type - Structr creates a session and redirects to the configured return URL
Customizing User Creation
When a user logs in via OAuth for the first time, Structr creates a new user node. You can customize this process by implementing the onOAuthLogin lifecycle method on your User type (or a User subtype).
Method Parameters
The onOAuthLogin method receives information about the login through $.methodParameters:
| Parameter | Description |
|---|---|
provider | The name of the OAuth provider (e.g., “google”, “github”, “azure”) |
userinfo | Object containing user details from the provider |
The userinfo object contains provider-specific fields. Common fields include:
| Field | Description |
|---|---|
name | User’s display name |
email | User’s email address |
sub | Unique identifier from the provider |
accessTokenClaims | Claims from the access token (provider-specific) |
Example: Basic User Setup
{
$.log('User ' + $.this.name + ' logged in via ' + $.methodParameters.provider);
// Update user name from provider data
const providerName = $.methodParameters.userinfo['name'];
if (providerName && $.this.name !== providerName) {
$.this.name = providerName;
}
// Set email if available
const providerEmail = $.methodParameters.userinfo['email'];
if (providerEmail) {
$.this.eMail = providerEmail;
}
}
Example: Azure AD Integration with Group Mapping
This example shows how to integrate with Azure Active Directory (Entra ID), including mapping Azure groups to Structr admin privileges:
{
const ADMIN_GROUP = 'bc6fbf5f-34f9-4789-8443-76b194edfa09'; // Azure AD group ID
$.log('User ' + $.this.name + ' just logged in via ' + $.methodParameters.provider);
$.log('User information: ', JSON.stringify($.methodParameters.userinfo, null, 2));
// Update username from Azure AD
if ($.this.name !== $.methodParameters.userinfo['name']) {
$.log('Updating username ' + $.this.name + ' to ' + $.methodParameters.userinfo['name']);
$.this.name = $.methodParameters.userinfo['name'];
}
// Check Azure AD group membership for admin rights
let azureGroups = $.methodParameters.userinfo['accessTokenClaims']['wids'];
$.log('Azure AD groups: ', JSON.stringify(azureGroups, null, 2));
if (azureGroups.includes(ADMIN_GROUP)) {
$.this.isAdmin = true;
$.log('Granted admin rights for ' + $.this.name);
} else {
$.this.isAdmin = false;
$.log('User ' + $.this.name + ' does not have admin rights');
}
}
Example: Mapping Provider Groups to Structr Groups
{
const GROUP_MAPPING = {
'azure-editors-group-id': 'Editors',
'azure-viewers-group-id': 'Viewers',
'azure-admins-group-id': 'Administrators'
};
let azureGroups = $.methodParameters.userinfo['accessTokenClaims']['groups'] || [];
for (let azureGroupId in GROUP_MAPPING) {
let structrGroupName = GROUP_MAPPING[azureGroupId];
let structrGroup = $.first($.find('Group', 'name', structrGroupName));
if (structrGroup) {
if (azureGroups.includes(azureGroupId)) {
$.add_to_group(structrGroup, $.this);
$.log('Added ' + $.this.name + ' to group ' + structrGroupName);
} else {
$.remove_from_group(structrGroup, $.this);
$.log('Removed ' + $.this.name + ' from group ' + structrGroupName);
}
}
}
}
Provider Setup
Each OAuth provider requires you to register your application and obtain client credentials. The general process is:
- Create a developer account with the provider
- Register a new application
- Configure the redirect URI to match
oauth.<provider>.redirect_uriexactly - Copy the client ID and client secret to your Structr configuration
Redirect URI Format
Your redirect URI typically follows this pattern:
https://your-domain.com/oauth/<provider>/auth
Register this URL with the provider and ensure it matches your Structr configuration exactly. Mismatched redirect URIs are a common source of OAuth errors.
Provider-Specific Setup Notes
Microsoft Entra ID (Azure AD)
- Register the application in Azure Portal under “App registrations”
- Configure “Redirect URIs” under Authentication – use
https://your-domain.com/oauth/azure/auth - Add required API permissions (e.g., User.Read, openid, profile)
- For group claims, configure “Token configuration” to include groups
- Use your Azure tenant ID in the
oauth.azure.tenant_idsetting, or usecommonfor multi-tenant apps
- Configure the OAuth consent screen in the Google Cloud Console, then create an OAuth client ID of type “Web application” under Credentials and register the redirect URI there
- The default scope is
email; no additional API needs to be enabled for it - Uses default endpoints, only client credentials required
GitHub
- Set “Authorization callback URL” in your OAuth App settings
- Request appropriate scopes (e.g.,
user:emailfor email access) - Uses default endpoints – only client credentials required
Keycloak
- Create a client in your Keycloak realm
- Set “Valid Redirect URIs” to
https://your-domain.com/oauth/keycloak/auth - Configure client authentication and standard flow
- Provide server URL and realm name for automatic endpoint construction
Auth0
- Create an application in the Auth0 dashboard
- Configure “Allowed Callback URLs” to
https://your-domain.com/oauth/auth0/auth - Copy your Auth0 tenant domain (e.g.,
your-tenant.auth0.com) - Tenant-based configuration automatically constructs all endpoints
Error Handling
When authentication fails, Structr redirects to the configured error_uri with error information in the query parameters.
Common error scenarios:
| Error | Cause | Solution |
|---|---|---|
invalid_client | Wrong client ID or secret | Verify credentials in Structr configuration |
redirect_uri_mismatch | Redirect URI doesn’t match | Ensure exact match between provider and Structr config |
access_denied | User denied permission | User must approve the requested permissions |
server_error | Provider-side error | Check provider status, retry later |
Best Practices
- Use HTTPS - OAuth requires secure connections in production
- Use tenant-based configuration - Simplifies setup and reduces configuration errors
- Validate user data - Don’t blindly trust data from providers; validate and sanitize
- Map groups carefully - Document the relationship between provider groups and Structr permissions
- Handle token expiration - OAuth tokens expire; implement refresh logic if needed
- Log authentication events - Track logins for security auditing
- Enable only needed providers - Use
oauth.serversto limit available authentication methods
Related Topics
- User Management - Users, groups, and the permission system
- JWT Authentication - Token-based authentication, including external JWKS providers for machine-to-machine scenarios
- Two-Factor Authentication - Adding a second factor after OAuth login
- REST Interface/Authentication - Resource Access Permissions and endpoint security
SSL Configuration
Structr terminates TLS itself. The embedded Jetty server that serves pages, files and the REST interface can open an HTTPS connector next to the plain HTTP connector, and it reads the server certificate and its private key from a Java keystore file. There is no separate web server in front of Structr and no PEM file that Structr would read directly: everything HTTPS needs is the keystore, its password, and the port to listen on.
You can fill that keystore in two ways. The letsencrypt maintenance command obtains a certificate from Let’s Encrypt and writes it into the keystore for you, including renewal on a schedule. If you have a certificate from another certificate authority, or you want a self-signed certificate for development, you build the keystore yourself with openssl and keytool and point Structr at it. Both ways end in the same two settings, application.keystore.path and application.keystore.password.
Enabling HTTPS
HTTPS is configured in the Server section of the Configuration Interface, or directly in structr.conf. The HttpService reads these settings when it starts, so port changes and switching HTTPS on need a restart of the HttpService (Services tab of the Configuration Interface, Restart button) or a restart of Structr.
| Setting | Default | Description |
|---|---|---|
application.https.enabled | false | Opens the HTTPS connector. Requires a keystore that contains the certificate and its private key. |
application.https.port | 8083 | Port of the HTTPS connector. |
application.http.port | 8082 | Port of the plain HTTP connector. It stays open when HTTPS is enabled; see Forcing HTTPS below for redirecting it. |
application.keystore.path | domain.key.keystore | Path to the keystore file. A relative path is resolved against the working directory of the Structr process. |
application.keystore.password | empty | Password of the keystore. Structr uses the same password for the store and for the private key entry inside it, so both must match. |
httpservice.sni.required | false | Rejects TLS handshakes that carry no Server Name Indication. |
httpservice.sni.hostcheck | false | Rejects requests whose Host header does not match the SNI name the handshake was made with. |
The default keystore path is deliberately the file the Let’s Encrypt command writes (letsencrypt.domain.key.filename plus .keystore), so a Let’s Encrypt setup does not need to change it. The working directory is /usr/lib/structr for the Debian package (set in the systemd unit) and /var/lib/structr in the Docker image. If you are not sure where the process runs, use an absolute path.
Jetty expects a PKCS12 keystore by default, which is also what the Let’s Encrypt command creates. A JKS keystore works as well, because the Java runtime loads JKS files through the PKCS12 type in its default compatibility mode. Structr does not configure a keystore type of its own.
When HTTPS is enabled, the HTTPS connector negotiates HTTP/2 through ALPN and falls back to HTTP/1.1 for clients that do not support it. The plain HTTP connector offers HTTP/2 over cleartext (h2c) in the same way. The setting httpservice.connection.ratelimit (default 1000) caps the number of HTTP/2 frames a single connection may send per second.
Obtaining a Certificate with Let’s Encrypt
Let’s Encrypt issues certificates through the ACME protocol. Structr implements the client side in the maintenance command letsencrypt: it registers an account, orders a certificate for the configured domains, answers the domain validation challenge, downloads the certificate chain and stores it together with the domain key in the keystore. Let’s Encrypt has to reach your server under the domain name to validate it, so this only works for a server with a public DNS name. For localhost or an internal hostname, use a self-signed certificate as described below.
Settings
The command reads its defaults from the Letsencrypt section in the Security group of the Configuration Interface. In most installations only letsencrypt.domains needs a value.
| Setting | Default | Description |
|---|---|---|
letsencrypt.domains | empty | Domain names the certificate is issued for, separated by spaces. Required. |
letsencrypt.challenge.type | http | Validation method, http or dns. The challenge parameter of the command overrides it. |
letsencrypt.wait | 30 | Seconds to wait between publishing the challenge and asking Let’s Encrypt to validate it. The wait parameter of the command overrides it. |
letsencrypt.production.server.url | acme://letsencrypt.org | ACME directory used when the command runs with server: production. |
letsencrypt.staging.server.url | acme://letsencrypt.org/staging | ACME directory used with server: staging. Staging certificates are not trusted by browsers but do not count against the rate limits. |
letsencrypt.user.key.filename | user.key | File holding the ACME account key. Created on the first run. |
letsencrypt.domain.key.filename | domain.key | File holding the private key of the certificate. Created on the first run and reused on renewal. |
letsencrypt.domain.csr.filename | domain.csr | File the certificate signing request is written to. |
letsencrypt.domain.chain.filename | domain-chain.crt | File the issued certificate chain is written to, in PEM format. |
letsencrypt.key.size | 2048 | RSA key length used when the account key or the domain key is generated. |
All file names are resolved against the working directory of the Structr process, just like application.keystore.path.
Running the Command
The command is available as a maintenance resource and as a scripting function. Both require a superuser or admin session. Over REST you call it with a JSON body:
curl -X POST http://your-domain.com/structr/rest/maintenance/letsencrypt \
-H "X-User: admin" \
-H "X-Password: admin" \
-H "Content-Type: application/json" \
-d '{"server": "production", "challenge": "http", "wait": 10, "reload": true}'
From a script, $.maintenance('letsencrypt', { server: 'production', challenge: 'http', wait: 10, reload: true }) does the same. The result is an object with success (boolean) and errors (list of messages).
| Parameter | Description |
|---|---|
server | Required. production requests a trusted certificate, staging a test certificate. Any other value falls back to staging. Without this parameter the command aborts with HTTP 422 and the message No server supplied, aborting. |
challenge | http or dns. Overrides letsencrypt.challenge.type. |
wait | Seconds to wait before validation. Overrides letsencrypt.wait. Accepts a number or a numeric string. |
reload | true makes the running HttpService load the new keystore after the certificate was written. Defaults to false. |
mode | wait (default) runs order, challenge, pause and validation in one go. create only creates the order and publishes the challenge; verify creates the order again and validates immediately. The split is useful with the DNS challenge when creating the TXT record takes longer than you want to wait inside one request. |
verbose | true logs the JSON of the ACME order, authorizations and challenges. |
keepChallengeFiles | true leaves the challenge files in the internal file system after the run; see the HTTP challenge below. |
Test a new setup against the staging server first. Let’s Encrypt enforces rate limits on the production endpoint, and a misconfigured challenge that fails repeatedly can lock you out for a while.
The HTTP Challenge
With challenge: http, Let’s Encrypt fetches http://<domain>/.well-known/acme-challenge/<token> on port 80. Structr first tries to start a small temporary HTTP server on port 80 that answers exactly this path. That only works when port 80 is free and the Structr process is allowed to bind it. When binding fails, the log shows Unable to start temporary HTTP server for challenge authorization, trying internal file server... and the command falls back to creating the folder /.well-known/acme-challenge/ and the token file in Structr’s internal file system, both visible to public users. Structr then serves the file itself, which means the Structr HTTP port must be the one Let’s Encrypt reaches on port 80, either because application.http.port is 80 or because a port forward or reverse proxy delivers port 80 to it. After the run, the command removes the /.well-known folder again unless keepChallengeFiles is set.
The DNS Challenge
With challenge: dns, Let’s Encrypt looks up a TXT record _acme-challenge.<domain>. whose value the command computes for each domain. Structr does not talk to your DNS provider itself. Instead, if a user-defined method named onAcmeChallenge exists, the command calls it with the parameters { type: 'dns', domain: <domain>, record: <record>, digest: <digest> }, so you can create the record through your provider’s API in that method. If no such method exists, the command logs the record and its value with the note that it will be probed after the waiting time, and you create the record by hand within wait seconds. DNS propagation is usually slower than the default 30 seconds, so raise wait accordingly or use mode: create and mode: verify in two separate calls.
Independent of the challenge type, a user-defined method named afterAcmeChallenge, if it exists, is called at the end of every run with { success: <boolean>, errors: [<messages>] }. Use it to remove the TXT record again or to send a notification when a renewal fails.
What the Command Writes
A successful run leaves these files in the working directory: the account key (user.key), the domain key (domain.key), the signing request (domain.csr), the certificate chain in PEM format (domain-chain.crt), and the keystore. The keystore is created as PKCS12 if it does not exist yet and is protected with application.keystore.password. The certificate chain and the domain key are stored under an alias built from the domain names and the domain key file name. The keystore file name comes from application.keystore.path; if that setting is blank, the command uses letsencrypt.domain.key.filename plus .keystore, which is domain.key.keystore with the default settings, and therefore matches the default keystore path.
Back up user.key, domain.key and the keystore together with structr.conf. Losing the account key is harmless, a new one is created; losing the keystore password makes the existing keystore unreadable.
How the Server Picks Up the New Certificate
Jetty reads the keystore when the HttpService starts. Two things follow from that. When you enable HTTPS for the first time, the HttpService must be restarted after the keystore exists, because the HTTPS connector is created at startup. For every later run, passing reload: true is enough: the command asks the running HttpService to re-read application.keystore.path and application.keystore.password and to reload the TLS context, and new connections use the new certificate without a restart or a dropped connection. If HTTPS was not active when the server started, the reload does nothing and logs Server started without SSL. Need to restart service.
The order for a new installation is therefore: set letsencrypt.domains and application.keystore.password, run the command once against staging and then against production, set application.https.enabled to true, and restart the HttpService.
Scheduled Renewal
Let’s Encrypt certificates are valid for 90 days at the time of writing. Renewal is the same command again, so you automate it with a scheduled task, as recommended in the Best Practices chapter. Create a user-defined function, for example renewSSLCertificate:
{
let result = $.maintenance('letsencrypt', {
server: 'production',
challenge: 'http',
wait: 10,
reload: true
});
if (result.success === true) {
$.log('Certificate renewed.');
} else {
$.log('Certificate renewal failed: ' + result.errors);
}
}
Then register it with the CronService by adding its name to CronService.tasks and defining renewSSLCertificate.cronExpression. Structr cron expressions have six fields starting with seconds, so 0 0 3 19 * * runs at 03:00 on the 19th of every month. Avoid the first of the month at midnight, when Let’s Encrypt sees the most traffic. Changes to CronService.tasks need a restart of the CronService. The Scheduled Tasks chapter describes the cron syntax in detail.
Using a Certificate from Another Certificate Authority
If a commercial or internal certificate authority issued your certificate, you typically hold three PEM files: the certificate, the private key and the intermediate chain. Structr does not read PEM files, so you combine them into a PKCS12 keystore with OpenSSL. Use one password for the store and the key; Structr passes application.keystore.password for both.
openssl pkcs12 -export \
-in your-domain.crt \
-inkey your-domain.key \
-certfile intermediate-chain.crt \
-name your-domain \
-out /etc/structr/your-domain.p12 \
-passout pass:changeit
Restrict the file to the user Structr runs as (chown structr:structr and chmod 600), then set application.keystore.path to /etc/structr/your-domain.p12 and application.keystore.password to the password you chose, enable HTTPS and restart the HttpService. If you already have a JKS keystore, you can point the settings at it directly, or convert it with keytool -importkeystore -srckeystore old.jks -destkeystore new.p12 -deststoretype PKCS12.
When the authority renews the certificate, rebuild the keystore in the same place and restart the HttpService. The in-place reload is only triggered by the Let’s Encrypt command, so a keystore you maintain yourself is picked up at the next start of the HttpService.
Self-Signed Certificates for Development
For a development machine without a public domain, generate a self-signed certificate straight into a PKCS12 keystore with keytool, which ships with the JDK:
keytool -genkeypair \
-alias localhost \
-keyalg RSA -keysize 2048 -validity 365 \
-storetype PKCS12 \
-keystore localhost.p12 \
-storepass changeit \
-dname "CN=localhost" \
-ext "SAN=dns:localhost,ip:127.0.0.1"
Set application.keystore.path to the path of localhost.p12, application.keystore.password to changeit, application.https.enabled to true, and restart the HttpService. Browsers warn about the certificate because no authority they trust signed it; accept the warning for development. If you prefer a certificate your machine trusts, create one with mkcert (mkcert localhost 127.0.0.1 ::1) and convert the resulting PEM files into a PKCS12 keystore with the openssl pkcs12 -export command from the previous section.
Forcing HTTPS
By default the plain HTTP connector stays available next to HTTPS. Setting httpservice.force.https to true installs a redirect handler in front of everything Structr serves: every request that arrives over HTTP is answered with a 302 redirect to the same path on the https scheme and the configured application.https.port. In addition the session cookie is marked as secure-only, so a session can only be established over HTTPS.
Enable this setting only after HTTPS works. The redirect is installed whether or not the HTTPS connector actually came up, so with a broken keystore or application.https.enabled still false every HTTP request is redirected to a port nobody listens on. Changing the setting requires a restart of the HttpService.
Two related cookie settings matter here. httpservice.cookies.secure (default true) sets the Secure flag on the JSESSIONID cookie so browsers only send it over HTTPS, which is the desired behaviour for any HTTPS installation; on a plain HTTP installation it must be turned off, otherwise logins do not persist. httpservice.cookies.samesite (default Lax) sets the SameSite attribute; the value None only works together with the Secure flag.
Protocols and Cipher Suites
Structr passes three settings from the HTTPS Settings section to Jetty’s TLS context. They take the protocol and cipher suite names of the Java runtime (JSSE), not OpenSSL names.
| Setting | Default | Description |
|---|---|---|
httpservice.ssl.protocols.included | TLSv1.2 | Comma-separated list of TLS versions the server offers. Only the listed versions are enabled. |
httpservice.ssl.protocols.excluded | TLSv1,TLSv1.1 | Comma-separated list of TLS versions that are disabled even if included elsewhere. |
httpservice.ssl.ciphers.excluded | empty | Comma-separated list of cipher suite names or regular expressions to disable. |
Note that with the default settings the server offers TLS 1.2 only. To also offer TLS 1.3, set httpservice.ssl.protocols.included to TLSv1.2,TLSv1.3. Leave httpservice.ssl.ciphers.excluded empty unless you have a concrete requirement: while it is empty, Jetty applies its own exclusion list, which already removes suites without forward secrecy and suites based on MD5 or SHA-1. As soon as you set the setting, your list replaces Jetty’s list completely, so you have to spell out every suite you want disabled. Setting httpservice.log.jetty.startupconfig to true dumps the complete connector configuration, including the effective protocol and cipher lists, into the log when the HttpService starts.
HSTS
Structr does not send a Strict-Transport-Security header from the TLS layer. The header comes from the servlets: htmlservlet.customresponseheaders is a comma-separated list of headers that the HTML servlet, the REST servlet, the login and logout servlets and the other servlets add to every response, and its default value already contains Strict-Transport-Security:max-age=60. A max-age of 60 seconds is a placeholder rather than a real HSTS policy; once HTTPS is stable, raise it to a value such as max-age=31536000 in that setting. Do this only when every hostname the header is sent for is reachable over HTTPS, because browsers remember the policy for the whole max-age.
Troubleshooting
The HttpService logs Unable to configure SSL, please make sure that application.https.port, application.keystore.path and application.keystore.password are set correctly in structr.conf. when HTTPS is enabled but the keystore path is empty. Structr then starts with the HTTP connector only.
If the keystore file does not exist or the password is wrong, Jetty fails while opening the connectors. The HttpService tries three times with ten seconds in between, each attempt logging Error, retrying N more times after 10s - Caught: followed by the Java message, for example keystore password was incorrect, and finally Exception occurred when trying to start service HttpService. Because Jetty starts all connectors of the server together, neither the HTTPS nor the HTTP port is served in this case. Fix the path or the password and restart.
If reload: true has no effect and the log says Server started without SSL. Need to restart service., HTTPS was not active when the HttpService started. Enable application.https.enabled and restart the HttpService once; later renewals reload without a restart.
If the Let’s Encrypt command reports Unable to create certificate order, check that letsencrypt.domains contains the full domain names and that they resolve to this server. If the HTTP challenge fails although the domain is correct, check whether port 80 reaches this Structr instance: the log line about the temporary HTTP server tells you whether Structr answered on port 80 itself or fell back to the internal file system, and in the second case application.http.port or a port forward has to deliver port 80 to Structr. If the DNS challenge fails, the TXT record was not visible yet when Let’s Encrypt probed it; raise wait or split the run with mode: create and mode: verify. Set verbose: true to see the ACME responses, including the error object Let’s Encrypt returns for a failed authorization.
If every HTTP request is redirected to a port that does not answer, httpservice.force.https is on while the HTTPS connector is not running. Turn the setting off, get HTTPS working, then turn it on again.
To inspect what the server actually presents, use openssl s_client -connect your-domain.com:8083 -servername your-domain.com and check the certificate chain, the negotiated protocol version and the expiry dates in its output.
Related Topics
- Configuration - The
structr.conffile, its location and the settings reference - Best Practices - Production checklist including HTTPS and certificate renewal
- Scheduled Tasks - The CronService that runs the renewal function
- User Management - Users, groups and the admin privileges the maintenance command requires
SSH Access
Structr includes a built-in SSH server that provides command-line access to the Admin Console and filesystem. Administrators can connect via SSH to execute scripts, run queries, and manage files without using the web interface.
Overview
The SSH service provides two main capabilities:
- Admin Console - An interactive command-line interface for executing JavaScript, StructrScript, Cypher queries, and administrative commands
- Filesystem Access - SFTP and SSHFS access to Structr’s virtual filesystem
Any user with a configured key can log in. The Admin Console, however, requires backend access, which only admin users have: a non-admin user who opens a shell or runs a console command is refused with “Access denied. User has no backend access.” or “Access denied. Console commands require backend (admin) access.”, while SFTP and scp remain available to them, subject to the permissions on the individual files and folders.
Enabling the SSH Service
The SSH service is not enabled by default. To activate it:
- Open the Configuration Interface
- Enable the
SSHServicein the list of configured services - Save the configuration
- Navigate to the Services tab
- Start the SSHService
When the service starts successfully, you see log entries like:
INFO org.structr.files.ssh.SSHService - Setting up SSH server..
INFO org.structr.files.ssh.SSHService - Initializing host key generator..
INFO org.structr.files.ssh.SSHService - Configuring SSH server..
INFO org.structr.files.ssh.SSHService - Starting SSH server on port 8022
INFO org.structr.files.ssh.SSHService - Initialization complete.
On first startup, Structr generates an SSH host key and stores it locally. This key identifies your Structr instance to SSH clients.
Configuration
Configure the SSH service in structr.conf:
| Setting | Default | Description |
|---|---|---|
application.ssh.port | 8022 | The port the SSH server listens on. In maintenance mode, Structr uses maintenance.application.ssh.port instead (default 8122). |
application.ssh.forcepublickey | true | Rejects password logins, so that only public key authentication is accepted |
Remember that structr.conf only contains settings that differ from defaults. If you want to use port 8022, you do not need to add this setting.
Setting Up User Access
By default, SSH authentication uses public key authentication. Each user who needs SSH access must have their public key configured in Structr. The user’s publicKey property holds a single key, and the publicKeys property holds an array of further keys; a login succeeds if the presented key matches any of them.
To add a public key for a user:
- Open the Security area in the Admin UI
- Select the user
- Open the Edit dialog
- Navigate to the Advanced tab
- Paste the user’s public key into the
publicKeyfield - Save the changes
The public key is typically found in ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub on the user’s machine. The entire contents of this file should be pasted into the field.
Structr also supports password authentication with the user’s Structr password, but rejects it while application.ssh.forcepublickey is set (the default), logging “Password-based SSH connections are forbidden”. Set it to false only if you cannot distribute keys, since the password then travels with every login.
Note: Every user can log in, but only users with
isAdmin = truehave backend access and can use the Admin Console. Non-admin users are limited to SFTP and scp.
Connecting via SSH
Connect to Structr using a standard SSH client:
ssh -p 8022 admin@localhost
Replace admin with your username, localhost with your server address, and 8022 with your configured port.
On first connection, you are prompted to verify the server’s host key fingerprint:
The authenticity of host '[localhost]:8022 ([127.0.0.1]:8022)' can't be established.
RSA key fingerprint is SHA256:9YVTKL8x/PUhOdQUPdDmwdCDqZmDzbE5NuXlY16jQeI.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
After confirming, you see the welcome message and enter the Admin Console:
Welcome to the Structr 6.2-SNAPSHOT JavaScript console. Use <Shift>+<Tab> to switch modes.
admin@Structr/>
Admin Console
The Admin Console provides an interactive environment for executing commands. It supports multiple modes, each with different capabilities.
Switching Modes
Use Console.setMode() to switch between modes:
Console.setMode('JavaScript') // Default mode
Console.setMode('StructrScript')
Console.setMode('Cypher')
Console.setMode('AdminShell')
You can also press Shift+Tab to cycle through available modes.
JavaScript Mode
The default mode. Execute JavaScript code with full access to Structr’s scripting API:
admin@Structr/> $.find('User')
admin@Structr/> $.find('Project', { status: 'active' })
admin@Structr/> $.create('Task', { name: 'New Task' })
StructrScript Mode
Execute StructrScript expressions:
admin@Structr/> find('User')
admin@Structr/> size(find('Project'))
Cypher Mode
Execute Neo4j Cypher queries directly:
admin@Structr/> MATCH (n:User) RETURN n
admin@Structr/> MATCH (p:Project)-[:HAS_TASK]->(t:Task) RETURN p.name, count(t)
AdminShell Mode
Access administrative commands. Type help to see available commands:
admin@Structr/> Console.setMode('AdminShell')
Mode set to 'AdminShell'. Type 'help' to get a list of commands.
admin@Structr/> help
Filesystem Access
You can mount Structr’s virtual filesystem on your local machine using SSHFS. This allows you to browse and edit files using standard file management tools.
Mounting with SSHFS
Install SSHFS on your system if not already available, then mount the filesystem:
sshfs admin@localhost:/ mountpoint -p 8022
Replace:
adminwith your usernamelocalhostwith your server addressmountpointwith your local mount directory8022with your configured SSH port
After mounting, you can navigate the Structr filesystem like any local directory:
cd mountpoint
ls -la
Unmounting
To unmount the filesystem:
fusermount -u mountpoint # Linux
umount mountpoint # macOS
Troubleshooting
Connection Refused
If you cannot connect:
- Verify the SSHService is running in the Services tab
- Check that the port is not blocked by a firewall
- Confirm you are using the correct port (default: 8022)
# Check if the port is listening
netstat -tlnp | grep 8022
Authentication Failures
If authentication fails:
- Verify the public key is correctly entered in the user’s
publicKeyorpublicKeysfield - If you log in with a password, check that
application.ssh.forcepublickeyis set tofalse - Check that you are using the matching private key on the client
# Test with verbose output to see authentication details
ssh -v -p 8022 admin@localhost
“Access denied. User has no backend access.”
This error, or “Access denied. Console commands require backend (admin) access.” when running a single command, indicates that the user authenticated successfully but does not have admin privileges. The user can still use SFTP and scp. Set isAdmin = true on the user to grant access to the Admin Console.
Security Considerations
SSH access provides powerful administrative capabilities. Consider these security practices:
- Limit admin users - Only grant admin status to users who genuinely need it
- Protect private keys - Users should secure their private keys with passphrases
- Use strong keys - Prefer Ed25519 or RSA keys with at least 4096 bits
- Monitor access - Review server logs for SSH connection attempts
- Firewall the port - Restrict SSH port access to trusted networks if possible
Related Topics
- User Management - Managing users and the
publicKeyproperty - Configuration - Service configuration in structr.conf
- Admin Console - Detailed documentation of console commands and modes
Rate Limiting
Structr can limit how many requests a single client makes per second, to protect an instance from being overwhelmed by one client sending far too much: a runaway script, a broken retry loop, a crawler that ignores every convention, or someone deliberately hammering the server from one machine.
Be clear about the limits of this, though. It caps what a single client can do; it is not protection against a distributed denial-of-service attack. A distributed attacker spreads the load over many addresses, each staying comfortably under the limit, so the limiter never triggers. Defend against that at the edge, with a reverse proxy, CDN or web application firewall.
Rate limiting is disabled by default. To enable it, set httpservice.ratelimiting.enabled to Enabled in the Configuration Interface under Rate Limiting.
How It Works
Each client gets a leaking bucket. The bucket holds up to bucketsize requests and drains continuously at maxrequestspersecond. A client may therefore burst up to the bucket size and is then held to the sustained rate.
The burst allowance is what makes this usable in front of a web application: a single page load pulls in stylesheets, scripts, images and REST calls all at once, easily dozens of requests in a moment, and that must not be treated as an attack.
A request that fits in the bucket is served normally. A request that does not is not served:
- it is held for
rejectdelaymilliseconds, which slows the client down without occupying a request thread - it is then answered with
rejectstatus(429 by default) - if the delay queue is already full, it is rejected straight away, without the delay
The delay is deliberate backpressure before the refusal, not a grace period: an over-limit request is always refused in the end, never served late. That makes the bucket size the number that matters most: if it does not comfortably cover a real page load, ordinary use will draw 429s rather than merely feel slow.
Only inbound HTTP requests are limited. Outbound requests made by your own scripts are not affected.
Identifying Clients
Clients are identified by their remote address, which has one important consequence: if Structr runs behind a reverse proxy, every request appears to come from the proxy, so all users share a single bucket and one busy user can throttle everyone else.
To fix that, enable httpservice.forwardedfor, which takes the client address from the X-Forwarded-For / Forwarded headers instead.
Only enable this when Structr is genuinely reachable through a trusted reverse proxy. Those headers are ordinary request headers, so if clients can reach Structr directly they can set them themselves, claim any address they like, evade the limit entirely and make the logs untrustworthy.
The proxy must overwrite the header, not append to it. Structr is open source, so an attacker knows the exempt addresses without having to guess: with forwarded-for enabled and a proxy that passes a client-supplied X-Forwarded-For through, sending X-Forwarded-For: 127.0.0.1 claims the loopback address, which excludeaddresses exempts by default. That is a complete bypass of rate limiting from a single header. In nginx use proxy_set_header X-Forwarded-For $remote_addr (which replaces the value) rather than $proxy_add_x_forwarded_for (which appends to whatever the client sent), and make sure Structr cannot be reached except through the proxy.
Stricter Limits for Sign-In
The login and token endpoints get their own, much lower limit, configured by httpservice.ratelimiting.auth.maxrequestspersecond (5 by default). Guessing credentials only needs a handful of requests per second to be effective, so those endpoints deserve a far tighter budget than page serving. The paths follow the configured loginservlet.path and tokenservlet.path.
Its bucket is separate too, and deliberately small (auth.bucketsize, 10). The burst allowance is what an attacker gets for free before the sustained rate applies, so a large bucket here would let a hundred guesses through at once however low the rate is. Signing in is a single request rather than a burst, so a handful is enough to absorb retries and typos.
Set the rate to 0 to drop the separate limit and let the general one apply.
Self-registration and password reset have their own independent rate limits, which are fixed in the code: at most 20 requests per source address and 3 requests per email address within an hour, counted separately for each of the two endpoints. The only configurable part is security.emailratelimit.whitelist, a comma-separated list of source addresses or CIDR ranges that are exempt from both counters, intended for development.
Configuration
| Setting | Default | Description |
|---|---|---|
httpservice.ratelimiting.enabled | Disabled | Enable or disable rate limiting. |
httpservice.ratelimiting.maxrequestspersecond | 100 | Sustained requests per second per client. |
httpservice.ratelimiting.bucketsize | 100 | How large a burst a client may make before the sustained rate is enforced. |
httpservice.ratelimiting.idletimeout | 1000 | How long, in milliseconds, an empty bucket is kept before the client is forgotten. |
httpservice.ratelimiting.maxtrackers | 100000 | Maximum number of clients tracked at once, bounding the limiter’s memory use. |
httpservice.ratelimiting.rejectdelay | 1000 | How long, in milliseconds, an over-limit request is held before it is rejected. 0 rejects immediately. |
httpservice.ratelimiting.rejectqueuesize | 1000 | How many delayed requests are held at once. |
httpservice.ratelimiting.rejectstatus | 429 | Status code for a rejected request. 429 or 503 are the usual choices. |
httpservice.ratelimiting.excludeaddresses | 127.0.0.1,::1 | Addresses or CIDR ranges exempt from rate limiting. |
httpservice.ratelimiting.excludepaths | (empty) | Path specs exempt from rate limiting, for example /structr/metrics/*. |
httpservice.ratelimiting.auth.maxrequestspersecond | 5 | Sustained requests per second on the login and token endpoints. 0 disables the separate limit. |
httpservice.ratelimiting.auth.bucketsize | 10 | Burst allowed on the login and token endpoints. Keep small: the burst is what an attacker gets before the rate applies. |
httpservice.ratelimiting.rejectuntracked | Disabled | Whether to refuse requests once maxtrackers addresses are already tracked. |
httpservice.ratelimiting.log.escalateafter | 10 | Refusals from one address within the window that escalate the log entry to an error with full detail. |
httpservice.ratelimiting.log.distinctclients | 20 | Distinct refused addresses within the window reported as a probable distributed flood. |
httpservice.forwardedfor | Disabled | Take the client address from the forwarded-for headers, server-wide. Only behind a trusted proxy. Listed under HTTP Settings, not Rate Limiting. |
Two more settings shape the logging, and they live under Logging rather than Rate Limiting because they apply to every throttled log statement, not just refused requests:
| Setting | Default | Meaning |
|---|---|---|
log.throttle.window | 60000 | Length of the throttling window in milliseconds. |
log.throttle.maxlines | 200 | Hard ceiling on throttled entries per window per log site, whatever the caller varies. 0 removes it. |
Both take effect immediately, with no restart. They are shared on purpose: the authentication paths throttle their own logging the same way, and they do so whether or not rate limiting is enabled, which it is not by default. Turning rate limiting off therefore does not stop this throttling, and raising log.throttle.maxlines while chasing a problem raises it everywhere at once.
What Gets Logged
Refused requests are logged with their remote address, because that address is what you need to block the source at firewall or host level, which is where a serious flood has to be stopped.
The logging is deliberately graduated, so that everyday overshoots stay quiet and real trouble stands out:
- A client’s first refusal in the window is a warning naming the method, path and address. A brief overshoot is an everyday event and needs no more than that.
- A client still being refused after
log.escalateafterrequests in the same window is logged as an error with the full picture: address, method, path, user agent and the count. This is a single source flooding, and it names what to block. log.distinctclientsdifferent addresses refused within one window is logged once as an error, reporting the number of addresses and total refusals. Many sources at once is a distributed flood, which per-client rate limiting cannot stop; it has to be shed upstream.
The logging is itself rate limited, in two ways: each address is logged at most twice per log.throttle.window, and log.throttle.maxlines caps the entries per window no matter how many addresses are involved. The second ceiling matters because a flood that rotates its source address defeats per-address throttling on its own: the address table is bounded, so returning addresses look like first offences again. Once the ceiling is reached, refusals are still counted and totalled when the window ends, just not logged one by one. That is on purpose. Logging every refused request would let a flood of thousands of requests per second turn into thousands of log lines per second, exhausting disk or the log pipeline, which simply does the attacker’s work for them.
Request bodies, cookies and Authorization headers are never logged, since that would put credentials into the log.
For lower-level detail you can raise org.eclipse.jetty.server.handler.DoSHandler to DEBUG, which logs every tracking decision. Leave that off in production; it logs per request.
If you know earlier Structr versions: the old DoS ALERT: ... warning per delayed request is gone, replaced by the throttled logging above.
Under a Serious Attack
Structr does not stop serving when it is attacked, and that is deliberate. Shutting the HTTP service down would turn a partial problem into a total outage, take the administration interface offline with it, and hand an attacker a cheap way to kill the instance outright: they would only need to trip the threshold, which costs far less than sustaining a flood. Rate limiting already degrades in the right direction, refusing the offending client while everyone else continues to be served.
For loads beyond what per-client limiting can absorb, escalate in this order:
rejectuntracked: oncemaxtrackersaddresses are being tracked, further ones are unlimited by default. Setting this toEnabledrefuses them instead, capping the damage of a flood spread over very many addresses. Note that it will also refuse legitimate clients that arrive while the table is full.- Shed at the connection level with the settings below, which act before a request even exists.
- Block upstream. Take the addresses from the error entries described above and block them at the firewall, or put a reverse proxy, CDN or web application firewall in front. This is the only layer that can absorb a genuinely large or distributed attack, because it stops the traffic before it reaches the application at all.
Load Shedding
Rate limiting counts requests, so it cannot see a flood that never becomes a complete request: thousands of half-open connections, or a storm of connection attempts. Three connection-level guards close that gap, acting at TCP accept, before connection setup, TLS handshake and HTTP parsing. All are disabled by default, because a cap set too low locks out legitimate users, the administrator included.
| Setting | Default | Description |
|---|---|---|
httpservice.connections.max | 0 | Maximum simultaneous connections; beyond it new ones are not accepted. 0 means unlimited. |
httpservice.accept.maxratepersecond | 0 | Maximum new connections accepted per second. 0 means unlimited. |
httpservice.lowresources.enabled | Disabled | Watch for low resources and shed idle connections while the condition lasts. |
httpservice.lowresources.maxmemory | 0 | Heap usage in MB counting as low. 0 watches only the thread pool. |
httpservice.lowresources.idletimeout | 60000 | Idle timeout in milliseconds applied while resources are low. |
httpservice.lowresources.stopaccepting | Disabled | Also refuse new connections entirely while resources are low. |
Connection limit and accept rate limit are safe to enable: they only govern whether new connections are accepted and never touch established ones, so they cannot interrupt anyone already connected. Size the connection limit well above normal use, remembering that a browser opens several connections per user and that each open administration interface holds a websocket.
The low resource monitor is the one guard that touches established connections, and it needs a deliberate value. While resources are low it applies lowresources.idletimeout to every connection on the connector, so a short value closes the administration interface’s websocket and any server-sent-event stream along with the idle connections you wanted to shed. Structr therefore defaults it to 60000, matching the websocket idle timeout; Jetty’s own default of 1000 would break both. Do not lower it.
lowresources.stopaccepting is the closest thing to an emergency brake: while resources are low no new connection is accepted, and normal service resumes by itself when they recover. It is bounded and self-healing, which is why Structr has this rather than a switch that stops serving altogether. Stopping the HTTP service would convert a partial problem into a total outage, take the administration interface down with it, and let an attacker kill the instance simply by tripping a threshold.
Exempting Trusted Clients
Monitoring systems and internal services often poll frequently and legitimately. Exempt them by address or range:
httpservice.ratelimiting.excludeaddresses = 127.0.0.1, ::1, 10.0.0.0/8, 192.168.1.50
Loopback is exempt by default, which also covers requests the server makes to itself.
Whole endpoints can be exempted instead, when it is the path rather than the caller that should be unrestricted:
httpservice.ratelimiting.excludepaths = /structr/metrics/*, /structr/health/*
Choosing Limits
Start from what a real page load costs. Open the busiest screen in the application, count the requests in the browser’s network tab, and make sure bucketsize comfortably exceeds it, otherwise ordinary use will draw 429s.
maxrequestspersecond then governs sustained traffic. The default of 100 suits an interactive application; a pure API backend with batch clients may need considerably more. If legitimate users see 429 responses, the limit is too low. Raise the bucket size first, since that absorbs bursts without lowering the sustained ceiling.
Business Logic
Security
All code runs in the security context of the current user. Objects without read permission are invisible – they don’t appear in query results. Attempting to modify objects without write permission returns a 403 Forbidden error.
Admin Access
The admin user has full access to everything. Keep this in mind during development: if you only test as admin, permission problems won’t surface until a regular user tries the application. Test with non-admin users early.
Elevated Permissions
Sometimes you need to perform operations the current user isn’t allowed to do directly. Structr provides several functions for this.
Privileged Execution
$.doPrivileged() runs code with admin access:
{
let projectId = this.project.id;
$.doPrivileged(() => {
// find() with a UUID string returns the object directly, not a collection
let project = $.find('Project', projectId);
project.taskCount = project.taskCount + 1;
});
}
$.callPrivileged() calls a user-defined function with admin access:
{
$.callPrivileged('updateStatistics', { projectId: this.project.id });
}
Executing as Another User
$.doAs() runs code as a specific user:
{
$.doAs(targetUser, () => {
// This code runs with targetUser's permissions
});
}
Separate Transactions
$.doInNewTransaction() runs code in a separate transaction:
{
$.doInNewTransaction(() => {
// Changes here are committed independently
});
}
Context Boundaries
These functions create a new context. You can’t use object references from the outer context directly – pass the UUID and retrieve the object inside:
{
let id = this.id; // Get the ID in the outer context
$.doPrivileged(() => {
// find() can be used to get a single object by ID
let obj = $.find('MyType', id); // Retrieve in inner context
// ...
});
}
Best Practices
Security
Security requires attention at multiple levels. A system is only as strong as its weakest link.
Enable HTTPS
All production deployments should use HTTPS. Structr integrates with Let’s Encrypt for free SSL certificates:
- Configure
letsencrypt.domainsinstructr.confwith your domain - Request the certificate with the
letsencryptmaintenance command, for example viaPOST /structr/rest/maintenance/letsencrypt - Enable HTTPS:
application.https.enabled = true - Configure ports:
application.http.port = 80andapplication.https.port = 443 - Force HTTPS:
httpservice.force.https = true
Automate Certificate Renewal
Let’s Encrypt certificates expire after 90 days. Create a user-defined function that runs the letsencrypt maintenance command via $.maintenance('letsencrypt', { server: 'production', challenge: 'http', wait: 10, reload: true }) and register it as a scheduled task in CronService.tasks. The SSL Configuration chapter describes the complete setup.
Enable Password Security Rules
Configure password complexity requirements in structr.conf:
security.passwordpolicy.complexity.minlength = 8
security.passwordpolicy.complexity.enforce = true
security.passwordpolicy.complexity.requiredigits = true
security.passwordpolicy.complexity.requirelowercase = true
security.passwordpolicy.complexity.requireuppercase = true
security.passwordpolicy.complexity.requirenonalphanumeric = true
security.passwordpolicy.maxfailedattempts = 4
Use the LoginServlet for Authentication
Configure your login form to POST directly to /structr/login instead of implementing authentication in JavaScript. This handles session management automatically.
Secure File Permissions
On the server filesystem, protect sensitive files:
structr.confshould be readable only by the Structr process (mode 600)- Follow Neo4j’s file permission recommendations for database files
Use Encrypted String Properties
For sensitive data like API keys or personal information, use the EncryptedString property type. Data is encrypted using AES with a key configured in structr.conf or set via $.set_encryption_key().
Use Parameterized Cypher Queries
Always use parameters instead of string concatenation when building Cypher queries. This protects against injection attacks and improves readability.
Recommended:
$.cypher('MATCH (n) WHERE n.name CONTAINS $searchTerm', { searchTerm: 'Admin' })
Not recommended:
$.cypher('MATCH (n) WHERE n.name CONTAINS "' + searchTerm + '"')
The parameterized version passes values safely to the database regardless of special characters or malicious input.
Use Group-Based Permissions for Type Access
Grant groups access to all instances of a type directly in the schema. This is simpler than managing individual object permissions.
Set Visibility Flags Consistently
Login pages should be visibleToPublicUsers but not visibleToAuthenticatedUsers. Protected pages should be visibleToAuthenticatedUsers only.
Test With Non-Admin Users Early
Admin users bypass all permission checks. If you only test as admin, permission problems won’t surface until a regular user tries the application.
The Main Areas
Security
Here you can manage users and groups, configure resource access grants, and set up CORS.

Admin User Interface
Security
The Security area is where you manage access control for your application. Here you create users and organize them into groups, define which REST endpoints are accessible to authenticated and anonymous users, and configure cross-origin request settings for browser-based clients. The permission model supports both role-based access through groups and fine-grained object-level permissions. Each of these concerns has its own tab.

Users and Groups
The first tab, “Users and Groups”, displays two lists side by side: users on the left, groups on the right. Both lists are paginated and filterable, which is helpful when you have many users.
Creating Users and Groups
Click the Create button above either list to add a new user or group. If you’ve extended the User or Group types (by creating subclasses or adding the User trait to another type), a dropdown appears next to the button that lets you choose which type to create.
Organizing Your Security Model
You can drag users onto groups to make them members, and drag groups onto other groups to create hierarchies. This flexibility lets you model complex organizational structures: departments containing teams, teams containing members, with permissions flowing through the hierarchy.
Editing Users
Click a user to edit the name inline. For more options, hover over the user and click the menu icon to open the context menu.
General Dialog
Here you can edit essential user properties: name, password, and email address. Three flags control special behaviors:
- Is Admin User – Grants full access, bypassing all permission checks
- Skip Security Relationships – Optimizes performance for users who do not need fine-grained permissions
- Enable Two-Factor Authentication – Adds an extra security layer for this user
- Two-Factor Authentication confirmed for this User - this user has previously logged in via 2FA and will not be shown the setup QR code on next login
- Device Trust Possible for this User - Per-user setting to allow/disallow device trust (depends on global configuration)
The Failed Login Attempts counter (useful for diagnosing lockouts) and the Confirmation Key (used during self-registration) are also available here. The button “Rotate Device Trust Secret” replaces the secret that signs the device trust cookies of this user, which invalidates all devices the user has marked as trusted.
Advanced Dialog
This shows all user attributes in a raw table format.
Security Dialog
This opens the access control dialog for the user object itself.
Delete User
This removes the account.
See the User Management chapter for detailed explanations of these settings.
Editing Groups
Groups have names and members but fewer special properties. Click to edit the name inline. Use the context menu to access the Advanced dialog (all attributes), Security dialog (access control for the group object), or Delete Group. Groups of type LDAPGroup additionally offer the entry “LDAP Config”, which opens the dialog for the LDAP path and filter settings of that group.
Resource Permissions
The second tab, “Resource Permissions”, controls which REST endpoints are accessible and to whom.
The Resource Access Table
The “Filter/Search…” input above the table narrows the list down to grants whose signature contains the entered text. Each row represents a grant with:
- Signature – The URL pattern this grant applies to
- Permissions – Checkboxes for each HTTP method (GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH), separately for authenticated and non-authenticated users
Creating Grants
Enter a signature in the input field next to the Create button and click Create. For details on signature syntax and configuration patterns, see the User Management chapter.
Per-User and Per-Group Grants
Resource Access grants are themselves objects with their own access control. Click the lock icon at the end of any row to open the access control dialog for that grant.
This means you can create multiple grants for the same signature, each visible to different users or groups. One grant might allow read-only access for regular users, while another allows full access for administrators. Each user sees only the grants that apply to them.
Visibility Options
The Settings menu on the right side of the tab bar includes options for showing visibility flags and bitmask columns in the table. The bitmask is a numeric representation of the permission flags, which can be useful for debugging.
CORS Settings
The third tab, “CORS Settings”, configures Cross-Origin Resource Sharing settings.
The CORS Table
Each row configures CORS for one URL path. Enter a path in the input field, click Create, then fill in the columns. A “Filter/Search…” input above the table filters the rows by request URI.
Accepted Origins
This specifies which domains can make requests. Use * to allow any origin, or list specific domains like https://example.com. This becomes the Access-Control-Allow-Origin header.
Max Age
This tells browsers how long to cache the CORS preflight response, in seconds. Higher values reduce preflight requests but delay the effect of configuration changes.
Allow Methods
This lists which HTTP methods are permitted: GET, POST, PUT, DELETE, etc.
Allow Headers
This specifies which request headers clients can send: Content-Type, Authorization, etc.
Allow Credentials
This controls whether browsers include cookies and HTTP authentication with cross-origin requests.
Expose Headers
This determines which response headers JavaScript can access. By default, only a few headers are exposed; list additional ones here.
The delete button is in the second column.
For details on CORS concepts and configuration patterns, see the Authentication chapter in REST Interface.
Related Topics
- User Management – Concepts behind users, groups, and permissions
- REST Interface/Authentication – Resource Access Permissions and CORS
Context Menu
Security
A submenu with:
- Access Control / Visibility – Opens the full access control dialog
- Quick toggles – Make the file visible to authenticated users, public users, or both
Best Practices
Security
- Store SMTP and mailbox passwords securely
- Use TLS/SSL for all mail connections
- Be cautious with attachments from unknown senders