Pre-Authorized Code
Medplum supports the OAuth 2.0 pre-authorized code grant type, urn:ietf:params:oauth:grant-type:pre-authorized_code, as defined by OpenID for Verifiable Credential Issuance 1.0. This flow is useful for magic links and other issuer-initiated flows where user authentication and consent have already happened before the client asks Medplum for tokens.
Overview
This flow has two steps:
- A trusted backend creates a one-time pre-authorized code by calling
/auth/preauthorize. - The receiving application redeems that code at
/oauth2/tokento obtain Medplum tokens.
Unlike the authorization code flow, this flow does not redirect the user through the authorization endpoint.
Prerequisites
To use this flow:
- Create a
ClientApplicationwith Project Admin privileges. - Authenticate your backend as that
ClientApplication, typically with the Client Credentials Flow. - Include the
X-Medplum-On-Behalf-Ofheader when creating the pre-authorized code. This identifies the Medplum user who will receive the resulting token. See On-Behalf-Of for details.
Pre-authorized codes are single-use and expire automatically. By default, Medplum issues codes that expire after 1 hour. The maximum allowed lifetime is 7 days.
Create a pre-authorized code
Send an authenticated POST request to /auth/preauthorize.
Request headers:
Authorization: YourClientApplicationaccess token or basic auth credentialsX-Medplum-On-Behalf-Of: The targetProjectMembership,Practitioner,Patient, or other supported profile referenceContent-Type: application/json
Request body:
clientId: Required. The targetClientApplicationID.scope: Optional. See Scope for the default and the allowed values.nonce: Optional. If omitted, Medplum generates a random nonce.expiresIn: Optional expiration time in seconds. Must be between1and604800.
Example:
const medplum = new MedplumClient({
baseUrl: MEDPLUM_BASE_URL,
clientId: MY_CLIENT_ID,
clientSecret: MY_CLIENT_SECRET,
});
const preAuthResult = await medplum.post(
'auth/preauthorize',
{
clientId: MY_CLIENT_ID,
scope: 'openid',
expiresIn: 3600,
nonce: 'optional-nonce-value',
},
undefined,
{
headers: {
'X-Medplum-On-Behalf-Of': ON_BEHALF_OF,
},
}
);curl -X POST 'https://api.medplum.com/auth/preauthorize' \
--user "$MY_CLIENT_ID:$MY_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-H 'X-Medplum-On-Behalf-Of: Practitioner/00000000-0000-0000-0000-000000000000' \
--data-raw '{
"clientId": "00000000-0000-0000-0000-000000000000",
"scope": "openid",
"expiresIn": 3600
}'On success, Medplum returns a response like:
{
"preAuthorizedCode": "<ONE_TIME_CODE>",
"expiresAt": "2026-05-16T20:15:00.000Z"
}
You can package this code into a magic link, QR code, or handoff to a wallet or application that will redeem it.
Scope
The scope of a pre-authorized code can never be broader than the scope of the access token used to create it.
If the calling access token has no SMART scopes, it is unrestricted, and scope defaults to openid.
If the calling access token has SMART scopes, such as user/*.rs:
- When
scopeis omitted, it defaults toopenidplus the caller's SMART scopes. Other scopes, such asoffline_access, are not inherited. - Each requested SMART scope must be covered by one of the caller's SMART scopes, with the same or fewer resource types and permissions.
- A
system/scope covers the sameuser/scope. Asystem/oruser/scope covers the samepatient/scope only when the target user's profile is aPatient, and the code is then limited to that patient's compartment. scopemust include at least one SMART scope. A scope with no SMART scopes, such asopenidalone, would be unrestricted.- Any other requested scope, except
openid, must also be in the caller's scope.
For example, a caller with openid user/*.rs can create a code with openid user/Patient.rs, or with openid patient/*.rs for a Patient user, but not with openid user/Patient.cruds.
If the requested scope is broader than the caller's scope, Medplum returns 400 Bad Request with Invalid scope.
Redeem the pre-authorized code
To redeem the code, send a form-encoded POST request to /oauth2/token with:
grant_type:urn:ietf:params:oauth:grant-type:pre-authorized_codeclient_id: TheClientApplicationIDpre-authorized_code: The one-time code returned by/auth/preauthorize
Example:
const tokenResponse = await fetch(`${MEDPLUM_BASE_URL}oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:pre-authorized_code',
client_id: MY_CLIENT_ID,
'pre-authorized_code': preAuthResult.preAuthorizedCode,
}),
});
const tokens = await tokenResponse.json();
console.log(tokens.access_token);curl -X POST 'https://api.medplum.com/oauth2/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code' \
--data-urlencode 'client_id=00000000-0000-0000-0000-000000000000' \
--data-urlencode 'pre-authorized_code=<PRE_AUTHORIZED_CODE>'On success, the response includes a Medplum access token and ID token:
{
"token_type": "Bearer",
"access_token": "<ACCESS_TOKEN>",
"id_token": "<ID_TOKEN>",
"scope": "openid",
"expires_in": 3600
}
Notes
- Pre-authorized codes can only be redeemed once.
- If a code is expired, already used, revoked, or presented for the wrong client, Medplum returns an OAuth error response from
/oauth2/token. - Medplum currently supports the pre-authorized code flow without an additional transaction code (
tx_code).
For more details, see the OpenID for Verifiable Credential Issuance 1.0 specification, especially the sections on Pre-Authorized Code Flow and Token Request.