Part 2 of 4: Architectural Evolution in Horde 6
In part 1 of the evolution series: The jQuery Problem I discussed frontend concerns – How to move off a dead mobile-only framework towards a mobile-first responsive design and not get caught in the next framework I don’t need. This time we move towards authentication concerns.
Several years ago I started exploring JWT authentication for Horde. The timing seemed wrong. Too many moving parts, too many unknowns about how it would integrate with our session-based architecture. I shelved the work and moved on to other priorities. Also life got a say in my time table more than once.
The trigger to revisit JWT wasn’t a sudden technical insight or some grand vision. It was user requests. Boring, persistent, increasingly urgent user requests. Enterprise administrators kept asking about OpenID Connect support, OAuth 2.0 integration, SAML single sign-on. These aren’t niche use cases anymore. They’re what enterprise deployments usually do in 2026. A web app with direct access to the company LDAP is becoming more rare, especially when talking about partial write access. “Can we authenticate with our Azure AD?” “Can users log in with their company Google account?” “Does Horde support SSO?” The questions kept coming and I kept saying, well, yes, partly, there are plans, … sometimes I knew what to do from custom developments not fit for public release.
Yet the answer was no. Not exactly like this. Not now. Not production ready. No. No. No. And the reason was architectural: Horde’s authentication stack was built entirely on PHP sessions. Twenty years of PHP sessions. Those enterprise authentication protocols expect token-based flows. Without JWT infrastructure, we couldn’t even start building those integrations without awkward compromises. And some things we did with sessions were starting to break because of tightened cookie and cross site security policies in browsers. You can’t really tell users to change their browser security and remove generally accepted and sane defaults because your app wants that.
So I revisited JWT. This time with production requirements pushing me forward and no excuses left to hide behind except, obviously, this is a free time volunteer work and I can do or not do what I want.
The Session-Based World
Horde has used PHP sessions for authentication since the beginning. The flow is straightforward:
- User submits username and password
- Authentication backend validates credentials (LDAP, SQL, IMAP, etc.)
- PHP session is created, credentials stored in
$_SESSION - Session ID cookie sent to browser
- Every subsequent request: PHP loads session from storage (files or database)
- Session contains actual credentials for IMAP/SMTP connections
This works. It’s worked for 20+ years. But it has limitations:
I/O overhead: Every HTTP request reads session data from filesystem or database. For high-traffic deployments this overhead is measurable.
No stateless operations: Everything requires a session internally. API clients get session cookies. They have no use for them but Horde needs to have a session so it issues one. Mobile apps need session persistence. Third-party integrations need session management. Long lived sessions keep the backend credentials between calls which only authenticate by token. Some recognition flows use sessions even in anonymous guest mode. That’s more expensive than it should be.
Limited scalability: Load balancing requires sticky sessions or shared session storage. Horizontal scaling is more complex than it should be.
No modern auth flows: OpenID Connect, OAuth 2.0, SAML all expect token-based authentication. Bridging sessions to tokens is awkward.
The federated authentication requests made it clear: we needed token infrastructure. And once we have it we can use it for a load of new features and integrations. But first things first.
The JWT Experiment
JWT (JSON Web Tokens) are a standard for representing claims securely. A JWT contains three parts:
- Header: Metadata (algorithm, token type)
- Payload: Claims (user identity, expiration, etc.)
- Signature: Cryptographic signature proving authenticity
Example JWT (decoded):
// Header
{
"typ": "JWT",
"alg": "HS256"
}
// Payload
{
"sub": "aliced@spam.com",
"iat": 1709856000,
"exp": 1709859600,
"jti": "a1b2c3d4"
}
// Signature (HMAC-SHA256 of header + payload + secret)</code></pre>A token is base64-encoded and signed. Clients send it in HTTP headers: Authorization: Bearer eyJ0eXAiOiJKV1Qi...
The appeal: servers can validate tokens cryptographically without database lookups. Stateless authentication. Perfect for APIs and modern auth flows. The server gets to know
This is user ID aliced123 coupled to display name “Alice Dee <aliced@spam.com>” and I can trust she authenticated recently
The server does not need to look up the frequently required public name and it does not need to validate if alice is logged in – the token will expire without frequent renewal.
I built an initial JWT implementation for Horde. It worked. Technically. Somewhat. Users could authenticate, receive tokens, make some specific API calls which don’t need backend credentials. But production testing revealed problems.
Subtle problems. The kind that don’t show up in a proof of concept but bite you in production.
Security Edge Case #1: Information Disclosure
JWTs are, by themselves, not encrypted. They’re base64-encoded and signed, but the payload is readable by anyone.
This is documented. RFC 7519 is clear about it. I knew this. But knowing it intellectually and designing around it properly are not the same things.
My initial implementation put too much data in JWT claims:
// Header
{
"typ": "JWT",
"alg": "HS256"
}
// Payload
{
"sub": "aliced@spam.com",
"iat": 1709856000,
"exp": 1709859600,
"jti": "a1b2c3d4"
}
// Signature (HMAC-SHA256 of header + payload + secret)Why did I do this? Performance optimization. Showing off I didn’t need to hit much of the backend at all. The apps list and preferences are needed on every request. Loading them from the token avoids database queries.
Problem! Problem! This leaks information.
An attacker who intercepts the token (network sniffing, XSS, stolen logs) can see:
- Which apps the user has access to (privilege enumeration)
- User’s language and timezone (geographic tracking)
- User preferences (fingerprinting)
This is information disclosure. Even if the attacker can’t forge tokens (signature verification prevents that), they learn things they shouldn’t.
The fix is minimal claims. Only put identity in the token:
{
"sub": "aliced@spam.com",
"iat": 1709856000,
"exp": 1709859600,
"jti": "a1b2c3d4"
}That’s it. No apps list, no preferences, no metadata. Everything else comes from normal controller logic: $registry->listApps(), $prefs->getValue(), etc.
Does this hurt performance? Slightly. Much less than a full session. Those calls hit the database unless cached. But security beats optimization.
Security Edge Case #2: The Credentials Problem
Back to the fundamental tension with JWT in Horde: we need the user’s actual password for some backends.
Not for authentication. The token proves identity and recent credential check. But for many types of IMAP/SMTP connections, for some types of LDAP integration and some other old-enterprise technologies. When a user reads email on their primary account, Horde connects to their company’s IMAP server using their credentials just like Outlook and Thunderbird do.
When they send email Horde connects to their SMTP server with their credentials.
Those credentials must be stored somewhere accessible to the PHP process.
You absolutely should not put passwords in into the JWTs. Even if you encrypted the token (you can, using JWE), the token gets transmitted to the browser, logged, cached. The attack surface is enormous. Users don’t like to rotate their single sign on passwords more often than their organization demands and a memorable but strong password is a conundrum of itself.
The credentials must stay server-side. But storing unhashed password strings into a permanent database is … not best practice even if done right. Which means we still need ephemeral sessions best served from a self wiping store such as an in-memory filesystem or an in-memory database table. Shutdown the system for regular reboot and all is gone. Less ambitions installations just put the session in a regular filesystem or database table and run cleanup processes for anything PHP hasn’t cleaned up itself for whatever reasons. Avoid password leaking.
This realization was a bit frustrating. I thought JWT would let us eliminate sessions and now they won’t Instead, we need JWT and sessions.
The Hybrid Model:
- User authenticates with username/password (or some mechanism which transparently provides them)
- Credentials stored in PHP session (server-side only)
- JWT access token issued (short-lived, contains only identity)
- JWT refresh token issued (long-lived, bound to session)
- Browser stores both tokens
- API calls use access token (stateless, no session lookup)
- IMAP/SMTP operations load session for credentials
Not everything needs credentials. Most HTTP requests just need identity: “Is this user Alice?” Reading ticket lists, viewing calendars, browsing files—these operations check permissions but don’t need the user’s IMAP password.
For those operations, JWT provides stateless authentication. For operations that need credentials, we load the session.
Security Edge Case #3: Session File Pollution
PHP sessions are typically stored as files: /var/lib/php/sessions/sess_a1b2c3d4...
Our JWT refresh tokens have a 30-day lifetime. When they expire, the session should be destroyed. But my initial implementation had a bug:
// Refresh token expired
if ($refreshToken->isExpired()) {
// Oops - session data cleared but file remains
$_SESSION = [];
return false;
}This cleared session data but didn’t call session_destroy(). The session file remained on disk, empty but present. Over 30 days, thousands of empty session files accumulated.
File systems don’t love thousands of small files in a single directory. Even with tweaked settings using multiple sub directories to divide and conquer that indexing problem.Performance degrades. Disk space gets wasted. Cleanup scripts struggle. Databases also degrade performance rapidly once you are beyond in-memory reads.
The fix:
if ($refreshToken->isExpired()) {
session_destroy(); // Actually delete the file
return false;
}Seems obvious in retrospect. But this is the kind of bug that only shows up in production after weeks of accumulated sessions.
Security Edge Case #4: Token Refresh Race Conditions
Users often have multiple tabs open. When the access token expires (1 hour), all tabs need to refresh it.
What happens when two tabs try to refresh simultaneously?
Initial implementation (broken):
function refreshAccessToken($refreshToken) {
// Tab 1 and Tab 2 both enter here simultaneously
if (!$refreshToken->isValid()) {
return null;
}
// Generate new access token
$newAccessToken = generateAccessToken($refreshToken->getUserId());
// Tab 1 and Tab 2 both succeed, returning different tokens
return $newAccessToken;
}Both tabs get new access tokens. But now you have two valid tokens in circulation, potentially with different expiry times. Session state becomes confusing.
Worse scenario: if refresh token validation involves updating session state (tracking last refresh time, etc.), simultaneous updates can corrupt session data.
The fix: locking and validation:
function refreshAccessToken($refreshToken) {
// Acquire session lock
session_start();
// Validate refresh token against session
$sessionJti = $_SESSION['jwt_refresh_jti'] ?? null;
if ($refreshToken->jti !== $sessionJti) {
// Token doesn't match session, reject
return null;
}
if ($refreshToken->isExpired()) {
return null;
}
// Generate new access token
$newAccessToken = generateAccessToken($refreshToken->getUserId());
// Release lock
session_write_close();
return $newAccessToken;
}The jti (JWT ID) claim is a unique identifier for each token. We store the current refresh token’s jti in the session. When refreshing, we verify the token’s jti matches the session. This prevents:
- Using old/stolen refresh tokens
- Race conditions from simultaneous refreshes
- Token confusion from multiple clients
Only the token that matches the session’s jti can refresh. Other attempts are rejected.
Security Edge Case #5: Refresh Token Validation
The initial implementation had insufficient validation:
// BAD: Only checks signature and basic claims
if ($jwt->verify($secret)) {
return $jwt;
}This caught forged tokens (signature verification) but missed:
- Expired refresh tokens: A refresh token with
expin the past should be rejected - Wrong token type: Using an access token as a refresh token (or vice versa)
- Unbound tokens: Refresh tokens not associated with a session or associated with a closed session.
- Revoked tokens: Tokens that should no longer be valid
Comprehensive validation:
function validateRefreshToken($token, $secret) {
// Verify signature
if (!$token->verify($secret)) {
return null;
}
// Check expiration
if ($token->exp < time()) {
return null;
}
// Verify it's a refresh token
if (($token->type ?? null) !== 'refresh') {
return null;
}
// Verify jti matches session
session_start();
$sessionJti = $_SESSION['jwt_refresh_jti'] ?? null;
if ($token->jti !== $sessionJti) {
return null;
}
// Check session is still valid
$sessionExp = $_SESSION['jwt_session_exp'] ?? 0;
if ($sessionExp < time()) {
session_destroy();
return null;
}
return $token;
}Each validation step catches a different attack or misconfiguration:
- Signature verification: prevents forged tokens
- Expiration check: prevents replay attacks with old tokens
- Type check: prevents token confusion attacks
- JTI check: prevents stolen token reuse
- Session validation: ensures session hasn’t expired independently
This is defense in depth. If one check fails to catch an attack, others provide backup.
The Hybrid Architecture
After working through these edge cases, the architecture stabilized:
Access Tokens (stateless):
- Lifetime: 1 hour initial default (configurable, 5 minutes to 2 hours). We are still figuring which value is best but I guess shorter is the general trend.
- Contains: user identity, token ID, type, expiry
- Used for: API authentication, stateless operations
- Validation: signature + expiration check (no database)
- Storage: client-side only (memory, localStorage, sessionStorage)
Refresh Tokens (session-bound):
- Lifetime: 30 days (configurable, up to session max lifetime)
- Contains: user identity, token ID, type, expiry
- Used for: obtaining new access tokens
- Validation: signature + expiration + jti check + session check
- Storage: client-side (httpOnly cookie recommended) + session-side (jti)
Sessions (credential storage):
- Lifetime: Traditional session max lifetime
- Contains: credentials for IMAP/SMTP, refresh token jti, session metadata
- Used for: operations requiring credentials
- Validation: PHP session handling
- Storage: server-side (files or database)
The flow:
1. Login (username + password)
↓
2. Create session, store credentials
↓
3. Generate refresh token (jti stored in session)
↓
4. Generate access token
↓
5. Return both tokens to client
// Later: API call
6. Client sends access token
↓
7. Server validates token (stateless, no DB)
↓
8. Request proceeds
// Later: Access token expires
9. Client sends refresh token
↓
10. Server validates refresh token (checks session)
↓
11. Generate new access token
↓
12. Return new access token
// Later: IMAP operation
13. Controller loads session (needs credentials)
↓
14. Connect to IMAP with stored credentials
↓
15. Fetch emailMost operations (reading tickets, viewing calendars, browsing files) could use access tokens. No session lookup, no database I/O for authentication.
Credential operations (email, calendar sync with auth, etc.) load sessions. Required for security. User-provided credentials must stay server-side and should best be forgotten once a session ends.
Configuration
JWT is optional and disabled by default. Enabling it:
// conf.php
$conf['auth']['jwt']['enabled'] = true;
$conf['auth']['jwt']['secret_file'] = ''; // Default: ${HORDE_CONFIG_BASE}/horde/jwt.secret
$conf['auth']['jwt']['issuer'] = 'mail.example.com';
$conf['auth']['jwt']['access_ttl'] = 3600; // 1 hour
$conf['auth']['jwt']['refresh_ttl'] = 2592000; // 30 daysThe secret file is critical. Generate strong random data:
openssl rand -base64 32 > /var/horde/config/horde/jwt.secret
chmod 600 /var/horde/config/horde/jwt.secret
chown www-data:www-data /var/horde/config/horde/jwt.secretImportant: Token lifetimes must be ≤ session max lifetime. If your PHP session max lifetime is 1440 seconds (24 minutes), you can’t set refresh token TTL to 30 days. The session will expire first, breaking refresh token validation.
The test.php diagnostic screen has been amended to assist with this. It validates JWT configuration:
- Secret file exists and is readable
- Token lifetimes are reasonable
- Issuer is configured
- Permissions are correct
If the configuration is wrong, test.php shows red warnings with specific issues and suggestions.
API Authentication Flow
With JWT enabled, future API clients can authenticate without session cookies:
# Login
curl -X POST https://mail.example.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "aliced",
"password": "secret123"
}'
# Response
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"token_type": "Bearer",
"expires_in": 3600
}Store both tokens. Use access token for API calls:
After 1 hour, access token expires. Use refresh token to get a new one:
# Fetch contacts
curl https://mail.example.com/api/v1/contacts \
-H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..."
# Response
{
"contacts": [
{"id": 1, "name": "Bob Smith", "email": "bob@example.com"},
{"id": 2, "name": "Carol Jones", "email": "carol@example.com"}
]
}After 30 days, refresh token expires. Full re-authentication required.
What This Enables
JWT authentication is infrastructure. By itself it doesn’t change user experience. Users logging in via web browser still get sessions as before.
But JWT opens doors:
Native Mobile Apps
Mobile apps can authenticate via API, store tokens, make authenticated requests. No session cookies, no web views, no embedded browsers. Native Swift/Kotlin apps will feel right at home.
This wasn’t possible before. Mobile apps had to embed web views and manage session cookies. Clunky and slow. A real turn off and guess what – I always wanted to do it but never finished my experiments.
Third-Party Integrations
External services will be able to authenticate as Horde users via API. Calendar sync tools, contact managers, email clients all can integrate via JWT tokens.
Previously, third-party integrations required storing user passwords or complex OAuth proxies. JWT tokens are revocable and scoped. It’s tempting to deliver permissions in scopes but I guess that’s just the data exposure problem in new clothes. If exposing permissions is needed (rather than checking permissions) I guess we better authenticate an API call rather than carry it around cookie-style.
OpenID Connect (Maybe, Finally)
OpenID Connect (OIDC) is a popular authentication scheme on top of OAuth 2.0. It’s how “Sign in with Google” works. With JWT infrastructure we can build OIDC integration for sites that want it:
- User clicks “Sign in with Google”
- Redirect to Google’s OIDC endpoint
- User authenticates with Google
- Google returns OIDC token
- Horde validates token, creates local session
- Issue Horde JWT tokens
- User is logged in
This also enables enterprise SSO scenarios: “Sign in with Azure AD”, “Sign in with Okta”, etc. Sign in with your mastodon account to some horde-backed services, why not?
The infrastructure (JWT handling, token validation, session binding) is ready. Building the OIDC integration is still a huge project. But now it’s straightforward.
OAuth 2.0 Providers (Will we?)
Horde could become an OAuth 2.0 provider. Other applications could delegate authentication to Horde: “Sign in with your Horde-Powered foo.org account.”
Use case: organization has Horde as central mail system. Internal wiki, ticket system, file server all delegate authentication to Horde. Single identity source, centralized access control. Also, inter-organization “welcome guest” or “granted trust” scenarios can avoid onboarding accounts. OAuth 2.0 is a more general approach to the OpenID Connect scenario which uses a specific case of OAuth 2.0 under the hood. Horde has had support for the original OAuth but back then it was not a major technology. Original OAuth and OAuth 2.0 are almost unrelated and ironically, OpenID and OpenID Connect aren’t really the same thing either.
SAML (If you can convince me)
SAML is another enterprise SSO standard. Like OIDC but older, more complex, more common in large organizations. OIDC is a lean and webby json solution, SAML is XML Enterprise Java era gore.
SAML integration requires token infrastructure. JWT provides a critical part but you have to deal with namespaces, complex xml messages, server-to-server out of band communication and it’s all heavyweight. Building SAML support now has a clear foundation but I am not motivated at all. Maybe somebody will sponsor it or has the compelling use case which thrills me and makes me long for SAML support without an external OIDC->SAML bridge. If such a thing exists. It’s like Skittles is for pubs and Bowling is that completely different activity but both roll a ball towards pins.
Performance Impact
Theory: stateless JWT authentication should dramatically reduce database I/O.
Practice: Modest improvement and little out of the box gains in typical Horde deployments. We will have to work to earn more benefits.
Why? Most Horde requests aren’t authentication-bound. They’re data-bound—loading emails, fetching calendar events, querying contacts. Authentication is 1-2% of total request time.
For API-heavy workloads (mobile apps polling for updates, sync services, webhooks), JWT provides measurable improvement. No session file reads, no database authentication queries.
For traditional web browser workflows, improvement is minimal. Sessions are already cached in memory (via opcache, redis or memcached), so session reads are fast.
The story isn’t about dramatic performance speedups for most installations. It’s about enabling scenarios that weren’t possible before (native mobile apps, third-party APIs) and providing foundation for future authentication features (OIDC, OAuth, SAML).
Backward Compatibility
JWT is additive. With JWT disabled (default), authentication works exactly as before.
With JWT enabled, both paths work:
- API clients can use JWT tokens
- Web browsers can use sessions
- Old code using sessions continues working unchanged
No rewriting required. Controllers that check $registry->getAuth() work identically—the authentication layer abstracts whether auth came from session or JWT.
This was critical for acceptance. We couldn’t require rewriting thousands of lines of authentication code. JWT had to slot in without breaking existing functionality.
What I’d Do Differently
If I were starting over:
Start with minimal claims: I should have designed with minimal claims from day one instead of optimizing prematurely with apps list and preferences in tokens. Security beats performance, especially when the performance gain is not there out of the box.
Test session lifecycle earlier: The session file pollution bug took weeks to notice. I should have tested 30-day token lifecycles in development with accelerated time.
Document race conditions: I should have documented the refresh token race condition scenario explicitly in planning documents. I discovered it through alpha tester production logs. Should have anticipated it.
Lessons Learned
JWTs Are Not Magic
JWT authentication doesn’t eliminate sessions or magically make everything stateless. It provides selective statelessness. Operations that don’t need credentials can skip session lookups. Designing for this or reimplementing functionality is work and best integrated with other changes which touch particular sub systems.
The hybrid model is more complex than pure sessions or pure JWTs. But it’s the right architecture for Horde’s use case.
Security Edge Cases Are Subtle
Information disclosure (readable JWTs), session cleanup (filesystem pollution), race conditions (simultaneous refreshes) are all subtle. None of these are obvious without careful thought or production testing.
Reading JWT specs (RFC 7519) helps but doesn’t catch implementation-specific issues like session binding.
Validation Is Defense in Depth
Each validation check (signature, expiration, type, jti, session) catches different attacks. Comprehensive validation is tedious but necessary. In theory JWT backed services are less prone to CSRF. Also it’s nice NOT to automatically attach your credential to every call even for static or public assets.
Production Testing Finds Problems
Development testing caught signature verification issues. Production testing caught race conditions, session cleanup bugs, edge cases with token lifetimes.
No substitute for real traffic hitting the system.
Looking Forward
JWT authentication is shipping in Horde 6. The core infrastructure is stable. Edge cases resolved, production-tested, security-reviewed. What’s next?
Next steps:
Permission scopes: Current tokens grant full user access. While scoping tokens by default is not useful, issuing restricted tokens for a sub set of resources and permissions is useful. However we will need to re-validate if an opaque token and server side permission management isn’t more practical.
Token rotation: Automatic refresh token rotation on a regular schedule. Reduces window for stolen token attacks.
These enhancements build on the JWT foundation without changing the core architecture. That’s the value of getting infrastructure right. Future features become incremental additions rather than rewrites.
Coming up in Part 3: Vanilla web development and the economic sustainability argument for framework-free architecture in volunteer open source projects.
This is Part 2 of a 4-part series on Horde 6’s architectural evolution. Part 1 covered jQuery Mobile deprecation, Part 3 covers vanilla web sustainability, and Part 4 covers developer infrastructure improvements.
Leave a Reply