DEV Community

Cover image for Building Security-monitoring in Finovara
Marcin Parśniak
Marcin Parśniak

Posted on

Building Security-monitoring in Finovara

Building a Risk Engine: Training the Finovara system to determine when user authentication is required.

For a while, Finovara treated every sensitive action the same way: if you wanted to change your password or email, you had to punch in a code, no matter what. It worked, but it was blunt.

So I built a new microservice, security-monitoring-backend, whose only job is to answer one question for every sensitive thing that happens in the system: how risky does this look, and what should we ask the user before letting it through? This post walks through how it scores risk, how the rest of the system talks to it, and the OAuth2 rabbit hole I fell into along the way.

The Core Idea: A Score

Every risky action in Finovara — logging in, changing your password, adding a large expense — now goes through the same four possible outcomes:

public enum RiskAction {
    LOG_ONLY,
    SOFT_CHALLENGE,
    AUTHORIZATION_REQUIRED,
    FULL_VERIFICATION_REQUIRED
}
Enter fullscreen mode Exit fullscreen mode

LOG_ONLY means "carry on, we just noted it happened." Everything past that asks for something extra — a password re-confirmation, an emailed code, or both. What decides which one you get is a numeric score (0–100) built up from small, independent rules, each contributing points. No single rule blocks anything on its own; it's the accumulation that matters.

Part 1: One Endpoint, Three Specialized Risk Services

The whole thing is exposed through a single internal endpoint:

@RestController
@RequestMapping("/internal/risk")
@RequiredArgsConstructor
public class RiskEvaluationController {

    private final RiskEngineService riskEngineService;

