Introduction
Architecture
Process topology, request paths, trust boundaries and the WebAuthn ceremony sequence.
Two processes and one browser. There is no message broker, no cache tier and no service mesh — the design intent is that a district IT cell can run it.
Topology
Hover a node to isolate its connections. Click a node to open its reference page.
graph TB
subgraph BROWSER["Browser — desktop or mobile"]
App["Next.js app<br/><small>8 routes</small>"]
Sdk["@mobilesigner/web-sdk"]
Ceremony["window.WebAuthn<br/><small>webauthn.js from Quarkus</small>"]
Creds["navigator.credentials"]
Key(["FIDO2 security key<br/><small>private key never leaves</small>"])
end
subgraph NEXT["Next.js server — port 3000"]
Rewrites["standalone server.js<br/><small>proxy rewrites only</small>"]
end
subgraph QUARKUS["Quarkus backend ×2 — port 8090"]
WebAuthnExt["/q/webauthn/*<br/><small>WebAuthn extension</small>"]
Api["/api/*<br/><small>7 JAX-RS resources</small>"]
Panache["Panache entities"]
end
Db[("PostgreSQL 17<br/><small>StatefulSet + PersistentVolumeClaim</small>")]
App --> Sdk
Sdk --> Ceremony
Ceremony --> Creds
Creds --> Key
App -- "/backend/*" --> Rewrites
Ceremony -- "/q/webauthn/*" --> Rewrites
Rewrites -- "proxy" --> WebAuthnExt
Rewrites -- "strips /backend, adds /api" --> Api
WebAuthnExt --> Panache
Api --> Panache
Panache --> Db
click App "/workstation" "Workstation UI reference"
click Sdk "/web-sdk" "Web SDK reference"
click Api "/backend-api" "Backend API reference"
click Panache "/data-model" "Data model reference"
click Db "/data-model" "Data model reference"
classDef edge fill:#e8f0f9,stroke:#1d4e89,color:#18221d
classDef store fill:#fdf3dd,stroke:#7a5200,color:#18221d
class Rewrites edge
class Db,Key store
Request paths
Everything the browser calls is same-origin. There is no CORS configuration anywhere in the backend, and none is needed, because the Next.js server proxies both API families.
From next.config.mjs:
const backend = process.env.BACKEND_URL ?? 'http://localhost:8090';
async rewrites() {
return [
{ source: '/q/webauthn/:path*', destination: `${backend}/q/webauthn/:path*` },
{ source: '/backend/:path*', destination: `${backend}/api/:path*` }
];
}
The two rules behave differently, and the difference matters:
graph LR
B1["Browser<br/>/backend/files"] --> R1{{"rewrite"}}
R1 -->|"prefix replaced"| S1["Backend<br/>/api/files"]
B2["Browser<br/>/q/webauthn/login"] --> R2{{"rewrite"}}
R2 -->|"path preserved"| S2["Backend<br/>/q/webauthn/login"]
classDef browser fill:#e8f0f9,stroke:#1d4e89,color:#18221d
classDef server fill:#e4f4ec,stroke:#0d6b4f,color:#18221d
class B1,B2 browser
class S1,S2 server
/backend/filesin the browser reaches/api/fileson the backend. The path prefix changes across the proxy.src/lib/api.tssetsconst BASE = '/backend'and the SDK defaultsapiBaseto'/backend'for the same reason./q/webauthn/*is not rewritten to/api. It passes through with its path intact, because that is where the Quarkus WebAuthn extension mounts its ceremony endpoints and serveswebauthn.js.
Same-origin also means the encrypted Quarkus session cookie is sent on every fetch without any cross-site cookie negotiation. quarkus.webauthn.cookie-same-site=strict is therefore safe to keep strict.
How BACKEND_URL is resolved
Next.js resolves rewrite destinations when it builds, not when it serves. BACKEND_URL is read in next.config.mjs, which executes during next build, and the value is baked into .next/routes-manifest.json.
The Dockerfile supplies it as a build argument for exactly this reason:
ARG BACKEND_URL=http://backend:8090
ENV BACKEND_URL=${BACKEND_URL}
RUN npm run build
The image therefore ships with the in-cluster address already compiled in. To point a build at a different backend, pass --build-arg BACKEND_URL=…; setting the variable on a running container has no effect.
The WebAuthn ceremony
layout.tsx loads the ceremony client before hydration:
<Script src="/q/webauthn/webauthn.js" strategy="beforeInteractive" />
That script defines window.WebAuthn. The SDK never touches navigator.credentials itself — it delegates through MobileSignerWebSdk.frameworkClient(), which constructs new window.WebAuthn() and fails fast with a typed error if the script has not loaded:
private frameworkClient(): FrameworkWebAuthnClient {
if (typeof window === 'undefined' || typeof PublicKeyCredential === 'undefined') {
throw new MobileSignerError('UNSUPPORTED_BROWSER', 'This browser does not support WebAuthn.');
}
if (!window.WebAuthn) {
throw new MobileSignerError('SDK_NOT_LOADED', 'The Quarkus WebAuthn ceremony client did not load.');
}
return new window.WebAuthn();
}
The three flows below share that delegation. Switch tabs to compare them.
### Registration
sequenceDiagram
autonumber
participant U as Officer
participant S as web-sdk
participant W as window.WebAuthn
participant Q as Quarkus
participant K as FIDO2 key
U->>S: registerCredential({username})
Note over S: emits registration:started
S->>W: register()
W->>Q: POST /q/webauthn/register-options-challenge
Q-->>W: challenge, rp.id, user handle
W->>K: navigator.credentials.create()
K-->>K: user presence + verification
K-->>W: attestation object
W->>Q: POST /q/webauthn/register
Note over Q: store() removes any prior<br/>credential for this username,<br/>then persists credentialId,<br/>publicKey, algorithm,<br/>counter, aaguid
Q-->>W: Set-Cookie: encrypted session
S->>Q: GET /api/session
Q-->>S: {authenticated: true, username}
Note over S: emits registration:completed,<br/>then session:loaded
### Authentication
sequenceDiagram
autonumber
participant U as Officer
participant S as web-sdk
participant W as window.WebAuthn
participant Q as Quarkus
participant K as FIDO2 key
U->>S: authenticate({username})
Note over S: emits authentication:started
S->>W: login()
W->>Q: POST /q/webauthn/login-options-challenge
Q-->>W: challenge, allowCredentials
W->>K: navigator.credentials.get()
K-->>K: user presence + verification
K-->>W: signed assertion
W->>Q: POST /q/webauthn/login
Note over Q: verifies signature against<br/>stored public key, then<br/>update(credentialId, counter)
Q-->>W: Set-Cookie: encrypted session
S->>Q: GET /api/session
Q-->>S: {authenticated: true, username}
Note over S: emits authentication:completed,<br/>then session:loaded
### Signing a file
sequenceDiagram
autonumber
participant U as Officer
participant V as FileDetailView
participant C as crypto.subtle
participant Q as Quarkus
participant D as Database
U->>V: clicks "Approve & Sign"
V->>C: sha256(file.body)
C-->>V: 64 hex characters
V->>Q: POST /backend/files/{id}/action<br/>{action, note, documentHash}
Note over Q: proxied to<br/>POST /api/files/{id}/action
Q->>Q: identity anonymous? → 401
Q->>Q: action not a known verb? → 400
Q->>Q: hash not 64 hex? → 400
rect rgb(228, 244, 236)
Note over Q,D: one transaction
Q->>D: append FileNote (GREEN,<br/>signatureHash, signedAt)
Q->>D: GovFile.status = APPROVED
Q->>D: persist Approval
Q->>D: record FILE_APPROVED
end
Q-->>V: full refreshed FileDetail
Note over V: replaces state from<br/>server truth, so badge and<br/>noting cannot drift
Counter regression is what a cloned-authenticator detection check looks for, and the Quarkus extension performs it against the stored counter on every assertion.
The action endpoint returns the complete refreshed FileDetail rather than an acknowledgement. The workstation replaces its state with that response, which is why the status badge and the new noting-sheet entry appear together and cannot drift apart.
Trust boundaries
| Boundary | Enforcement |
|---|---|
| Authenticator → browser | CTAP2; the private key never crosses |
| Browser → Next.js server | none; the Next server is a pass-through proxy and holds no secrets |
| Next.js → Quarkus | plain HTTP inside the cluster |
| Quarkus request → identity | SecurityIdentity from the encrypted WebAuthn session cookie |
| Write endpoints | @RolesAllowed("approver") on file decisions, administrator on device revocation |
| Read endpoints | @RolesAllowed("officer") — nothing but /api/session is readable anonymously |
| Audit ledger | UPDATE, DELETE and TRUNCATE refused by database triggers; rows hash-chained |
| Private key | never leaves the FIDO2 authenticator; the backend stores only the public key |
Roles are real. UserAccount carries a role column constrained to officer, approver, auditor or administrator, and a role implies the lesser capabilities so an endpoint can require the narrowest one it needs. A noting records the signatory's own designation rather than a hardcoded constant.
One constraint shapes how roles are resolved: Quarkus builds the security identity inside the HTTP authentication mechanism, which runs on the IO thread, and WebAuthnUserProvider.getRoles is a synchronous call on that path. Reading the account from the database there throws BlockingOperationNotAllowedException, because Hibernate refuses to block an event loop. So RoleDirectory holds the mapping in memory — warmed at startup, updated whenever an identity is established, and failing safe to read-only access on a miss while it refreshes in the background. Security model covers what this does and does not establish.
Persistence
PostgreSQL 17 in every deployed environment, reached over the network and configured entirely from the environment:
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/mobilesigner}
quarkus.datasource.username=${DB_USERNAME:mobilesigner}
quarkus.datasource.password=${DB_PASSWORD:mobilesigner}
Dev and test override this to H2, so ./gradlew quarkusDev needs no database running:
%dev.quarkus.datasource.db-kind=h2
%dev.quarkus.datasource.jdbc.url=jdbc:h2:file:./data/mobilesigner;AUTO_SERVER=TRUE
%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1
quarkus.datasource.db-kind is a build-time property, so the value baked into a container image is whichever profile was active during ./gradlew build — the default, production one. Both JDBC drivers are on the classpath to make the dev override possible.
Why this is not H2 in the cluster
An embedded file database is a poor fit for the deployment target, for three separate reasons that are worth keeping apart:
| Concern | With a file database | With PostgreSQL |
|---|---|---|
| Durability | the file lives in the container filesystem, so every restart loses all credentials, approvals and audit rows and re-seeds | a PersistentVolumeClaim outlives the pods entirely |
| Horizontal scale | H2 file mode admits one writer, capping the backend at one replica | any number of replicas share one database |
| Rolling updates | a new pod cannot open the file while the old one holds it | replicas start and stop independently |
The important nuance is that only persistence was ever the blocker. The application tier itself is stateless: the WebAuthn challenge and the officer's session both travel in encrypted cookies, so nothing is held in a pod's memory between requests. Swapping the datasource is therefore all that was required to scale out, and the backend now runs replicas: 2.
That statelessness has one hard requirement: every replica must encrypt cookies with the same key, or a request balanced onto another pod cannot be decrypted. SESSION_ENCRYPTION_KEY is supplied from a Kubernetes Secret for exactly this reason.
The seeding race
With more than one replica, every pod runs DemoDataSeeder on startup and they can all observe an empty table before any of them commits. The unique constraint on gov_file.file_number is what prevents a duplicated working set — the losing transaction fails, and that failure is expected:
void onStart(@Observes StartupEvent ev) {
try {
QuarkusTransaction.requiringNew().run(this::seed);
} catch (RuntimeException e) {
LOG.infof("Seed data not written — another instance seeded this database first (%s)",
e.getClass().getSimpleName());
}
}
The transaction is started programmatically rather than with @Transactional on the observer, because an annotated observer would give the class no opportunity to catch the rollback and the pod would fail to start.
Schema is still generated by Hibernate with schema-management.strategy=update. That is additive only — it never drops or narrows a column — so a non-additive change needs a migration tool such as Flyway before this carries data that matters. Seed data describes what gets loaded.