SMART on FHIR Authentication with Keycloak
By David Le -- Part 4 of the FhirHub Series
Authentication in healthcare applications isn't optional -- it's regulated. SMART on FHIR is the industry-standard framework for securing FHIR-based applications. FhirHub implements SMART on FHIR using Keycloak as the identity provider, with PKCE for public client security and a fine-grained RBAC matrix.
Why SMART on FHIR?
SMART (Substitutable Medical Applications, Reusable Technologies) defines how applications launch within EHR systems and obtain authorized access to FHIR data. It builds on OAuth 2.0 and OpenID Connect, adding healthcare-specific scopes like patient/*.read and launch/patient.
FhirHub doesn't run inside an EHR, but it adopts the SMART on FHIR security patterns because they're well-designed and widely understood in the healthcare developer community.
The Authentication Flow
FhirHub uses the Authorization Code flow with PKCE (Proof Key for Code Exchange), which is the recommended flow for browser-based applications:
1. User visits FhirHub → redirected to Keycloak login
2. User enters credentials
3. Keycloak returns authorization code
4. Frontend exchanges code for JWT (using PKCE code_verifier)
5. JWT included in all API requests
6. API validates JWT signature via Keycloak OIDC discovery
Frontend: Keycloak-JS Integration
The frontend initializes Keycloak with a singleton pattern:
// frontend/src/lib/keycloak.ts
import Keycloak from "keycloak-js";
let instance: Keycloak | null = null;
let _initialized = false;
export function getKeycloak(): Keycloak {
if (!instance) {
instance = new Keycloak({
url: process.env.NEXT_PUBLIC_KEYCLOAK_URL || "http://localhost:8180",
realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM || "fhirhub",
clientId:
process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || "fhirhub-frontend",
});
}
return instance;
}
export async function initKeycloak(
options: {
onLoad?: "login-required" | "check-sso";
silentCheckSsoRedirectUri?: string;
} = {}
): Promise<boolean> {
const kc = getKeycloak();
if (_initialized) {
return kc.authenticated ?? false;
}
_initialized = true;
return kc.init({ ...options, pkceMethod: "S256" });
}
Key design decisions:
- Singleton pattern prevents multiple Keycloak instances from being created during React re-renders
- PKCE with S256 eliminates the need for a client secret in the browser
login-requiredonLoad means unauthenticated users are immediately redirected to Keycloak- Environment variables allow configuration without code changes
Backend: JWT Validation
The .NET API validates every request's JWT token using the JWT Bearer middleware:
// Program.cs
var keycloakSettings = builder.Configuration.GetSection("Keycloak");
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = keycloakSettings["Authority"];
options.Audience = keycloakSettings["Audience"];
options.RequireHttpsMetadata =
keycloakSettings.GetValue<bool>("RequireHttpsMetadata", true);
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
NameClaimType = "preferred_username",
RoleClaimType = ClaimTypes.Role
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Log.Warning("Authentication failed: {Error}",
context.Exception.Message);
return Task.CompletedTask;
}
};
});
The middleware automatically:
- Fetches Keycloak's OIDC discovery document to find the JWKS endpoint
- Downloads the signing keys
- Validates the JWT signature, issuer, audience, and expiration
- Maps claims to the
HttpContext.Userprincipal
Role-Based Access Control
The Role Hierarchy
FhirHub defines six roles with progressively restricted access:
| Role | Description |
|---|---|
admin | Full system access including user management and audit logs |
practitioner | Clinical read/write access, export management |
nurse | Clinical read access, can write vitals, manage alerts |
front_desk | Patient demographics read-only |
patient | Own data read-only |
The Policy Matrix
Authorization policies are defined in a declarative matrix:
// Authorization/AuthorizationPolicies.cs
public static readonly Dictionary<string, string[]> PolicyRoles = new()
{
[CanReadPatients] = ["admin", "practitioner", "nurse", "front_desk", "patient"],
[CanWritePatients] = ["admin", "practitioner"],
[CanReadVitals] = ["admin", "practitioner", "nurse", "patient"],
[CanWriteVitals] = ["admin", "practitioner", "nurse"],
[CanReadConditions] = ["admin", "practitioner", "nurse", "patient"],
[CanWriteConditions] = ["admin", "practitioner"],
[CanReadMedications] = ["admin", "practitioner", "nurse", "patient"],
[CanWriteMedications] = ["admin", "practitioner"],
[CanReadLabs] = ["admin", "practitioner", "nurse", "patient"],
[CanOrderLabs] = ["admin", "practitioner"],
[CanReadClinicalOverviews] = ["admin", "practitioner", "nurse"],
[CanViewDashboard] = ["admin", "practitioner", "nurse"],
[CanManageAlerts] = ["admin", "practitioner", "nurse"],
[CanManageExports] = ["admin", "practitioner"],
[CanDeleteExports] = ["admin"],
[CanManageUsers] = ["admin"],
[CanViewAuditLogs] = ["admin"],
};
That's 17 policies across 6 roles. Each policy lists which roles are authorized. This matrix is registered at startup:
builder.Services.AddAuthorization(options =>
{
foreach (var (policyName, roles) in AuthorizationPolicies.PolicyRoles)
{
options.AddPolicy(policyName, policy => policy.RequireRole(roles));
}
options.AddPolicy(AuthorizationPolicies.PatientDataAccess, policy =>
policy.Requirements.Add(new PatientDataRequirement()));
});
Resource-Level Authorization
Beyond role checks, some endpoints also enforce resource-level access through the PatientDataAccess policy. This ensures patients can only access their own data:
// PatientsController.cs
[HttpGet("{id}")]
[Authorize(Policy = AuthorizationPolicies.CanReadPatients)]
[Authorize(Policy = AuthorizationPolicies.PatientDataAccess)]
public async Task<IActionResult> GetById(string id, CancellationToken ct)
Notice the stacked [Authorize] attributes -- both policies must pass. The role check (CanReadPatients) and the resource check (PatientDataAccess) are evaluated independently.
Keycloak Claims Transformation
Keycloak puts roles inside realm_access.roles in the JWT. .NET expects them as standard role claims. A custom IClaimsTransformation bridges the gap:
builder.Services.AddTransient<
IClaimsTransformation,
KeycloakClaimsTransformer>();
This transformer extracts roles from the Keycloak-specific JWT structure and adds them as standard .NET role claims that RequireRole() can evaluate.
SMART on FHIR Tools
FhirHub includes a SMART on FHIR developer tools page with three components:
- Launch Simulator (
launch-simulator.tsx) -- Simulates a SMART launch with configurable context parameters - Token Inspector (
token-inspector.tsx) -- Decodes and displays the current JWT token claims - Scope Visualizer (
scope-visualizer.tsx) -- Shows which FHIR scopes are granted to the current session
These tools are invaluable for debugging authentication issues during development.
What's Next
In Part 5, we'll explore the FHIR R4 resources that FhirHub implements and how they map to the API's DTOs.