Skip to content

Security

This page helps you report a security issue in Goiabada, and shows what it does to keep sign-ins, tokens and sessions safe.

  1. Email [email protected] with what you found: what an attacker can do, how to reproduce it, and the Goiabada version you tried it on.

  2. Keep it private, out of GitHub issues and discussions, until a fix is released, so people running Goiabada can upgrade before the details are public.

  • Passwords are hashed with bcrypt, and never stored or logged as typed. See passwords.
  • New passwords follow NIST SP 800-63B by default: at least 15 characters, no required kinds of character, and never a commonly used password or the account’s own details, as section 3.1.1.2 of SP 800-63B-4 requires. Two of the section’s SHOULDs aren’t met: the maximum is bcrypt’s 72 bytes rather than 64 characters in every script, and passwords aren’t normalized to NFC. See Password policy.
  • Two-factor authentication uses one-time codes from an authenticator app, optional or required per client. See ACR and AMR.
  • A one-time code works once. Once a code has been accepted, it’s refused everywhere, at sign-in, while setting up an authenticator and through the account API, with the same error a wrong code gets. Two submissions of the same code at the same moment get at most one success. A refused replay leaves an otp_code_replay_detected entry in the audit log. Removing an authenticator clears that history.
  • PKCE is required for every public client, and by default for every other one.
  • Guessing is rate limited. Wrong passwords for one account, at the sign-in form and through the password grant together, wrong one-time codes, email verification, credential changes on the Account pages, and password-reset and registration mails to one address each get a fixed budget that always applies. GOIABADA_AUTHSERVER_RATELIMITER_ENABLED, off by default, adds the limits counted by an IP address, among them dynamic client registration. See rate limits.
  • Anyone who knows an email can block its password sign-in for up to an hour, with 100 wrong passwords, since the limit on wrong passwords for one account holds whatever address they come from. Anyone already signed in stays signed in. The Turnstile challenge closes this, unless a public client has the password grant enabled: see What it can’t protect. Without it, block the attacker at your proxy or CDN. See rate limits.
  • An optional Turnstile challenge keeps scripts off the sign-in, forgot-password and registration forms, so they can’t spend an account’s or an address’s rate limits there. It’s off by default. When Cloudflare can’t answer, the forms work as they do without it. See Require a Turnstile challenge.
  • Wrong passwords are limited by a rate, not disabled at a count. NIST SP 800-63B section 3.2.2 asks for the password, like the one-time code, to be disabled after 100 wrong attempts in a row. Goiabada departs from that on purpose: anyone who knows an email address could run up that count, and the disable would block the account’s sign-in for good rather than for up to an hour. Instead, Goiabada uses a per-account sliding rate configured for 100 wrong passwords an hour, and a successful sign-in doesn’t clear the earlier ones: they wear off as the hour moves on.
  • 100 wrong one-time codes in a row disable the user’s authenticator, as NIST SP 800-63B section 3.2.2 requires, and the audit log records otp_authenticator_disabled. Whoever sent those codes knew the password or held one of the user’s sessions. The password isn’t disabled, and it still signs in where a client asks for a password alone, so the way back replaces it: see A user who lost their authenticator. A user who kept their recovery codes can still sign in with one.
  • Recovery codes are look-up secrets, as NIST SP 800-63B-4 section 3.1.2 defines them: ten per authenticator, 120 random bits each, stored only as their SHA-256 hash. Each works once, and two submissions of one code at the same moment get at most one success. At 120 bits, section 3.1.2.2 asks for no failure count of their own; wrong ones share the otp rate limit with one-time codes, so switching between the two buys no extra guesses. A sign-in with one says amr ["pwd", "recovery_code"]. Administrators never see a user’s codes. See Recovery codes.
  • No error is redirected before the user signs in. A request that fails while nobody is signed in sends the user to sign in first, and the error reaches the client afterwards, so one crafted link can’t send a visitor straight to any address a client has registered, as RFC 9700 section 4.11.2 requires. A silent request, which must never show a page, is answered at once. See When an error reaches your app.
  • Every authorization response names the auth server. A code, the implicit flow’s tokens and every error carry iss, so an app that signs in with more than one auth server can tell which one answered, and a response from one can’t pass for another’s (RFC 9700 section 4.4). See The response.
  • A redirect after a sign-in form is a 303 See Other. Every redirect after the password, one-time code or consent form is a 303, never a 302 or a 307, so the browser follows it with a GET and the password or code in the form goes no further, as RFC 9700 section 4.12 recommends.
  • A self-registered client is never sent an error. Anyone can register a client and point it anywhere, so instead of redirecting, the auth server shows the user a page naming the address. See self-registered clients.

