---
title: "Login"
method: POST
path: "/auth/login"
tags: ["auth"]
---

# Login

`POST /auth/login`

## Request

- Base URL: `https://api.instruxi.dev/api/v1/enforcer`
- URL: `https://api.instruxi.dev/api/v1/enforcer/auth/login`
- Auth: none declared

Authenticates a credential and, on success, issues an enforcer access/refresh token pair (200, `handler.AuthResponse`). `provider` in the request selects the scheme and its required fields: `email_otp` needs `email`+`code`, `phone_otp` needs `phone`+`code`, `siwe` needs `message`+`signature`, every other provider needs `token` (e.g. a Privy access token). 401 on a rejected credential or wrong/expired OTP, 403 `account_inactive` for a deactivated account or `wrong_auth_scheme` when the tenant requires a different provider, 404 when no tenant matches the credential, 409 only on a benign concurrent-refresh race elsewhere in the family, 503 when the upstream identity provider (Privy/Google) is unreachable. An expired workspace code gets 404 `tenant_code_expired`.

## Request body

- EnforcerV3InternalUsecaseAuthLoginRequest
  - `email` string
  - `message` string: SIWE EIP-4361 message
  - `otp` string
  - `phone` string
  - `provider` 'privy' | 'email_otp' | 'google' | 'siwe' | 'phone_otp' | 'passkey' | 'oidc' | 'saml'
  - `signature` string: SIWE signature
  - `tenant_code` string
  - `token` string

## Response `200`

OK

