Skip to content

Authentication 🔑

mangoo I/O provides cookie-based authentication, not a full user-management system. You remain responsible for storing users, validating passwords, and deciding who may log in; the framework's job starts once you have already decided "yes, this person may in." After a successful check, you call authentication.login(subject) and the framework sets a signed, encrypted JWT cookie on the response.

That design fits applications that already have a user table (or an external identity source) and want session-like behavior without server-side session storage for auth state. Because the cookie itself carries everything needed to verify it, it works in a share-nothing setup: any node behind a load balancer can validate it using the same configured keys, with no shared session store, no sticky sessions, and no "which server did this user log in on" problem to solve.

Optional pieces build on the same model:

  • Remember me. Longer cookie lifetime via rememberMe(), for the "keep me logged in" checkbox users expect.
  • Two-factor. Flag the cookie until TOTP is verified, using TotpUtils and isValidSecondFactor, so a stolen password alone is not enough to authenticate.
  • Route protection. withAuthentication() on routes or controllers, so protecting a page is a one-line addition in Bootstrap.java rather than a check repeated in every method.
  • API keys. ApiKeyFilter for machine clients using the Authorization header, since a script calling your API has no browser to hold a cookie for it.

Inject Authentication into a controller method:

public Response login(Authentication authentication) {
    return Response.ok().render();
}

Password hashing

Hash passwords with Argon2id via CommonUtils. Argon2id was chosen because it is memory-hard: unlike older algorithms such as bcrypt or SHA-based hashes, it is deliberately expensive to parallelize on GPUs, which is exactly the kind of attack password hashes need to resist once a database leaks.

String hash = CommonUtils.hashArgon2("password", "salt");

Store the hash (and the salt) with your user record; never store the plain password anywhere, not even temporarily in a log line.

The returned value is a PHC string that carries the parameters and the salt it was created with:

$argon2id$v=19$m=32768,t=3,p=1$<salt-b64>$<hash-b64>

Because the parameters travel with the hash, matchArgon2 verifies with the parameters of the stored hash and never with the current configuration. Changing authentication.hashing.memory, authentication.hashing.iterations or authentication.hashing.parallelism therefore does not lock anybody out.

The salt you pass to matchArgon2 still has to be the one the hash was created with. The embedded salt is not used as a fallback: if the two differ, the verification fails. A hash whose embedded salt someone swapped out is rejected rather than verified against the attacker's salt.

Note

The salt ends up in the stored string, as the PHC format prescribes. If you pass a secret as the salt — hashArgon2(cleartext) uses application.secret — it is no longer secret once the hash is stored. Use a per-user random salt and keep a pepper out of the hash.

Because Argon2id is memory-hard, every call holds authentication.hashing.memory KiB of heap (32 MB by default) for as long as it runs. The framework limits how many of these computations may run at the same time, so that a burst of concurrent logins cannot exhaust the heap. A call that finds every slot taken waits up to authentication.hashing.timeout milliseconds and is then rejected with a MangooHashingException. See Argon2 hashing for the configuration and for what happens on the individual call sites.

Rehashing

A stored value that is not in PHC format was created by a mangoo I/O version before 10.13.0. It is verified with the parameters that version used (m=80000,t=6,p=2) and the salt you pass in, so an upgrade does not lock out existing users.

CommonUtils.needsRehash tells you whether a stored hash should be replaced — true for a legacy hash and for a PHC hash whose parameters differ from the current configuration:

if (authentication.isValidLogin(identifier, password, salt, hash)) {
    if (CommonUtils.needsRehash(hash)) {
        user.setPassword(CommonUtils.hashArgon2(password, salt));
        datastore.save(user);
    }
    authentication.login(identifier);
}

Warning

Only evaluate needsRehash after a successful verification. Recomputing a hash needs the clear text, and only a successful login proves that the clear text is the right one.

Login

if (authentication.isValidLogin("subject", "password", "salt", "hash")) {
    authentication.login("subject");
}

isValidLogin also applies lockout: after authentication.lock failed attempts (default 10), the identifier is locked for authentication.lock.duration minutes (default 60). This happens automatically so that a brute-force attempt against one account gets throttled without you having to implement rate limiting by hand. A successful login clears the budget.