Goiabada emails a user when their sign-in details change or their account is deleted, so someone who stole a password can’t act on the account unnoticed:

What changed Who’s told
Their email address, changed on the Account pages The address the account had
Their password, changed on the Account pages The account’s address
Their password, changed by an administrator The account’s address
Their password, reset with a Forgot your password? link The account’s address
Two-factor authentication set up, on the Account pages or during a sign-in The account’s address
Two-factor authentication turned off on the Account pages The account’s address
Two-factor authentication turned off by an administrator The account’s address
Their authenticator, disabled after 100 wrong codes in a row The account’s address
A recovery code, used to sign in. The email says how many unused codes are left The account’s address
A new set of recovery codes, made on the Account pages The account’s address
Their account, deleted on the Account pages The address the account had
Their account, deleted by an administrator The address the account had

A notice is sent only while Send email is on under Admin, Email, and only to an address the user has verified. An administrator changing a user’s email address sends nothing. Neither does a user’s first password, whether they set it with the link an administrator emailed them or an administrator set it before they did.

Each notice says what changed and what to do if the user didn’t change it, in the user’s language. It carries no link and no password or code. If the mail server can’t take it, it’s lost and the auth server logs an ERROR record. If too many are already being sent, it’s dropped, which monitoring counts.

  • Access tokens are short-lived: 5 minutes by default.
  • Tokens are signed with RS256, by keys you can rotate in the admin console. See Discovery and JWKS.
  • A refresh token works once. Each refresh replaces it, and two refreshes with the same token at the same moment get at most one new token. See rotation.
  • A replayed refresh token revokes its family. Presenting one that a refresh already replaced revokes every other token in its line of refreshes, and leaves a refresh_token_replay_detected entry in the audit log. See replay.
  • Ending a session revokes what it authorized: its refresh tokens, offline ones included, and its unredeemed authorization codes. See what ending a session revokes.
  • Tokens can be bound to a key with DPoP. An app that sends a proof signed by its key gets access tokens bound to that key, a public client’s refresh tokens too, and with dpop_jkt its authorization code. A copied token or code is useless without the key. Each proof is accepted once, and at the token endpoint it must carry a nonce the auth server handed out, so a proof can’t be made in advance. See DPoP.
  • A bound token is never taken as a bearer token. UserInfo and the Admin and Account APIs accept a bound token only with the DPoP scheme and a proof by its key, as RFC 9449 section 7.2 requires. Protect an API shows your API how to do the same.
  • The cookie holds an identifier, nothing else. The session’s contents stay on the server. The admin console’s sign-in is the one exception: while it’s under way, the console carries its handshake, and no tokens, in a sealed cookie with the same attributes, for 30 minutes at most. So a visitor who never signs in stores nothing on the server.
  • The cookie is HttpOnly, so no script can read it, SameSite=Lax, so the browser leaves it off another site’s form posts, and Secure when the server’s base URL is https, so it never travels over plain HTTP. On https its name carries the __Host- prefix, so a sibling subdomain can’t set one.
  • Another cookie names the browser, not a session. It holds a random identifier, with the same attributes, for the session maximum lifetime, up to 400 days, and is used only to end the user’s own earlier sessions from that browser when they sign in again. A session stores only its SHA-256. See Sessions.
  • Sessions expire: after 2 hours idle or 24 hours in all, by default. See Sessions.
  • By default, the auth server’s cookie outlives the browser only when the user asks. Under Keep users signed in after the browser closes, the default Let each user choose shows an unticked Keep me signed in box on the password page. Ticked, the cookie lasts as long as its session, up to 400 days; unticked, the browser drops it when it closes. Always and Never decide for everyone. See Closing the browser.
  • The admin console’s cookie has no expiry, so the browser drops it when it closes. A browser that restores its last session can bring it back. The console signs in through the auth server, so an administrator whose auth server cookie outlived the browser is signed back in without a password: administrators should leave Keep me signed in unticked. See Administrators.

Every form the auth server and the admin console serve, the sign-in, two-factor and consent screens and every admin console page, accepts a POST only from its own origin. A request the browser says came from another site, a sibling subdomain included, is refused with 403 Forbidden.

