Sprisk Engine is a lightweight and extensible risk scoring engine for Spring Boot, designed to detect suspicious login behavior such as brute force, credential stuffing, and velocity anomalies.
This guide explains how the Sprisk Engine modules work together, how to integrate the Spring Boot starter into your applications, and where you can customise behaviour. It is intended both for application teams that consume the starter and for engineers extending the engine itself.
-
Add the dependency
repositories { mavenCentral() } dependencies { implementation("io.github.sahinemirhan:sprisk-engine-starter:0.0.2") } -
Annotate the endpoint – decorate the HTTP or service method that should trigger a risk evaluation:
@GetMapping("/transfer") @RiskCheck(action = "TRANSFER", evaluateOnFailure = true) public ResponseEntity<?> transfer(...) { ... }
The
actionattribute becomes part of the risk profile and helps with reporting and rule configuration. -
Provide a user identifier – Sprisk requires a unique user identifier for every risk evaluation. Reference the identifier with the SpEL expression on
@RiskCheckeither at controller or service level:// From a header @RiskCheck(userId = "#headers['X-User-Id']") // From a request parameter @GetMapping("/test") @RiskCheck(userId = "#id") //or @RiskCheck(userId = "#request.getParameter('name')") public String getUser(@RequestParam String id){ return id; }; // From a path variable @GetMapping("/test/{id}") @RiskCheck(userId = "#id") //or @RiskCheck(userId = "#pathVariables['id']") public String getUser(@PathVariable String id) { return id; } // From a request attribute set by a filter @RiskCheck(userId = "#request.getAttribute('sprisk.userId')") // From the Spring Security principal @RiskCheck(userId = "#request.userPrincipal?.name")
If you want to place the user id on the request yourself, register a simple filter:
@Component
class UserAttributeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String userId = authenticationService.resolveUser(request);
if (userId != null) {
request.setAttribute("sprisk.userId", userId);
}
chain.doFilter(request, response);
}
}-
Challenge / block behaviour – By default the starter throws exceptions when a challenge or block decision is reached. If you want JSON responses or other behaviour, register your own
ChallengeHandlerand/orBlockHandler. Whatever type your handler returns throughChallengeResolution.returning(...)must match the annotated method signature (for exampleResponseEntity<?>in the demo app). If you want to return different types per endpoint, tailor the handler accordingly or keep the default exception-throwing strategy. -
Redis (optional) – If your application exposes a
StringRedisTemplatebean the starter automatically pings Redis. A successful ping enables the Redis-backed storage and logs[Sprisk] RedisStorage activated. If the ping fails you will see[Sprisk] Redis unavailable, falling back to InMemoryStorageand the in-memory store takes over transparently.
| Layer | Module | Responsibility |
|---|---|---|
| Core | sprisk-engine-core |
RuleEngine, RiskResult, DecisionProfile, low-level rule interfaces |
| Spring Starter | sprisk-engine-starter |
Auto-configuration, @RiskCheck, RiskAspect, hard-rule evaluation, handlers |
| Example App | sprisk-engine-example-app |
Demonstrates handlers, listeners, composite resolver, and integration patterns |
To enable Redis support simply expose StringRedisTemplate in your application context (see section 10).
RiskAspectintercepts the@RiskCheckmethod and builds aRiskInvocation.RuleEngineexecutes every registeredRiskRule.- The combined score and triggered rules are stored in a
RiskResult. HardRuleEvaluatorchecks both the defaults and the YAML-defined hard rules.DecisionProfilecompares the score to the configured challenge and block thresholds.- If the decision is
CHALLENGEorBLOCK, the relevant handler is invoked and returns aChallengeResolution. ChallengeOutcomeListenerbeans fire, letting you push metrics or logs downstream.- The current
HttpServletRequestreceivesspriskRuleFlagsandspriskRuleFlagsStringattributes for debugging or telemetry.
Priority order:
(1) Rule class defaults → (2) application.yaml → (3) class-level @RiskCheck → (4) method-level @RiskCheck → (5) programmatic overrides.
sprisk:
enabled: true
fail-closed: false
challenge-threshold: 50
block-threshold: 150
timezone: Europe/Istanbul
window-strategy: SLIDING
policy:
challenge-ttl: 2m
temporary-block-ttl: 15m
permanent-block-ttl: 7d
escalation-threshold: 3
permanent-block-enabled: true
rules:
ip-velocity:
enabled: true
window-seconds: 60
max-per-window: 50
risk-score: 30
user-velocity:
enabled: true
window-seconds: 60
max-per-window: 20
risk-score: 40
brute-force:
enabled: false
window-seconds: 300
max-fail: 5
risk-score: 60
credential-stuffing:
enabled: false
window-seconds: 300
max-distinct-user-count: 20
risk-score: 70
night-time:
enabled: true
start-hour: 1
end-hour: 5
risk-score: 20
hard-rules:
distributed-user-attack:
match:
ip-velocity: false
user-velocity: true
action: BLOCKChallengeResolution.proceed()/returning()/throwing()drive how execution continues.ChallengeOutcomecarries status, TTL, persistence, and metadata.ChallengePolicyStrategydetermines which policy applies to the current request.- Default handlers throw exceptions; supply your own implementations for REST-friendly responses or custom flows.
- When you return a value using
ChallengeResolution.returning(...), ensure the type matches what the intercepted method expects (string, DTO,ResponseEntity, etc.).
@Component
public class ExampleChallengeHandler implements ChallengeHandler {
private final ChallengeTelemetry telemetry;
public ExampleChallengeHandler(ChallengeTelemetry telemetry) {
this.telemetry = telemetry;
}
@Override
public ChallengeResolution handleChallenge(ChallengeContext context) {
telemetry.record(context);
Duration ttl = context.policy() != null
? context.policy().challengeTtl()
: Duration.ofMinutes(5);
Map<String, Object> body = Map.of(
"status", "CHALLENGE",
"reason", context.reason(),
"score", context.result().score(),
"totalChallenges", telemetry.totalChallenges(),
"retryAfterSeconds", ttl.toSeconds()
);
ChallengeOutcome outcome = ChallengeOutcome.challenge()
.message(context.reason())
.ttl(ttl)
.metadata("hardRule", context.hardRuleHit() != null ? context.hardRuleHit().ruleName() : null)
.build();
return ChallengeResolution.returning(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body), outcome);
}
}@Component
public class CustomBlockHandler implements BlockHandler {
@Override
public ChallengeResolution handleBlock(BlockContext context) {
Map<String, Object> payload = Map.of(
"status", "blocked",
"reason", context.reason(),
"score", context.result().score()
);
return ChallengeResolution.returning(ResponseEntity.status(HttpStatus.FORBIDDEN).body(payload));
}
}Hard rules are defined under sprisk.hard-rules. The engine loads every rule irrespective of its identifier, so you can freely add names like fraud-block or vip-allow. Each rule declares a match map that references other rule codes and an action to execute (BLOCK or CHALLENGE). Keep the keys aligned with RiskRule.code() outputs.
Example:
sprisk:
hard-rules:
vip-protection:
match:
brute-force: true
user-velocity: true
action: CHALLENGERules are evaluated in YAML order; place more specific matches first.
| Rule | Description | Default |
|---|---|---|
IP_VELOCITY |
Tracks per-IP request rate | windowSeconds=60, maxPerWindow=50, riskScore=30 |
USER_VELOCITY |
Tracks request rate per user id | windowSeconds=60, maxPerWindow=20, riskScore=40 |
BRUTE_FORCE |
Counts failed attempts | enabled=false, windowSeconds=300, maxFail=5, riskScore=60 |
CREDENTIAL_STUFFING |
Detects many user ids from the same IP | enabled=false, windowSeconds=300, maxDistinctUserCount=20, riskScore=70 |
NIGHT_TIME |
Flags activity during night hours | enabled=true, startHour=2, endHour=6, riskScore=15 |
Rules are customisable via YAML:
sprisk:
rules:
ip-velocity:
window-seconds: 30
max-per-window: 15
risk-score: 50
brute-force:
enabled: true
max-fail: 3
risk-score: 80The starter automatically gathers every RiskRule bean in the Spring context. To add your own heuristics, implement the interface and either annotate the class with @Component or expose it via a @Bean. Return a unique code() (used by hard rules and logs) and an evaluate score greater than zero when the rule should contribute risk.
@Component
class SuspiciousIpRule implements RiskRule {
private final Set<String> blockedIps;
SuspiciousIpRule(DenyListService denyListService) {
this.blockedIps = denyListService.currentEntries();
}
@Override
public int evaluate(RiskContext ctx) {
return blockedIps.contains(ctx.ip()) ? 80 : 0;
}
@Override
public String code() {
return "suspicious-ip";
}
}If you prefer Java configuration, declare the rule inside a configuration class:
@Configuration
class CustomRuleConfiguration {
@Bean
RiskRule deviceVelocityRule(WindowManager windowManager) {
return new RiskRule() {
@Override
public int evaluate(RiskContext ctx) {
Map<String, Object> attributes = ctx.attributes();
Integer recentDeviceCount = (Integer) attributes.getOrDefault("recentDeviceCount", 0);
return recentDeviceCount > 3 ? 40 : 0;
}
@Override
public String code() {
return "device-velocity";
}
};
}
}As soon as the bean exists, RuleEngine logs it during startup and evaluates it alongside the built-ins. You can reference the returned code() in YAML hard rules or overrides exactly as you do with the default rules.
When a StringRedisTemplate bean is present the starter issues a PING before enabling Redis storage:
- Successful ping →
[Sprisk] RedisStorage activated (Redis connection successful) - Failed ping →
[Sprisk] Redis unavailable, falling back to InMemoryStorage
Redis configuration example:
@Configuration
public class RedisClientConfiguration {
@Bean
public LettuceConnectionFactory redisConnectionFactory(RedisProperties props) {
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(props.getHost(), props.getPort());
cfg.setPassword(props.getPassword());
return new LettuceConnectionFactory(cfg);
}
@Bean
public StringRedisTemplate stringRedisTemplate(LettuceConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}spring:
data:
redis:
host: localhost
port: 6379For local development you can spin up Redis with docker run --rm -p 6379:6379 redis:7-alpine.
| Symptom | Likely Cause | Resolution |
|---|---|---|
| IP velocity triggers incorrectly | Client IP not forwarded | Add ForwardedHeaderFilter or override @RiskCheck(ip = ...) |
User id resolves to null |
Resolver chain cannot locate an id | Ensure headers/session/JWT provide a user id |
| Default hard rule blocks a user | Same user accesses from multiple IPs | Tweak or disable sprisk.hard-rules.distributed-user-attack |
| Redis keys keep growing | TTL/window values too long | Revisit sprisk.policy.* and per-rule window settings |
Use the example app’s RiskDebugLoggingFilter to inspect request attributes during development.
The published artefact coordinates are io.github.sahinemirhan:sprisk-engine-starter. Releases are tagged in Git with matching versions, and each release includes sources and javadoc jars. If you want to depend on an unreleased build, use ./gradlew publishToMavenLocal and point your consuming project at mavenLocal() during development.
- Fork the repository and create feature branches from
main. - Update documentation (
README.MD) whenever you add features or behaviour flags. - Add unit or integration tests for changes that affect challenge/block logic or rule outcomes.
- Run
./gradlew buildbefore opening a pull request and attach the relevant output. - Follow the existing code style and avoid introducing unnecessary dependencies.
The project welcomes issues and discussions on GitHub. Bug reports with reproduction steps and proposed improvements are especially helpful.
For questions, open an issue in the repository or reach out to the maintainers. Happy building!