- InternalDeliveryHttpHandlerAuthResponse
  - `data` EnforcerV3InternalUsecaseAuthResult
    - `account` EnforcerV3InternalDomainAccountAccount
      - `active` boolean
      - `authenticated_at` string
      - `claimed_at` string
      - `created_at` string
      - `created_by` string: CreatedBy is the account that minted a service account; nil for humans and for any account whose creator was deleted (ON DELETE SET NULL).
      - `description` string: Description is a service account's free-text purpose; nil for humans.
      - `email` string
      - `email_verified` boolean
      - `first_name` string
      - `id` string
      - `invited_by` string
      - `is_discoverable` boolean: IsDiscoverable gates whether this account can be added as a linked contact by others in the same tenant. Defaults true (see migration 000022).
      - `kind` string: Kind is KindHuman or KindService (000108). Set once at creation and never rewritten (accountNonUpdatableColumns): turning a human into a service account, or back, is a different principal, not an edit. Empty on a struct about to be created means "the column default" (human).
      - `last_active_at` string: warm-path activity, throttled
      - `last_login_at` string
      - `last_name` string
      - `passkey_mfa_verified_at` string: PasskeyMfaVerifiedAt stamps when the SERVER observed, via the Privy management API, that this account's passkey MFA enrollment is complete (factor advertised + a linked credential backing it). NULL = never verified. Written only by SetPasskeyMfaVerified from the /auth/me/passkey/verify flow — the client's own view of Privy state is eventually consistent and must never be the source of this stamp.
      - `person` EnforcerV3InternalDomainPersonPerson
        - `created_at` string
        - `dob` string
        - `email_verified` boolean
        - `first_name` string
        - `home_address_line1` string
        - `home_address_line2` string
        - `home_city` string
        - `home_country` string
        - `home_place_id` string: Home address — canonical, owned here (cybrid KYC + report-builder read it). HomePlaceID references an enforcer-geo place; the rest is a denormalized snapshot so /auth/me serves the address without a geo round-trip.
        - `home_postal_code` string
        - `home_region` string
        - `id` string
        - `last_name` string
        - `name` string: deprecated display name; prefer first_name/last_name
        - `phone_verified` boolean
        - `primary_email` string
        - `primary_phone` string
        - `profile_image_url` string
        - `updated_at` string
      - `person_id` string: PersonID is nil exactly when Kind is KindService (ck_accounts_kind_person, 000108). A pointer, not a uuid.UUID: the zero UUID would read as a real person id everywhere it is .String()'d — onto the accessor, the wire and policy input — so a service account must be unrepresentable as one.
      - `privy_user_id` string
      - `profile_completed` boolean
      - `provisioned_via` string: ProvisionedVia is how this membership came to exist (migration 000088): ProvisionedViaPortalToken marks a SHELL ACCOUNT minted by a cold portal link; nil is an ordinary signup (every row that predates the column). ClaimedAt is when a verified login by the same person bound a credential to a shell; nil on an unclaimed shell and on every non-shell row.
      - `role` EnforcerV3InternalDomainRoleRole
        - `admin_managed` boolean
        - `created_at` string
        - `cross_tenant` boolean: Capability grants (additive; all false = baseline-only, like "user").
        - `description` string
        - `id` string
        - `mpp_ops` boolean: MPPOps: read-only MPP deposit-intent operator views (migration 000129).
        - `name` string
        - `slug` string
        - `tenant_manage` boolean
        - `tenant_read` boolean
        - `tenant_write` boolean
        - `updated_at` string
      - `role_id` string
      - `tenant` EnforcerV3InternalDomainTenantTenant
        - `account_abstraction` boolean
        - `allow_user_discovery` boolean: AllowUserDiscovery gates contact discovery for the whole tenant: when true, accounts may be discoverable (and new accounts default to discoverable); when false, discovery is off tenant-wide regardless of account.is_discoverable.
        - `app_url` string
        - `auth_provider` string: AuthProvider / WalletProvider: nil = inherit the instance default.
        - `banner_url` string
        - `billing_status` string: BillingStatus is the tenant's platform-billing dunning state, PROJECTED from enforcer-billing (migration 000087): "" | active | past_due | restricted | suspended | collections. enforcer-billing is the source of truth; this is a copy the billing.status_changed consumer keeps current so the value rides Accessor.billing_status on every resolve with no hop. "" means UNKNOWN and readers MUST treat it as active (fail open). `<-:false` makes GORM never write these two columns: Repository.Update is a whole-row Save, so without it an admin editing the tenant's name from a stale struct would silently roll the projection back. Only BillingStatusStore.ApplyBillingStatus writes them, by targeted UPDATE under the monotonic entered_at guard.
        - `billing_status_entered_at` string
        - `code` string
        - `code_expires_at` string: CodeExpiresAt is when the join code stops working everywhere a code is accepted, signing in and joining alike, as if it had been rotated away. Absent: the code never expires. Set by POST /tenants/{id}/code.
        - `created_at` string
        - `description` string
        - `id` string
        - `logo_url` string
        - `metadata` EnforcerV3InternalDomainTenantJSONBMap
        - `metered_sends_enabled` boolean: MeteredSendsEnabled turns on per-transaction limit metering: every value leg leaving the platform is measured against the caller's live cap before the server co-signs. Meaningful only while Web3Enabled is true — the pair (false, true) is refused on write; see ValidateWeb3Gates.
        - `modules` string[]: Modules is the tenant's MODULE entitlements as group slugs, prefixed `module_` (migration 000094, V3-02). Which HALO-Lego surfaces this tenant has bought — NOT which of them any given account may use, which is the role's question. It lives on the tenant rather than in account_groups because it is a fact about the TENANT: a module per account would mean auto-joining every new account and would make "remove one person from a module the tenant paid for" expressible. The resolver UNIONS this into Accessor.GroupSlugs, so every leaf's RequireModule reads it with no new field and no extra hop. Same `<-:false` guard and the same targeted-write rule as BillingStatus above: Repository.Update is a whole-row Save, so without it an admin editing the tenant's name from a stale struct would silently revoke every module. Only ModuleStore writes these two columns.
        - `modules_updated_at` string
        - `name` string
        - `payment_link_url` string
        - `privy_app_id` string
        - `self_join_policy` 'open' | 'invite_only'
        - `status` 'active' | 'inactive' | 'suspended'
        - `theme_colors` EnforcerV3InternalDomainTenantJSONBMap
        - `thumbnail_url` string
        - `updated_at` string
        - `wallet_provider` string
        - `web3_enabled` boolean: Web3Enabled is the tenant-wide web3 kill switch. False rejects EVERY web3 transaction for the tenant — guarded sends, signing, and RPC writes alike — so it is deliberately a gate rather than an enforcement mode. Defaults true (migration 000066) because false would disable web3 for every existing tenant on deploy.
      - `tenant_id` string
      - `updated_at` string
      - `username` string
      - `wallet_address` string
      - `wallet_send_migrated_at` string: WalletSendMigratedAt stamps when this account's smart wallet had the mandatory app-key owner quorum attached (wallet-send-enforcement). NULL = not yet migrated; the login-heal signals the PWA to prompt for the one-time owner-change authorization when the tenant is send-enforced.
    - `expires_at` string
    - `is_new_account` boolean
    - `refresh_token` string
    - `token` string
  - `success` boolean

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `403`: Forbidden
- `404`: Not Found
- `409`: Conflict
- `503`: Service Unavailable

---

[API](https://skmtc.dev/instruxi/apis/enforcer-api.md) · [All operations](https://skmtc.dev/instruxi/apis/enforcer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/instruxi/apis/enforcer-api/revisions/fa82bc1becc0?raw)
