happyDomain 0.6.0: security audit, interface overhaul, and what's next

By happyDomain' latests news on Mar 12 2026

Table of Contents

A few days ago, Anthropic published the details of a collaboration with Mozilla in which Claude Opus 4.6 found 22 vulnerabilities in Firefox over two weeks, 14 of them rated high-severity by Mozilla, accounting for nearly a fifth of all high-severity Firefox fixes in 2025.
The result was striking enough that we wanted to try the same thing on happyDomain.

The scope we chose: authentication, session management, and the OIDC/OAuth2 flow.
Three areas where mistakes tend to be subtle, where the consequences of getting them wrong are serious, and where a fresh pair of eyes (even a synthetic one) can catch things that familiarity makes invisible.


Security audit: 16 findings, 14 fixes

The audit ran in three successive passes over the course of two days. Each pass focused on a distinct layer: the password login flow first, then the session store, then OIDC.

In total, 16 issues came up.
14 were fixed and committed to master on the same day they were found. Two were acknowledged and intentionally left as-is after looking at the actual risk (more on that at the end of this section).

The login flow

The most interesting finding here was a timing side-channel.

When a login attempt used an email address that didn’t exist in the database, the server returned almost immediately: the lookup fails, there’s nothing more to do.
When the email did exist but the password was wrong, bcrypt comparison ran first, adding about 100 ms of latency before the error came back.

That difference is measurable.
An attacker sending a list of email addresses and watching response times could silently identify which ones have accounts, without ever triggering a failed-login counter.
The fix is to always run a dummy bcrypt.CompareHashAndPassword on the not-found path; the call is meaningless, but it equalises the timing of both branches (9e8e1b50).

Two other findings in the same area were less subtle but no less real. Passwords were hashed with bcrypt.GenerateFromPassword(pwd, 0), which maps to Go’s DefaultCost of 10.
Cost 12 has become the widely accepted industry baseline; it is roughly four times harder to brute-force offline than cost 10. That’s now fixed (46a5d15a), and existing passwords stored at cost 10 are transparently re-hashed on the user’s next successful login.

The second: no maximum password length was enforced.
bcrypt silently truncates input at 72 bytes, which creates two problems at once. A user who registers with a 200-character password can later log in with only the first 72 characters.
And an attacker submitting multi-kilobyte passwords can put the server under real CPU pressure before truncation kicks in.
A hard limit of 72 characters is now enforced both at registration and at login (043b81a3).

Brute-force tracking also had a quiet flaw. All calls to the failure tracker were gated behind a check for a configured captcha provider.
On deployments without one (which is the default), failed login attempts went completely untracked and unlimited.
Tracking now runs unconditionally.
Without a captcha provider, exceeding the threshold returns HTTP 429 with a rate_limited: true flag and the login form disables the submit button (5542c58e).

Speaking of captcha providers: this release also introduces full CAPTCHA support on both the login and registration flows (00900543).
Four providers are supported out of the box: hCaptcha, reCAPTCHA v2, Cloudflare Turnstile and lastly Altcha, as a self-hosted, privacy-friendly alternative (e0d85265).
On the registration endpoint the challenge is always required when a provider is configured; on the login endpoint it is only triggered after a configurable number of consecutive failures for the same IP or email address.

Finally, session fixation.
After a successful login, session.Clear() was called before saving, which zeroes the session’s value map but leaves the session ID untouched.
An attacker who obtains a session ID before authentication (say, by visiting the login page) can plant it in the victim’s browser, wait for them to log in, and then replay the same cookie to hijack the authenticated session.
Login now performs a proper two-phase rotation: the old session is deleted from the database, and the gorilla session’s ID field is reset to "" so the store generates a fresh random ID on the next save (18df8d59).

The session store

Two high-severity findings here, and one that had been quietly lurking for a long time.

In SessionStore.Save(), when a session needed to be invalidated (negative MaxAge), storage.DeleteSession() was called but its return value was discarded entirely.
If the storage layer returned an error, the session record silently stayed in the database.
A client keeping the session ID as a Bearer token could still authenticate after the cookie had been cleared on the browser side.
The error is now propagated: if the deletion fails, Save() returns immediately (502e8710).

The session store also accepted IDs from the Authorization header, either as a Bearer token or as the Basic Auth username, and forwarded them straight to the storage layer without any format check.
There was nothing stopping an attacker from sending an arbitrarily crafted string as a storage key.
A new isValidSessionID() helper now enforces the exact format that NewSessionID() produces: standard base32 alphabet [A-Z2-7], exactly 103 characters.
Anything that doesn’t match is silently ignored, producing a fresh anonymous session instead of a storage probe (c889eef9).

The session lifetime deserves its own paragraph. SESSION_MAX_DURATION was set to 365 days.
The cookie MaxAge was 30 days.
These two numbers were inconsistent with each other and both well outside what any reasonable security policy would accept.
The renewal logic made things worse: it compared the stored expiry against now + maxAge and updated it on every single request, so sessions effectively never expired as long as the user visited at least once a month.
And the load() path read ExpiresOn from storage but never actually checked whether it was in the past, so Bearer tokens could outlive their expiry indefinitely.

