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.
  • 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. If it happens, block the attacker at your proxy or CDN. See rate limits.
  • 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.
  • 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.
  • The cookie holds an identifier, nothing else. The session’s contents stay 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.
  • A second cookie names the browser, not a session. It holds a random identifier, with the same attributes, for 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.
  • 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.

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.
/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 frame-ancestors 'none' The same, for browsers that read the policy. It sets nothing else.
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.
  • Secrets are encrypted in the database with AES-256-GCM, under GOIABADA_AES_ENCRYPTION_KEY: client secrets, the SMTP password, 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 leaves out what users type. An email address typed into the sign-in, registration or forgot-password form is recorded only as a digest, unless it becomes an account’s address, as when registering creates the account: created_user names it. 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.
  • Browser calls across origins to /auth/token, /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.