On this page

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:

  1. A trusted backend creates a one-time pre-authorized code by calling /auth/preauthorize.
  2. The receiving application redeems that code at /oauth2/token to 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 ClientApplication with Project Admin privileges.
  • Authenticate your backend as that ClientApplication, typically with the Client Credentials Flow.
  • Include the X-Medplum-On-Behalf-Of header when creating the pre-authorized code. This identifies the Medplum user who will receive the resulting token. See On-Behalf-Of for details.
One-time codes

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: Your ClientApplication access token or basic auth credentials
  • X-Medplum-On-Behalf-Of: The target ProjectMembership, Practitioner, Patient, or other supported profile reference
  • Content-Type: application/json

Request body:

  • clientId: Required. The target ClientApplication ID.
  • 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 between 1 and 604800.

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 scope is omitted, it defaults to openid plus the caller's SMART scopes. Other scopes, such as offline_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 same user/ scope. A system/ or user/ scope covers the same patient/ scope only when the target user's profile is a Patient, and the code is then limited to that patient's compartment.
  • scope must include at least one SMART scope. A scope with no SMART scopes, such as openid alone, 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_code
  • client_id: The ClientApplication ID
  • pre-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.