Securing Vaadin applications with Spring Security
Add login, logout, and role-based access control to a Vaadin Flow application using Spring Security, view access annotations, and AuthenticationContext.
Security in a server-side UI
Because Vaadin Flow runs your UI logic on the server, users cannot bypass a hidden button by editing JavaScript: if a view or component is not created on the server, it simply does not exist for that user. That is a strong foundation, but you still need to answer the classic questions: who is the user, and which views and actions may they access?
Vaadin integrates with Spring Security to handle authentication and to protect views with simple annotations. The examples target Vaadin 24 with Spring Boot 3. Newer Vaadin versions introduce an updated configuration API; if a class used below is marked as deprecated in your version, follow the migration notes in the Vaadin documentation. The concepts stay the same.
Dependencies
Add Spring Security to a Vaadin Spring Boot project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
The security configuration
Vaadin provides a base class that configures Spring Security correctly for Vaadin's internal requests, static resources, and CSRF handling:
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@EnableWebSecurity
@Configuration
public class SecurityConfig extends VaadinWebSecurity {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/images/**", "/public/**").permitAll());
super.configure(http);
setLoginView(http, LoginView.class);
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
The order matters: add your own rules for public resources first, then call super.configure(http), which secures everything else, and finally register the login view.
Where users come from
Spring Security needs a UserDetailsService. In a real application it loads users from your database:
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser user = users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Unknown user"));
return User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.roles(user.getRoles().toArray(String[]::new))
.disabled(!user.isActive())
.build();
}
}
Store only password hashes produced by the PasswordEncoder, never plain text passwords. If your organization already has an identity provider such as Keycloak, Microsoft Entra ID, or Google Workspace, you can use Spring Security's OAuth2 login instead and skip password handling entirely.
The login view
Vaadin includes a ready-made LoginForm component that posts to Spring Security's login processing URL:
@Route("login")
@PageTitle("Sign in")
@AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver {
private final LoginForm login = new LoginForm();
public LoginView() {
setSizeFull();
setAlignItems(Alignment.CENTER);
setJustifyContentMode(JustifyContentMode.CENTER);
login.setAction("login");
login.setForgotPasswordButtonVisible(false);
add(new H1("Shop Admin"), login);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
boolean failed = event.getLocation().getQueryParameters()
.getParameters().containsKey("error");
login.setError(failed);
}
}
After a failed attempt, Spring Security redirects to /login?error, and the form shows an error message. LoginOverlay is an alternative that displays the same form as a full-screen overlay with a title and description.
Protecting views with annotations
Once security is enabled, every view is denied by default. You must explicitly declare who may access each one:
| Annotation | Who can access |
|---|---|
@AnonymousAllowed |
everyone, including users who are not logged in |
@PermitAll |
any authenticated user |
@RolesAllowed("ADMIN") |
only users with the listed roles |
@DenyAll |
nobody (the default for unannotated views) |
@Route(value = "", layout = MainLayout.class)
@PermitAll
public class DashboardView extends VerticalLayout { }
@Route(value = "users", layout = MainLayout.class)
@RolesAllowed("ADMIN")
public class UserManagementView extends VerticalLayout { }
The "deny by default" behavior is a safe design: forgetting an annotation hides a view instead of exposing it. Unauthenticated users who open a protected view are redirected to the login view.
Annotations on layouts work too, but remember that a view needs access to both its own route and its parent layout.
The current user and logout
Inject AuthenticationContext to read the logged-in user and to log out:
public class MainLayout extends AppLayout {
public MainLayout(AuthenticationContext authContext) {
var nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class));
if (authContext.hasRole("ADMIN")) {
nav.addItem(new SideNavItem("Users", UserManagementView.class));
}
var header = new HorizontalLayout(new DrawerToggle(), new H1("Shop Admin"));
authContext.getAuthenticatedUser(UserDetails.class).ifPresent(user -> {
var logout = new Button("Log out " + user.getUsername(), e -> authContext.logout());
header.add(logout);
});
addToNavbar(header);
addToDrawer(nav);
}
}
Hiding the "Users" menu item is about user experience. The real protection is the @RolesAllowed("ADMIN") annotation on the view, which applies even if someone types the URL directly.
Protecting actions and services
Views are not the only entry points. A button that deletes data should be safe even if it is rendered in a view many roles can see. Protect service methods with method security:
@Configuration
@EnableMethodSecurity(jsr250Enabled = true)
class MethodSecurityConfig { }
@Service
public class OrderService {
@RolesAllowed("ADMIN")
public void deleteOrder(long id) { /* ... */ }
@PreAuthorize("hasRole('ADMIN') or #order.createdBy == authentication.name")
public void cancel(Order order) { /* ... */ }
}
If an unauthorized user triggers the method, Spring throws an AccessDeniedException. Defending the service layer protects you from mistakes in the UI and keeps the rules consistent if the same services are later exposed through a REST API.
Session and production tips
- Use HTTPS everywhere. Session cookies must never travel over plain HTTP.
- Configure session timeouts with
server.servlet.session.timeoutto match your security requirements. - Add remember-me carefully, only if users really need it, and with a persistent token store.
- Audit sensitive actions such as role changes and deletions, including who did them and when.
- Test access rules. Write tests that verify an ordinary user cannot reach admin views or call admin services.
Exercise
Create three roles: USER, MANAGER, and ADMIN. Give everyone access to the dashboard, managers access to a reports view, and admins access to user management. Show only the permitted menu items, display the user's name next to a logout button, and protect a deleteUser service method so that only admins can call it.
