Show Navigation

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/ and complete/

  • Docker — required for integration tests (Testcontainers PostgreSQL)

  • PostgreSQL on localhost:5432 — required for ./gradlew bootRun in both initial/ and complete/ (default database devDb; see grails-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:

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:

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

grails-app/domain/example/User.groovy
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

grails-app/domain/example/Role.groovy
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:

grails-app/domain/example/UserRole.groovy
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:

src/main/groovy/example/UserPasswordEncoderListener.groovy
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:

grails-app/conf/spring/resources.groovy
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:

grails-app/controllers/example/UrlMappings.groovy
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:

grails-app/controllers/example/ResourceController.groovy
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 on index and show

  • 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:

grails-app/init/example/BootStrap.groovy
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

user

password

ROLE_USER

admin

password

ROLE_ADMIN, ROLE_USER

7 Testing security rules

Unit tests

Controller unit tests exercise action logic with mocked domains:

src/test/groovy/example/ResourceControllerSpec.groovy
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:

src/integration-test/groovy/example/ResourceSecurityIntegrationSpec.groovy
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/resources returns 200

  • Anonymous POST returns 401

  • Authenticated ROLE_USER can create resources

  • ROLE_USER without admin receives 403 on update

  • ROLE_ADMIN can 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, and UserRole domains with password encoding

  • @Secured rules 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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.