Identity
Identity gives your App its own authentication, so your Visitors can sign up, sign in, and stay signed in. It is backed by Better Auth, and every App gets a fully isolated auth instance with its own user database — one App’s accounts and secrets never cross into another’s.
You reach Identity through sdk.auth inside any Hono API handler. The baseline is always available: email + password sign-up, sign-in, sign-out, and reading the current session. On top of that sits an opt-in enterprise tier — MFA, passkeys, session management, organizations, SSO, SCIM, and anomaly detection — that is off by default. When every enterprise flag is off, the baseline behaves exactly as if the enterprise features did not exist.
There is nothing to install. Identity is part of the runtime. You consume it through the SDK, and the platform manages every credential the auth instance needs.
Quick reference
Section titled “Quick reference”The baseline methods, always present on sdk.auth:
| Method | Shape | Returns |
|---|---|---|
signUp | signUp({ email, password, name? }) | Promise<Response> |
signIn | signIn({ email, password }, req?) | Promise<Response> |
signOut | signOut(req) | Promise<Response> |
session | session(req) | Promise<SessionResult | null> |
requestPasswordReset | requestPasswordReset({ email, redirectTo? }) | Promise<Response> |
resetPassword | resetPassword(req, { newPassword, token }) | Promise<Response> |
sendVerificationEmail | sendVerificationEmail({ email, callbackURL? }) | Promise<Response> |
You reach these through the SDK inside a handler:
const sdk = c.get("sdk")const session = await sdk.auth.session(c.req.raw)Signing up and signing in
Section titled “Signing up and signing in”signUp creates an account; signIn authenticates one. Both return a Response — the standard Better Auth response, complete with the Set-Cookie header that establishes the session. Return it straight to the Visitor’s browser so the cookie is set.
signUp(input: { email: string; password: string; name?: string }): Promise<Response>signIn(input: { email: string; password: string }, req?: Request): Promise<Response>const sdk = c.get("sdk")const { email, password, name } = await c.req.json()return sdk.auth.signUp({ email, password, name })const sdk = c.get("sdk")const { email, password } = await c.req.json()return sdk.auth.signIn({ email, password }, c.req.raw)Every signIn passes through built-in brute-force protection before the password is even checked — see Things to know.
Reading the current session
Section titled “Reading the current session”session(req) reads the signed-in user from the request cookie. It returns the user and session, or null when there is no valid session (including after an idle timeout when session management is enabled). Use it as your authorization gate at the top of protected handlers.
session(req: Request): Promise<SessionResult | null>
type SessionResult = { user: { id: string; email: string; name: string } session: { expiresAt: Date }}const sdk = c.get("sdk")const result = await sdk.auth.session(c.req.raw)if (result === null) { return c.json({ error: "unauthorized" }, 401)}// result.user.id is your authenticated VisitorSigning out
Section titled “Signing out”signOut clears the session. Like sign-in, it returns a Response carrying the cookie change — return it to the browser.
signOut(req: Request): Promise<Response>const sdk = c.get("sdk")return sdk.auth.signOut(c.req.raw)Password reset and email verification
Section titled “Password reset and email verification”requestPasswordReset starts the reset flow and resetPassword completes it with the token from the reset email. sendVerificationEmail sends a verification link.
requestPasswordReset(input: { email: string; redirectTo?: string }): Promise<Response>resetPassword(req: Request, input: { newPassword: string; token: string }): Promise<Response>sendVerificationEmail(input: { email: string; callbackURL?: string }): Promise<Response>These flows send email through your App’s Messaging Capability. If your App has no messaging provider configured, the flows still run but no email is sent — the operation degrades gracefully rather than failing. See Things to know.
Routing auth requests
Section titled “Routing auth requests”Better Auth has its own HTTP surface under /api/auth/* (the endpoints behind the methods above, plus passkey, SSO, and SCIM endpoints when those are enabled). Your App must pass every /api/auth/* request to the auth handler:
handleAuthRequest(req: Request): Promise<Response>// in your server's catch-all for the auth namespaceapp.all("/api/auth/*", (c) => sdk.auth.handleAuthRequest(c.req.raw))When you build your App through the Builder, this wiring is generated for you. If you maintain your own server entry, you must replicate it — without it, the auth endpoints (and SSO/SCIM) are unreachable.
Enterprise tier
Section titled “Enterprise tier”The features below are off by default. Each is gated by a plan flag, enabled by setting an environment variable on your App. While a flag is off, its methods throw NotAvailableInV1Error — guard your call sites if a flag might be absent.
| Feature | Flag | Env var | Requires |
|---|---|---|---|
| MFA (TOTP + backup codes) | authMfa | AUTH_MFA | — |
| Passkeys (WebAuthn) | authPasskey | AUTH_PASSKEY | — |
| Session management | authSessions | AUTH_SESSIONS | — |
| Email verification | authEmailVerification | AUTH_EMAIL_VERIFICATION | — |
| Organizations | authOrganizations | AUTH_ORGANIZATIONS | — |
| SSO (SAML + OIDC) | authSso | AUTH_SSO | — |
| SCIM provisioning | authScim | AUTH_SCIM | authOrganizations |
| Anomaly detection | authAnomaly | AUTH_ANOMALY | — |
Set a flag by setting its env var to "1" or "true". Enabling a feature on a live App is safe: schema changes are additive (ALTER TABLE ADD COLUMN and new plugin tables), so existing users and sessions are untouched.
authMfa adds time-based one-time passwords with backup codes. enableMfa returns a totpUri (encode it as a QR code for an authenticator app) and a set of backup codes; the Visitor confirms with verifyMfa.
enableMfa(req: Request, input: { password: string }): Promise<MfaEnableResult>verifyMfa(req: Request, input: { code: string }): Promise<Response>disableMfa(req: Request, input: { password: string }): Promise<void>generateBackupCodes(req: Request, input: { password: string }): Promise<MfaBackupCodesResult>verifyBackupCode(req: Request, input: { code: string }): Promise<Response>
type MfaEnableResult = { totpUri: string; backupCodes: string[] }type MfaBackupCodesResult = { backupCodes: string[] }Passkeys
Section titled “Passkeys”authPasskey adds WebAuthn passkeys.
registerPasskey(req: Request): Promise<Response>signInWithPasskey(req: Request): Promise<Response>listPasskeys(req: Request): Promise<PasskeySummary[]>deletePasskey(req: Request, input: { id: string }): Promise<void>
type PasskeySummary = { id: string name: string | null deviceType: string createdAt: string | null}listPasskeys returns only the display fields above — never the raw credential, public key, or counter.
Session management
Section titled “Session management”authSessions lets a Visitor see and revoke their active sessions across devices. The current flag marks the session making the request.
listSessions(req: Request): Promise<SessionSummary[]>revokeSession(req: Request, sessionId: string): Promise<void>revokeOtherSessions(req: Request): Promise<void>
type SessionSummary = { id: string ipAddress: string | null userAgent: string | null createdAt: string | null lastActivityAt: string | null current: boolean}Defaults: sessions idle out after 30 minutes (AUTH_SESSION_IDLE_TIMEOUT_MS, in milliseconds) and a user can hold at most 5 concurrent sessions (AUTH_MAX_CONCURRENT_SESSIONS).
Organizations
Section titled “Organizations”authOrganizations adds multi-tenant organizations with teams, members, invitations, and custom roles.
createOrganization(req: Request, input: CreateOrganizationInput): Promise<unknown>inviteToOrganization(req: Request, input: InviteToOrganizationInput): Promise<InviteToOrganizationResult>acceptInvitation(req: Request, input: { invitationId: string }): Promise<unknown>listOrganizationMembers(req: Request, input?: { organizationId?: string }): Promise<unknown>updateMemberRole(req: Request, input: UpdateMemberRoleInput): Promise<unknown>createRole(req: Request, input: CreateRoleInput): Promise<unknown>
type CreateOrganizationInput = { name: string slug: string logo?: string metadata?: Record<string, unknown>}type InviteToOrganizationInput = { email: string role: string organizationId?: string teamId?: string}type InviteToOrganizationResult = { invitationId: string; inviteUrl?: string }type UpdateMemberRoleInput = { memberId: string; role: string; organizationId?: string }type CreateRoleInput = { role: string permission: Record<string, string[]> organizationId?: string}inviteToOrganization sends an email invitation when Messaging is configured and returns { invitationId }. With no messaging provider it falls back to a shareable link and returns { invitationId, inviteUrl } — surface that URL in your UI.
authSso adds SAML 2.0 and OIDC single sign-on. Registering a provider is an admin-only operation — never expose registerSsoProvider to a Visitor.
registerSsoProvider(req: Request, input: RegisterSsoProviderInput): Promise<RegisterSsoProviderResult>signInWithSso(req: Request, input: { providerId?: string; email?: string }): Promise<Response>ssoServiceProviderMetadata(input: { providerId: string }): Promise<Response>
type RegisterSsoProviderInput = { providerId: string issuer: string domain: string organizationId?: string oidcConfig?: Record<string, unknown> samlConfig?: Record<string, unknown>}type RegisterSsoProviderResult = { providerId: string; issuer: string }Provider secrets (SAML certificates, OIDC client secrets) are encrypted at rest before they are stored — the raw values never persist in plaintext.
authScim adds SCIM 2.0 directory provisioning, and requires authOrganizations to also be on (enabling SCIM alone fails loudly when the auth instance is built). SCIM has no SDK method — your identity provider provisions users by calling the reserved /api/auth/scim/v2/* endpoints with a bearer token. Those endpoints are served as long as you route /api/auth/* through handleAuthRequest.
Anomaly detection
Section titled “Anomaly detection”authAnomaly scores the risk of each sign-in from coarse geo signals. signInWithRisk is a drop-in replacement for signIn that adds a riskLevel to the response. assessSession reports the current session’s risk.
signInWithRisk(input: { email: string; password: string }, req?: Request): Promise<Response>assessSession(req: Request): Promise<AssessSessionResult | null>
type AssessSessionResult = { riskLevel: "low" | "medium" | "high" | null lastCountry: string | null}const sdk = c.get("sdk")const { email, password } = await c.req.json()return sdk.auth.signInWithRisk({ email, password }, c.req.raw)Scoring: impossible travel is high, a new country or new device is medium, and known factors are low. The travel ceiling defaults to 900 km/h (AUTH_ANOMALY_MAX_KM_PER_HOUR). On medium or high risk with MFA enabled, Better Auth issues a twoFactorRedirect step-up; without MFA the sign-in proceeds and the event is recorded.
signInWithRisk only adds { riskLevel } to the existing response — code that ignores unknown fields is unaffected. assessSession returns only { riskLevel, lastCountry }; it never exposes IP addresses or coordinates.
Fail-open by design: if geo lookup is unavailable or unconfigured, the risk resolves to low and the sign-in always proceeds. Anomaly detection never blocks a legitimate Visitor.
Things to know
Section titled “Things to know”These are the details that most often trip people up when wiring Identity into handlers.
-
Identity needs the SQLite driver. Auth stores its tables in your App’s SQLite/D1 database. If your App’s Structured Storage driver is set to Postgres or MySQL, enabling auth fails loudly at runtime — the two cannot coexist. If your App has login, keep the default SQLite driver. (See Structured Storage.)
-
Brute-force lockout is built-in. Every
signIn(andsignInWithRisk) runs through lockout before the password is checked. After 5 failed attempts within 15 minutes, further attempts return HTTP 429 with{ error: "too_many_attempts" }and aretry-afterheader. On a store error, lockout fails closed and returns 503 with{ error: "auth_unavailable" }. It is skipped transparently in local development. -
Password reset and verification need Messaging. With no messaging provider configured,
requestPasswordResetandsendVerificationEmailrun but send no email — the flow degrades gracefully and a breadcrumb is recorded. Configure Messaging if your App relies on these emails reaching Visitors. -
Org invitations fall back to a shareable URL. Without Messaging,
inviteToOrganizationreturns{ invitationId, inviteUrl }. Share that URL from your own UI instead of relying on an emailed link. -
Route
/api/auth/*throughhandleAuthRequest. The full Better Auth HTTP surface — including passkey, SSO, and the SCIM provisioning endpoints — only works if every/api/auth/*request reachessdk.auth.handleAuthRequest(req). The Builder generates this wiring; a hand-maintained server must replicate it. -
Enterprise methods throw when their flag is off. Calling an MFA, passkey, session, organization, SSO, or anomaly method while its flag is unset throws
NotAvailableInV1Error. Guard the call site, or only render the feature’s UI when you know the flag is on. -
authScimrequiresauthOrganizations. Enabling SCIM without Organizations is a loud failure at auth-instance build time, not a silent no-op. -
Auth secrets are managed for you. You do not set, name, or read any secret to make sign-in work — the auth instance’s internal keys are platform-managed. The only auth-related secret you ever name is
ANOMALY_WEBHOOK_SECRET, and only if you opt into delivering anomaly events to a webhook. SSO and SCIM provider secrets are encrypted at rest automatically. -
The audit trail runs automatically. Every
auth.*event is recorded for you, with actor identifiers and IPs hashed before they are written. There is nothing to configure, and it runs regardless of which flags are enabled.