> For the complete documentation index, see [llms.txt](https://docs.durohub.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.durohub.com/account-management/creating-an-account.md).

# Creating an Account

Self-serve signup is open to anyone. Two calls take someone from nothing to a live session.

```
POST /auth/signup ──► 202, no session
        │
        ▼
   verification email ──► https://durohub.com/verify-email?token=…
        │
        ▼
POST /auth/verify-email ──► 200, first session issued
```

## Step 1 — Register

```bash
curl -sX POST https://api.durohub.com/auth/signup \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://durohub.com' \
  -d '{
        "email": "dana@acme.com",
        "name": "Dana Reyes",
        "password": "correct-horse-battery-9!"
      }'
```

```json
{ "status": "pending_verification" }
```

`202 Accepted`, and no session — signup creates an identity and stops there.

| Field      | Required | Notes                                                                |
| ---------- | -------- | -------------------------------------------------------------------- |
| `email`    | Yes      | Max 254 characters                                                   |
| `password` | Yes      | Must satisfy the live policy — see [Password rules](#password-rules) |
| `name`     | No       | Derived from the address when omitted                                |

### Every signup returns the same 202

Register an address that already has an account and you get the same status and the same body. Nothing is created and no existing account is touched. Duro emails the address's owner instead, letting them know someone tried to sign up and offering a password reset.

{% hint style="info" %}
**Do not render "that email is already taken."** The response cannot tell you, because a distinguishable answer would let anyone test a list of addresses to learn who has a Duro account.

Show the same "check your email" state for every accepted signup and let the mailbox resolve it.
{% endhint %}

A rejected password is the exception — that comes back as a `400` naming the rule that failed, since it describes what the caller just typed rather than who exists.

## Password rules

The rules are served at runtime rather than fixed, so fetch them instead of hardcoding:

```bash
curl -s https://api.durohub.com/auth/config
```

```json
{
  "passwordPolicy": {
    "mode": "duro",
    "minLength": 12,
    "maxLength": 256,
    "requireClasses": true
  }
}
```

|                                                        | `duro` | `nist` |
| ------------------------------------------------------ | ------ | ------ |
| Minimum length                                         | 12     | 15     |
| Maximum length                                         | 256    | 256    |
| Lowercase, uppercase, number, and symbol each required | Yes    | No     |
| Common-pattern check                                   | Yes    | Yes    |

Which mode is active is a deployment setting — read `passwordPolicy.mode` rather than assuming. `nist` follows NIST SP 800-63B, which asks for more length and no character-class requirements.

Two things worth knowing if you validate before submitting:

* **Symbols are anything that is not a letter, a digit, or whitespace** — not a fixed `!@#$%^&*` list, so `£`, `€`, and non-Latin symbols all count
* **Common patterns are rejected** — a capitalised word followed by digits and punctuation (`Password1!`, `Summer2026!`), keyboard runs like `qwerty`, and long character repeats

A rejected password returns `400` with a message naming the rule that failed. That is the one signup response that is not a fixed `202`, since it describes what the caller just typed rather than who exists.

{% hint style="info" %}
The endpoint is unauthenticated and cheap, so call it before rendering a signup form and show the live rules. A form that says "at least 12 characters" against a server enforcing 15 accepts the password and then fails on submit with nothing explaining why.
{% endhint %}

## Step 2 — Verify the email address

The email links to the Duro app, not the API:

```
https://durohub.com/verify-email?token=<token>
```

That page posts the token:

```bash
curl -sX POST https://api.durohub.com/auth/verify-email \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://durohub.com' \
  -c cookies.txt \
  -d '{"token":"<token>"}'
```

```json
{ "status": "verified", "csrfToken": "a1b2c3…" }
```

The response sets `__Host-duro_session`. Verifying signs the user in — the token already proved they control the mailbox.

{% hint style="warning" %}
**If you build your own verification page, make it POST — never link straight to a `GET` that consumes the token.**

Outlook Safe Links, Gmail's proxy, and corporate URL scanners all fetch links in email, often before the recipient opens the message. A `GET` endpoint would have its single-use token spent by the scanner, and the real user would always land on "this link is invalid". Scanners do not run JavaScript, so a page that renders first and posts second only fires for a human.
{% endhint %}

Tokens are single-use and expire after **one hour**. Unknown, expired, already-used, and wrong-purpose tokens all return:

```json
{ "code": "AUTH_TOKEN_INVALID", "message": "This link is invalid or has expired" }
```

Give that case somewhere to go — a "request a new link" action and a route back to sign-in. A link that sat in an inbox overnight is the common case, not the edge case.

## Verification is required

An address must be verified before Duro will apply an invitation, an allowlist entry, or a domain match to it. Until then the account exists but cannot join anything.

Verification comes from one of three places:

* **Password accounts** — consuming the emailed token
* **Google** — Google asserting the address is verified
* **SAML** — an organization asserting an address inside a domain it has [verified ownership of](/account-management/enterprise-sso.md#step-1-verify-your-domain)

Completing a password reset also counts, since it proves the same thing.

## What a new account can do

Very little until something admits it. Duro lets someone in when any of these is true:

| Route in                | How it happens                                                 |
| ----------------------- | -------------------------------------------------------------- |
| **Existing membership** | They already belong to an organization                         |
| **Invitation**          | An admin invited their address, and they verified it           |
| **Allowlist**           | An organization's allowlist covers their address or its domain |
| **Org-creation grant**  | Duro issued a pending grant to create a new organization       |

With none of those, organization-scoped queries are refused. `user { me { hasOrganizations } }` tells you which state you are in — see [Current User](/getting-started/current-user.md).

{% hint style="info" %}
If people are meant to arrive by invitation, **send the invitation first**. Invitations match the verified address case-insensitively, so an invitation to `dana@acme.com` is not consumed by an account that verified `dana.reyes@acme.com` — it stays open and Duro reports that it was issued to a different address.
{% endhint %}

## Rate limits

| Route                     | Budget, per IP    |
| ------------------------- | ----------------- |
| `POST /auth/signup`       | 10 per 10 minutes |
| `POST /auth/verify-email` | 20 per 10 minutes |

Past the budget, requests slow down rather than failing outright; a `429 AUTH_RATE_LIMITED` only appears well beyond it. Limits are keyed on IP and never on the account, so nobody can lock an address out by hammering it.

## Errors

| Code                    | Status | Means                                                |
| ----------------------- | ------ | ---------------------------------------------------- |
| `AUTH_ORIGIN_REJECTED`  | 403    | Missing or non-allowlisted `Origin` header           |
| `AUTH_TOKEN_INVALID`    | 400    | Verification token unknown, expired, or already used |
| `AUTH_RATE_LIMITED`     | 429    | Well past the route budget                           |
| `AUTH_REQUEST_REJECTED` | 400    | Malformed request, or a password the policy refused  |

Every `/auth/*` error returns this flat shape. Branch on `code`; the `message` is copy and may be reworded.

```json
{ "code": "AUTH_TOKEN_INVALID", "message": "This link is invalid or has expired" }
```

## Next steps

Once accounts exist, [Enterprise SSO](/account-management/enterprise-sso.md) lets your identity provider sign those people in, and [SCIM Provisioning](/account-management/scim-provisioning.md) creates and deprovisions them from your directory automatically.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.durohub.com/account-management/creating-an-account.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
