# Your own user database

> Keep users in your database: 9in calls three signed endpoints to check passwords and look users up.

Source: https://9in.io/docs/own-database/

You can keep users in your own database and still use 9in for sign-in, tokens, social login, two-factor and the hosted pages. 9in then asks your server, through three signed HTTP endpoints, to check passwords and look users up. This is the **adapter** (Connections → User database → Your database).

```text
9in ──POST /authenticate──▶ your server: is this the right password?
9in ──POST /user─────────▶ your server: who is this (by id or identifier)?
9in ──POST /event────────▶ your server: a user was created, changed, signed out; send this code
```

Users still get a 9in user (for sessions, tokens and identities), linked to your id. Passwords stay in your database; 9in never stores them.

## Set it up

1. Implement the endpoints (below) under a base URL, e.g. `https://api.acme.com/9in`.
2. In the console, **Connections → User Database → Your server (adapter)**: enter the base URL, save, and copy the **shared secret** it shows (once; you can generate a new one later). Then press **Test**.
3. Optional: **Import users on first login** copies each user into 9in hosted storage as they sign in, with their password once it has been checked. It also turns on sign-up and password changes, which then happen in 9in. When everyone has moved, switch the database to 9in hosted storage.

## With the SDK

```ts
import { adapterHandler } from "@9in-io/sdk/server";

export default {
  fetch: adapterHandler({
    secret: process.env.NINE_ADAPTER_SECRET!,           // or [newSecret, oldSecret] while rotating
    async authenticate({ type, value }, password) {     // type: "email" | "username" | "phone"
      const user = await db.findByLogin(type, value);
      if (!user) return "invalid";
      if (user.disabled) return "blocked";
      return (await verifyPassword(password, user.hash)) ? toAdapterUser(user) : "invalid";
    },
    async findUser(query) {                             // { id } or { identifier: { type, value } }
      const user = "id" in query ? await db.findById(query.id) : await db.findByLogin(query.identifier.type, query.identifier.value);
      return user ? toAdapterUser(user) : null;
    },
    async onEvent(event) {
      if (event.type === "otp.requested") await sendCode(event.data.to, event.data.code);
    },
  }),
};

function toAdapterUser(u: DbUser) {
  return { id: String(u.id), email: u.email, email_verified: u.emailConfirmed, name: u.fullName, phone_number: u.phone };
}
```

`adapterHandler` verifies the request signature, routes the three paths, and signs the responses. It works in Node 18+, Workers, Deno and Bun; mount it on any framework that gives you a `Request`. `@9in-io/express-server` (Express) and the PHP and Laravel packages have equivalents.

## The endpoints

All are `POST` with a JSON body, signed both ways (see [signatures](#signatures)).

**`/authenticate`** checks a password:

```http
POST /9in/authenticate
{ "identifier": { "type": "email", "value": "jane@acme.com" }, "password": "…" }

200 { "user": { "id": "42", "email": "jane@acme.com", "email_verified": true, "name": "Jane" } }
401 { "error": "invalid_credentials" }
403 { "error": "blocked" }
```

**`/user`** looks a user up, by your id or by an identifier:

```http
POST /9in/user
{ "id": "42" }      or      { "identifier": { "type": "email", "value": "jane@acme.com" } }

200 { "user": { … } }
404 { "error": "not_found" }
```

**`/event`** receives notifications; answer `204` (any 2xx):

```http
POST /9in/event
{ "id": "evt_…", "type": "otp.requested", "createdAt": "2026-11-09T08:00:00.000Z",
  "data": { "userId": "42", "channel": "email", "to": "jane@acme.com", "code": "482913",
            "purpose": "login", "expiresAt": "2026-11-09T08:10:00.000Z" } }
```

| Event | When | `data` |
|---|---|---|
| `otp.requested` | A one-time code must reach the user: you deliver it by email or SMS | `userId`, `channel` (`email`/`sms`), `to`, `code`, `purpose` (`login`/`password_reset`), `expiresAt` |
| `user.updated` | The user changed their profile on the account page | `userId`, `fields` (the names that changed) |
| `user.password_changed` | The user set a new password in 9in (with **Import users on first login**) | `userId`, `fields` |

Passwords are never sent to you. For everything else (sign-ups, sign-ins, sessions), subscribe to [webhooks](https://9in.io/docs/webhooks/).

The user object: `id` (required, your id), `email`, `email_verified`, `phone_number`, `phone_number_verified`, `username`, `name`, `given_name`, `family_name`, `picture`, `locale`, `zoneinfo`, `birthdate`, `blocked`, `metadata`. See the [adapter protocol reference](https://9in.io/docs/reference/adapter-protocol/) and [JSON Schemas](https://9in.io/schemas/adapter-user.json).

## Signatures

Every request carries `9in-signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 with your signing secret over:

```text
<t>.<METHOD>.<path>.<raw body>
```

Reject requests whose timestamp is more than 5 minutes off or whose signature doesn't match (compare in constant time). Sign your response the same way, over the same path plus `#<request id>` (the `9in-request-id` header) and your response body, so a response can't be replayed for another request. The SDK handlers do all of this.

## Good to know

- 9in calls your server on each password sign-in and lookup, and waits up to 3 seconds (0.5 to 10, configurable). Keep the endpoints fast and available: if they fail or time out, users see "Sign-in is temporarily unavailable".
- Social, enterprise and one-time-code sign-ins don't need `/authenticate`; they use `/user` to find the account.
- The sandbox never calls your adapter: it creates test users on the fly.
