> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Verified-Domain SSO (v2)

> Let your generic OIDC provider auto-create accounts for members whose email belongs to a company domain you own — no manual connect step required.

External Login v1 lets members log in with Google (which can create new accounts), or connect a Facebook, generic-OIDC, or generic-OAuth2 identity while already signed in — all three require an authenticated connect step in v1. **Verified-Domain SSO** extends v2 to let a `generic_oidc` provider also create new accounts — but only for email addresses in a domain the hub has proven it controls via DNS.

This is the "company SSO" pattern: your team registers `acme.com`, proves DNS ownership, and from then on any new `@acme.com` member who authenticates through your Okta / Azure AD / Auth0 provider gets a member.dev account created for them automatically.

***

## How it works at a glance

```
Hub admin                                  DNS registrar
   │                                            │
   │── POST /teams/{id}/verified-domains ──────▶│ (pending row created)
   │◀── {txt_record_host, txt_record_value} ────│
   │                                            │
   │── Publish TXT record at txt_record_host ──▶│
   │                                            │
   │── POST /teams/{id}/verified-domains/{id}/verify
   │   Backend resolves the TXT record
   │   Verification confirmed → status = verified
   │
Member (new @acme.com user)
   │── GET /api/v1/external-login/{cfg}/start ─▶ mio-backend
   │   Backend: email domain = acme.com?
   │   Verified for this hub? → YES
   │   Existing account? → NO
   │   → Creates new contact (JIT)
   │◀── {access_token} + mio_contact_refresh cookie
```

The only new power v2 adds is: a verified-domain `generic_oidc` provider may **create a brand-new account** for an in-domain email. Everything else (connect flow, Facebook, generic OAuth2, row-1 returning logins) is unchanged from [v1](/guides/external-login).

***

## The connect-first rule

**A verified-domain provider creates accounts — it never merges into an existing one.**

