ZevAuth
Sign up

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:

PropertyGives you
zevauth.environment-idA ZevAuthVerifier bean and a filter that reads who every request is from
zevauth.secret-keyA ZevAuthClient bean for the backend API
zevauth.webhook-secretA ZevAuthWebhooks bean
zevauth.bridge.enabled=trueThe 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:

Statuserror.codeWhen
401unauthorizedNot signed in
403session_task_pendingTwo-step setup is still pending. Use @RequireAuth(allowPendingTasks = true) on the routes that help finish it
403mfa_requiredrequireMfa = 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.

PropertyDefault
zevauth.bridge.path/zevauth/bridge
zevauth.bridge.handshake-path/zevauth/handshake
zevauth.bridge.allowed-originsthe request’s own originOrigins that may post to the bridge
zevauth.bridge.cookie-domainnoneShare the cookies with subdomains
zevauth.bridge.securetruefalse for plain http:// local development only
zevauth.bridge.ui-script-urljsDelivrServe 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