Custom authorization rules¶
The built-in rules (allow, deny, authenticated, hasRole, hasAnyRole) cover the common
cases, but real applications often need decisions that depend on the request itself — the
caller's IP must be on an allowlist, the request must arrive during business hours, and so on.
javalin-security exposes a small Rule interface for exactly these cases. You can plug custom
rules into the same security.rules table you already use for the built-ins.
What a Rule is¶
Rule is a single-abstract-method interface:
authentication— the resolvedAuthenticationfor the request.authentication.identityisnullfor anonymous callers.authentication.rolesis the set of granted roles.context— the JavalinContextfor HTTP rules (or the upgradeContextfor WebSocket rules). Read the client IP, headers, path or query parameters, or anything else from it.- Return
trueto grant access,falseto deny. Never throw — an exception is treated as a denial and turns into 401 / 403 based on whether the caller is authenticated.
Because Rule is a fun interface, Kotlin lambdas and Java -> lambdas both work out of the
box.
When to reach for a custom rule¶
| You need to check… | Use… |
|---|---|
| The caller is logged in. | authenticated (built-in). |
| The caller has one specific role. | hasRole(role) (built-in). |
| The caller has any of several roles. | hasAnyRole(...) (built-in). |
| Request-time constraints (IP, time, feature). | Custom rule — read from the context. |
If the check does not depend on the request, prefer role-based authorization: grant the role in
your Authenticator and use hasRole in the rule table. Custom rules are for the cases where
role membership alone cannot answer the question.
Example: IP allowlist¶
A common case: an internal admin API should only be reachable from a small set of trusted IP
addresses (an office network, a jump host, a corporate VPN). Express that as a rule that
inspects the client IP on the Context.
import io.github.mzlnk.javalin.security.authorization.Rules
import io.github.mzlnk.javalin.security.security
val allowedIps = setOf("10.0.0.10", "10.0.0.11", "203.0.113.42")
// Grant only when the caller IP is on the allowlist
val fromTrustedIp = Rule { _, ctx -> ctx.ip() in allowedIps }
config.security { security ->
security.rules.any("/admin/*", fromTrustedIp)
security.http.authentication = myStrategy
security.http.fallback = Rules.deny()
}
import io.github.mzlnk.javalin.security.authorization.Rule;
import io.github.mzlnk.javalin.security.authorization.Rules;
Set<String> allowedIps = Set.of("10.0.0.10", "10.0.0.11", "203.0.113.42");
// Grant only when the caller IP is on the allowlist
Rule fromTrustedIp = (auth, ctx) -> allowedIps.contains(ctx.ip());
config.registerPlugin(new JavalinSecurityPlugin(security -> {
security.rules.any("/admin/*", fromTrustedIp);
security.http.authentication = myStrategy;
security.http.fallback = Rules.deny();
}));
Anonymous callers whose IP is not on the list get a 401. Authenticated callers from an unlisted IP get a 403. See Denial status.
Behind a proxy or load balancer
Context.ip() returns the socket-level peer. Behind a reverse proxy, that is the proxy's
address — configure Javalin to trust X-Forwarded-For (or read the forwarded header
directly in the rule) so you compare against the real client IP.
WebSocket rules¶
Rule is the same type for HTTP and WebSocket authorization. Register WebSocket entries with
security.rules.ws(…) instead of verb methods. WebSocket rules match on path only (no method),
and they run once at upgrade time — the resulting Authentication is then reused for every
message on that session.
Rules for well-behaved rules¶
Never throw from a rule
A rule that throws is treated as a denial. Instead, return false — the guard renders the
proper 401 or 403 based on whether the caller is authenticated. Reserve exceptions for real
programming errors.
- Always null-check the identity. Anonymous callers reach custom rules too.
- Do not do I/O in a rule. Rules run on the request thread. If you need remote lookups, cache the answer in the authenticator (attach it to the identity) so the rule can read it synchronously.
- Keep rules pure. No mutation, no logging of sensitive request data, no side effects on
the
Context— a rule only decides, the handler acts. - Order matters in the table. Entries are evaluated top-to-bottom and the first match wins. Put specific patterns before broader ones.
- Prefer role checks when possible. If the answer only depends on identity, put the check
in your
Authenticator(grant a role) and usehasRole— it is faster to read and easier to audit.
Testing rules¶
Because Rule is a plain functional interface, you can unit-test it without spinning up
Javalin. Build an Authentication with Authentication.authenticated(...) (or
Authentication.unauthenticated()) and pass a mocked Context.
import io.github.mzlnk.javalin.security.authentication.Authentication
import io.javalin.http.Context
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import kotlin.test.Test
class FromTrustedIpTest {
@Test
fun `grants when the caller IP is on the allowlist`() {
val ctx = mockk<Context> { every { ip() } returns "10.0.0.10" }
assertThat(fromTrustedIp.isGranted(Authentication.unauthenticated(), ctx)).isTrue()
}
@Test
fun `denies when the caller IP is not on the allowlist`() {
val ctx = mockk<Context> { every { ip() } returns "198.51.100.7" }
assertThat(fromTrustedIp.isGranted(Authentication.unauthenticated(), ctx)).isFalse()
}
}
import io.github.mzlnk.javalin.security.authentication.Authentication;
import io.javalin.http.Context;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FromTrustedIpTest {
@Test
void grants_when_caller_ip_is_on_the_allowlist() {
Context ctx = mock(Context.class);
when(ctx.ip()).thenReturn("10.0.0.10");
assertThat(fromTrustedIp.isGranted(Authentication.unauthenticated(), ctx)).isTrue();
}
@Test
void denies_when_caller_ip_is_not_on_the_allowlist() {
Context ctx = mock(Context.class);
when(ctx.ip()).thenReturn("198.51.100.7");
assertThat(fromTrustedIp.isGranted(Authentication.unauthenticated(), ctx)).isFalse();
}
}
For an end-to-end assertion (real server, real HTTP status), see Testing secured apps.
Next steps¶
- Authorization — how the rule table fits into the request lifecycle.
- Error handling — customize the 401 / 403 responses your rules produce.
- Custom authentication — grant custom roles or attach extra data to the identity so rules can read it.