Skip to content

Security

Security is a top priority for Goiabada. This page covers security practices and how to report vulnerabilities.

If you discover a security issue, please report it via email to [email protected].

We’re very thankful to Adrean Boyadzhiev from Lambda Bit, who provided a security assessment and consultancy for Goiabada. Adrean’s work focused on an earlier version (0.0.3), and while much has changed since, his insights were invaluable.

If you’re looking for a security expert, we recommend reaching out to Adrean.

Goiabada includes several built-in security features:

  • PKCE support - Configurable PKCE enforcement at global and per-client levels, protecting against code interception attacks. PKCE is required by default (OAuth 2.1 recommendation).
  • Two-factor authentication - Optional or mandatory 2FA using TOTP
  • One-time use of 2FA codes - Each TOTP code can only be used once by the authenticator currently enrolled, enforced atomically, so two simultaneous submissions of the same code yield at most one success. Once a code has been accepted it is refused everywhere afterwards, at the login prompt, during enrollment and through the account API, with the same generic error a wrong code gets. Removing an authenticator clears that history, so enrolling again starts fresh. An otp_code_replay_detected audit event is recorded whenever a replayed code is refused
  • Rate limiting - Configurable rate limiting on sensitive endpoints (login, OTP verification, password reset)
  • Session management - Configurable session timeouts with idle and absolute expiration
  • Durable session termination - Ending a session revokes the refresh tokens and authorization codes that session authorized, including offline refresh tokens that outlive the browser session. A terminated_user_session audit event records what each termination revoked. See ending a session
  • Short-lived access tokens - Default 5-minute expiration to limit exposure
  • Refresh token rotation - Each refresh token can only be used once, enforced atomically, so two simultaneous requests presenting the same token yield at most one new token set
  • Replay containment - Presenting a refresh token that rotation already retired revokes the rest of that token’s rotation family: its successor and any further descendants still live when containment runs. A refresh_token_replay_detected audit event is recorded whenever that revokes at least one. See refresh token replay
  • Signed JWTs - All tokens are cryptographically signed
  • Secure flag - Cookies marked as secure in production (HTTPS only)
  • HttpOnly flag - Cookies not accessible via JavaScript
  • SameSite attribute - Protection against CSRF attacks
  • Same-origin requirement - Every state-changing request to a cookie-authenticated page (the login form, the OTP prompt, the consent screen, and every admin console form) must come from Goiabada’s own origin. A request the browser reports as coming from another site, a sibling subdomain included, is refused with a 403
  • How it is checked - From the browser’s own Sec-Fetch-Site header, which every browser has sent since 2023. same-origin is allowed; cross-site and same-site are refused. none is also allowed: browsers send it only for a navigation the person started themselves, by typing the address or picking a bookmark, which no other page can cause. When the header is absent altogether, the Origin header’s host is compared against the host the request was made to instead
  • No token and no cookie - There is no hidden CSRF token in the forms and no CSRF cookie. The origin is the whole check, so there is nothing to expire, nothing to go stale in a page left open, and nothing to configure
  • No trusted origins - There is no list of extra origins to trust and no setting that creates one, so no other site can be granted access to a protected form. The exempt endpoints below are exempt by path, for the protocol reasons given, rather than by trusting the origin a request arrives from
  • Exempt endpoints - The OAuth2 and OIDC endpoints are cross-origin by design and are exempt, each with a control of its own: /auth/token requires a client secret from a confidential client, while a public client instead redeems a one-time authorization code bound to its registered redirect URI and, with PKCE required by default, to a code verifier only the client that began the flow holds. /userinfo and everything under /api/ authenticate a bearer token, and /auth/callback is protected by the OAuth state parameter. /auth/authorize is exempt because OpenID Connect requires it to accept both GET and POST, and a cross-site POST to it reaches nothing that a plain link would not. /connect/register is exempt because it creates a client rather than acting on a signed-in user; it is disabled by default and rate limited. /auth/logout is exempt only for a POST carrying an id_token_hint, which is the binding RP-initiated logout requires; a hintless POST keeps full protection
  • Referrer-Policy - Goiabada sends Referrer-Policy: same-origin. Do not override it with no-referrer: that makes browsers send an opaque origin on form posts, which the fallback check cannot match
  • No open redirection for self-registered clients - Authorization errors are normally delivered by redirecting the browser to the client’s registered address. For a client that registered itself, Goiabada shows the user a page naming that address instead. Anyone can register a client and choose where it points, so without this a refused authorization is a way to send someone’s browser to an unvetted site. Clients an administrator created are unaffected
  • CORS configuration - Explicit web origin configuration required for JavaScript clients
  • Proxy header trust - Forwarded IP headers are ignored by default; enable TRUST_PROXY_HEADERS only behind a proxy. Behind multiple proxies or a CDN, set TRUSTED_PROXIES to your proxy IPs/CIDRs so the client IP is resolved from the trusted end of X-Forwarded-For and cannot be spoofed
  • Query strings are redacted from HTTP request logging - GOIABADA_AUTHSERVER_LOG_HTTP_REQUESTS and GOIABADA_ADMINCONSOLE_LOG_HTTP_REQUESTS write one record per request, apart from health checks (/health), static assets (/static/) and /favicon.ico, which are never logged. Parameter names are kept, so you can still see what arrived, but a parameter’s value is only written when its name is one of twelve that carry no user data: client_id, response_type, response_mode, scope, prompt, max_age, acr_values, code_challenge_method, ui_locales, error, page and size. Every other value becomes [redacted]. That is what keeps an id_token_hint, an account activation code and a password reset code out of the log, along with anything a client puts in a parameter nobody has assessed. The two emailed-link codes reach a URL only once each now: following an activation or password reset link validates the code and answers a redirect to the same page with no query string, so the code appears in a single request and never afterwards in the address bar, in browser history, or in the Referer of anything that page loads. Neither link carries an email address at all. Names are matched exactly, so Client_ID is redacted like any other unrecognised name. This is a bound on what gets written, not a promise about what a client sends: a value kept under one of the twelve is written whatever it turns out to contain, up to 512 bytes. Each parameter name and each kept value is clipped at 512 bytes and the whole request target at 4096, each with a marker giving the true length, so one very long URL cannot fill your log
  • What the redacted query string is not - It is a faithful inventory rather than a copy. The query is parsed and re-encoded, so parameters appear in alphabetical order and escaping is canonical: scope=openid%20profile is recorded as scope=openid+profile. Names, values and repeated parameters all survive that, but the exact bytes do not. A query the server cannot parse is recorded as ?[unparsable query, N bytes] with no names at all, which is deliberate: a query Goiabada rejects is one it has not made sense of, and guessing at its parameters is how a redacted log ends up printing the thing it withheld. A request target long enough to pass 4096 bytes loses its later parameters to the clip
  • HTTP request records go to the application log on stderr - Earlier versions wrote them as a single line on stdout, in a different format. They are now structured attributes on the same stream as every other message the server writes, so there is one log to collect instead of two. If your deployment captures only stdout, you will stop seeing request records until you capture stderr too. Silence there looks like an absence of traffic rather than a redirected stream, so it is worth checking after upgrading. The request_id, method and ip attributes are clipped at 128 bytes with the byte count in the marker, so a very long proxy-supplied request id shows up shortened rather than missing
  • Credentials are redacted from verbose API logging - GOIABADA_AUTHSERVER_DEBUG_API_REQUESTS writes the request and response bodies of every /api/v1/admin and /api/v1/account call to the log, so credential values are replaced by [redacted] before anything is written: passwords, TOTP codes, TOTP secrets and their enrollment QR image, client secrets, the SMTP password, email verification codes, and the logout URL that carries a signed id_token_hint. Field names are kept, so the shape of the body is still readable. The request URL it logs has its query string redacted the same way as above, so a user search string is not written either. The Authorization header has always been reduced to Bearer [redacted]. A body that is not valid JSON, is larger than 256 KB, or nests more than 32 levels deep outside a redacted value is replaced by a one-line note giving its size and why it was not logged, rather than being written out. Depth is only counted through the parts of the body that get logged, since a redacted value is replaced whole and whatever it contains is never looked at. The flag is off by default and is meant for development