If no hashing slot becomes available within authentication.hashing.timeout, isValidLogin fails closed and returns false. The failed attempt budget stays untouched in that case, an overload situation must not lock a user out.

The lockout is an absolute point in time, set once when the budget is used up. Further failed attempts during the lockout are rejected without extending it, so an attacker cannot keep the rightful owner of an account locked out indefinitely by simply continuing to guess.

The same budget applies to the second factor, see Two-factor authentication. Both steps are counted separately per identifier, so a failed password attempt never consumes the budget of the second factor and vice versa.

With persistence enabled the budget is kept in MongoDB (mangooio_attempts), so several instances behind a load balancer share one budget per identifier and a restart does not reset it. The lock time is computed by the database, clock differences between the instances do not shift it. If MongoDB is unavailable, the attempt is refused and the identifier counts as locked. Without persistence the budget is kept in memory per process. Bind your own io.mangoo.interfaces.AttemptStore in app.Module to keep it elsewhere. See Framework collections for all collections the framework creates.

rememberMe() and twoFactorAuthentication(boolean) only set fields. The cookie is written when login() or update() has marked it for this response. Call them in the same request, before the method returns:

authentication.login(username);
authentication.rememberMe();

twoFactorAuthentication(true) is the same kind of flag. Set it in that same request, before the method returns. On a later request, either call alone does not rewrite a cookie that is already valid. Call update() as well.

Methods

authentication.getSubject();   // logged-in subject, or null
authentication.isValid();      // authentication is complete, use this for authorization
authentication.hasSubject();   // a subject is present, second factor may still be pending
authentication.logout();       // expire the cookie
authentication.invalidate();   // expire the cookie
authentication.update();       // refresh the cookie on this response
authentication.rememberMe(true);
authentication.twoFactorAuthentication(true);
authentication.userHasLock("subject");              // password step
authentication.userHasSecondFactorLock("subject");  // second factor step
authentication.isValidSecondFactor("subject", secret, totp);

isValid() and hasSubject() are not interchangeable. hasSubject() is the raw check whether a subject is set, which is already the case right after the password step, because that is when the authentication cookie is issued. isValid() additionally requires that no second factor is outstanding, and it is the one to base an authorization decision on. Use hasSubject() only where the incomplete state is exactly the state you are working on, which in practice is the page that asks for the TOTP.

This matters because an Authentication object is bound on every request, not only on routes bound with withAuthentication(). A filter or controller method on an unprotected route that derives access from getSubject() alone lets a visitor in who knows the password but not the second factor.

userHasLock() and userHasSecondFactorLock() are queries, not a step you have to perform. isValidLogin() and isValidSecondFactor() check the lock before doing any work and count the failed attempt afterwards, both on their own. Query the lock only where you want to show "this account is locked" instead of the same "login failed" that a wrong password produces.

logout() and invalidate() behave identically: both expire the authentication cookie with the current response and, with authentication.blacklist, revoke its token.

Protecting routes

Bind.controller(AccountController.class).withRoutes(
    On.get().to("/account").respondeWith("index").withAuthentication()
);

You can also call withAuthentication() on the controller binder so every route on it requires a cookie, see Routing. Missing authentication redirects to authentication.redirect.login (the archetype sets /login), or returns HTTP 403 if that key is unset. When two-factor is still pending, the user is sent to authentication.redirect.mfa, or to the login redirect if mfa is not set. Set authentication.origin to append the request URI. The separator is ? when the redirect has no query string and & when it already has one. The value is URL-encoded and always starts with one slash, which encoding turns into %2F, so it cannot arrive as //evil.com.

That last point still leaves the final decision to you: origin is a value a visitor chose, and it is only guaranteed to be a path on your own host, not a path that visitor is allowed to reach. Before redirecting to it after a successful login, check it against the routes your application actually serves rather than passing it straight to a redirect.

Route protection covers controller routes only. SSE and WebSocket routes are registered straight on Undertow's path handler and never reach the authentication check, so withAuthentication() does not exist on them and no cookie is verified when a client connects. See Routing for what that means in practice.

