Securing Grails Applications with Spring Security Core
Configure Spring Security Core on a Grails 8 REST API with User/Role domains, HTTP Basic auth, @Secured controller rules, and Spock tests.
Authors: Sanjana
Grails Version: 8
1 Introduction
Learn how to secure a Grails 8 REST API with Spring Security Core: configure the plugin, model User and Role domains, enforce access with @Secured on controller actions, and verify behaviour with Spock unit and integration tests.
This guide follows the standard Grails guides layout: work in the initial/ project and compare your progress with complete/.
2 What you will need
-
Approximately 45 minutes
-
JDK 21 (Apache Grails 8 requires Java 21)
-
A Grails installation or the bundled Gradle wrapper in
initial/andcomplete/ -
Docker — required for integration tests (Testcontainers PostgreSQL)
-
PostgreSQL on
localhost:5432— required for./gradlew bootRunin bothinitial/andcomplete/(default databasedevDb; seegrails-app/conf/application.yml)
3 Getting Started
Clone the repository and run the starting application:
git clone -b grails8 https://github.com/grails-guides/grails-spring-security.git
cd grails-spring-security/initial
./gradlew bootRun
Open http://localhost:8080/ for the welcome JSON payload. The initial/ project exposes a simple Resource REST API with no security plugin yet.
To skip ahead, cd complete and run the same commands — that tree contains the finished security configuration, domains, and tests.
Verify tests
./gradlew test
Both initial and complete must pass in CI (see .github/workflows/grails8.yml).
4 Install Spring Security Core
Add the Spring Security Core plugin to build.gradle:
implementation "org.apache.grails:grails-spring-security:8.0.0-SNAPSHOT"
Configure HTTP Basic authentication and point the plugin at your domain classes in grails-app/conf/application.yml:
plugin:
springsecurity:
useBasicAuth: true
basic:
realmName: Securing Grails Applications
rejectIfNoRule: true
userLookup:
userDomainClassName: example.User
authorityJoinClassName: example.UserRole
authority:
className: example.Role
controllerAnnotations:
staticRules:
- { pattern: '/', access: ['permitAll'] }
- { pattern: '/error', access: ['permitAll'] }
- { pattern: '/notFound', access: ['permitAll'] }
- { pattern: '/shutdown', access: ['permitAll'] }
- { pattern: '/assets/**', access: ['permitAll'] }
- { pattern: '/**/favicon.ico', access: ['permitAll'] }
useBasicAuth: true enables HTTP Basic for REST clients. rejectIfNoRule: true denies any controller action that lacks an explicit @Secured rule — a safe default for APIs.
The userLookup and authority settings tell the plugin which domain classes represent users, roles, and their join table.
5 User, Role, and password encoding
Spring Security expects a User, a Role, and a join class linking them.
User
package example
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
import grails.persistence.Entity
@GrailsCompileStatic
@Entity
@EqualsAndHashCode(includes = 'username')
@ToString(includes = 'username', includeNames = true, includePackage = false)
class User implements Serializable {
private static final long serialVersionUID = 1
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
Set<Role> getAuthorities() {
(UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
}
static constraints = {
password blank: false, password: true
username blank: false, nullable: false, unique: true
}
static mapping = {
table '`user`'
password column: '`password`'
}
}
Role
package example
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
import grails.persistence.Entity
@GrailsCompileStatic
@Entity
@EqualsAndHashCode(includes = 'authority')
@ToString(includes = 'authority', includeNames = true, includePackage = false)
class Role implements Serializable {
private static final long serialVersionUID = 1
String authority
static constraints = {
authority blank: false, nullable: false, unique: true
}
static mapping = {
table '`role`'
cache true
}
}
UserRole
The join class uses a composite primary key:
package example
import grails.gorm.DetachedCriteria
import groovy.transform.ToString
import org.codehaus.groovy.util.HashCodeHelper
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@ToString(cache = true, includeNames = true, includePackage = false)
class UserRole implements Serializable {
private static final long serialVersionUID = 1
User user
Role role
@Override
boolean equals(other) {
if (other instanceof UserRole) {
other.userId == user?.id && other.roleId == role?.id
}
}
@Override
int hashCode() {
int hashCode = HashCodeHelper.initHash()
if (user) {
hashCode = HashCodeHelper.updateHash(hashCode, user.id)
}
if (role) {
hashCode = HashCodeHelper.updateHash(hashCode, role.id)
}
hashCode
}
static UserRole get(long userId, long roleId) {
(UserRole) criteriaFor(userId, roleId).get()
}
static boolean exists(long userId, long roleId) {
criteriaFor(userId, roleId).count()
}
private static DetachedCriteria criteriaFor(long userId, long roleId) {
UserRole.where {
user == User.load(userId) &&
role == Role.load(roleId)
}
}
static UserRole create(User user, Role role, boolean flush = false) {
def instance = new UserRole(user: user, role: role)
instance.save(flush: flush)
instance
}
static boolean remove(User u, Role r) {
if (u != null && r != null) {
UserRole.where { user == u && role == r }.deleteAll()
}
}
static int removeAll(User u) {
u == null ? 0 : UserRole.where { user == u }.deleteAll() as int
}
static int removeAll(Role r) {
r == null ? 0 : UserRole.where { role == r }.deleteAll() as int
}
static constraints = {
role validator: { Role r, UserRole ur ->
if (ur.user?.id) {
UserRole.withNewSession {
if (UserRole.exists(ur.user.id, r.id)) {
return ['userRole.exists']
}
}
}
}
}
static mapping = {
id composite: ['user', 'role']
version false
}
}
Password encoding
Hash passwords before they reach the database with a GORM event listener:
package example
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener
import org.grails.datastore.mapping.engine.event.EventType
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationEvent
import org.springframework.security.crypto.password.PasswordEncoder
@SuppressWarnings(['UnnecessaryGetter', 'LineLength', 'Instanceof'])
@CompileStatic
class UserPasswordEncoderListener extends AbstractPersistenceEventListener {
@Autowired
PasswordEncoder passwordEncoder
UserPasswordEncoderListener(final Datastore datastore) {
super(datastore)
}
@Override
protected void onPersistenceEvent(AbstractPersistenceEvent event) {
if (event.entityObject instanceof User) {
User u = (User) event.entityObject
if (u.password && (event.eventType == EventType.PreInsert || (event.eventType == EventType.PreUpdate && u.isDirty('password')))) {
event.getEntityAccess().setProperty('password', encodePassword(u.password))
}
}
}
@Override
boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
eventType == PreUpdateEvent || eventType == PreInsertEvent
}
private String encodePassword(String password) {
if (password?.startsWith('$2a$') || password?.startsWith('$2b$')) {
return password
}
passwordEncoder.encode(password)
}
}
Register the listener as a Spring bean:
import example.UserPasswordEncoderListener
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener, ref('hibernateDatastore'))
}
The listener skips values that already look like BCrypt hashes ($2a$ / $2b$), so re-saving a user does not double-encode the password.
6 Securing REST controllers
Map REST resources under /api:
package example
class UrlMappings {
static mappings = {
"/$controller/$action?/$id?(.$format)?"{
constraints {
// apply constraints here
}
}
"/api/resources"(resources: 'resource')
"/"(controller: 'application', action: 'index')
"500"(view: '/error')
"404"(view: '/notFound')
}
}
Apply @Secured on each action. Spring Security evaluates access before your controller runs:
package example
import grails.gorm.transactions.Transactional
import grails.plugin.springsecurity.annotation.Secured
// Default for actions without their own @Secured; rejectIfNoRule denies anything that slips through unannotated.
@Secured(['ROLE_USER'])
class ResourceController {
static responseFormats = ['json']
static allowedMethods = [index: 'GET', show: 'GET', save: 'POST', update: 'PUT', delete: 'DELETE']
@Secured(['permitAll'])
def index(Integer max) {
params.max = Math.min(max ?: 10, 100)
respond Resource.list(params), model: [resourceCount: Resource.count() as Long]
}
@Secured(['permitAll'])
def show(Long id) {
respond Resource.get(id)
}
@Secured(['isAuthenticated()'])
@Transactional
def save() {
def resource = new Resource(request.JSON as Map)
if (!resource.validate()) {
respond resource.errors, status: 422
return
}
resource.save(failOnError: true, flush: true)
respond resource, status: 201
}
@Secured(['ROLE_ADMIN'])
@Transactional
def update(Long id) {
def resource = Resource.get(id)
if (!resource) {
render status: 404
return
}
resource.properties = request.JSON
if (!resource.validate()) {
respond resource.errors, status: 422
return
}
resource.save(failOnError: true, flush: true)
respond resource
}
@Secured(['ROLE_ADMIN'])
@Transactional
def delete(Long id) {
def resource = Resource.get(id)
if (!resource) {
render status: 404
return
}
resource.delete(flush: true)
render status: 204
}
}
-
permitAll— anonymous read access onindexandshow -
isAuthenticated()— any logged-in user can create resources -
ROLE_ADMIN— only administrators can update or delete
Call validate() after binding request JSON and before save. That returns structured 422 responses for constraint violations.
Seed development users and sample data in BootStrap:
package example
class BootStrap {
def init = { servletContext ->
environments {
development {
Role.withTransaction {
if (Role.count() == 0) {
def userRole = new Role(authority: 'ROLE_USER').save(failOnError: true)
def adminRole = new Role(authority: 'ROLE_ADMIN').save(failOnError: true)
def user = new User(username: 'user', password: 'password', enabled: true).save(failOnError: true)
UserRole.create(user, userRole, true)
def admin = new User(username: 'admin', password: 'password', enabled: true).save(failOnError: true)
UserRole.create(admin, adminRole, true)
UserRole.create(admin, userRole, true)
}
if (Resource.count() == 0) {
new Resource(name: 'Public Overview', description: 'Readable without authentication').save(failOnError: true)
new Resource(name: 'Admin Console', description: 'Restricted to administrators').save(failOnError: true)
}
}
}
}
}
def destroy = {
}
}
Development users:
| Username | Password | Roles |
|---|---|---|
|
|
|
|
|
|
7 Testing security rules
Unit tests
Controller unit tests exercise action logic with mocked domains:
package example
import grails.testing.gorm.DataTest
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
class ResourceControllerSpec extends Specification implements ControllerUnitTest<ResourceController>, DataTest {
Class<?>[] getDomainClassesToMock() {
[Resource, User, Role, UserRole] as Class[]
}
void 'index lists resources without authentication'() {
given:
new Resource(name: 'Public').save(flush: true)
when:
controller.index()
then:
model.resourceList
model.resourceCount == 1
}
void 'show returns 404 when resource missing'() {
when:
controller.show(99L)
then:
response.status == 404
}
void 'save creates resource when valid'() {
given:
request.method = 'POST'
request.JSON = [name: 'Team Notes', description: 'Shared notes']
when:
controller.save()
then:
response.status == 201
Resource.count() == 1
Resource.first().name == 'Team Notes'
}
void 'update persists changes when valid'() {
given:
def resource = new Resource(name: 'Original', description: 'Before').save(flush: true)
request.method = 'PUT'
request.JSON = [name: 'Updated', description: 'After']
when:
controller.update(resource.id)
then:
response.status == 200
resource.refresh().name == 'Updated'
}
void 'update returns 404 when resource missing'() {
given:
request.method = 'PUT'
request.JSON = [name: 'Nobody']
when:
controller.update(99L)
then:
response.status == 404
}
void 'delete removes resource'() {
given:
def resource = new Resource(name: 'Disposable').save(flush: true)
request.method = 'DELETE'
when:
controller.delete(resource.id)
then:
response.status == 204
Resource.count() == 0
}
void 'delete returns 404 when resource missing'() {
given:
request.method = 'DELETE'
when:
controller.delete(99L)
then:
response.status == 404
}
}
Integration tests
Integration specs boot the full application context and exercise real HTTP requests with Basic authentication:
package example
import grails.testing.mixin.integration.Integration
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestTemplate
import spock.lang.Specification
@Integration
class ResourceSecurityIntegrationSpec extends Specification {
RestTemplate restTemplate = new RestTemplate()
private String baseUrl() {
"http://localhost:${serverPort}"
}
private HttpHeaders basicAuthHeaders(String username, String password) {
HttpHeaders headers = new HttpHeaders()
headers.setBasicAuth(username, password)
headers
}
private HttpEntity<String> jsonEntity(Map body, HttpHeaders headers = new HttpHeaders()) {
headers.setContentType(MediaType.APPLICATION_JSON)
new HttpEntity(groovy.json.JsonOutput.toJson(body), headers)
}
private void seedUsersAndResource() {
UserRole.withTransaction {
UserRole.list()*.delete(flush: true)
User.list()*.delete(flush: true)
Role.list()*.delete(flush: true)
Resource.list()*.delete(flush: true)
def userRole = new Role(authority: 'ROLE_USER').save(failOnError: true, flush: true)
def adminRole = new Role(authority: 'ROLE_ADMIN').save(failOnError: true, flush: true)
def user = new User(username: 'user', password: 'password', enabled: true).save(failOnError: true, flush: true)
UserRole.create(user, userRole, true)
def admin = new User(username: 'admin', password: 'password', enabled: true).save(failOnError: true, flush: true)
UserRole.create(admin, adminRole, true)
new Resource(name: 'Secured Notes', description: 'Used in security integration tests').save(failOnError: true, flush: true)
}
}
void 'anonymous users can read the public resource index'() {
given:
seedUsersAndResource()
when:
ResponseEntity<String> response = restTemplate.getForEntity("${baseUrl()}/api/resources", String)
then:
response.statusCode == HttpStatus.OK
}
void 'anonymous POST receives 401 unauthorized'() {
given:
seedUsersAndResource()
when:
restTemplate.postForEntity("${baseUrl()}/api/resources", jsonEntity([name: 'New Resource']), String)
then:
HttpClientErrorException ex = thrown(HttpClientErrorException)
ex.statusCode == HttpStatus.UNAUTHORIZED
}
void 'authenticated user with ROLE_USER can create resources'() {
given:
seedUsersAndResource()
when:
ResponseEntity<String> response = restTemplate.exchange(
"${baseUrl()}/api/resources",
HttpMethod.POST,
jsonEntity([name: 'User Created'], basicAuthHeaders('user', 'password')),
String
)
then:
response.statusCode == HttpStatus.CREATED
new groovy.json.JsonSlurper().parseText(response.body).name == 'User Created'
}
void 'authenticated user without ROLE_ADMIN receives 403 on update'() {
given:
seedUsersAndResource()
Long resourceId = Resource.withTransaction { Resource.first().id }
when:
restTemplate.exchange(
"${baseUrl()}/api/resources/${resourceId}",
HttpMethod.PUT,
jsonEntity([name: 'Blocked Update'], basicAuthHeaders('user', 'password')),
String
)
then:
HttpClientErrorException ex = thrown(HttpClientErrorException)
ex.statusCode == HttpStatus.FORBIDDEN
}
void 'admin can update and delete resources'() {
given:
seedUsersAndResource()
Long resourceId = Resource.withTransaction { Resource.first().id }
when:
ResponseEntity<String> updateResponse = restTemplate.exchange(
"${baseUrl()}/api/resources/${resourceId}",
HttpMethod.PUT,
jsonEntity([name: 'Admin Updated'], basicAuthHeaders('admin', 'password')),
String
)
then:
updateResponse.statusCode == HttpStatus.OK
new groovy.json.JsonSlurper().parseText(updateResponse.body).name == 'Admin Updated'
when:
ResponseEntity<String> deleteResponse = restTemplate.exchange(
"${baseUrl()}/api/resources/${resourceId}",
HttpMethod.DELETE,
new HttpEntity<>(basicAuthHeaders('admin', 'password')),
String
)
then:
deleteResponse.statusCode == HttpStatus.NO_CONTENT
}
void 'invalid create payload returns 422'() {
given:
seedUsersAndResource()
when:
restTemplate.exchange(
"${baseUrl()}/api/resources",
HttpMethod.POST,
jsonEntity([description: 'missing name'], basicAuthHeaders('user', 'password')),
String
)
then:
HttpClientErrorException ex = thrown(HttpClientErrorException)
ex.statusCode.value() == 422
}
}
These tests verify the security rules end to end:
-
Anonymous
GET /api/resourcesreturns 200 -
Anonymous
POSTreturns 401 -
Authenticated
ROLE_USERcan create resources -
ROLE_USERwithout admin receives 403 on update -
ROLE_ADMINcan update and delete -
Invalid payloads return 422
Run unit tests:
cd complete
./gradlew test
Run integration tests (requires Docker):
./gradlew integrationTest
8 Running the application
cd complete
./gradlew bootRun
Public read access (no credentials):
curl -s http://localhost:8080/api/resources
curl -s http://localhost:8080/api/resources/1
Create a resource (requires authentication — any logged-in user):
curl -s -u user:password -H 'Content-Type: application/json' \
-d '{"name":"Team Notes","description":"Shared notes"}' \
http://localhost:8080/api/resources
Update or delete (requires ROLE_ADMIN):
curl -s -u admin:password -X PUT -H 'Content-Type: application/json' \
-d '{"name":"Admin Console","description":"Updated by admin"}' \
http://localhost:8080/api/resources/2
curl -s -u admin:password -X DELETE http://localhost:8080/api/resources/2
Anonymous create attempts return 401; authenticated users without ROLE_ADMIN receive 403 on update/delete.
9 Summary
You secured a Grails 8 REST API with Spring Security Core:
-
Plugin dependency and HTTP Basic configuration
-
User,Role, andUserRoledomains with password encoding -
@Securedrules on controller actions -
Spock unit and integration tests against real HTTP and PostgreSQL
Next steps: add JWT or session-based authentication, integrate Spring Security REST, or extend role-based access to service-layer methods.
10 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.