IBM Developer

Tutorial

Secure your Quarkus REST API with OpenID Connect and Keycloak

Implement role-based access control with JWT authentication using Quarkus Dev Services

This 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 start or 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.

  1. 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-api
    

    The --no-code flag skips generating example classes so you start with a clean project. The extensions add these:

  2. 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/resource
    
  3. Configure the database. Edit src/main/resources/application.properties so 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=true
    
    • quarkus.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).
  4. 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 id field and static methods such as findById, listAll, and deleteById, plus instance methods like persist() and persistAndFlush(), so that you don't write repository or DAO boilerplate.

  5. 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.

  6. 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 at http://localhost:8080/q/dev-ui lets 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/persons or POST a 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 /persons or GET /persons/{id} to read data
  • Call POST /persons to 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.

  1. Run one CLI command to add both extensions:

     quarkus extension add quarkus-oidc,quarkus-smallrye-jwt
    
  2. Here'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 @RolesAllowed and @Claim available on your resource classes. Quarkus enforces @RolesAllowed declaratively and injects JWT claims (for example, sub, iss, or custom claims) via @Claim so 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.

  1. Ensure your container runtime is running (for example, podman machine start or start the Docker daemon). Dev Services needs it to start the Keycloak container.

  2. 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=admin
    

    Per the Keycloak initialization docs, Dev Services for Keycloak creates a realm quarkus, a client quarkus-app with secret secret, and users alice and bob (passwords match the username). By default, alice gets both admin and user roles and bob gets user only. The preceding properties override roles so that alice has only user and bob has admin, which matches the role model we enforce in Step 5 (read for user/admin, create/delete for admin only).

  3. Start the application in dev mode:

     quarkus dev
    

    When 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
  4. 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_URL in a later step.
    • Use the Dev UI to get tokens interactively (which is useful for quick manual checks).
  5. 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 quarkus realm and its clients
    • View the test users (alice, bob) and their role assignments

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.

  1. Open src/main/java/com/ibm/developer/resource/PersonResource.java and add the following imports:

     import jakarta.annotation.security.RolesAllowed;
     import io.quarkus.security.Authenticated;
    
  2. Add @Authenticated at 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.

  3. Annotate each method with the appropriate role:

    • GET /persons and GET /persons/{id}: @RolesAllowed({"user", "admin"}) — any authenticated user with the user or admin role can read.
    • POST /persons: @RolesAllowed("admin") — only admins can create.
    • DELETE /persons/{id}: @RolesAllowed("admin") — only admins can delete.
  4. 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.)

  1. 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 the access_token value from the JSON response by hand.)

  2. 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')
    
  3. 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/persons
      

      Expected: 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/persons
      

      Expected: 403 Forbidden. Alice has user but not admin—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/persons
      

      Expected: 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/persons
      

      Expected: 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.

  1. Add three test dependencies (none are brought in by the OIDC extension). Check pom.xml and add them if missing:

    • quarkus-test-security — Provides @TestSecurity so you can simulate an authenticated user and roles in @QuarkusTest without 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>
      
  2. Disable Keycloak and OIDC for tests, and enable embedded users. Add the following to src/main/resources/application.properties (or create src/test/resources/application.properties and put it there). This keeps quarkus test from 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=admin
    

    See Using Security with .properties File for embedded realm configuration. With this in place, quarkus test runs without Docker/Podman and without Keycloak, and @TestSecurity still supplies the identity for each test method.

  3. Create the test class at src/test/java/com/ibm/developer/resource/PersonResourceTest.java (the package must be com.ibm.developer.resource to 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);
         }
     }
    
  4. Run the tests:

     quarkus test
    

    All 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. @TestSecurity injects the identity per test method, and Quarkus applies the same @RolesAllowed logic 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. Press r in the dev-mode console to resume or re-run tests, or h for 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.

  1. 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;
         }
     }
    

    JsonWebToken gives you access to standard claims (subject, issuer, expiration, and so on). @Claim can inject a standard claim (as in the preceding code) or a custom claim name. The null checks in the preceding code avoid NullPointerException if 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.

  2. Restart or rely on live reload (if you use quarkus dev), then call the new endpoint. Use the same ALICE_TOKEN and BOB_TOKEN (and optional KEYCLOAK_URL) from Step 6.

    No token (expect 401):

     curl -s -w "\nHTTP %{http_code}\n" http://localhost:8080/me
    

    Expected: 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/me
    

    Expected: 200 and a body like subject=<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/me
    

    Expected: 200 and a body like subject=<bob-id>, issuer=http://localhost:8180/realms/quarkus.

    These three checks confirm that the /me endpoint 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-api with REST, Panache, and PostgreSQL and added the Person CRUD API.
  • Threat model: we identified the desired role model (read for user/admin, create/delete for admin only).
  • Declarative enforcement: @RolesAllowed and @Authenticated on PersonResource with 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 alice and bob and confirmed 200/403/201/401 for the four cases.
  • Tested with @TestSecurity: four @QuarkusTest tests 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.