
# Link a federated identity to a user

`POST /v1/users/{user_id}/identities` · permission `users:write`

Links a federated identity (the external subject of a social, SAML or OIDC connection) to the user, so the user can sign in with that connection. `linked_via` is always `admin_api`. The connection must be an enabled connection of this environment whose kind matches `type`. If the same identity is already linked to this or another user the request fails with `409` and the owner is not revealed. The external subject is personal data: it is stored encrypted and never returned, and the management audit record `user.identity_linked` (actor: the API key) does not contain it. Merging two users (`source_user_id`) is not available; an unknown field returns `400 unknown_field`.

## Request

| Parameter | In | Required | Description |
|---|---|---|---|
| `user_id` | path | yes | User id, prefixed `usr_`. |

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | `social`, `saml` or `oidc`; must match the connection (`social` for a social connection, `saml`/`oidc` for an enterprise connection of the same strategy). |
| `connection_id` | string | yes | Connection id, prefixed `conn_`. |
| `subject` | string | yes | External subject at the identity provider (OIDC `sub`, SAML NameID), at most 1024 bytes of UTF-8. |

## Response

`201 Created` with the new identity, in the shape returned by `GET /v1/users/{user_id}/identities`.

```json
{
  "id": "idn_0a1b2c3d4e5f6g7h8j9k0m1n2r",
  "type": "oidc",
  "connection_id": "conn_0a1b2c3d4e5f6g7h8j9k0m1n2s",
  "provider": null,
  "linked_via": "admin_api",
  "created_at": "2026-01-15T09:30:00Z"
}
```

## Errors

Every error is an `application/problem+json` body with a stable `code`.

| HTTP | `code` | When |
|---|---|---|
| 400 | `malformed_request`, `unknown_field` | The body is not valid JSON or has an unknown field. |
| 409 | `conflict` | The identity is already linked to a user (`errors[]`: `identity_already_linked`), or the user reached the per-user identity limit (`errors[]`: `identity_limit_reached`). |
| 415 | `unsupported_media_type` | The body was sent without `Content-Type: application/json`. |
| 422 | `validation_failed` | A field is missing or invalid (`/type`, `/connection_id`, `/subject`), or the connection is not an enabled connection of this environment matching `type` (`errors[]`: `/connection_id`, `connection_unavailable`). |
| 404 | `not_found` | The user does not exist in this environment, is being deleted, or the id is malformed. |
| 401 | `unauthenticated` | The API key is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The key lacks the `users:write` permission. |
| 429 | `rate_limited` | The rate limit was exceeded; honor `Retry-After`. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry later. |

## Example

```bash
curl -X POST https://api.eu.keenauth.com/v1/users/usr_0a1b2c3d4e5f6g7h8j9k0m1n2p/identities \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type": "oidc", "connection_id": "conn_0a1b2c3d4e5f6g7h8j9k0m1n2s", "subject": "248289761001"}'
```

## Personal data

This endpoint carries personal data: linked identities. [Personal data in the KeenAuth APIs](/docs/policies/personal-data) describes what is stored, how long it is kept and how it is deleted. Do not send special categories of personal data (health, biometric data, religion, political opinions, sexual orientation) in any field.
