Your own user database
Keep users in your database: 9in calls three signed endpoints to check passwords and look users up.
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).
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 codeUsers 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
- Implement the endpoints (below) under a base URL, e.g.
https://api.acme.com/9in. - 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.
- 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
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).
/authenticate checks a password:
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:
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):
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.
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 and JSON Schemas.
#Signatures
Every request carries 9in-signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with your signing secret over:
<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/userto find the account. - The sandbox never calls your adapter: it creates test users on the fly.