The browser’s Sec-Fetch-Site header decides: same-origin and none, which a browser sends only for an address the user typed or a bookmark they picked, are accepted, and same-site and cross-site are refused. A browser that sends no Sec-Fetch-Site has its Origin header’s host compared with the host it called. There’s no CSRF token and no CSRF cookie, so there’s nothing to expire in a page left open, and no setting that trusts another origin.

The protocol endpoints are called from other sites by design, so the auth server leaves them out of the check. Each is protected by something else:

Path Why another site may POST to it
/auth/authorize OpenID Connect requires it to take a POST as well as a GET, and a POST only starts a sign-in, as a link would. Every step after it is checked.
/auth/token A confidential client presents its secret. A public client presents a one-time code, bound to its redirect URI and to a PKCE verifier only it holds.
/auth/revoke A client presents its secret, or a public client its client_id, with a token it already holds. It reads no cookie, and it ends only that token and the grant it belongs to.
/auth/introspect A confidential client presents its secret, which a cross-site page doesn’t have. It reads no cookie and changes nothing.
/userinfo It takes an access token, never a cookie.
/connect/register It creates a client and acts on no signed-in user. It’s off by default.
/auth/logout Only a POST carrying an id_token_hint, which RP-initiated logout requires. A POST without one is checked.
/api/ and everything under it Every API route takes an access token, never a cookie.
/static/ and everything under it Images, styles and scripts, read with a GET, which the check never applies to.

The admin console leaves out only /auth/callback, its sign-in callback, which the OAuth state parameter protects, and /static/.

Both servers add these headers to every answer:

Header Value What it does
X-Frame-Options DENY No other site can show a page in a frame, so it can’t trick a user into clicking.
Content-Security-Policy script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' The browser runs only script files the server serves itself, never an inline script, an on… attribute or a javascript: URL. It also refuses plugins, a <base> element and framing. Styles, images and requests aren’t restricted.
X-Content-Type-Options nosniff The browser takes each answer, an uploaded image included, as the type it’s labelled with.
Referrer-Policy same-origin A URL carrying a code or a state never reaches another site in the Referer header.
Strict-Transport-Security max-age=31536000; includeSubDomains The browser uses only https for a year, subdomains included. Only when the server’s base URL is https, and never on the HTTP listener’s redirects, so it reaches the browser over HTTPS alone. See listen addresses.

While the Turnstile challenge is on, the sign-in, forgot-password and registration forms add https://challenges.cloudflare.com to script-src, for the widget’s script. Every other page keeps the policy above.

  • Secrets are encrypted in the database with AES-256-GCM, under GOIABADA_AES_ENCRYPTION_KEY: client secrets, the SMTP password, the Turnstile secret key, authenticator seeds, emailed codes and the keys that sign tokens. See Secrets.
  • Authorization codes are stored as a digest. The database holds each code’s SHA-256, so a copy of it holds no code anyone could redeem. The identifier in a session cookie is stored the same way.
  • The audit log records no email address. An address typed into the sign-in, registration or forgot-password form, an account’s address and a test email’s recipient are each recorded only as a digest, and an entry about an account names it by its id. See the audit log’s details.
  • Forwarded headers are ignored until you turn on GOIABADA_AUTHSERVER_TRUST_PROXY_HEADERS, so a client can’t pick the IP address the rate limiter counts and the audit log records. See client IP addresses.
  • The admin console’s calls to the auth server are plain HTTP with the generated setups, carrying its client secret and administrators’ tokens, which is sound only on a network you trust. See the hop from the admin console to the auth server.
  • The auth server’s connection to the database checks the database’s certificate only when GOIABADA_DB_TLS_MODE is verify-ca or verify-full. It checks it against the authorities in GOIABADA_DB_TLS_CA_FILE, or the system’s when that’s empty. Left unset, it’s prefer, which checks nothing and warns at every start. It encrypts when the server offers TLS, though on SQL Server only the login unless the server forces encryption. See Where the database sits.
  • Logout tokens go to the back-channel logout URIs registered on clients. Only authserver:manage can set the URI of a client an administrator created. A self-registered client is reached only at a public address, so registering a client can’t point the auth server at your internal network. See Where the auth server connects.
  • Browser calls across origins to /auth/token, /auth/revoke, /auth/logout and /userinfo are allowed only from the web origins registered on clients. Discovery and the JWKS answer any origin, since they’re public, and nothing else answers a cross-origin call.

Request records redact every query value but a short list of safe ones, and verbose API logging redacts credentials. See What a request record leaves out and Verbose API logging.

Adrean Boyadzhiev of Lambda Bit assessed Goiabada’s security and advised on it. Thank you, Adrean.