From Basic Auth to Real Login: Form Auth, Remember Me & GitHub in Quarkus
Upgrade your Quarkus security-jpa app with Qute login pages, persistent sessions, logout handling, and GitHub OIDC social login—production-ready and database-backed.
I have been wanting to do this follow-up post since very long and just recently discovered an older version in my drafts folder. So, let’s get to it before I push it out another year. The first part was published April last year. So you can go through it if you want the foundations in place.
Your API is secured with HTTP Basic and JPA-backed users—but people using the app in a browser expect a real login page, a way to stay signed in, a logout that works, and maybe “Sign in with GitHub.” This tutorial gets you there.
We’ll extend the app from the first article with form-based authentication, a Qute-rendered login and home page, a session that survives browser restarts when “remember me” is in effect, a proper logout that clears the session cookie, and GitHub as an OpenID Connect (OIDC) social login provider. By the end, you’ll have a single app where users can sign in with a username and password or with GitHub.
Prerequisites
JDK 21
Apache Maven 3.9.x
Quarkus 3.32.1 (or match the version in your pom.xml)
Optional: Quarkus CLI (quarkus)
The security-jpa app from the first article (JPA-backed users, REST endpoints)
Add Form-Based Auth and a Qute Login Page
We’ll switch from HTTP Basic to form-based authentication and serve a login page from a Qute template. First, add the extension that lets you return HTML from REST using Qute.
You can also just look at the source code of the article in my Github Repository.
Add the Qute REST extension so we can serve HTML from templates:
./mvnw quarkus:add-extension -Dextensions='quarkus-rest-qute'Switch to form auth and configure the login flow. In src/main/resources/application.properties:
# Database (unchanged)
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
# Form-based authentication
quarkus.http.auth.form.enabled=true
quarkus.http.auth.session.encryption-key=mySecretEncryptionKey16
quarkus.http.auth.form.login-page=login
quarkus.http.auth.form.landing-page=/
quarkus.http.auth.form.error-page=loginUse a strong, unique value for quarkus.http.auth.session.encryption-key in production (at least 16 characters). The session is stored in an encrypted cookie; this key is used to encrypt it.
Create the login template. Add src/main/resources/templates/login.html with a form that posts to Quarkus’s built-in form auth endpoint:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<div class="layout">
<h1>Sign in</h1>
{#if error}
<p class="error">Invalid username or password.</p>
{/if}
<form action="/j_security_check" method="post">
<div class="form-group">
<label for="j_username">Username</label>
<input type="text" id="j_username" name="j_username" required>
</div>
<div class="form-group">
<label for="j_password">Password</label>
<input type="password" id="j_password" name="j_password" required>
</div>
<div class="form-group">
<label><input type="checkbox" name="remember_me" value="true"> Remember me</label>
</div>
<button type="submit">Login</button>
</form>
<p class="muted">Or <a href="/github">Sign in with GitHub</a></p>
</div>
</body>
</html>The form uses the standard names j_username and j_password and posts to /j_security_check so Quarkus form auth can process the login.
Serve the template from a REST resource. Create src/main/java/org/acme/security/jpa/LoginResource.java so the path /login returns the rendered page:
package org.acme.security.jpa;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/login")
public class LoginResource {
private final Template login;
public LoginResource(Template login) {
this.login = login;
}
@GET
@Produces(MediaType.TEXT_HTML)
@PermitAll
public TemplateInstance get(@QueryParam("error") @jakarta.ws.rs.DefaultValue("false") boolean error) {
return login.data("error", error);
}
}Quarkus injects the template for login.html when the constructor parameter is named login. We mark the endpoint with @PermitAll so unauthenticated users can open the login page.
Add a landing page. Create src/main/resources/templates/index.html to show the current user and a logout control:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title>
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<div class="layout">
<h1>Welcome, {username}!</h1>
<p class="nav-links">
<a href="/api/user">User endpoint</a>
<a href="/api/admin">Admin endpoint</a>
</p>
<div class="inline-actions">
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</div>
</div>
</body>
</html>Create the resource that serves the landing page. Create src/main/java/org/acme/security/jpa/HomeResource.java:
package org.acme.security.jpa;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.security.identity.SecurityIdentity;
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("/")
public class HomeResource {
@Inject
SecurityIdentity identity;
private final Template index;
public HomeResource(Template index) {
this.index = index;
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance get() {
return index.data("username", identity.getPrincipal().getName());
}
}Do not mark the root path as @PermitAll; the form auth mechanism will redirect unauthenticated users to the login page.
Allow the login flow without authentication. Add these lines to application.properties so the login page and form POST are public, and unauthenticated users hitting / are sent to the login page instead of getting a 401:
quarkus.http.auth.proactive=false
quarkus.http.auth.permission.login.paths=/login,/j_security_check
quarkus.http.auth.permission.login.policy=permit
quarkus.http.auth.permission.authenticated.paths=/,/api,/logout
quarkus.http.auth.permission.authenticated.policy=authenticatedMake “Remember Me” Actually Remember
Quarkus form auth stores the session in an encrypted cookie. By default that cookie is a session cookie—the browser drops it when you close the tab or window. To let users stay logged in across restarts, we set a cookie max age and an inactivity timeout.
Add to application.properties:
quarkus.http.auth.form.cookie-max-age=30D
quarkus.http.auth.form.timeout=7Dcookie-max-age=30D means the browser keeps the cookie for 30 days. timeout=7D means after 7 days of inactivity the session is no longer renewed and the user must log in again. In other words, the cookie can live up to 30 days, but if the user does nothing for 7 days, the next request will require login again.
Add a “Remember me” checkbox to the login form so the behavior is clear to users:
<label><input type="checkbox" name="remember_me" value="true"> Remember me</label>The checkbox documents the behavior; the actual persistence is controlled by the two properties above. For a per-user “remember me” (only when the box is checked), you’d need a custom approach, such as a persistent token table.
Add Proper Logout
We’ll add an endpoint that clears the session cookie and redirects to the login page. The landing page already posts to /logout; we just need the handler. For now we only handle form-based sessions; in Step 4 we’ll extend this so GitHub (OIDC) users can log out too.
Create src/main/java/org/acme/security/jpa/LogoutResource.java:
package org.acme.security.jpa;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
@Path("/logout")
public class LogoutResource {
@Inject
SecurityIdentity identity;
@POST
public Response logout() {
if (identity.isAnonymous()) {
return Response.seeOther(UriBuilder.fromPath("/login").build()).build();
}
FormAuthenticationMechanism.logout(identity);
return Response.seeOther(UriBuilder.fromPath("/login").build()).build();
}
}When the user clicks Logout, the browser POSTs to /logout. We call FormAuthenticationMechanism.logout(identity) to clear the session cookie, then redirect to /login.
Add Social Login with GitHub (OIDC)
We’ll add Sign in with GitHub using Quarkus OIDC. OIDC (OpenID Connect) is a layer on top of OAuth 2.0 that lets the app verify the user’s identity and get basic profile information. GitHub supports it as a well-known provider, so we need minimal configuration.
Add the OIDC and context propagation extensions:
./mvnw quarkus:add-extension -Dextensions='quarkus-oidc,quarkus-smallrye-context-propagation'Context propagation is required when the logout endpoint returns Uni<Response> (for OIDC logout); it propagates the request context across the reactive chain and avoids a NullPointerException in the security layer.
Configure GitHub as the OIDC provider. In application.properties:
quarkus.oidc.provider=github
quarkus.oidc.client-id=${GITHUB_CLIENT_ID:your-client-id}
quarkus.oidc.credentials.secret=${GITHUB_CLIENT_SECRET:your-client-secret}
# Callback URL for GitHub; must match exactly what you register in the GitHub OAuth App
quarkus.oidc.authentication.redirect-path=/githubWith quarkus.oidc.provider=github, Quarkus sets the application type to web-app internally, so you can omit quarkus.oidc.application-type.
Create a GitHub OAuth App. GitHub must know your app’s callback URL or you’ll see “The redirect_uri is not associated with this application” when users try to sign in.
In GitHub go to Settings (your profile) → Developer settings → OAuth Apps → New OAuth App (or edit an existing one).
Set Authorization callback URL to exactly:
http://localhost:8080/github
Use this exact value: no trailing slash,
http(nothttps) for localhost. GitHub compares the redirect URL from the request to this field; if they differ, the flow is rejected.Copy the Client ID and create a Client secret; put them in application.properties (or set
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRETas environment variables). Never commit real secrets to the codebase.
Form login and OIDC can both protect the same paths. Quarkus will use the form cookie when present; when the user clicks “Sign in with GitHub,” they go through the OIDC flow and end up with a SecurityIdentity just like after form login.
Add an endpoint that triggers the OIDC flow. Create a REST resource at /github and annotate it with @AuthorizationCodeFlow so that unauthenticated users hitting this path are redirected to GitHub (and after callback, redirect them to home):
package org.acme.security.jpa;
import io.quarkus.oidc.AuthorizationCodeFlow;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
@Path("/github")
public class GitHubAuthResource {
@GET
@AuthorizationCodeFlow
public Response github() {
return Response.seeOther(UriBuilder.fromPath("/").build()).build();
}
}Use one authenticated policy with JAX-RS so /github gets OIDC. Instead of a dedicated HTTP policy for GitHub, include /github in the same authenticated paths and set applies-to=jaxrs. Then Form authentication and @AuthorizationCodeFlow work together: unauthenticated requests to / get the form login page (form has higher priority), and requests to /github use the OIDC flow because of the annotation. Add or update application.properties:
quarkus.http.auth.permission.authenticated.paths=/,/api,/logout,/github
quarkus.http.auth.permission.authenticated.policy=authenticated
quarkus.http.auth.permission.authenticated.applies-to=jaxrs
# Form issues the challenge first; OIDC users are still accepted on / and /api
quarkus.http.auth.form.priority=2000
quarkus.oidc.priority=1000Add this link to the login page (inside the <body>, after the form):
<p>Or <a href="/github">Sign in with GitHub</a></p>When the user clicks the link, they hit /github; the @AuthorizationCodeFlow annotation selects the OIDC mechanism, which redirects to GitHub. After the user authorizes, GitHub redirects back to /github (your callback URL); Quarkus completes the flow and establishes the session, then your endpoint redirects to /.
Update logout to support GitHub (OIDC) users. In Step 3 we only cleared the form session. To support both, check the credential type: identity.getCredential(AccessTokenCredential.class) is non-null for OIDC (e.g. GitHub) users and null for form users. With quarkus.http.auth.proactive=false, resolving the identity must not block the I/O thread—inject CurrentIdentityAssociation, call getDeferredIdentity() to get a Uni<SecurityIdentity>, and do the logout logic inside a .flatMap() so it runs when the identity is available. Update LogoutResource.java:
package org.acme.security.jpa;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcSession;
import io.quarkus.security.identity.CurrentIdentityAssociation;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
@Path("/logout")
public class LogoutResource {
@Inject
CurrentIdentityAssociation currentIdentityAssociation;
@Inject
OidcSession oidcSession;
private static Response redirectToLogin() {
return Response.seeOther(UriBuilder.fromPath("/login").build()).build();
}
@POST
public Uni<Response> logout() {
return currentIdentityAssociation.getDeferredIdentity()
.flatMap(identity -> {
if (identity.isAnonymous()) {
return Uni.createFrom().item(redirectToLogin());
}
if (identity.getCredential(AccessTokenCredential.class) != null) {
return oidcSession.logout().replaceWith(redirectToLogin());
}
FormAuthenticationMechanism.logout(identity);
return Uni.createFrom().item(redirectToLogin());
});
}
}Give OIDC users a role. Form-login users get roles from your JPA User entity. OIDC users (e.g. GitHub) have no application roles in the token by default, so @RolesAllowed("user") would deny them. Register a SecurityIdentityAugmentor that adds a default role for any authenticated identity that has no roles:
@ApplicationScoped
public class OidcRolesAugmentor implements SecurityIdentityAugmentor {
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
return Uni.createFrom().item(build(identity));
}
private SecurityIdentity build(SecurityIdentity identity) {
if (identity.isAnonymous() || !identity.getRoles().isEmpty()) {
return identity;
}
return QuarkusSecurityIdentity.builder(identity).addRole("user").build();
}
}Form users keep their DB-backed roles; OIDC users get the "user" role and can access /api/user and other @RolesAllowed("user") endpoints.
Sync OIDC users to the DB and show a name. This is how applications usually handle it: one app-user record per person, synced from the IdP on first use. Then name, roles, and profile come from one place—no endless workarounds.
Extend your
Userentity — UsePanacheEntityBase(notPanacheEntity) and assign ids from a custom sequence so seed data and OIDC-created users don’t clash. Define the sequence and id field, e.g.@SequenceGenerator(name = "test_user_SEQ", sequenceName = "test_user_SEQ", allocationSize = 50)and@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "test_user_SEQ")on aLong idfield. AddoidcSubject(unique, nullable),displayName, andemail, and a helper likegetDisplayNameOrUsername()that returnsdisplayNamewhen set, otherwiseusername. Form users keep usingusername/password/role; OIDC users will have a row withoidcSubject,displayName(from the IdP’s “name”), and optionalemail.Add a request-scoped
CurrentUserServicethat resolves the current request to yourUserentity:Form users: look up
Userbyusername(fromSecurityIdentity.getPrincipal().getName()).OIDC users: read
UserInfofromidentity.getAttribute("userinfo")(Quarkus sets this when UserInfo is requested from the provider). FindUserbyoidcSubject(e.g. from UserInfo"sub"or"id"); if none, create one withdisplayNamefrom UserInfo"name"/"login",role = "user", and persist. Return thatUser.
Use
CurrentUserServiceeverywhere you need a name or app user: e.g. inHomeResourceand in/api/user, callcurrentUserService.getCurrentUser()and useuser.getDisplayNameOrUsername()for the greeting. Form users see their username; OIDC users see the name from GitHub (or whatever IdP).
After the first GitHub login, that user has a row in test_user; you can assign roles there, and the same code path serves both form and OIDC users. This is the standard pattern, not a one-off fix.
Seed data and sequence. Hibernate creates the sequence from @SequenceGenerator when it builds the schema, so import.sql should not create it again. In import.sql, insert seed users and then advance the sequence so the next persist() (e.g. OIDC user) gets a new id. Use the lowercased sequence name in PostgreSQL (test_user_seq):
-- Hibernate creates test_user_SEQ from @SequenceGenerator; we only seed data and advance the sequence
INSERT INTO test_user(id, username, password, role) VALUES (1, 'alice', 'alicePassword', 'admin');
INSERT INTO test_user(id, username, password, role) VALUES (2, 'bob', 'bobPassword', 'user');
SELECT setval('test_user_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM test_user));allocationSize = 50 on the entity should match the sequence’s increment (Hibernate creates it with the same allocation size).
Test and Run
Start the app (with Dev Services or a running PostgreSQL):
./mvnw quarkus:devThen:
Open http://localhost:8080/ — you should be redirected to the login page.
Log in as alice / alicePassword (or bob / bobPassword from import.sql).
You should see the landing page with “Welcome, alice!” and the Logout button.
Click Logout; you should be back at the login page.
Use “Sign in with GitHub” (after configuring the OAuth App) to log in with your GitHub account.
To test the API endpoints while logged in (using the same session cookie):
curl -b cookies.txt -c cookies.txt http://localhost:8080/api/userIf something goes wrong:
“The redirect_uri is not associated with this application” — The callback URL in your GitHub OAuth App does not match what Quarkus sends. In the OAuth App set Authorization callback URL to exactly
http://localhost:8080/github(no trailing slash,httpfor localhost), then save.Redirected to form login after signing in with GitHub — The authenticated permission for
/and/apimust accept both form and OIDC (do not setauth-mechanism=formonly). Usequarkus.http.auth.form.priority=2000andquarkus.oidc.priority=1000so unauthenticated users get the form login page and GitHub-login users are still accepted.Form login or OIDC not working — Ensure the session encryption key is at least 16 characters, that
/loginand/j_security_checkare permitted, and that the authenticated permission (which includes/github) usesapplies-to=jaxrsso the@AuthorizationCodeFlowannotation is applied for the GitHub endpoint.NullPointerExceptioninSmallRyeContextManagerProvider.getManager()— The logout endpoint returnsUni<Response>; add thequarkus-smallrye-context-propagationextension so the request context is propagated in the reactive chain.BlockingOperationNotAllowedException: Cannot call getIdentity() from the IO thread when lazy authentication is in use— Withquarkus.http.auth.proactive=false, do not injectSecurityIdentitydirectly in the logout endpoint. InjectCurrentIdentityAssociationand usegetDeferredIdentity().flatMap(identity -> ...)so the identity is resolved asynchronously.
Further Learning
Authentication mechanisms in Quarkus — Form auth, cookies, and logout.
Configuring well-known OIDC providers — GitHub, Google, and others.
Qute Reference — Templating and type-safe templates.
Your Quarkus app now has a polished login experience: form login, remember me, logout, and Sign in with GitHub.


