Security
Security is a top priority for Goiabada. This page covers security practices and how to report vulnerabilities.
Reporting vulnerabilities
Section titled “Reporting vulnerabilities”If you discover a security issue, please report it via email to [email protected].
Security assessment
Section titled “Security assessment”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.
Security features
Section titled “Security features”Goiabada includes several built-in security features:
Authentication security
Section titled “Authentication security”- 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_detectedaudit 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_sessionaudit event records what each termination revoked. See ending a session
Token security
Section titled “Token security”- 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_detectedaudit event is recorded whenever that revokes at least one. See refresh token replay - Signed JWTs - All tokens are cryptographically signed
Cookie security
Section titled “Cookie security”- Secure flag - Cookies marked as secure in production (HTTPS only)
- HttpOnly flag - Cookies not accessible via JavaScript
- SameSite attribute - Protection against CSRF attacks
Cross-site request forgery
Section titled “Cross-site request forgery”- 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-Siteheader, which every browser has sent since 2023.same-originis allowed;cross-siteandsame-siteare refused.noneis 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, theOriginheader’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/tokenrequires 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./userinfoand everything under/api/authenticate a bearer token, and/auth/callbackis protected by the OAuthstateparameter./auth/authorizeis exempt because OpenID Connect requires it to accept bothGETandPOST, and a cross-sitePOSTto it reaches nothing that a plain link would not./connect/registeris exempt because it creates a client rather than acting on a signed-in user; it is disabled by default and rate limited./auth/logoutis exempt only for aPOSTcarrying anid_token_hint, which is the binding RP-initiated logout requires; a hintlessPOSTkeeps full protection - Referrer-Policy - Goiabada sends
Referrer-Policy: same-origin. Do not override it withno-referrer: that makes browsers send an opaque origin on form posts, which the fallback check cannot match
Network security
Section titled “Network security”- 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_HEADERSonly behind a proxy. Behind multiple proxies or a CDN, setTRUSTED_PROXIESto your proxy IPs/CIDRs so the client IP is resolved from the trusted end ofX-Forwarded-Forand cannot be spoofed
Logging
Section titled “Logging”- Query strings are redacted from HTTP request logging -
GOIABADA_AUTHSERVER_LOG_HTTP_REQUESTSandGOIABADA_ADMINCONSOLE_LOG_HTTP_REQUESTSwrite 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,pageandsize. Every other value becomes[redacted]. That is what keeps anid_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 theRefererof anything that page loads. Neither link carries an email address at all. Names are matched exactly, soClient_IDis 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%20profileis recorded asscope=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,methodandipattributes 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_REQUESTSwrites the request and response bodies of every/api/v1/adminand/api/v1/accountcall 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 signedid_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. TheAuthorizationheader has always been reduced toBearer [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