Anonymous Account Swap
On first launch, the logi mobile app creates an anonymous (device-bound) user A without requiring any sign-up. If that user later signs in with Apple/Google and the resulting sub is already bound to an existing identified account B, you need to move A's assets into B, hard-delete A, and have the same device act as B. This is the "swap." This endpoint performs it.
Key points
- Anonymous JWT only: returns
non_anonymous_callerif the caller is not an anonymous user. - B-wins merge: for duplicate RP grants or agent approvals, B's existing row wins.
- Single transaction: asset transfer, PAK issuance, and the hard-delete of A all run inside the same
ApplicationRecord.transaction. - Response = DeviceBootstrapResponse +
mergedstats: the counts are echoed back so the client can show a "{N} items were consolidated" toast.
When to call it
The normal flow is POST /api/v1/me/connected_identities (adding a provider). However, when all of the following conditions are met, that endpoint returns 409 identity_owned_by_another_account, and the client routes to this anonymous-swap endpoint:
- The currently signed-in user is anonymous (
current_user.anonymous?== true). - The
subof the new Apple/Googleidentity_tokenmaps to a different identified user B.
sequenceDiagram
autonumber
participant App as iOS LogiCore<br/>(AccountSwitchSheet)
participant API as logi server
participant DB as DB
App->>App: Bootstrap complete as anonymous user A
Note over App: User taps "Sign in with Apple"
App->>API: POST /api/v1/me/connected_identities<br/>{provider, identity_token, raw_nonce}
API->>DB: Look up sub → already owned by another user (B)
API-->>App: 409 identity_owned_by_another_account
App->>App: Show AccountSwitchSheet<br/>(preview of merged assets + confirm)
App->>API: POST /api/v1/me/anonymous_swap<br/>{provider, identity_token, raw_nonce, merge_options}
API->>DB: Transfer A → B assets (single transaction)
API->>DB: A.destroy!
API->>DB: Issue new PAK + device_secret for B
API-->>App: 200 OK (DeviceBootstrapResponse + merged stats)The iOS client entry point is AccountSwitchSheet (LogiCore, commit 5700c24b4) — it shows the user a preview of what will be moved and lets them choose which RPs to move via merge_options.
Endpoint
| Method | Path | Auth |
|---|---|---|
POST | /api/v1/me/anonymous_swap | Bearer (PAK, anonymous user only) · scope profile:write |
Route definition — config/routes.rb (~line 682):
post "me/anonymous_swap" => "me/anonymous_swaps#create", as: :me_anonymous_swapRequest
POST /api/v1/me/anonymous_swap HTTP/1.1
Host: api.1pass.dev
Authorization: Bearer <PAK of anonymous user A>
Content-Type: application/json
{
"provider": "apple",
"identity_token": "eyJhbGciOiJSUzI1NiIs...",
"raw_nonce": "Zk9pT2g...",
"full_name": {
"given_name": "다혜",
"family_name": "김"
},
"merge_options": {
"rps": ["logi_a1b2...", "logi_c3d4..."],
"agent_approvals": true,
"notifications": true
},
"device_uuid": "00000000-0000-0000-0000-000000000000",
"platform": "ios"
}| Field | Type | Required | Description |
|---|---|---|---|
provider | string | ✅ | "apple" or "google". Any other value returns unknown_provider. |
identity_token | string | ✅ | The OIDC id_token issued by Apple/Google. Verified against JWKS plus aud / iss / nonce checks. |
raw_nonce | string | ✅ | The raw nonce the client generated during the authorize request. The token's nonce claim is compared against SHA256(raw_nonce). |
full_name | object | (Apple only) | {given_name, family_name}. Apple sends the name only on the first sign-in, so pass through exactly what you received in the ASAuthorization response. It is normalized to a hash after permitting. |
merge_options | object | (optional) | Selects which assets to move. If omitted, everything is transferred. |
merge_options.rps | array<string> | (optional) | List of RP client_ids to move. If omitted, all RP grants are transferred. |
merge_options.agent_approvals | bool | (optional) | If false, agent approval rows are not transferred. |
merge_options.notifications | bool | (optional) | If false, the push inbox (push_approval_requests) is not transferred. |
device_uuid | string | (optional) | Used for the PAK label and audit log. |
platform | string | (optional) | "ios" / "android" / "web". Used for the PAK label and audit log. |
Success response (200 OK)
{
"user": {
"id": 123,
"contact_email": "user@example.com",
"name": "김다혜",
"anonymous": false
},
"access_token": "lpk_live_...",
"token_type": "Bearer",
"scopes": [
"profile:read",
"profile:write",
"login_history:read",
"account:delete",
"agent_approvals:read",
"agent_approvals:manage"
],
"merged": {
"rps": {
"transferred": 2,
"skipped_duplicate": 1
},
"agent_approvals": {
"transferred": 5,
"skipped_duplicate": 0
},
"notifications": {
"transferred": 3
}
},
"needs_onboarding": false,
"device": {
"id": 99,
"device_uuid": "00000000-0000-0000-0000-000000000000",
"platform": "ios",
"attestation_verified": true,
"first_seen_at": "2026-05-12T07:00:00Z",
"last_seen_at": "2026-05-27T09:14:22Z"
},
"device_secret": "ds_..."
}The response shape is identical to the DeviceBootstrapResponse from /auth/{apple,google}/session, with only the merged block added.
| Field | Description |
|---|---|
user | The identified user B after the swap (User#api_payload). |
access_token | A newly issued PAK under B's identity. A's token is revoked along with the destroy. |
scopes | The default scope set for the PAK right after the swap (the six fixed scopes above). |
merged.rps.transferred | The number of RP grant rows moved from A to B. |
merged.rps.skipped_duplicate | The number of rows skipped because B already held the same RP grant (B wins). |
merged.agent_approvals.transferred / skipped_duplicate | The same meaning applied to agent approval rows. |
merged.notifications.transferred | The number of push approval request rows moved (there is no skip concept — this is a union merge). |
needs_onboarding | result.user.contact_email.blank? — true when B has no contact email, prompting the client to show the onboarding sheet. |
device | The DeviceCredential snapshot after the current device has been reassigned to B. |
device_secret | The rotated device_secret in plaintext. It is exposed only in this response, so the client must store it in the Keychain immediately. |
Error responses
Every error is returned as HTTP 422 Unprocessable Content (or 400 for invalid_request), with a JSON body of the form { "error": "<code>" }.
| HTTP | error code | Cause |
|---|---|---|
| 400 | invalid_request | identity_token or raw_nonce is empty. |
| 422 | non_anonymous_caller | The caller is not anonymous. An identified user must use POST /api/v1/me/connected_identities. |
| 422 | unknown_provider | provider is a value other than apple / google. |
| 422 | invalid_identity_token | JWKS / nonce / aud / iss verification failed. The reason is echoed in error_description. |
| 422 | identity_does_not_resolve_to_existing_account | The sub maps to no identified user. In this case the client must route to the link flow (POST /api/v1/me/connected_identities), not a swap. |
| 422 | identity_belongs_to_another_anonymous_user | The sub is bound to yet another anonymous user (a sanity check — anonymous ↔ anonymous swaps are not allowed). |
Controller mapping (anonymous_swaps_controller.rb:51-65):
rescue AnonymousSwapService::NonAnonymousCaller
render json: { error: "non_anonymous_caller" }, status: :unprocessable_content
rescue AnonymousSwapService::UnknownProvider
render json: { error: "unknown_provider" }, status: :unprocessable_content
rescue AnonymousSwapService::IdentityDoesNotResolveToExistingAccount
render json: { error: "identity_does_not_resolve_to_existing_account" },
status: :unprocessable_content
rescue AnonymousSwapService::IdentityBelongsToAnotherAnonymousUser
render json: { error: "identity_belongs_to_another_anonymous_user" },
status: :unprocessable_content
rescue AnonymousSwapService::InvalidIdentityToken => e
render json: { error: "invalid_identity_token", error_description: e.message },
status: :unprocessable_contentSecurity model
- Caller requirements: an anonymous user's PAK plus scope
profile:write. Enforced bybefore_action :require_anonymous_user!(lines 23, 72-76). - Single transaction: asset transfer → device reassignment → PAK/device_secret issuance →
A.destroy!all run inside the sameApplicationRecord.transaction. A raise at any step rolls back the entire operation, preserving A (service lines 66-99). - PushApprovalRequest defense-in-depth: residual FKs may remain even after the union merge, so
requested_user_id/approved_user_idare swept again withupdate_all(service lines 81-84). - Separate from MergeService: this endpoint is dedicated to swapping an anonymous user into a real account on the same physical device. When a cross-host identity-graph merge is required,
MergeServicehandles it and fires theuser.mergedwebhook. Anonymous swaps do not fire a webhook, because an anonymous user has no grants of its own from the RP's point of view.
Related
- Connected Identities (adding a provider) — when an identified user links a new provider.
- Account Merge overview — cross-host identity-graph merge.
- Merge Idempotency — merge retry safety.
- Anonymous Grants — the conditions under which an anonymous user can hold an RP grant.