Cookie names, SameSite, Secure, signing keys, and lifetimes are described in Configuration. Set authentication.cookie.key and authentication.cookie.secret with env{}, arg{} or file{} rather than writing them out in config.yaml; in prod mode literal values fail the startup. See Secrets.

Two-factor authentication

Generate a secret and verify TOTP codes with TotpUtils (SHA-512, 6 digits, 30-second period, the same parameters most authenticator apps expect out of the box):

String secret = TotpUtils.createSecret();
String qr = TotpUtils.getQRCode("user@example.com", "My App", secret);
String url = TotpUtils.getOtpAuthURL("user@example.com", "My App", secret);

if (authentication.isValidSecondFactor(subject, secret, totpFromUser)) {
    authentication.twoFactorAuthentication(false);
    authentication.update();
}

A typical flow: after the password check succeeds, call login(subject) with twoFactorAuthentication(true) set, redirect to your TOTP entry page, and only clear the flag once isValidSecondFactor confirms the code the user typed in actually matches their secret. Call update() afterwards so the cleared flag reaches the cookie on that same response. See Complete login flow for both steps written out.

While the flag is set, isValid() returns false, so the TOTP entry page itself has to use hasSubject() to find the subject the code is being checked for.

Pass the identifier as the first argument. A TOTP has six digits and is verified without a tolerance window, so exactly one of a million codes is valid per 30-second window — that is only a second factor as long as something limits how often it may be guessed. With the identifier, the same authentication.lock budget as for the password step applies, counted under its own key, and a successful check clears it. An upstream reverse proxy only counts requests per source address and does not bound the total number of guesses; a budget per identity does.

Each code is accepted once per identifier: the used 30-second step is stored in the OneTimeStore (MongoDB with persistence, shared by all instances), so a code that was observed on its way cannot be replayed within its window; the collection is mangooio_claims, see Framework collections. TotpUtils.verifyTotp(identifier, secret, totp) does the same outside of Authentication; TotpUtils.verifyTotp(secret, totp) without an identifier accepts a valid code any number of times.

Complete login flow

The flows below show every call to Authentication a controller needs — and nothing else. In particular there is no lock handling in them: isValidLogin and isValidSecondFactor check the lock before they do any work and count the failed attempt afterwards, both on their own. Querying the lock is a separate, optional step, see Telling a lock apart.

Without two-factor

public Response doLogin(Form form, Authentication authentication, Flash flash) {
    String username = form.get("username");
    User user = userService.findByUsername(username);

    if (user == null || !authentication.isValidLogin(username, form.get("password"), user.getSalt(), user.getPassword())) {
        flash.setError("Login failed");
        return Response.redirect("/login");
    }

    authentication.login(username);
    if (form.getBoolean("remember").orElse(false)) {
        authentication.rememberMe();
    }

    return Response.redirect("/dashboard");
}

No update() here: login() writes a fresh cookie on its own, even if the browser still sends a stale one.

With two-factor

The password step is the same up to the last two calls:

    authentication.login(username);
    authentication.twoFactorAuthentication(true);

    return Response.redirect("/twofactor");

From here on isValid() is false and hasSubject() is true. The cookie is written regardless and carries the flag, which is what keeps the second step stateless.

The page that asks for the code must not be bound with withAuthentication(). That route protection redirects to authentication.redirect.mfa exactly while the flag is set, so the TOTP page would redirect to itself:

public Response twofactor(Authentication authentication) {
    if (!authentication.hasSubject()) {
        return Response.redirect("/login");
    }

    return Response.ok().render();
}
public Response doTwofactor(Form form, Authentication authentication, Flash flash) {
    if (!authentication.hasSubject()) {
        return Response.redirect("/login");
    }

    String subject = authentication.getSubject();
    User user = userService.findByUsername(subject);

    if (!authentication.isValidSecondFactor(subject, user.getTotpSecret(), form.get("totp"))) {
        flash.setError("Invalid code");
        return Response.redirect("/twofactor");
    }

    authentication.twoFactorAuthentication(false);
    authentication.update();

    return Response.redirect("/dashboard");
}

