Migrating from M2M to Connect
If your integration currently uses Machine-to-Machine (client_credentials) tokens to access user data, this guide explains when and how to migrate to the Connect flow (authorization_code + PKCE).
When to migrate
| Scenario | Keep M2M | Migrate to Connect |
|---|---|---|
| Service-to-service calls with no user context (cron jobs, internal pipelines) | Yes | No |
| Reading/writing data on behalf of a specific user | No | Yes |
| Admin operations (provisioning, bulk exports) | Yes | No |
| User-facing app (web, mobile, SPA) | No | Yes |
| Partner integration that accesses user health data | No | Yes |
Rule of thumb: if your code runs because a user did something, use Connect. If it runs on a schedule or in response to an internal event, keep M2M.
What changes
Before (M2M)
# Get a service token (no user involved)
curl -X POST https://identity.platform.loop.health/v1/oauth/token \
-d "grant_type=client_credentials" \
-d "client_id=$SERVICE_CLIENT_ID" \
-d "client_secret=$SERVICE_CLIENT_SECRET" \
-d "scope=admin:clinical"
# Call with admin-level access — no scope boundaries per user
curl https://clinical.platform.loop.health/v1/biomarkers?user_id=user_01HXY \
-H "Authorization: Bearer $SERVICE_TOKEN"After (Connect)
# User grants specific scopes via the consent screen
# Your app receives an authorization code, exchanges it:
curl -X POST https://identity.platform.loop.health/v1/oauth/token \
-d "grant_type=authorization_code" \
-d "code=$AUTH_CODE" \
-d "redirect_uri=$REDIRECT_URI" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code_verifier=$VERIFIER"
# Call with user-scoped access — can only see what the user allowed
curl https://clinical.platform.loop.health/v1/biomarkers/me \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"Key differences
| Aspect | M2M (client_credentials) | Connect (authorization_code + PKCE) |
|---|---|---|
| User context | None — calls act as the service itself | Always tied to a specific user |
| Scopes | admin:* — full service access | read:* / write:* — limited to user consent |
| Token lifetime | Configurable (often long-lived) | 1 hour access / 90-day refresh |
| Consent | No user involvement | User sees consent screen, can revoke |
| Audit trail | Logged as service action | Logged as user-authorized action with app attribution |
| Resource path | /v1/resource?user_id=... | /v1/resource/me (user inferred from token) |
| HIPAA | Covered by service BAA | Requires per-app BAA for PHI scopes |
| Revocation | Service credential rotation | Per-user grant revocation |
Migration steps
1. Register an OAuth client
If your integration only has M2M credentials, register a new OAuth client at developers.platform.loop.health. You will get a separate client_id for user-facing flows.
2. Identify the scopes you need
Map your current admin:* usage to user-facing scopes:
| M2M scope | Connect scope(s) |
|---|---|
admin:clinical (reading biomarkers) | read:biomarkers |
admin:clinical (starting protocols) | write:protocols |
admin:payments (viewing history) | read:payments |
admin:comms (reading inbox) | read:inbox |
See scopes.md for the complete list.
3. Implement the authorization flow
Follow the quickstart or one of the language-specific guides:
4. Update your API calls
Replace user_id query parameters with the /me convention:
- GET /v1/biomarkers?user_id=user_01HXY
- Authorization: Bearer $SERVICE_TOKEN
+ GET /v1/biomarkers/me
+ Authorization: Bearer $USER_ACCESS_TOKENServices infer the user from the access token’s sub claim.
5. Handle token refresh
M2M tokens are typically long-lived. Connect access tokens expire after 1 hour. Your app must handle 401 responses by refreshing:
async function callWithRefresh(url: string, tokens: Tokens): Promise<Response> {
let res = await fetch(url, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (res.status === 401) {
const refreshed = await refreshTokens(tokens.refreshToken);
tokens.accessToken = refreshed.access_token;
tokens.refreshToken = refreshed.refresh_token;
// Persist the new tokens
await saveTokens(tokens);
res = await fetch(url, {
headers: { Authorization: `Bearer ${refreshed.access_token}` },
});
}
return res;
}6. Run both flows during transition
During migration, you can run both M2M and Connect in parallel:
- Keep M2M for admin/batch operations that have no user context.
- Use Connect for user-facing features.
- Both token types are valid simultaneously; services distinguish them by the token prefix and claims.
7. Clean up
Once all user-facing paths use Connect:
- Remove any
admin:*scope usage from user-facing code paths. - Audit your M2M credentials — restrict them to only the admin scopes they genuinely need.
- Update your
service.yamlif it declares scopes.
Common pitfalls
“My cron job needs user data.” If a background job processes data for many users, it should still use M2M. Only migrate if the job runs in direct response to a user action and can carry the user’s token.
“I need to access multiple users’ data.” Connect tokens are per-user. If you need to aggregate across users, keep M2M for the aggregation layer and use Connect for individual user-facing views.
“The user’s refresh token expired.” Refresh tokens last 90 days from last use. If a user hasn’t used your app in 90 days, you’ll need to re-prompt for consent. This is by design — stale grants should not persist indefinitely.
Related reading
- Authorization flow — the full state machine
- Tokens — access token lifetimes, refresh rotation
- Security — why PKCE is required for all clients