All three issues are now addressed in a single commit (41fac845):
SESSION_MAX_DURATION is 15 days, a new SESSION_RENEWAL_THRESHOLD of 7 days controls when renewal actually triggers, and load() now rejects expired sessions by deleting the record and returning an error.

The OIDC flow

The OIDC findings were the most varied in nature.

The simplest to understand: the next query parameter on the login page was decoded and passed directly to navigate() without any origin check. Crafting a link like /login?next=https%3A%2F%2Fevil.com would redirect the user to an arbitrary external site immediately after authentication, a classic phishing setup.
The fix is straightforward: the decoded value must start with / and must not start with // (which browsers treat as a protocol-relative URL).
Anything else falls back to / (bd1a2ab1).

The OIDC authorization code flow was missing both of its standard cryptographic protections.\

No nonce was included in the authorization request, and no nonce claim was verified in the returned ID token.
The nonce is the anti-replay mechanism for ID tokens: without it, a stolen token can be submitted to the callback endpoint in a different session and accepted as valid.
This is code I originally wrote across several projects; the nonce was actually missing from my first implementations and I had since corrected it in other codebases and happyDomain was the one I had overlooked.
Now a 32-byte random nonce is generated in RedirectOIDC, stored in the session, and checked against idToken.Nonce in CompleteOIDC (12d08769).

No PKCE was used either.
Without it, an attacker who intercepts the authorization code (through a misconfigured redirect URI at the provider, or a network-level intercept) can exchange it for tokens independently of the session that initiated the flow. A PKCE S256 verifier is now generated alongside the nonce and verified during token exchange (f968caaf).

Two smaller findings: when no explicit user identifier was present in the OIDC claims, a deterministic user ID was derived from the email using sha1.Sum(). SHA-1 has known chosen-prefix collision attacks; it’s been replaced with SHA-256 (f8fa209c). And all four error paths in CompleteOIDC were returning raw error strings from the OIDC library in the HTTP response body, strings that can expose internal URLs, endpoint addresses, and library version details.
Each path now logs the full detail server-side and returns a generic message to the client (5f195055).

What wasn’t fixed

Two findings were left open after looking at the actual exposure.

The UserAuth struct has json:"password,omitempty" tags on its password hash and recovery key fields, but []byte is never considered empty by the JSON encoder, so both fields would appear in any marshalled output. The struct is used exclusively for database serialisation and backup tooling and is never returned through the API, so the practical risk is low.

GetOIDCProvider, GetOAuth2Config, and NewOIDCProvider all access o.OIDCClients[0] without a bounds check, causing a panic if the slice is empty. The call site already checks len(o.OIDCClients) > 0 before reaching NewOIDCProvider, so it’s guarded in practice.


User interface overhaul

A large amount of UI work also landed in 0.6.0.

The domain and the providers pages have both been rebuilt as interactive, filterable tables. The filter state is synchronised with the URL, so a filtered view is bookmarkable and shareable.
Creating a new domain or a new provider no longer navigates to a separate page; a modal opens inline instead.

The new domains page with its title and table

Service editing has been reworked more deeply.
Each service now has its own dedicated page with a sidebar showing the associated DNS records and available actions.
The sidebar tracks scroll position in the main content pane so the context follows you as you move through subdomains.
The zone view gained DNS syntax highlighting (via highlight.js), and zone export moved from a modal to its own page.

The new service page, replacing the hard to navigate modal

On the provider side, the edit page was redesigned with a sidebar surfacing configuration details at a glance.

An admin interface is also included in this release, covering users, domains, providers, zones, sessions, and backup/restore. It is not well battle tested, and mostly done with the help of AI, but it’s also not exposed on the same user interface. To access it, you’ll need to open another port with -admin-bind. Pay attention that there is no authentication on this interface/API.

On the provider side, three new DNS registrars are available: DNScale, Gidinet, and Infomaniak. Thanks to the amazing work of the DNScontrol people.

Finally, happyDomain can now be hosted at a sub-path of a domain by setting the --base-path flag, useful when you want to serve it at example.com/dns/ rather than the root.


What’s coming next

The next feature we’re working on is domain and service health checks: domain expiration, automated validations that surface common misconfigurations directly in the interface.

Most of the work has been done during the previous months, we hope to ship those incredible new features soon!


← Previous article

Would you like to try happyDomain?

You can choose to try it out:

  1. Online: create your user account on https://happydomain.org/.
  2. On your server: download the binaries here: https://get.happydomain.org/master/. You'll find them for Linux, both for classic machines and servers (amd64), and for recent Raspberry Pi models such as armv7 or arm64, and older ones like armhf.
  3. You can also launch our Docker image:
    docker container run -e HAPPYDOMAIN_NO_AUTH=1 -p 8081:8081 happydomain/happydomain
    The NO_AUTH option bypasses user account creation, which is ideal for testing. Of course, don't use it in everyday life.
    Then go to http://localhost:8081/ to start managing your domains!

You can help us go further!

happyDomain is growing, and we need your talents to make it even simpler and more useful.

Users, administrators, newcomers, give your opinion to guide future functionalities by suggesting or voting for future features.

Developers, translators, copywriters, screen designers, testers, join the joyeuxDNS team! You'll find us on our Git repository here.