    @PostMapping("/evaluate")
    public RiskEvaluationResponse evaluate(@RequestBody RiskEvaluationRequest request) {
        return riskEngineService.evaluate(request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, RiskEngineService doesn't try to be one giant rule engine. It delegates to three focused services, each backed by its own behavioral profile:

  • TransactionRiskService — is this expense way bigger than usual? Is it a new spending record? First transaction ever and already huge? New category out of nowhere? Backed by a TransactionProfile that tracks running averages and largest amounts per user.
  • LoginRiskService — unknown device, unknown location, "impossible travel" (logged in from Warsaw two hours after logging in from somewhere else), or suspiciously too many known devices. Backed by a LoginProfile with a list of known ClientData (ip + browser pairs).
  • AccountChangeRiskService — changing the same field twice in 15 minutes, changing your username and profile picture right after a password change (an "identity repaint"), or a brand-new account already racking up changes. Backed by an AccountChangeProfile.

All three profiles are built passively, from Kafka events the rest of the system was already publishing — logins, expenses, revenues, account changes. No new instrumentation needed on the producing side; security-monitoring-backend just listens in:

@KafkaListener(topics = "user.logged-in")
public void handleLogin(LoginActivityEvent event) {
    loginProfileUpdateService.handleLoginEvent(event);
}

@KafkaListener(topics = "expense.created")
public void handleExpense(ExpenseActivityEvent event) {
    transactionProfileUpdateService.handleExpenseEvent(event);
}
Enter fullscreen mode Exit fullscreen mode

Each risk service returns a list of whatever rules fired, with their points:

public record TriggeredRule(RiskRule rule, int points) {}
Enter fullscreen mode Exit fullscreen mode

And the engine just sums everything up, clamps it to 0–100, and decides an action:

int totalScore = Math.clamp(triggered.stream().mapToInt(TriggeredRule::points).sum(), 0, 100);
RiskAction action = resolveAction(request.triggerType(), totalScore);
Enter fullscreen mode Exit fullscreen mode

Login gets a slightly different treatment than everything else — it's essentially binary:

private RiskAction resolveAction(RiskTriggerType triggerType, int score) {
    if (triggerType == RiskTriggerType.LOGIN) {
        return score > thresholds.getLoginLogOnlyMaxPoints() ? RiskAction.AUTHORIZATION_REQUIRED : RiskAction.LOG_ONLY;
    }

    if (score >= thresholds.getFullVerificationPoints()) return RiskAction.FULL_VERIFICATION_REQUIRED;
    if (score >= thresholds.getAuthorizationPoints()) return RiskAction.AUTHORIZATION_REQUIRED;
    if (score >= thresholds.getSoftChallengePoints()) return RiskAction.SOFT_CHALLENGE;
    return RiskAction.LOG_ONLY;
}
Enter fullscreen mode Exit fullscreen mode

A slightly-off login either passes quietly or gets an email code — there's no "soft challenge" tier for it. Money movements and account changes get the full four-tier treatment, because there's more room for nuance there.

Part 2: Idempotency via sourceEventId

Here's the tricky part: evaluating risk is only step one. If the action needs a challenge, the client has to solve it and then retry the exact same request. That retry should not be scored again from scratch — the user already proved they're legit, and re-scoring could (in theory) send a second email code or produce a different score for the same event. So every evaluation request carries a client-generated sourceEventId, and it's the first thing checked:

@Transactional
public RiskEvaluationResponse evaluate(RiskEvaluationRequest request) {
    return riskOperationRepository.findBySourceEventId(request.sourceEventId())
            .map(this::toResponse)
            .orElseGet(() -> computeAndSave(request));
}
Enter fullscreen mode Exit fullscreen mode

If a RiskOperation already exists for that ID, we just report its current verification state instead of touching the risk rules again. That one findBySourceEventId check is what makes the whole "evaluate → challenge → retry" dance safe to repeat.

Part 3: Guarding a Business Endpoint

On the auth-backend and finance-backend side, calling into the risk engine is wrapped in a small RiskGuardService that every sensitive service call goes through:

public void guard(Long userId, RiskTriggerType triggerType, String email, String sourceEventId,
                  HttpServletRequest servletRequest) {

    String ipAddress = ClientIp.getClientIpAddress(servletRequest);

    RiskEvaluationRequest request = new RiskEvaluationRequest(
            userId, triggerType, sourceEventId, null, null,
            ipAddress, UserLocation.getLocationFromIp(ipAddress),
            UserBrowser.getBrowser(servletRequest), email
    );

    RiskEvaluationResponse response = securityMonitoringClient.evaluate(request);

    if (response.action() == RiskAction.LOG_ONLY) {
        return;
    }
    if (response.isFullyVerified()) {
        return;
    }

    throw new RiskVerificationRequiredException(response.riskOperationId(), response.action(),
            response.passwordRequired(), response.emailCodeRequired());
}
Enter fullscreen mode Exit fullscreen mode

Any place in the code that needs this — changing a password, changing an email, adding an expense — just calls riskGuardService.guard(...) before doing the actual work:

riskGuardService.guard(userId, RiskTriggerType.PASSWORD_CHANGED, user.getEmail(),
        changePasswordDto.riskVerificationSourceEventId(), request);

passwordUpdateService.updatePassword(user, newPassword, request);
Enter fullscreen mode Exit fullscreen mode

If the guard throws, it never gets to updatePassword. And that exception maps cleanly to a 428 Precondition Required response telling the frontend exactly what's needed:

@ExceptionHandler(RiskVerificationRequiredException.class)
public ResponseEntity<RiskVerificationRequiredResponse> handleRiskVerification(RiskVerificationRequiredException ex) {
    return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED)
            .body(new RiskVerificationRequiredResponse(
                    ex.getRiskOperationId(), ex.getAction(),
                    ex.isRequiresPassword(), ex.isRequiresEmailCode()));
}
Enter fullscreen mode Exit fullscreen mode

Part 4: The Confirmation Flow

Whether the frontend needs to ask for a password, an emailed code, or both is entirely derived from the action on the RiskOperation itself:

public boolean requiresPassword() {
    return action == RiskAction.SOFT_CHALLENGE || action == RiskAction.FULL_VERIFICATION_REQUIRED;
}

public boolean requiresEmailCode() {
    return action == RiskAction.AUTHORIZATION_REQUIRED || action == RiskAction.FULL_VERIFICATION_REQUIRED;
}

public boolean isFullyVerified() {
    boolean passwordVerificationComplete = !requiresPassword() || passwordConfirmed;
    boolean emailVerificationComplete = !requiresEmailCode() || emailCodeConfirmed;
    return passwordVerificationComplete && emailVerificationComplete;
}
Enter fullscreen mode Exit fullscreen mode

Confirming a password doesn't reinvent password checking — it just asks auth-backend, which already owns that logic:

@Transactional
public ChallengeConfirmationResponse confirmPassword(Long riskOperationId, String password) {
    RiskOperation operation = getOperation(riskOperationId);

    if (!operation.requiresPassword()) {
        throw new RiskOperationStateException("Risk operation id=" + riskOperationId + " does not require password confirmation");
    }

    try {
        authBackendClient.verifyPassword(operation.getUserId(), new ConfirmPasswordDto(password));
    } catch (FeignException.Unauthorized | FeignException.Forbidden exception) {
        throw new InvalidChallengeException("Incorrect password for userId=" + operation.getUserId());
    }

    operation.setPasswordConfirmed(true);
    riskOperationRepository.save(operation);
    return toConfirmationResponse(operation);
}
Enter fullscreen mode Exit fullscreen mode

The email code path is a plain 6-digit code with an expiry, generated only when the action actually needs one, and rate-limited at the gateway (10 attempts / 15 minutes) so it can't be brute-forced.

Part 5: OAuth2 Had to Learn About Risk Too

This was the fiddliest part. Google login used to be a straight line: authenticate with Google, issue a JWT, redirect to /oauth2/success with the user's info stuffed into query params. It completely bypassed the risk engine — a Google login from a brand-new country would sail straight through.

Now, OAuth2LoginSuccessHandler doesn't issue anything final. It stores an encrypted, short-lived cookie with the user id and a fresh sourceEventId, and redirects to a "please wait" screen instead:

User user = googleOAuth2UserService.synchronize(oauth2User);
pendingLoginCookie.add(response, user.getId(), UUID.randomUUID().toString(), request.isSecure());
// ...
response.sendRedirect("https://localhost:5173/oauth2/verify");
Enter fullscreen mode Exit fullscreen mode

That screen calls a new /api/auth/complete endpoint, which reads the pending cookie, runs it through the exact same RiskGuardService every password-based login goes through, and only then issues the real access token:

public OAuth2LoginResponseDto complete(HttpServletRequest request, HttpServletResponse response) {
    PendingLogin pending = pendingLoginCookie.read(request)
            .orElseThrow(() -> new InvalidCredentialsException("OAuth2 login session expired"));

    User user = userManagerService.getUserByIdOrThrow(pending.userId());

    riskGuardService.guard(user.getId(), RiskTriggerType.LOGIN, user.getEmail(), pending.sourceEventId(), request);

    // ...issue JWT, publish login activity, clear pending cookie
}
Enter fullscreen mode Exit fullscreen mode

If the risk guard throws here, the frontend gets the same 428 response as anywhere else and can prompt for an email code before the OAuth2 login is allowed to finish.

A Small Aside: Testing Location Rules Without Leaving My Laptop

The login rules ("unknown location", "impossible travel") are useless to test locally when every request comes from 127.0.0.1. So I added a dev-only escape hatch in ClientIp, gated entirely behind an environment variable:

private static final boolean DEV_IP_OVERRIDE_ENABLED =
        Boolean.parseBoolean(System.getenv().getOrDefault("DEV_IP_OVERRIDE_ENABLED", "false"));

public static String getClientIpAddress(HttpServletRequest request) {
    if (DEV_IP_OVERRIDE_ENABLED) {
        String debugIp = request.getHeader("X-Debug-Ip");
        if (debugIp != null && !debugIp.isBlank()) {
            return debugIp.trim();
        }
        if (DEV_IP_RANDOM_ENABLED) {
            return randomPublicIp();
        }
    }
    // ...normal X-Forwarded-For handling
}
Enter fullscreen mode Exit fullscreen mode

With DEV_IP_RANDOM_ENABLED=true, every request in local dev gets a plausible random public IP, which is enough to reliably trigger "unknown location" and "impossible travel" without touching a VPN. It's wired up only in docker.yaml for local containers and never set in anything resembling production.

Architecture, Roughly

                 ┌────────────────────┐
   client  ───▶  │     api-gateway     │
                 └─────────┬───────────┘
                           │
        ┌──────────────────┼──────────────────┐
        ▼                                      ▼
┌───────────────┐                    ┌───────────────────┐
│ auth-backend   │                    │ finance-backend    │
│ (login, pw,    │──riskGuard.guard──▶│ (expense, revenue, │
│  email, etc.)  │        │           │  piggy bank)       │
└───────┬────────┘        │           └─────────┬──────────┘
        │                 ▼                     │
        │      ┌────────────────────────┐       │
        └─────▶│ security-monitoring-    │◀──────┘
   Kafka events │ backend                 │  Kafka events
 (login, expense,│  - RiskEngineService   │
  account.changed)│  - Login/Transaction/  │
                  │    AccountChange       │
                  │    profiles            │
                  └────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

What This Taught Me

**I started thinking "how many points does this accumulate," a whole category of awkward edge cases disappeared — a slightly-large expense from a known device doesn't need the same friction as a slightly-large expense from a brand-new country.

Any two-step "check, then confirm" HTTP flow needs an idempotency key. sourceEventId isn't glamorous, but without it, retrying a challenged request would either re-score it or double-send an email code.

Reuse the events you already have. security-monitoring-backend never asked any other service to publish anything new — it just listens to the same expense.created, user.logged-in, and account.changed topics that activity-log-backend and notification-backend already consume.

Retrofitting risk checks into an existing OAuth2 flow is the hard part. Password logins already had a natural place to hook in a risk check. OAuth2 didn't — it went straight from "Google says this is you" to "here's your token." Building a short-lived pending-login step was the only clean way to give it the same treatment.

I want to emphasize: this was one of the most interesting systems I implemented at Finovara, and I believe it was worth it. It certainly wasn't among the easiest, but it definitely wasn't the hardest, either.

Thanks for reading!
My GitHub:

GitHub logo M4rc1nek / finovara-backend

Backend service for a personal finance management application

💰 Finovara — Backend

Backend REST API for a personal finance management application built with Java 25 and Spring Boot 4.


📖 About the Project

Finovara is a personal finance platform designed to help users take full control of their money. The backend exposes a secure REST API that powers tracking of income and expenses, budget management, savings goals, and financial reporting — all wrapped in a bank-grade security model based on JWT authentication.

The application is designed with scalability in mind and is fully containerized via Docker, with separate production and test database environments managed through Docker Compose.


🎯 Key Features

  • 🔐 Authentication & Authorization — JWT-based stateless security with Spring Security; access and refresh token flow with device/user-agent detection
  • 💸 Income & Expense Tracking — full CRUD for financial operations with category tagging
  • 📊 Statistics & Reports — aggregated financial summaries, spending trends, and exportable PDF reports
  • 🏦…




Top comments (0)