# SMART on FHIR OAuth with PKCE in React and Node.js.

A security-first walkthrough of discovery, authorization, callback validation, and token exchange for a standalone patient-facing application.

[Production-grade access is free with an account.](https://finchnode.com/signup) The $0/month plan includes 100 connected patient-months, 100,000 production API calls per month, one production application, and an unlimited synthetic sandbox.

- Author: [FinchNode Engineering](https://finchnode.com/authors/finchnode-engineering)

- Published: 2026-08-25

- Last reviewed: 2026-08-25

- Canonical URL: https://finchnode.com/blog/smart-on-fhir-oauth-pkce-react-node

- Evidence boundary: Protocol behavior is grounded in SMART App Launch 2.2. The code is educational and uses placeholder endpoints rather than a live EHR.

## Bottom line

A SMART standalone launch is an OAuth 2.0 authorization-code flow with FHIR-specific discovery, audience, scopes, and patient context. Generate PKCE and state on the server, send only the authorization URL to React, and exchange the returned code from a trusted backend.

## Key takeaways

- Use `.well-known/smart-configuration` instead of hard-coding authorization endpoints when the server publishes it.
- Bind state and the PKCE verifier to a short-lived server-side transaction.
- Treat the browser as a navigation surface; keep token exchange and persistent tokens on the backend.

## The standalone launch sequence

1. **1. Discover** Read the server’s SMART configuration and select its authorize and token endpoints.
2. **2. Prepare** Generate unpredictable state and a PKCE verifier; store them with a short expiry.
3. **3. Redirect** Request an authorization code with the registered redirect URI, FHIR audience, minimum scopes, and S256 challenge.
4. **4. Validate** On callback, reject missing, expired, reused, or mismatched state before exchanging the code.
5. **5. Exchange** Send the code, verifier, client identifier, and exact redirect URI to the token endpoint.
6. **6. Bind** Validate the response and associate the returned patient context with the authorized source connection.

## Generate state and PKCE on the Node.js server

The verifier must remain secret until the token exchange. Store only the transaction identifier in the browser session or secure, same-site cookie.

```javascript
import { createHash, randomBytes } from 'node:crypto';

const base64url = (value) => value.toString('base64url');
const state = base64url(randomBytes(32));
const verifier = base64url(randomBytes(64));
const challenge = base64url(createHash('sha256').update(verifier).digest());

await transactions.save({ state, verifier, expiresAt: Date.now() + 5 * 60_000 });

const authorize = new URL(smart.authorize_endpoint);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('client_id', process.env.FHIR_CLIENT_ID);
authorize.searchParams.set('redirect_uri', 'https://app.example.com/auth/callback');
authorize.searchParams.set('aud', fhirBaseUrl);
authorize.searchParams.set('scope', 'openid fhirUser launch/patient patient/*.rs');
authorize.searchParams.set('state', state);
authorize.searchParams.set('code_challenge', challenge);
authorize.searchParams.set('code_challenge_method', 'S256');

return { authorizationUrl: authorize.toString() };
```

## Validate the callback before exchanging the code

Use the exact redirect URI registered with the server. Consume state once so a valid callback cannot be replayed. Do not log authorization codes or token responses.

```javascript
const transaction = await transactions.consume(request.query.state);
if (!transaction || transaction.expiresAt < Date.now()) throw new Error('invalid_state');

const body = new URLSearchParams({
  grant_type: 'authorization_code',
  code: request.query.code,
  client_id: process.env.FHIR_CLIENT_ID,
  redirect_uri: 'https://app.example.com/auth/callback',
  code_verifier: transaction.verifier,
});

const tokenResponse = await fetch(smart.token_endpoint, {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body,
});
if (!tokenResponse.ok) throw new Error('token_exchange_failed');
const token = await tokenResponse.json();
```

### Primary sources

- [SMART launch and authorization](https://hl7.org/fhir/smart-app-launch/STU2.2/app-launch.html)
- [SMART scopes and launch context](https://hl7.org/fhir/smart-app-launch/STU2/scopes-and-launch-context.html)

## Production checklist

- Require HTTPS and exact pre-registered redirect URIs.
- Validate issuer, audience, signature, expiry, and nonce before trusting an ID token.
- Encrypt refresh tokens at rest and keep them out of browser storage.
- Request the least data and shortest duration the product needs.
- Handle denied consent, missing patient context, token expiry, revocation, and reauthorization as normal states.
- Apply timeouts, response-size limits, and an outbound-host allowlist to discovery and token requests.

## Frequently asked questions

### Is PKCE required for SMART on FHIR?

SMART App Launch 2.2 requires apps to support PKCE. Servers validate the code verifier during token exchange. Use the S256 challenge method.

### Should React exchange the authorization code?

A browser-only public client can implement PKCE, but applications with a backend should generally keep token exchange and persistent tokens on the trusted server so tokens are not exposed to browser storage or application JavaScript.

### What is the SMART aud parameter?

It identifies the FHIR resource server the application intends to access. Use the server’s advertised FHIR base URL and follow the implementation guide and vendor registration requirements.

### Where does patient context come from?

For a standalone patient launch, the authorization server can return a patient identifier in the token response when the granted launch context includes a patient.