Tutorial
Secure your Quarkus REST API with OpenID Connect and Keycloak
Implement role-based access control with JWT authentication using Quarkus Dev ServicesThis tutorial creates a new Quarkus project from the command line and builds a Person CRUD API that we then secure with OpenID Connect (OIDC) and Keycloak with zero infrastructure setup. Quarkus Dev Services starts both PostgreSQL and Keycloak in containers for you, and the OIDC extension validates JWTs and enforces role-based access declaratively. By the end of this tutorial, you'll have a protected API, tests that simulate authenticated users, and a clear path to a production configuration.
Prerequisites
- Java 17+
- Quarkus CLI
- Maven (or Gradle)
- A container runtime (Podman or Docker) for Dev Services. The runtime must be running (that is,
podman machine startor the Docker daemon) so that Quarkus can start PostgreSQL and Keycloak. No Keycloak installation or manual realm setup is needed, as Quarkus handles it.
This tutorial uses the Quarkus community distribution. If you need enterprise support, long-term compatibility, or additional IBM integrations, see the IBM Enterprise Build of Quarkus.
Step 1. Create the project and the Person API
We create a new application with the Quarkus CLI and add a minimal Person CRUD API so we have something to secure in the following steps.
Generate a new project with no scaffolded code. From a directory of your choice, run:
quarkus create app secure-person-api \ --extension=rest-jackson,hibernate-orm-panache,jdbc-postgresql \ --no-code cd secure-person-apiThe
--no-codeflag skips generating example classes so you start with a clean project. The extensions add these:- rest-jackson — Jakarta REST (JAX-RS) with Jackson for JSON serialization. See the REST guide for endpoints and JSON serialisation.
- hibernate-orm-panache — Simplified Hibernate ORM with the Panache pattern (active record or repository) and less boilerplate. See the Hibernate ORM with Panache guide.
jdbc-postgresql — PostgreSQL JDBC driver and datasource support. Dev Services can start PostgreSQL when no URL is set. See the Datasource guide.
The Quarkus BOM in
pom.xmlmanages extension versions.
Create the Java package directories for the entity and resource classes (and for tests later):
mkdir -p src/main/java/com/ibm/developer/{entity,resource} mkdir -p src/test/java/com/ibm/developer/resourceConfigure the database. Edit
src/main/resources/application.propertiesso it contains (or add to) the following. Dev Services will start PostgreSQL automatically when no URL is set:quarkus.datasource.db-kind=postgresql quarkus.hibernate-orm.schema-management.strategy=drop-and-create quarkus.hibernate-orm.log.sql=truequarkus.datasource.db-kind=postgresql— Selects the PostgreSQL driver. It isn't strictly required when only one JDBC extension is on the classpath (Quarkus infers it). Set it explicitly if you add more than one JDBC extension (such as PostgreSQL and H2).quarkus.hibernate-orm.schema-management.strategy=drop-and-create— Drops and recreates the schema on every startup. Handy for dev; avoid in production.quarkus.hibernate-orm.log.sql=true— Logs SQL statements (useful for debugging).
Create the Person entity. Create
src/main/java/com/ibm/developer/entity/Person.java:package com.ibm.developer.entity; import io.quarkus.hibernate.orm.panache.PanacheEntity; import jakarta.persistence.Entity; import java.time.LocalDate; @Entity public class Person extends PanacheEntity { public String name; public LocalDate birthDate; public Person() { } public Person(String name, LocalDate birthDate) { this.name = name; this.birthDate = birthDate; } }Extending PanacheEntity gives the entity an auto-generated
idfield and static methods such asfindById,listAll, anddeleteById, plus instance methods likepersist()andpersistAndFlush(), so that you don't write repository or DAO boilerplate.Create the REST resource. Create
src/main/java/com/ibm/developer/resource/PersonResource.java:package com.ibm.developer.resource; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import com.ibm.developer.entity.Person; import java.util.List; @Path("/persons") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @ApplicationScoped public class PersonResource { @GET public List<Person> listAll() { return Person.listAll(); } @GET @Path("/{id}") public Response findById(@PathParam("id") Long id) { Person person = Person.findById(id); if (person == null) { return Response.status(Response.Status.NOT_FOUND).build(); } return Response.ok(person).build(); } @POST @Transactional public Response create(Person person) { if (person == null || person.id != null) { throw new WebApplicationException("Person ID must not be set", 422); } person.persistAndFlush(); return Response.status(Response.Status.CREATED).entity(person).build(); } @DELETE @Path("/{id}") @Transactional public Response delete(@PathParam("id") Long id) { boolean deleted = Person.deleteById(id); if (deleted) { return Response.noContent().build(); } else { return Response.status(Response.Status.NOT_FOUND).build(); } } }This example uses Panache's active record pattern: the entity class carries both data and persistence methods (
Person.listAll(),person.persistAndFlush(), and so on). If you prefer a separate persistence layer, see the Panache repository pattern or Jakarta Data in the Quarkus docs.Verify the API.
Start the app with
quarkus dev. Quarkus Dev Mode keeps the app running while you edit code: changes are compiled and reloaded on save, and the Dev UI athttp://localhost:8080/q/dev-uilets you inspect endpoints, configuration, and Dev Services without restarts.Dev Mode also exposes a Dev MCP server (Model Context Protocol) so AI-assisted tools can talk to your running app (see the Dev MCP guide for more information). This works well with IBM Bob, where you can add the Dev MCP endpoint as an MCP client in Bob to get context from your Quarkus app while you code.
Once the app is up, call
GET http://localhost:8080/personsorPOSTa JSON body to/persons. The API is running but has no authentication, meaning anyone can read, create, or delete. We fix that next.
Step 2. What we're securing and why
Before adding security, let's look at the why. The Person API we just created has no authentication or authorization.
Quick test with curl. With the app running (quarkus dev), you can add a person and list all persons from the command line:
# Add a person (birthDate in ISO format YYYY-MM-DD)
curl -X POST http://localhost:8080/persons \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","birthDate":"1990-05-15"}'
# List all persons
curl http://localhost:8080/persons
The first command returns 201 Created with the new person (including the generated id). The second command returns a JSON array of all persons. Once we add OIDC in the following steps, these same endpoints will require a valid JWT.
Anyone who can reach http://localhost:8080 can:
- Call
GET /personsorGET /persons/{id}to read data - Call
POST /personsto create records - Call
DELETE /persons/{id}to remove records
In a real deployment, unauthenticated POST and DELETE are unacceptable. Allowing anonymous create/delete is a data integrity and compliance risk. Adding security later often creates technical debt. Filters and checks end up in the wrong places, and the security model gets hard to reason about. Here we do the opposite: We add identity and roles from the start and keep enforcement declarative so the intent stays clear and testable.
Threat model in plain terms: We assume the API is reachable (locally or, later, in a cluster). We want only authenticated users to call it, and we want to restrict who can create or delete. Read access is allowed for any authenticated user with a "user" or "admin" role, whereas create and delete are restricted to "admin" only. Unauthenticated requests must receive a 401 Unauthorized message, and authenticated users without the right role must receive a 403 Forbidden message. This configuration sets up the "security from the start" angle that runs through the rest of the tutorial.
Step 3. Add the OIDC and roles extensions
From the root of your project (secure-person-api), we add the extensions that handle token validation and role-based access control. Stop the application if it is still running.
Run one CLI command to add both extensions:
quarkus extension add quarkus-oidc,quarkus-smallrye-jwtHere's what each extension does:
- quarkus-oidc Handles the OIDC token validation lifecycle, including the discovery of the OpenID Connect provider, fetching the JSON Web Key Set (JWKS) for signature verification, and extracting claims from the incoming JWT. It integrates with Keycloak (and any OIDC-compliant provider) so your app doesn't need to implement token parsing or crypto. See the OIDC Bearer token authentication guide.
- quarkus-smallrye-jwt Makes
@RolesAllowedand@Claimavailable on your resource classes. Quarkus enforces@RolesAlloweddeclaratively and injects JWT claims (for example,sub,iss, or custom claims) via@Claimso you can use them in method bodies. See the Using JWT RBAC guide.
No Keycloak installation is needed yet. That comes in the next step via Dev Services. (If you use Maven, ensure your pom.xml uses the Quarkus BOM so extension versions are managed automatically.)
Step 4. Keycloak via Dev Services — zero config startup
This is the "Dev Services moment" for security. It is the same idea as the PostgreSQL Dev Services that started in Step 1, but for identity. We add two lines to application.properties and Quarkus starts Keycloak in a container, creates a realm, and pre-populates test users. No manual realm import or admin UI setup is needed.
Ensure your container runtime is running (for example,
podman machine startor start the Docker daemon). Dev Services needs it to start the Keycloak container.Add the following to
src/main/resources/application.properties(alongside your existing data source and Hibernate settings from Step 1):# OIDC: Keycloak via Dev Services (dev mode only) quarkus.oidc.client-id=quarkus-app quarkus.oidc.credentials.secret=secret # Override default roles so alice=user only, bob=admin (for this tutorial's 403/201 test matrix) %dev.quarkus.keycloak.devservices.roles.alice=user %dev.quarkus.keycloak.devservices.roles.bob=adminPer the Keycloak initialization docs, Dev Services for Keycloak creates a realm
quarkus, a clientquarkus-appwith secretsecret, and usersaliceandbob(passwords match the username). By default, alice gets bothadminanduserroles and bob getsuseronly. The preceding properties override roles so that alice has onlyuserand bob hasadmin, which matches the role model we enforce in Step 5 (read foruser/admin, create/delete foradminonly).Start the application in dev mode:
quarkus devWhen Quarkus starts, it detects the OIDC configuration and the presence of a container runtime. It spins up a Keycloak container automatically, creates the realm and client, and pre-populates the test users with the roles we configured:
- alice (password:
alice) — role:user - bob (password:
bob) — role:admin
- alice (password:
Open the Quarkus Dev UI at http://localhost:8080/q/dev-ui. Find the OpenID Connect card. From there you can:
- See the Keycloak (auth server) URL, which you'll need for
KEYCLOAK_URLin a later step. - Use the Dev UI to get tokens interactively (which is useful for quick manual checks).
- See the Keycloak (auth server) URL, which you'll need for
In the same OpenID Connect card, click Keycloak Admin. A new browser window opens (
http://localhost:35353/admin/master/console/). Log in with username admin and password admin. From the Keycloak admin console you can:- Inspect the
quarkusrealm and its clients - View the test users (alice, bob) and their role assignments
- Inspect the
The same Dev Services philosophy that started PostgreSQL in Step 1 now applies to identity infrastructure. No Keycloak installation, no realm JSON import, just configuration and a running dev environment.
Step 5. Protect the endpoints with @RolesAllowed
We protect the Person API by annotating PersonResource with @RolesAllowed and @Authenticated. Quarkus enforces these declaratively, so you don't write filter chains or SecurityContext boilerplate.
Open
src/main/java/com/ibm/developer/resource/PersonResource.javaand add the following imports:import jakarta.annotation.security.RolesAllowed; import io.quarkus.security.Authenticated;Add
@Authenticatedat the class level. This means every method on the resource requires an authenticated user unless you explicitly allow anonymous access. It acts as a backstop so unannotated methods don't accidentally become public.Annotate each method with the appropriate role:
- GET /persons and GET /persons/{id}:
@RolesAllowed({"user", "admin"})— any authenticated user with theuseroradminrole can read. - POST /persons:
@RolesAllowed("admin")— only admins can create. - DELETE /persons/{id}:
@RolesAllowed("admin")— only admins can delete.
- GET /persons and GET /persons/{id}:
Here is the full annotated resource class:
package com.ibm.developer.resource; import java.util.List; import com.ibm.developer.entity.Person; import io.quarkus.security.Authenticated; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @Path("/persons") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @ApplicationScoped @Authenticated public class PersonResource { @GET @RolesAllowed({ "user", "admin" }) public List<Person> listAll() { return Person.listAll(); } @GET @Path("/{id}") @RolesAllowed({ "user", "admin" }) public Response findById(@PathParam("id") Long id) { Person person = Person.findById(id); if (person == null) { return Response.status(Response.Status.NOT_FOUND).build(); } return Response.ok(person).build(); } @POST @Transactional @RolesAllowed("admin") public Response create(Person person) { if (person == null || person.id != null) { throw new WebApplicationException("Person ID must not be set", 422); } person.persistAndFlush(); return Response.status(Response.Status.CREATED).entity(person).build(); } @DELETE @Path("/{id}") @Transactional @RolesAllowed("admin") public Response delete(@PathParam("id") Long id) { boolean deleted = Person.deleteById(id); if (deleted) { return Response.noContent().build(); } else { return Response.status(Response.Status.NOT_FOUND).build(); } } }
Quarkus enforces these rules at the boundary. Unauthenticated requests get a 401 message, and authenticated users without the required role get a 403 message. No manual checks in the method bodies. For stricter input validation in production, add Bean Validation to your Person entity (for example, @NotNull, @Size) and use @Valid Person person on the create method so invalid payloads are rejected with clear error messages.
Step 6. Get tokens and test manually
We verify the role model by getting tokens for alice and bob and calling the API with a Bearer header. The Keycloak token endpoint is provided by Dev Services. The base URL is not printed in the console. You should get it from the OpenID Connect card in the Dev UI (Step 4). The token URL has the form:
<KEYCLOAK_URL>/realms/quarkus/protocol/openid-connect/token
(Dev Services assigns a dynamic port. Use the base URL shown in the OpenID Connect card, for example http://localhost:35353.)
Get a token for alice (user role). Use the resource owner password grant. (Commands are one per line so copy/paste works in Terminal on macOS.)
export KEYCLOAK_URL="http://localhost:35353" export ALICE_TOKEN=$(curl -s -X POST "${KEYCLOAK_URL}/realms/quarkus/protocol/openid-connect/token" -H "Content-Type: application/x-www-form-urlencoded" -d "username=alice" -d "password=alice" -d "grant_type=password" -d "client_id=quarkus-app" -d "client_secret=secret" | jq -r '.access_token')(If you don't have
jq, install it first (on macOS,brew install jq), or copy theaccess_tokenvalue from the JSON response by hand.)Get a token for bob (admin role):
export BOB_TOKEN=$(curl -s -X POST "${KEYCLOAK_URL}/realms/quarkus/protocol/openid-connect/token" -H "Content-Type: application/x-www-form-urlencoded" -d "username=bob" -d "password=bob" -d "grant_type=password" -d "client_id=quarkus-app" -d "client_secret=secret" | jq -r '.access_token')Run the four-case test matrix. These map directly to the role model from Step 5:
alice, GET /persons (read allowed for
user):curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $ALICE_TOKEN" http://localhost:8080/personsExpected: 200 and a JSON list (possibly empty).
alice, POST /persons (create restricted to
admin):curl -s -w "\nHTTP %{http_code}\n" -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $ALICE_TOKEN" \ -d '{"name":"Test","birthDate":"2000-01-01"}' \ http://localhost:8080/personsExpected: 403 Forbidden. Alice has
userbut notadmin—correct behavior.bob, POST /persons (admin can create):
curl -s -w "\nHTTP %{http_code}\n" -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $BOB_TOKEN" \ -d '{"name":"Bob User","birthDate":"1990-05-15"}' \ http://localhost:8080/personsExpected: 201 Created and the created person in the response body.
No token, GET /persons (unauthenticated):
curl -s -w "\nHTTP %{http_code}\n" http://localhost:8080/personsExpected: 401 Unauthorized.
This four-case matrix is the clearest demonstration that the roles and endpoints are configured correctly.
Step 7. Write secured @QuarkusTest tests with @TestSecurity
Unit tests for secured endpoints should not depend on a running Keycloak container or real JWT issuance. Quarkus provides the Test Security Extension, where you add the quarkus-test-security dependency and use the @TestSecurity annotation on test methods (or the test class) to specify the identity the test runs under (username and roles) with no OIDC or Keycloak involved. The security subsystem then applies the same @RolesAllowed rules as in production. Tests run in-process and stay fast and maintainable as your role model evolves. Note that @TestSecurity works with @QuarkusTest only; it does not work with @QuarkusIntegrationTest.
By default, quarkus test still starts Keycloak Dev Services because OIDC is configured. To avoid that, we disable Keycloak and OIDC for the test profile and use the guide Using Security with .properties File to define embedded users (alice, bob) with the same roles. No container runs during tests; @TestSecurity continues to inject the test identity per method.
Add three test dependencies (none are brought in by the OIDC extension). Check
pom.xmland add them if missing:- quarkus-test-security — Provides
@TestSecurityso you can simulate an authenticated user and roles in@QuarkusTestwithout starting Keycloak or issuing real JWTs. - rest-assured — A fluent API for HTTP requests in tests (e.g.
given().when().get("/persons")). Quarkus recommends it for testing REST resources; the Quarkus BOM manages the version. quarkus-elytron-security-properties-file — Enables properties-file-based authentication so we can embed user and role definitions in application.properties for the test profile (development and testing only; not for production).
Add all three inside the
<dependencies>section:<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-test-security</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-elytron-security-properties-file</artifactId> <scope>test</scope> </dependency>
- quarkus-test-security — Provides
Disable Keycloak and OIDC for tests, and enable embedded users. Add the following to
src/main/resources/application.properties(or createsrc/test/resources/application.propertiesand put it there). This keepsquarkus testfrom starting a Keycloak container and uses the same alice/bob roles as in dev.# Test profile: no Keycloak container, use embedded users (see security-properties guide) %test.quarkus.keycloak.devservices.enabled=false %test.quarkus.oidc.enabled=false %test.quarkus.security.users.embedded.enabled=true %test.quarkus.security.users.embedded.plain-text=true %test.quarkus.security.users.embedded.users.alice=alice %test.quarkus.security.users.embedded.users.bob=bob %test.quarkus.security.users.embedded.roles.alice=user %test.quarkus.security.users.embedded.roles.bob=adminSee Using Security with .properties File for embedded realm configuration. With this in place,
quarkus testruns without Docker/Podman and without Keycloak, and@TestSecuritystill supplies the identity for each test method.Create the test class at
src/test/java/com/ibm/developer/resource/PersonResourceTest.java(the package must becom.ibm.developer.resourceto match the resource).package com.ibm.developer.resource; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.ibm.developer.entity.Person; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.restassured.http.ContentType; import jakarta.transaction.Transactional; @QuarkusTest public class PersonResourceTest { @BeforeEach @Transactional void cleanDatabase() { Person.deleteAll(); } @Test @TestSecurity(user = "alice", roles = "user") public void testAliceCanList() { given() .when().get("/persons") .then() .statusCode(200) .body("$.size()", greaterThanOrEqualTo(0)); } @Test @TestSecurity(user = "alice", roles = "user") public void testAliceCannotCreate() { given() .contentType(ContentType.JSON) .body("{\"name\":\"Alice Test\",\"birthDate\":\"2000-01-01\"}") .when().post("/persons") .then() .statusCode(403); } @Test @TestSecurity(user = "bob", roles = "admin") public void testBobCanCreate() { given() .contentType(ContentType.JSON) .body("{\"name\":\"Bob Test\",\"birthDate\":\"1990-05-15\"}") .when().post("/persons") .then() .statusCode(201) .body("name", is("Bob Test")) .body("id", notNullValue()); } @Test public void testUnauthenticatedIsRejected() { given() .when().get("/persons") .then() .statusCode(401); } }Run the tests:
quarkus testAll four tests should pass. Thanks to the test profile (step 2), no Keycloak container is started and embedded users and disabled OIDC are used instead.
@TestSecurityinjects the identity per test method, and Quarkus applies the same@RolesAllowedlogic as in production. This keeps security tests fast and stable as roles change.Continuous testing: When you run
quarkus dev, Quarkus can re-run tests automatically after you save code changes—only the tests affected by the change run, so you get fast feedback. Pressrin the dev-mode console to resume or re-run tests, orhfor options. See the Continuous Testing guide for details.
Step 8. Inspect the JWT claims (optional)
For readers who want to go further, Quarkus lets you inject JWT claims and the token itself into your resource methods. This step adds a separate "who am I?" endpoint so you can see the authenticated user's subject and issuer without touching PersonResource. You can use this pattern for attribute-based decisions, audit logging, or multi-tenant routing.
Create a new resource class for the endpoint. Create
src/main/java/com/ibm/developer/resource/MeResource.java:package com.ibm.developer.resource; import org.eclipse.microprofile.jwt.Claim; import org.eclipse.microprofile.jwt.Claims; import org.eclipse.microprofile.jwt.JsonWebToken; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @Path("/me") @Produces(MediaType.TEXT_PLAIN) public class MeResource { @Inject JsonWebToken jwt; @Inject @Claim(standard = Claims.sub) String subject; @GET @RolesAllowed({ "user", "admin" }) public String me() { String sub = (subject != null) ? subject : "unknown"; String iss = (jwt != null && jwt.getIssuer() != null) ? jwt.getIssuer() : "unknown"; return "subject=" + sub + ", issuer=" + iss; } }JsonWebTokengives you access to standard claims (subject, issuer, expiration, and so on).@Claimcan inject a standard claim (as in the preceding code) or a custom claim name. The null checks in the preceding code avoidNullPointerExceptionif a claim or the token is missing. This is the foundation for attribute-based checks, tenant resolution, or audit logs without going deep into refresh tokens, multi-tenancy, or advanced OIDC flows.Restart or rely on live reload (if you use
quarkus dev), then call the new endpoint. Use the sameALICE_TOKENandBOB_TOKEN(and optionalKEYCLOAK_URL) from Step 6.No token (expect 401):
curl -s -w "\nHTTP %{http_code}\n" http://localhost:8080/meExpected:
401 Unauthorized.With alice's token (expect 200 and subject):
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $ALICE_TOKEN" http://localhost:8080/meExpected:
200and a body likesubject=<alice-id>, issuer=http://localhost:8180/realms/quarkus(the subject is typically a Keycloak user ID; the issuer is your Keycloak realm URL).With bob's token (expect 200 and subject):
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $BOB_TOKEN" http://localhost:8080/meExpected:
200and a body likesubject=<bob-id>, issuer=http://localhost:8180/realms/quarkus.These three checks confirm that the
/meendpoint is protected, that only authenticated users get a response, and that the returned subject and issuer reflect the token you sent.
Conclusion
You've built a Person API from a new Quarkus project and moved it from completely open to declarative, role-based protection. Here's the progression:
- New project with CLI: we created
secure-person-apiwith REST, Panache, and PostgreSQL and added the Person CRUD API. - Threat model: we identified the desired role model (read for
user/admin, create/delete foradminonly). - Declarative enforcement:
@RolesAllowedand@AuthenticatedonPersonResourcewith no filter chains or manual security code. - Keycloak via Dev Services: zero Keycloak setup; Quarkus started the IdP and pre-populated test users.
- Manual verification: we got tokens for
aliceandboband confirmed 200/403/201/401 for the four cases. - Tested with @TestSecurity: four
@QuarkusTesttests that simulate identities and assert 401/403/201 without a real OIDC flow. - Production-ready config pattern: OIDC settings externalized via environment variables or secrets.
Next step
The app is now secured locally with Keycloak Dev Services. Deploying this to Kubernetes with a real Keycloak instance introduces configuration and secret management questions, including how to inject the auth server URL and client secret, how to configure Keycloak in the cluster or use a shared identity provider. That is where the deployment tutorial and this security tutorial meet: same app, same OIDC extension, with production config and secrets managed in the cluster.