If a member already has a member.dev account with `alice@acme.com` (created via Google or a magic link, for example), logging in through the `acme.com` OIDC provider will **not** silently attach to that account. Alice will see a generic error and must sign in using her existing method, then [connect the provider](/guides/external-login#connect-flow) while logged in.

This is intentional and permanent. Silent email-based merges from a custom IdP would allow any hub owner who controls a domain to take over arbitrary member accounts.

***

## One owner per domain

A domain may be verified by **at most one hub globally**. If another hub tries to verify the same domain, the `/verify` call returns `409 domain_already_owned`. Domains are owned at the hub level and managed under the team-admin API.

Public email domains (`gmail.com`, `outlook.com`, `yahoo.com`, and all free-provider domains) **cannot be verified** — the backend rejects them at the `POST /verified-domains` step.

**Exact full-domain match** — verifying `acme.com` does NOT cover `eu.acme.com` (each subdomain must be registered and verified separately).

***

## Step 1 — Register a domain

```bash theme={null}
curl -X POST "https://api.member.dev/api/v1/teams/$TEAM_ID/verified-domains" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "verified_domains",
      "attributes": {
        "hub_id": "'"$HUB_ID"'",
        "domain": "acme.com"
      }
    }
  }' | jq
```

**Response (201):**

```json theme={null}
{
  "data": {
    "type": "verified_domains",
    "id": "vd_abc123",
    "attributes": {
      "domain": "acme.com",
      "status": "pending",
      "verification_token": "HUCBaxttzx6p6NDJ7tQagbelTOfDHpMCXc5ZTNVBBLI",
      "txt_record_host": "_member-dev-challenge.acme.com",
      "txt_record_value": "member-dev-verify=HUCBaxttzx6p6NDJ7tQagbelTOfDHpMCXc5ZTNVBBLI",
      "verified_at": null,
      "last_checked_at": null,
      "hub_id": "hub-uuid-here",
      "team_id": "team-uuid-here",
      "created_at": "2026-06-25T10:00:00Z",
      "updated_at": "2026-06-25T10:00:00Z"
    }
  }
}
```

The response tells you exactly what to publish in DNS. The `txt_record_host` and `txt_record_value` are pre-computed — copy them verbatim to your DNS provider.

***

## Step 2 — Publish the DNS TXT record

In your DNS provider (Route 53, Cloudflare, Google Cloud DNS, etc.), create a **TXT record**:

| Field           | Value                                                            |
| --------------- | ---------------------------------------------------------------- |
| **Host / Name** | `_member-dev-challenge.acme.com`                                 |
| **Type**        | `TXT`                                                            |
| **Value**       | `member-dev-verify=<~43 url-safe chars from verification_token>` |
| **TTL**         | Any value (60 seconds or more is fine)                           |

The record is published on a dedicated challenge subdomain — it does not interfere with other DNS records on `acme.com` (SPF, DKIM, existing TXT entries, etc.).

Allow DNS propagation (typically seconds to a few minutes, though up to 48 hours in theory).

***

## Step 3 — Verify ownership

Once the record is live, trigger the verification check:

```bash theme={null}
curl -X POST "https://api.member.dev/api/v1/teams/$TEAM_ID/verified-domains/$DOMAIN_ID/verify" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq
```

**Response (200 — verified):**

```json theme={null}
{
  "data": {
    "type": "verified_domains",
    "id": "vd_abc123",
    "attributes": {
      "domain": "acme.com",
      "status": "verified",
      "verification_token": "HUCBaxttzx6p6NDJ7tQagbelTOfDHpMCXc5ZTNVBBLI",
      "txt_record_host": "_member-dev-challenge.acme.com",
      "txt_record_value": "member-dev-verify=HUCBaxttzx6p6NDJ7tQagbelTOfDHpMCXc5ZTNVBBLI",
      "verified_at": "2026-06-25T10:05:00Z",
      "last_checked_at": "2026-06-25T10:05:00Z",
      "hub_id": "hub-uuid-here",
      "team_id": "team-uuid-here",
      "created_at": "2026-06-25T10:00:00Z",
      "updated_at": "2026-06-25T10:05:00Z"
    }
  }
}
```

**Response (422 — record not yet found):**

```json theme={null}
{
  "errors": [{ "status": "422", "code": "not_yet_verified",
               "detail": "The TXT record was not found. Publish it and retry." }]
}
```

You can retry `/verify` as many times as needed until the DNS change propagates.

***

## Step 4 — Enable a generic OIDC provider for the hub

If you haven't already, register a `generic_oidc` provider for the same hub. See [Configure an external-login provider](/guides/external-login-admin#register-a-generic-oidc-provider) for the full setup. The verified-domain trust attaches to the hub — all `generic_oidc` providers configured on that hub can auto-create accounts for verified domains.

Once both the domain is verified and the provider is configured and enabled:

* `alice@acme.com` visits the hub and clicks "Log in with Acme SSO".
* The backend redirects her to the OIDC provider, authenticates her, and receives a signed id\_token with `email: "alice@acme.com"` and `email_verified: true`.
* The backend checks: is `acme.com` verified for this hub? Yes. Does a contact for `alice@acme.com` already exist? No. → **Creates a new contact (JIT)** and issues a token pair.
* Alice is logged in, no manual connect step required.

***

## Domain lifecycle management

### List all domains for a hub

```bash theme={null}
curl "https://api.member.dev/api/v1/teams/$TEAM_ID/verified-domains" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq '.data[] | {id: .id, domain: .attributes.domain, status: .attributes.status}'
```

Returns all domains including `pending` and `revoked` entries.

### Retrieve a single domain

```bash theme={null}
curl "https://api.member.dev/api/v1/teams/$TEAM_ID/verified-domains/$DOMAIN_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq
```

### Remove a domain (revoke)

```bash theme={null}
curl -X DELETE "https://api.member.dev/api/v1/teams/$TEAM_ID/verified-domains/$DOMAIN_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
# → 204 No Content
```

Deleting a domain **immediately stops new account creation** for that domain on the next login attempt. Members who already have accounts and have connected the provider continue to log in normally (their identity link still exists — revocation does not deprovision existing contacts).

***

## Freshness checks

The backend re-verifies that the DNS TXT record is still present before creating any new account (approximately once per hour per domain). If the TXT record has been removed, the domain is automatically revoked and new-account creation fails. Existing members are not affected.

Keep the TXT record in DNS for as long as you want the auto-create behavior to remain active.

***

## Domain attributes reference

| Attribute            | Type                | Notes                                                                                                                  |
| -------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `domain`             | string              | IDNA/punycode normalized, lowercase. Set on create; not updatable.                                                     |
| `status`             | string              | `pending` / `verified` / `revoked`                                                                                     |
| `verification_token` | string              | \~43 url-safe chars. Used to construct the TXT record value; keep the TXT record in DNS for the token to remain valid. |
| `txt_record_host`    | string              | The DNS host name to create the TXT record under.                                                                      |
| `txt_record_value`   | string              | The exact TXT record value to publish (`member-dev-verify=<verification_token>`).                                      |
| `verified_at`        | datetime (nullable) | Set when verification first succeeds.                                                                                  |
| `last_checked_at`    | datetime (nullable) | Timestamp of the most recent successful DNS freshness check.                                                           |
| `hub_id`             | string              | The hub this domain is scoped to.                                                                                      |
| `team_id`            | string              | The team that owns this domain record.                                                                                 |
| `created_at`         | datetime            | When the domain was registered.                                                                                        |
| `updated_at`         | datetime            | When the record was last modified.                                                                                     |

***

## Common errors

| HTTP  | Code                        | Meaning                                                                                            |
| ----- | --------------------------- | -------------------------------------------------------------------------------------------------- |
| `422` | `invalid_domain`            | The domain failed normalization (empty labels, invalid characters, etc.).                          |
| `422` | `public_suffix_domain`      | The domain is a public suffix (e.g. `co.uk`) — register a full domain instead.                     |
| `422` | `public_email_domain`       | The domain belongs to a free email provider (e.g. `gmail.com`) — shared domains cannot be claimed. |
| `422` | `not_yet_verified`          | `/verify` ran but the TXT record was not found. Retry after DNS propagates.                        |
| `409` | `domain_already_owned`      | Another hub has already verified this domain globally.                                             |
| `404` | `hub_not_found`             | The `hub_id` in the create request does not exist or does not belong to this team.                 |
| `404` | `verified_domain_not_found` | The domain ID does not exist or belongs to a different team.                                       |

***

## Security notes

* **Exact-domain match only.** Verifying `acme.com` does not grant trust to `eu.acme.com` or any other subdomain.
* **Signed email required.** The OIDC provider must return `email_verified: true` in a signed `id_token`. Generic OAuth2 providers (no id\_token) never earn domain trust even if a domain is verified.
* **No enumeration.** All failed login attempts from a `generic_oidc` provider that find an existing account return the same generic error — the specific reason (`account_exists_connect_required`) is written to the audit log only.
* **Public domains blocked.** The Public Suffix List is used to block free-provider and public-suffix domains at registration time.
* **`registration_enabled` still applies.** If the hub has disabled open registration, the domain-verified auto-create path is also blocked — the verified domain is a trust mechanism, not a registration bypass.

***

## See also

* [Log in with an external provider](/guides/external-login) — the member-facing flow (v1 + v2)
* [Configure an external-login provider (Admin)](/guides/external-login-admin) — register Google, Facebook, generic OIDC/OAuth2
* [Login with Hub](/guides/login-with-hub) — the inverse direction (hub as OIDC Identity Provider)
