Your backend
Spring Boot
Auto-configured ZevAuth for Spring Boot. Protect controllers with @RequireAuth, take the Auth as an argument, use Spring Security, and serve server-rendered pages.
<dependency>
<groupId>com.zevauth</groupId>
<artifactId>zevauth-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>
Spring Boot 4 on Java 17 or later, in Java or Kotlin. The starter builds on Java; everything on that page is available here too.
zevauth.environment-id=env_live_…
zevauth.secret-key=${ZEVAUTH_SECRET_KEY}
zevauth.webhook-secret=${ZEVAUTH_WEBHOOK_SECRET}
That is the whole setup. Each property switches on what it needs:
| Property | Gives you |
|---|---|
zevauth.environment-id | A ZevAuthVerifier bean and a filter that reads who every request is from |
zevauth.secret-key | A ZevAuthClient bean for the backend API |
zevauth.webhook-secret | A ZevAuthWebhooks bean |
zevauth.bridge.enabled=true | The session bridge, for server-rendered pages |
Define any of these beans yourself and the starter uses yours instead.
Protecting controllers
@RestController
class AccountController {
@RequireAuth
@GetMapping("/api/me")
Map<String, Object> me(Auth auth) {
return Map.of("userId", auth.getUserId());
}
@RequireAuth(requireMfa = true)
@PostMapping("/api/payouts")
void payout(Auth auth) { … }
@GetMapping("/api/public")
String open(Auth auth) {
return auth.isSignedIn() ? "hello " + auth.getUserId() : "hello";
}
}
@RequireAuth goes on a controller or a method; the method’s wins. It
answers with the shared envelope:
| Status | error.code | When |
|---|---|---|
| 401 | unauthorized | Not signed in |
| 403 | session_task_pending | Two-step setup is still pending. Use @RequireAuth(allowPendingTasks = true) on the routes that help finish it |
| 403 | mfa_required | requireMfa = true and the session did not pass a second factor |
Any controller method can take an Auth parameter. It is signed out when
the request carried no valid session.
Spring Security
With Spring Security on the classpath, every signed-in request also gets a
ZevAuthAuthentication. Its principal is the user id, and its authorities
are the active organization’s role and permissions:
@PreAuthorize("hasAuthority('org:members:manage')")
@PostMapping("/api/members")
void invite(Auth auth) { … }
To use ZevAuth in URL rules, add the starter’s filter to your chain, and keep the chain stateless so a session token is never stored in an HTTP session:
@Bean
SecurityFilterChain security(HttpSecurity http, ZevAuthRequestFilter zevauth) throws Exception {
return http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(zevauth, AuthorizationFilter.class)
.authorizeHttpRequests(a -> a.requestMatchers("/api/**").authenticated().anyRequest().permitAll())
.build();
}
The filter runs once per request, so it is safe to have it both in the chain and registered by the starter.
Server-rendered pages
Thymeleaf, JTE and other server-rendered apps get the session bridge from two properties:
zevauth.publishable-key=pk_live_…
zevauth.bridge.enabled=true
The starter then serves the bridge endpoint at /zevauth/bridge and the
handshake page at /zevauth/handshake, reads the session cookie on every
request, and sends a page through the handshake when the cookie has
expired. In the browser, pass sessionBridge: { url: '/zevauth/bridge' }
to the SDK.
| Property | Default | |
|---|---|---|
zevauth.bridge.path | /zevauth/bridge | |
zevauth.bridge.handshake-path | /zevauth/handshake | |
zevauth.bridge.allowed-origins | the request’s own origin | Origins that may post to the bridge |
zevauth.bridge.cookie-domain | none | Share the cookies with subdomains |
zevauth.bridge.secure | true | false for plain http:// local development only |
zevauth.bridge.ui-script-url | jsDelivr | Serve your own copy of @zevauth/ui under a strict CSP |
Webhooks
@PostMapping("/webhooks/zevauth")
ResponseEntity<Void> hook(@RequestBody byte[] body, @RequestHeader("zevauth-signature") String signature)
throws WebhookException {
WebhookEvent event = webhooks.constructEvent(body, signature);
return ResponseEntity.noContent().build();
}
Take the body as byte[]. A @RequestBody object would be parsed and
re-serialised, and the signature, which covers the exact bytes ZevAuth
sent, would correctly fail.
Backend API
@Service
class Members {
private final ZevAuthClient zevauth;
Members(ZevAuthClient zevauth) {
this.zevauth = zevauth;
}
void block(String userId) {
zevauth.users().setBlocked(userId, true);
}
}
See Java for every resource, iteration, errors and retries.
Testing
Replace the verifier with one that uses a local key set and a fixed clock:
@TestConfiguration
class ZevAuthTestKeys {
@Bean
ZevAuthVerifier zevAuthVerifier() {
return ZevAuthVerifier.builder("env_test_…")
.localKeys("https://api.zevauth.net/v1/env_test_…", jwksJson)
.clock(Clock.fixed(now, ZoneOffset.UTC))
.build();
}
}
Source and issues: github.com/zevop/zevauth-java.
Updated at, Sunday, November 1, 2026