update() is mandatory in this step. The authentication cookie already exists at this point and is only rewritten when it is missing or when update() asked for it — without it the cookie keeps twoFactor=true and the next request sends the user back to /twofactor.

Logout

public Response doLogout(Authentication authentication) {
    authentication.logout();
    return Response.redirect("/");
}

Telling a lock apart

Nothing above has to change for the lockout to work. A locked identifier makes isValidLogin return false without checking the password, which is the same false a wrong password produces — so the user is told "login failed" while the account is in fact locked.

Add a lock query only if you want a different message for that case:

    if (authentication.userHasLock(username)) {
        flash.setError("Account is temporarily locked, try again later");
        return Response.redirect("/login");
    }

userHasSecondFactorLock(subject) does the same for the TOTP step. Both are read-only: they neither consume an attempt nor set a lock, so an extra query costs nothing but also protects nothing on its own.

Be aware that this is a trade-off, not a free improvement. A message that distinguishes "locked" from "wrong password" confirms to whoever triggered the lock that the identifier exists. On a login form that already reveals existing accounts elsewhere, for example on registration or password reset, that changes nothing; on one that deliberately does not, keep the single generic message.

What the controller does not see

A hashing overload is reported to isValidLogin as false, indistinguishable from a wrong password. That is deliberate, see Argon2 hashing; the failed attempt budget stays untouched, so the situation cannot lock anyone out. If you need to tell the two apart, call CommonUtils.matchArgon2 yourself and catch MangooHashingException — at the price of losing the built-in lock handling.

Your own hashing outside the login path, on registration or a password change, needs no try/catch. The unchecked MangooHashingException travels to the ExceptionHandler and becomes a 503. Catching it and returning false would turn an overload into a silent "wrong password".

API keys

For machine clients that have no browser and therefore nowhere to keep a cookie, use ApiKeyFilter instead:

application:
  api:
    key: env{}
@FilterWith(ApiKeyFilter.class)
public Response export() {
    return Response.ok().bodyJson(data);
}

The filter reads Authorization, strips the literal substring Bearer (case-sensitive), and compares the remainder to application.api.key. Authorization: Bearer <key> and Authorization: <key> both match. A missing or wrong key is HTTP 401 with an empty body. If the key is set, startup requires at least 64 bytes.

This is one shared key, not a credential per client. Rotate it if it leaks. There is no per-caller revocation.

Blacklist

Set authentication.blacklist to true to revoke authentication cookies before their natural expiry. Revocations are handled by an io.mangoo.interfaces.TokenBlacklist, which you can inject like any other component.

  • Logout. authentication.logout() and authentication.invalidate() revoke the token of the current cookie automatically. A copy of that cookie, for example a stolen one, is rejected from then on until the token would have expired anyway.
  • All tokens of an account. revokeSubject(subject) revokes every token of a subject that was issued before now, for example right after a user changed their password or an account was compromised. Tokens issued afterwards, such as a new login in the same request, stay valid, so the user is not locked out.
@Inject
private TokenBlacklist tokenBlacklist;

public Response changePassword(Authentication authentication) {
    // ... update the password
    tokenBlacklist.revokeSubject(authentication.getSubject());
    authentication.login(authentication.getSubject());
    return Response.redirect("/");
}

Every revocation is kept exactly as long as the revoked token could still be accepted, that is until its expiry or, for a subject, for the longest token lifetime (authentication.cookie.remember.expires or authentication.cookie.token.expires), plus the 30 second clock tolerance of the JWT validation.

With persistence enabled, revocations are kept in MongoDB (mangooio_revocations): they survive a restart and apply to every instance behind a load balancer. Each authenticated request costs one lookup on the primary key; if MongoDB is unavailable, the cookie counts as revoked. Without persistence, revocations are kept in memory, only apply to the instance that received them and are lost on a restart.

To keep revocations elsewhere, bind your own implementation in your Module. It replaces the one of the framework:

public class Module extends AbstractModule {
    @Override
    protected void configure() {
        bind(TokenBlacklist.class).to(RedisTokenBlacklist.class);
    }
}

To invalidate every issued authentication cookie at once, independent of the blacklist, rotate authentication.cookie.secret.