MainSDK
Search⌘K
Interfaces

Identity and access

Sign-in and sessions

auth

Sign people in with passkeys, WhatsApp codes, sign-in links, social login or company SSO, and keep sessions short-lived, device-bound and audited.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec authFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

auth signs people into your app and tells your code who they are: passkeys by default, WhatsApp codes, sign-in links, social login, SSO, sessions and organisations. Users and sessions live in your own kv driver.

Reach for it on the first route that needs to know the user. You also get device-bound sessions, risk-based step-up, teams, invites, SCIM provisioning and enterprise sign-in for your own customers (OIDC and SAML). MainSDK never becomes an identity provider you depend on.

At a glance

auth:
  driver: github                       # owner default; the auth spec's example uses clerk
  methods: [passkeys, whatsapp, sso]
  signup: open                         # open | closed
  oauth:
    google: { preset: oidc, issuer: https://accounts.google.com, client-id: "...", key: secret:GOOGLE_SECRET }
  whatsapp: { driver: whatsapp-cloud }
  sso: { protocols: [saml, oidc], scim: true }
  session: { ttl: 15m, refresh: 7d, bind: dbsc }
  orgs:
    invites: { expires: 7d }
    domains: { auto-join: true }
  routes: { base: /_mainsdk/auth }     # where every adapter mounts the browser sign-in routes

Calls

  • auth.session(req)
  • auth.require(req, { role, aal, fresh })
  • auth.passkeys.register()
  • auth.passkeys.signIn()
  • auth.whatsapp.send()
  • auth.sso.enable()
  • auth.scim.enable()
  • auth.challenge.start({ method })
  • auth.challenge.verify({ message, signature })
  • auth.device.start({ scope })
  • auth.devices(user)
  • auth.devices.revoke(user, deviceId)
  • auth.orgs.create({ name, slug?, region })
  • auth.orgs.list()
  • auth.orgs.switch(req, tenantId)
  • auth.members.list({ cursor?, limit? })
  • auth.members.setRoles(userId, roles)
  • auth.members.remove(userId)
  • auth.teams.create(name)
  • auth.teams.list()
  • auth.teams.add(teamId, userId)
  • auth.teams.remove(teamId, userId)
  • auth.invites.create({ email, roles, teams?, expiresIn? })
  • auth.invites.accept(req, token)
  • auth.invites.revoke(inviteId)
  • auth.domains.add(domain)
  • auth.domains.verify(domain)
  • auth.domains.autoJoin(domain, { role } | false)
  • auth.sso.require(tenantId, { except? })
  • new Auth(opts)
  • new StepUpRequired(o)
  • assessRisk(input, cfg)
  • createAuth(opts)
  • isPhishingResistantMethod(method)
  • maskPhone(e164)
  • normalizePhone(input)
  • auth.handle(req, fn)
  • auth.passwords.register(req, { email, password, name? })
  • auth.passwords.signIn(req, { email, password })
  • auth.link.issue({ email } | { phone }, { bind?, req? })
  • auth.link.verify(req, token)
  • auth.oauth.start(provider, { redirectUri, returnTo? })
  • auth.oauth.callback(req)
  • auth.stepUp.start({ pending, challenge, method })
  • auth.stepUp.complete(req, { pending, challenge, method, ... })
  • auth.recover(user, { via, req? })
  • auth.recover.confirm(challengeId, code)
  • auth.recover.cancel(session)
  • auth.recover.complete(req, recovery)
  • auth.recover.withCode(req, user, code)
  • auth.recover.withPasskey(req, user, assertion)
  • auth.recover.generateCodes(session)
  • auth.recover.codesLeft(user)
  • auth.sso.get()
  • auth.sso.signIn({ email } | { org }, { redirectUri, returnTo? })
  • auth.sso.callback(req)
  • auth.sso.saml.metadata(org, { acsUrl })
  • auth.sso.saml.acs(req)
  • auth.risk(req, { userId })
  • auth.signOut(req)
  • auth.signOutEverywhere(req)
  • auth.onRisk(fn)
  • auth.raw()

Guarantees

Whatever the driver.

  • Short-lived, HttpOnly, Secure, SameSite sessions with rotating refresh tokens
  • Passkeys by default, phishing-resistant
  • Fails closed when the provider cannot be reached
  • WhatsApp codes single use, expiring and rate limited per phone and per IP
  • Every recovery audited, never weaker than sign-in

Drivers

7 drivers. Select one to see its line in main.yaml.

main.yamlGitHub (default)
auth:
  driver: github

Change this one line to swap providers; your calls stay the same.GitHub integration

Codes

CodeWhere it appearsMeaning
MAIN-AUTH-0053Checkmainsdk check, before deployauth.risk.step-up names a method that isn't on, or only methods weaker than the ones it steps up from
MAIN-AUTH-0055Checkmainsdk check, before deployThe identity-provider driver named by auth.driver does not offer a method (its capability profile says false). High, and it fails the check, when the app uses the method; a warning otherwise
MAIN-AUTH-0001RuntimeError responses and logsNot signed in (401 from auth.require())
MAIN-AUTH-0006RuntimeError responses and logsWhatsApp code reused, expired or tried too often; the challenge is closed
MAIN-AUTH-0007RuntimeError responses and logsOIDC SSO callback with a mismatched state or nonce
MAIN-AUTH-0008RuntimeError responses and logsSAML assertion unsigned, for the wrong audience, expired or replayed
MAIN-AUTH-0041RuntimeError responses and logsInvite used, expired, revoked, or accepted by a different email (410); a mismatch leaves the invite valid
MAIN-AUTH-0042RuntimeError responses and logsDomain not verified, already verified by another tenant, or a public email domain
MAIN-AUTH-0043RuntimeError responses and logsThe change would leave the tenant with no owner (409)
MAIN-AUTH-0044RuntimeError responses and logsThe tenant requires its SSO; this session came from another method (403)
MAIN-AUTH-0045RuntimeError responses and logsInvites, teams and domains are not available in a personal tenant (409)
MAIN-AUTH-0002RuntimeError responses and logsSee the spec
MAIN-AUTH-0003RuntimeError responses and logsSee the spec
MAIN-AUTH-0004RuntimeError responses and logsSee the spec
MAIN-AUTH-0005RuntimeError responses and logsSee the spec
MAIN-AUTH-0010RuntimeError responses and logsSee the spec
MAIN-AUTH-0011RuntimeError responses and logsSee the spec
MAIN-AUTH-0012RuntimeError responses and logsCheck (high): an admin role in auth.admin.roles can sign in with a method phishing can defeat while auth.admin.require isn't phishing-resistant. Runtime: auth.raw() was called (logged). Fix: set auth.admin.require: phishing-resistant, or remove the weak methods from auth.methods
MAIN-AUTH-0013RuntimeError responses and logsSee the spec
MAIN-AUTH-0014RuntimeError responses and logsSee the spec
MAIN-AUTH-0015RuntimeError responses and logsSee the spec
MAIN-AUTH-0016RuntimeError responses and logsSee the spec
MAIN-AUTH-0017RuntimeError responses and logsSee the spec
MAIN-AUTH-0018RuntimeError responses and logsSee the spec
MAIN-AUTH-0019RuntimeError responses and logsSee the spec
MAIN-AUTH-0020RuntimeError responses and logsSee the spec
MAIN-AUTH-0021RuntimeError responses and logsSee the spec
MAIN-AUTH-0022RuntimeError responses and logsSee the spec
MAIN-AUTH-0023RuntimeError responses and logsSee the spec
MAIN-AUTH-0024RuntimeError responses and logsSee the spec
MAIN-AUTH-0025RuntimeError responses and logsSee the spec
MAIN-AUTH-0026RuntimeError responses and logsSee the spec
MAIN-AUTH-0027RuntimeError responses and logsSee the spec
MAIN-AUTH-0050RuntimeError responses and logsSee the spec
MAIN-AUTH-0051RuntimeError responses and logsSee the spec
MAIN-AUTH-0052RuntimeError responses and logsSee the spec
MAIN-AUTH-0054RuntimeError responses and logsSee the spec
MAIN-AUTH-0056RuntimeError responses and logsSee the spec
MAIN-AUTH-0060RuntimeError responses and logsSee the spec
MAIN-AUTH-0061RuntimeError responses and logsSee the spec
MAIN-AUTH-0080RuntimeError responses and logsSee the spec
MAIN-AUTH-0081RuntimeError responses and logsThe person cancelled the passkey prompt, or the browser refused it (NotAllowedError or AbortError); not retryable without a new gesture
MAIN-AUTH-0082RuntimeError responses and logsThe auth routes answered 404 or HTML: the adapter isn't mounted or base is wrong
MAIN-AUTH-0083RuntimeError responses and logsThis browser has no WebAuthn, or no conditional mediation when mediation: "conditional" was asked for
MAIN-AUTH-0084RuntimeError responses and logsSee the spec
MAIN-AUTH-0040RuntimeError responses and logsA sign-in challenge was verified a second time; a challenge works once
MAIN-AUTH-0046RuntimeError responses and logsSign-in link used, expired, unknown, or opened in a browser other than the one that asked (401; the challenge is closed)
MAIN-AUTH-0090RuntimeError responses and logsA password is stored with a bare hash instead of passkeys or the auth interface
MAIN-AUTH-0091RuntimeError responses and logsThe sign-in uses the OAuth implicit flow (response_type=token)
MAIN-AUTH-0100RuntimeError responses and logsSee the spec
MAIN-AUTH-0101RuntimeError responses and logsassetlinks.json or apple-app-site-association does not match the signing identity declared in targets.mobile.signing, so app links and passkeys would not bind to the app
MAIN-AUTH-0102RuntimeError responses and logsSee the spec
MAIN-AUTH-0103RuntimeError responses and logsSee the spec
MAIN-AUTH-0104RuntimeError responses and logsSee the spec
MAIN-AUTH-0105RuntimeError responses and logsSee the spec
MAIN-AUTH-0106RuntimeError responses and logsSee the spec
MAIN-AUTH-0200RuntimeError responses and logsSee the spec
MAIN-AUTH-0201RuntimeError responses and logsSee the spec
MAIN-AUTH-0202RuntimeError responses and logsSee the spec
MAIN-AUTH-0203RuntimeError responses and logsSee the spec
MAIN-AUTH-0204RuntimeError responses and logsSee the spec
MAIN-AUTH-0205RuntimeError responses and logsSee the spec
MAIN-AUTH-0206RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Wallet sign-in by challenge signaturePhase 20.7web3-ready
Sessions and requirePhase 10.3auth#session-require
Web passkeys and browser QRPhase 10.3auth#passkeys-web
CLI device sign-inPhase 10.3auth#device-flow
WhatsApp one-time codesPhase 10.3auth#whatsapp-codes
OIDC single sign-onPhase 10.3auth#sso-oidc
SAML single sign-onPhase 10.3auth#sso-saml
SAML sign-in request in the redirect bindingPhase 10.3auth#sso-saml-request
SCIM provisioningPhase 10.3auth#scim
Native passkeys and app-to-desktop QRPhase 31.1mobile-auth
OrganisationsPhase 10.3auth#orgs-members
Email-boundPhase 10.3auth#invites
Teams usable as access subjectsPhase 10.3auth#teams
Verified email domains and auto-joinPhase 10.3auth#domains
Require SSO per organisationPhase 10.3auth#sso-per-tenant
Sign-in routes mounted by every adapterPhase 10.4auth-client#routes
Browser client for passkeys with autofillPhase 10.4auth-client#client-passkeys
Sign-in links and WhatsApp codes from the pagePhase 10.4auth-client#link-whatsapp
Step-upPhase 10.4auth-client#step-up

Quick start

Turn on the methods you want in main.yaml, then guard a route with auth.require().

main.yaml
auth:
  key: secret:AUTH_KEY            # signs sessions; required in production
  methods: [passkeys, whatsapp]
  passkeys:
    origins: ["https://app.example.com"]

app/api/me/route.ts

import { auth, problem } from "mainsdk";

export async function GET(req: Request) {
  try {
    const session = await auth.require(req);   // throws MAIN-AUTH-0001 (401) if not signed in
    return Response.json({ user: session.user, aal: session.aal });
  } catch (err) {
    return problem(err);                       // RFC 9457 body with code and fix
  }
}

Without a session cookie the route answers 401 with code MAIN-AUTH-0001 and a fix. With a valid session it answers 200. Where signing in is optional, call auth.session(req) instead: it returns the Session or null. A Session has id, user (the user id), tenant, aal, amr, authTime, issuedAt, expiresAt, deviceId and family; the tokens behind it never appear.

Browser sign-in

Every adapter mounts the sign-in routes under auth.routes.base (default /_mainsdk/auth), and mainsdk/auth/client is a dependency-free browser client for them: auth.session.get() and watch(), auth.passkeys.signIn() and register() (with autofill), auth.link.send() and complete(), auth.whatsapp.send() and verify(), auth.stepUp.send() and verify(), auth.orgs.switch() and auth.signOut(). Writes need the session's CSRF value, which the client sends for you. See Add sign-in to a page.

Configuration

Every key below lives under auth:.

main.yaml
auth:
  key: secret:AUTH_KEY                 # master secret; previous-keys: [secret:OLD_KEY] rotates it
  methods: [passkeys, password, whatsapp, oauth, sso, link]
  signup: open                         # open | closed
  passkeys:
    origins: ["https://app.example.com"]
    user-verification: required        # required | preferred
  whatsapp:
    driver: whatsapp-cloud             # whatsapp-cloud | bird | msg91
    ttl: 5m
    max-tries: 5
    limits: { per-phone: 3, per-ip: 10, window: 10m }
  oauth:
    google: { preset: oidc, issuer: https://accounts.google.com, client-id: "...", key: secret:GOOGLE_SECRET }
  sso: { default-role: member, jit: true }
  session: { ttl: 15m, refresh: 7d }
  recovery: { wait: 24h }
  admin: { roles: [admin, support], require: phishing-resistant }
  orgs:
    invites: { expires: 7d }
    domains: { auto-join: true }
  risk:
    mode: enforce                      # observe | enforce
    step-up: [passkeys, whatsapp]
    breached: { driver: hibp }         # opt in; off unless set
KeyTypeWhat it does
keysecret referenceSigns sessions and seals email and phone at rest. Without it, production fails with MAIN-AUTH-0011; other environments use a random key that ends with the process
methodslistpasskeys, password, whatsapp, oauth, sso, link. Default [passkeys]; a method that is not listed refuses its calls
signupstringopen (default) or closed; closed refuses new accounts with MAIN-AUTH-0017
passkeys.originslistThe https origins your app is served from. Passkeys and the device flow need one, or fail with MAIN-AUTH-0024
passkeys.user-verificationstringrequired (default) or preferred
whatsapp.driverstringSender for one-time codes, swappable with no code change
whatsapp.ttl, max-triesduration, numberCode lifetime (5 minutes) and wrong tries before it closes (5)
whatsapp.limitsmapSends per phone (3) and per IP (10) in a window (10 minutes)
oauth.<id>mapA social provider: preset, client-id, key (a secret reference), and issuer, tenant or scopes where the preset needs them
sso.default-rolestringRole of a person provisioned by single sign-on. Default member
sso.jitbooleanCreate an account on first SSO sign-in. Default true
session.ttldurationSession cookie lifetime, 1 second to 15 minutes. Default 15 minutes
session.refreshdurationRefresh token lifetime. Default 7 days; tokens rotate on every use
recovery.waitdurationHow long recovery waits before access. Default 24 hours
admin.roleslistRoles that auth.require() only grants to a phishing-resistant sign-in (a passkey, or SSO that reports MFA)
admin.requirestringphishing-resistant; mainsdk check fails when an admin role can sign in without it
orgs.invites.expiresdurationHow long an invite lasts. Default 7d
orgs.domains.auto-joinbooleanAllows auto-join; each organisation still turns it on per verified domain
risk.modestringobserve logs what a risk decision would do; enforce does it. A push may only move it to enforce
risk.step-uplistMethods a step-up may use, in order
risk.breached.driverstringOpt-in breached-password check by k-anonymity. Off unless set

Organisations, members, invites and domains need a tenancy: section (see tenants); without it auth.orgs.create() fails with MAIN-TENANT-0001.

Overlays by market

Market overlays change sign-in for a market without touching code. India users get WhatsApp first:

main.in.yaml
auth:
  methods: [whatsapp, passkeys]

See main.yaml for overlay order and merge rules.

What you get

Sessions and guards, sign-in methods, recovery and organisations, all on one auth object. The sections below show each call in full.

CallWhat it does
auth.session(req)The Session of the request, or null
auth.require(req, { role, aal, fresh })The session, or a 401 or 403 with a fix
auth.passkeys.register() and auth.passkeys.signIn()Phishing-resistant sign-in with passkeys
auth.whatsapp.send()One-time WhatsApp codes, single use and rate limited
auth.sso.enable() and auth.scim.enable()Company single sign-on and user provisioning
auth.orgs.create(), auth.members.list(), auth.invites.create()Organisations, members, teams and invites per tenant
auth.signOut(req) and auth.signOutEverywhere(req)End one session or every session of the person

Examples

Every call here ran against the in-memory fakes from the testing kit. Calls that sign a person in return an Issued session: a Session plus cookies, the Set-Cookie lines you must send to the browser.

Protect a route by role

auth.require() checks the role against the session's active organisation, the assurance level and how recently the person signed in. It fails closed with MAIN-AUTH-0020 (503) when the store cannot be reached.

app/api/admin/users/route.ts
import { auth, problem } from "mainsdk";

export async function DELETE(req: Request) {
  try {
    const session = await auth.require(req, {
      role: "owner",
      aal: "aal2",        // phishing-resistant, or two factors
      fresh: "5m",        // signed in within the last five minutes
    });
    return Response.json({ by: session.user });
  } catch (err) {
    return problem(err);
  }
}

A missing session is 401 MAIN-AUTH-0001. A missing role is 403 MAIN-AUTH-0002. Too weak a sign-in is 403 MAIN-AUTH-0003. A sign-in older than fresh is 401 MAIN-AUTH-0010.

Email and password

Passwords are a fallback; list password in methods to allow them. Sign-in sets the cookies from issued.cookies.

app/api/sign-in/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email, password } = (await req.json()) as { email: string; password: string };
  const issued = await auth.passwords.signIn(req, { email, password });
  const res = Response.json({ user: issued.user });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}

auth.passwords.register(req, { email, password, name? }) makes the account and signs in. Every sign-in goes through the risk check below, so a password sign-in from a new device can answer MAIN-AUTH-0050 instead of a session.

Passkeys

The server half is four calls. Registration needs a sign-in from the last 10 minutes, so a stolen session cannot plant a passkey. The options are WebAuthn JSON for the browser's navigator.credentials.

app/api/passkeys/route.ts
import { auth, type RegistrationResponse } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const body = (await req.json()) as Partial<RegistrationResponse>;
  if (!body.challengeId) {
    return Response.json(await auth.passkeys.registerOptions(session, { conditional: false }));
  }
  const passkey = await auth.passkeys.register(session, body as RegistrationResponse);   // { challengeId, response }
  return Response.json({ id: passkey.id });
}
app/api/passkeys/sign-in/route.ts
import { auth, type AssertionResponse } from "mainsdk";

export async function GET() {
  return Response.json(await auth.passkeys.signInOptions());      // no credential list: autofill works
}

export async function POST(req: Request) {
  const { challengeId, id, response } = (await req.json()) as AssertionResponse;
  const issued = await auth.passkeys.signIn(req, { challengeId, id, response });
  const res = Response.json({ user: issued.user, aal: issued.aal });   // "aal2"
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}

The browser half passes the options to navigator.credentials.create() or .get() and posts the result back; the browser's own cross-device QR needs no extra code. That half is browser code and is not run in the repo's tests; the server half is run against a fake authenticator. auth.passkeys.list(user) and auth.passkeys.remove(session, id) manage them, and auth.passkeys.report() returns { users, withPasskey, share }, the share of your users on passkeys.

CLI device sign-in

For a CLI or a TV app, use the device authorization grant (RFC 8628). Codes expire in 10 minutes.

cli/login.ts
import { auth } from "mainsdk";

const grant = await auth.device.start({ scope: "cli" });
console.log(`Open ${grant.verificationUri} and enter ${grant.code} (expires in ${grant.expiresIn}s)`);

// the device polls with the secret deviceCode, never with the code the person types
const result = await auth.device.poll(grant.deviceCode);
if (result.status === "approved") console.log("signed in as", result.session.user);

start() returns code (what the person types), deviceCode (the secret the device polls with), qr, verificationUri, expiresIn (600) and interval (5). poll() returns { status } of authorization_pending until the person approves, slow_down if you poll faster than the interval, expired_token after 600 seconds, access_denied if denied, and { status: "approved", session } once approved. On your verification page, a signed-in person approves with auth.device.approve(session, code) or refuses with auth.device.deny(session, code). The qr is drawn locally with @getqr/react <QRCode/>; it never becomes a getqr.id link.

WhatsApp one-time codes

Codes are single use, expire after 5 minutes, close after 5 wrong tries, and are rate limited per phone and per IP. Bot and pumping signals run before any message is sent.

app/sign-in/whatsapp/actions.ts
"use server";
import { auth } from "mainsdk";

export async function sendCode(req: Request, phone: string) {
  const { challengeId } = await auth.whatsapp.send(phone, { req });
  return challengeId;
}

export async function verifyCode(req: Request, challengeId: string, code: string) {
  return auth.whatsapp.verify(challengeId, code, { req });   // throws MAIN-AUTH-0006 on reuse, expiry or too many tries
}

A new phone number makes a new account unless signup: closed. verify() returns an Issued session: send its cookies to the browser as above. Without a sender it fails with MAIN-AUTH-0023.

With link in methods, auth.link.issue() makes a single-use token for a verified email or phone, valid 15 minutes. It returns null, after the same work, when there is no such account, so the answer never shows who has one. Deliver the token yourself, for example through links.signIn.

app/api/link/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email } = (await req.json()) as { email: string };
  const issued = await auth.link.issue({ email }, { req, bind: "browser" });
  const res = new Response(null, { status: 202 });                 // 202 whether or not there is an account
  for (const c of issued?.cookies ?? []) res.headers.append("set-cookie", c);
  // send issued.token to the person by email
  return res;
}

export async function PUT(req: Request) {
  const { token } = (await req.json()) as { token: string };
  const issued = await auth.link.verify(req, token);               // MAIN-AUTH-0046 if used, expired or in another browser
  const res = Response.json({ user: issued.user });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}

A link session is aal1. A used, expired, unknown or wrongly bound token is MAIN-AUTH-0046 and closes the challenge.

Social sign-in

auth.oauth.start() sends a person to Google, GitHub or Microsoft with PKCE and state; auth.oauth.callback() finishes it. The implicit flow is not offered.

app/auth/google/route.ts
import { auth } from "mainsdk";

export async function GET() {
  const { url, cookies } = await auth.oauth.start("google", { redirectUri: "https://app.example.com/auth/callback" });
  const res = new Response(null, { status: 302, headers: { location: url } });
  for (const c of cookies) res.headers.append("set-cookie", c);
  return res;
}
app/auth/callback/route.ts
import { auth } from "mainsdk";

export async function GET(req: Request) {
  const issued = await auth.oauth.callback(req);                    // MAIN-AUTH-0007 on a bad state, nonce or token
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}

Risk and step-up

Every sign-in is scored in process on device, country, impossible travel, bot score, failed tries, blocked networks and, if you opt in, breached passwords. The answer is allow, step-up or deny. A password sign-in from a new device, for example, throws MAIN-AUTH-0050 with the methods that satisfy it. Finish it with a second check; the session is issued only then.

app/api/sign-in/route.ts
import { auth, MainError } from "mainsdk";

export async function POST(req: Request) {
  const { email, password } = (await req.json()) as { email: string; password: string };
  try {
    const issued = await auth.passwords.signIn(req, { email, password });
    const res = Response.json({ user: issued.user });
    for (const c of issued.cookies) res.headers.append("set-cookie", c);
    return res;
  } catch (err) {
    if (err instanceof MainError && err.code === "MAIN-AUTH-0050") {
      return Response.json(err.toProblem(), { status: 401 });      // { stepUp, pending, challenge }
    }
    throw err;
  }
}

The browser then starts the second check and completes it:

app/api/step-up/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { pending, challenge, challengeId, code } = (await req.json()) as { pending: string; challenge: string; challengeId: string; code: string };
  const issued = await auth.stepUp.complete(req, { pending, challenge, method: "whatsapp", challengeId, code });
  const res = Response.json({ aal: issued.aal });                   // "aal2"
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}

auth.stepUp.start({ pending, challenge, method }) sends the WhatsApp code or returns the passkey options first. A wrong, expired or reused step-up is MAIN-AUTH-0052 and closes the pending sign-in. A blocked network is refused with MAIN-AUTH-0051. The challenge lasts 5 minutes. auth.risk(req, { userId }) assesses before a sensitive step and returns { level, action, reasons, stepUp }; auth.onRisk(fn) runs fn after each assessment and returns the function that stops it. With risk.mode: observe nothing is refused and each decision is logged.

Trusted devices and recovery

Every new device triggers a notice to the person's other devices, and any device can be revoked in one action.

app/api/devices/route.ts
import { auth } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  return Response.json(await auth.devices(session.user));
}

export async function DELETE(req: Request) {
  const session = await auth.require(req, { fresh: "5m" });
  const { deviceId } = (await req.json()) as { deviceId: string };
  await auth.devices.revoke(session.user, deviceId);   // its sessions end on the next request
  return new Response(null, { status: 204 });
}

Recovery is never weaker than sign-in. Recovery codes (auth.recover.generateCodes(session), ten, one use each) or a second passkey (withCode, withPasskey) come first. Otherwise a WhatsApp or email check plus a waiting period, which any existing device can cancel:

app/api/recover/route.ts
import { auth, type Session } from "mainsdk";

export async function recover(req: Request, userId: string, code: string, session: Session) {
  const { challengeId } = await auth.recover(userId, { via: "whatsapp", req });
  const { recovery, notBefore } = await auth.recover.confirm(challengeId, code);   // notBefore is 24 hours out
  await auth.recover.cancel(session);                  // from an existing device, inside the window
  return { back: await auth.recover.complete(req, recovery), notBefore };   // MAIN-AUTH-0019 until the wait has ended
}

Completing recovery ends every old session and device. Every recovery is written to the audit log. An unknown person gets the same answer as a known one.

Single sign-on for a customer organisation

Turn on SSO per customer organisation. The organisation must first prove its domain by DNS (see below). Sign-in routes by verified email domain, and deprovisioning through SCIM ends sessions.

server/sso.ts
import { Hono } from "hono";
import { auth } from "mainsdk";

const app = new Hono();

app.post("/orgs/:org/sso", async (c) => {
  const session = await auth.require(c.req.raw, { role: "owner" });
  const scim = await auth.handle(c.req.raw, async () => {
    await auth.sso.enable({
      protocol: "oidc",
      issuer: "https://idp.pinecart.com",
      clientId: "pinecart-app",
      clientSecret: "secret:PINECART_OIDC_SECRET",     // a secret reference, never the value
      domains: ["pinecart.com"],
    });
    return auth.scim.enable({ groupRoles: { eng: "editor", finance: "viewer" } });
  });
  return c.json({ by: session.user, scimEndpoint: scim.endpoint, scimToken: scim.token });
});

app.post("/sign-in/sso", async (c) => {
  const { email } = (await c.req.raw.json()) as { email: string };
  const { url, cookies } = await auth.sso.signIn({ email }, { redirectUri: "https://app.example.com/auth/sso" });
  const res = new Response(null, { status: 302, headers: { location: url } });
  for (const cookie of cookies) res.headers.append("set-cookie", cookie);
  return res;
});

app.get("/auth/sso", async (c) => {
  const issued = await auth.sso.callback(c.req.raw);   // MAIN-AUTH-0007 on a mismatched state or nonce
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const cookie of issued.cookies) res.headers.append("set-cookie", cookie);
  return res;
});

export default app;

auth.sso.enable() and auth.scim.enable() act on the current organisation; auth.handle(req, fn) runs fn inside the organisation the session names. The SCIM token is shown once; give it to the customer's IdP with the endpoint, and mount auth.scim.handle(req) at that path. OIDC uses PKCE and state, and a mismatched state or nonce is refused with MAIN-AUTH-0007.

SAML

SAML 2.0 is built into your app and works under a narrow profile: you start every sign-in, and the identity provider (IdP) answers it. Give the organisation's IdP metadata and its verified domains:

app/admin/sso/saml.ts
import { auth } from "mainsdk";

export async function enableSaml(acmeMetadataXml: string) {
  await auth.sso.enable("acme", {
    protocol: "saml",
    metadata: acmeMetadataXml,            // the XML, or an https URL it is read from
    domains: ["acme.com"],
    // entityId: "https://app.example.com/saml/acme"   // defaults to urn:mainsdk:sp:<organisation id>
  });
}

Two routes finish the job. Sign-in uses the same auth.sso.signIn call as OIDC, with redirectUri set to the URL of the second route:

app/auth/saml.ts
import { Hono } from "hono";
import { auth } from "mainsdk";

const app = new Hono();

// GET /auth/saml/metadata?org=acme: paste this into the IdP (or give it the URL)
app.get("/auth/saml/metadata", async (c) => {
  const org = new URL(c.req.raw.url).searchParams.get("org")!;
  const xml = await auth.sso.saml.metadata(org, { acsUrl: "https://app.example.com/auth/saml/acs" });
  return new Response(xml, { headers: { "content-type": "application/samlmetadata+xml" } });
});

// POST /auth/saml/acs: the IdP posts its response here
app.post("/auth/saml/acs", async (c) => {
  const issued = await auth.sso.saml.acs(c.req.raw);   // MAIN-AUTH-0008, with a `reason`, on any refusal
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const cookie of issued.cookies) res.headers.append("set-cookie", cookie);
  return res;
});

export default app;

What is accepted, and nothing else:

  • App-initiated only. Sign-in starts at auth.sso.signIn; a response with no pending request, or from another browser, is refused. The request works once, and its cookie is SameSite=None because the IdP answers with a cross-site POST.
  • One signature, from your metadata. The signature must cover the assertion, or the response holding it, and verify with a certificate listed in the IdP metadata (list two while the IdP rotates). KeyInfo in the response is never used. RSA or ECDSA P-256 with SHA-256 or SHA-512; SHA-1 is refused.
  • Checked every time: issuer, destination, recipient, InResponseTo, status, validity (60 seconds of clock skew) and audience, which is your entity ID. The person's address must be at a domain the organisation verified.
  • Never twice. Each assertion ID is kept in your database until it can no longer be replayed. If that store cannot answer, the sign-in is refused (the one place auth fails closed instead of static) and nothing else in your app stops.
  • Refused with a fix: encrypted assertions (turn encryption off at the IdP), IdPs that require signed requests, POST-only IdPs, documents over 256 KB or 64 levels deep, and any DTD or entity.

Every refusal is MAIN-AUTH-0008 with a fixed reason such as unsigned, audience, expired, replay or signature-signature, and an audit record auth.sso_refused.

Organisations, members, teams and invites

An organisation is a tenant of kind org (see tenants). Calls act on the current tenant, so run them inside auth.handle(req, fn). A personal tenant has no members, teams, invites or domains: those calls fail with MAIN-AUTH-0045.

Create and switch organisations

app/api/orgs/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { name } = (await req.json()) as { name: string };
  const org = await auth.handle(req, () => auth.orgs.create({ name, region: "eu" }));   // the creator becomes owner
  return Response.json(org);
}

export async function GET(req: Request) {
  return Response.json(await auth.handle(req, () => auth.orgs.list()));   // personal and org tenants this person belongs to
}

export async function PUT(req: Request) {
  const { tenantId } = (await req.json()) as { tenantId: string };
  return Response.json(await auth.orgs.switch(req, tenantId));   // 404 MAIN-TENANT-0002 if not a member
}

Members and teams

server/members.ts
import { auth } from "mainsdk";

export async function manage(req: Request, userId: string) {
  await auth.handle(req, async () => {
    const page = await auth.members.list({ limit: 50 });         // { items }
    await auth.members.setRoles(userId, ["admin"]);
    await auth.members.remove(userId);                           // the last owner cannot go: MAIN-AUTH-0043

    const eng = await auth.teams.create("eng");
    await auth.teams.add(eng.id, userId);
    await auth.teams.list();
    await auth.teams.remove(eng.id, userId);
    return page;
  });
}

A team can be an access subject: access.relate(team, "editor", folder) gives every member of the team that role.

Invites

An invite is bound to one email address, works once, expires in 7 days and goes out by email as a getqr.id link. Accepting it with another address fails with MAIN-AUTH-0041 and the invite stays valid.

app/api/invites/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email } = (await req.json()) as { email: string };
  const invite = await auth.handle(req, () => auth.invites.create({ email, roles: ["member"], expiresIn: "7d" }));
  return Response.json({ id: invite.id, url: invite.url, expiresAt: invite.expiresAt });
}

export async function PUT(req: Request) {
  const token = new URL(req.url).searchParams.get("invite")!;     // the link opens your app with ?invite=<token>
  return Response.json(await auth.invites.accept(req, token));     // the person joins with the invited roles
}

export async function DELETE(req: Request) {
  const { inviteId } = (await req.json()) as { inviteId: string };
  await auth.handle(req, () => auth.invites.revoke(inviteId));
  return new Response(null, { status: 204 });
}

Creating an invite needs an email driver (MAIN-AUTH-0025 without one). Only an owner can invite an owner.

Domains and required SSO

Prove an email domain by DNS, then let people who sign in with a verified email on it join on their own. auth.sso.require() makes members enter an organisation only through its SSO.

server/domains.ts
import { auth } from "mainsdk";

export async function verifyDomain(req: Request) {
  await auth.handle(req, async () => {
    const proof = await auth.domains.add("acme.com");                  // { record, value }: publish this TXT record
    await auth.domains.verify("acme.com");                             // MAIN-AUTH-0042 until the record is found
    await auth.domains.autoJoin("acme.com", { role: "member" });       // or false to turn it off
    await auth.sso.require({ except: ["owner"] });                     // others get MAIN-AUTH-0044 without SSO
    return proof;
  });
}

auth.sso.require(tenantId, opts) takes a tenant id when you are outside auth.handle. A public email domain such as gmail.com is refused, and so is a domain another tenant has verified.

Wallet sign-in

Wallet sign-in by challenge signature (auth.challenge.start() and auth.challenge.verify()) is planned for phase 2 (spec web3-ready). It is not built yet.

Reference

Calls and helpers that the sections above do not show in full.

CallWhat it does
createAuth(options)Builds the Auth object from the app's pieces (config, store, tenancy, log, WhatsApp sender). The runtime calls it for you; call it yourself in tests or a custom host
new Auth(options)The class createAuth returns. Prefer createAuth
auth.recover.withCode(req, user, code)Recovers with one recovery code. The code is spent, and the sign-in is risk-assessed but never stepped up. Limited to 10 tries in 15 minutes per person
auth.recover.withPasskey(req, user, assertion)Recovers with a second passkey: the same proof as a passkey sign-in, for a named person
auth.recover.codesLeft(user)How many recovery codes the person has left, as a number. Show it and offer generateCodes when it runs low
auth.sso.get()The single sign-on connection of the current organisation, or null
new StepUpRequired({ pending, challenge, stepUp })The MAIN-AUTH-0050 error (401) thrown when a second method is needed before a session is issued. stepUp names the methods that will do; finish one within 5 minutes
assessRisk(input, config)The pure risk function behind auth.risk. Returns the would-be action (allow, step up or deny) with its reasons, even in observe mode
auth.challenge.start({ method: "wallet" })Makes a single-use message bound to the app domain, valid for five minutes at most. Needs wallet in auth.methods and a wallet driver; returns { nonce, message }
auth.challenge.verify({ message, signature }, req?)The wallet driver checks the signature; the person behind the address signs in with the same session as any method. A used or expired challenge fails with MAIN-AUTH-0040 (401)
isPhishingResistantMethod(method)True for passkeys and sso-phr
normalizePhone(input)Returns an E.164 number or throws MAIN-AUTH-0014 (400)
maskPhone(e164)Hides the middle of an E.164 number, keeping the country prefix and last four digits, for logs and screens

Errors and checks

Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link.

CodeStatusWhenFix
MAIN-AUTH-0001401No valid sessionSign in, or call auth.session() where signing in is optional
MAIN-AUTH-0002403The session lacks the roleAsk an owner of the organisation to give the role
MAIN-AUTH-0003403The sign-in is too weak for aal or an admin roleSign in with a passkey, or complete a second check
MAIN-AUTH-0006401WhatsApp code reused, expired or tried too oftenSend a new code
MAIN-AUTH-0007401OIDC or social callback with a bad state, nonce or tokenStart sign-in again from your app
MAIN-AUTH-0010401Sign-in older than freshAsk the person to sign in again
MAIN-AUTH-0011500auth.key is not set in productionSet auth.key: secret:AUTH_KEY
MAIN-AUTH-0020503The store or database could not be reachedRetry; check the kv and db drivers
MAIN-AUTH-0008401A SAML response was refused; reason says whyCheck the IdP metadata, certificate and clock for the organisation
MAIN-AUTH-0041 to 00454xxInvite, domain, last owner, required SSO and personal-tenant refusalsEach problem body carries a fix
MAIN-AUTH-0046401Sign-in link used, expired, unknown or in another browserAsk for a new link in that browser
MAIN-AUTH-0050401Step-up needed before a session is issuedComplete one of the listed methods within 5 minutes
MAIN-AUTH-0051403Source network is blockedSign in from another network
MAIN-AUTH-0052401Step-up wrong, expired or reusedStart the sign-in again
MAIN-AUTH-0054422The new password is in a known breachChoose another password
401 response
{
  "type": "https://mainsdk.dev/errors/MAIN-AUTH-0001",
  "title": "Not signed in",
  "status": 401,
  "code": "MAIN-AUTH-0001",
  "fix": "Sign in, then retry; the session cookie is missing, expired or revoked",
  "retryable": false,
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}

mainsdk check fails with rule auth.admin_phishing_resistant (code MAIN-AUTH-0012) when a role under auth.admin.roles can sign in with weak methods and admin.require is not phishing-resistant. It fails with MAIN-AUTH-0053 when risk.step-up names a method that is not in methods, or holds only methods weaker than the ones it steps up from. Run mainsdk explain MAIN-AUTH-0001 for the cause and fix offline.

Swapping drivers

Provider drivers are planned. The calls on this page are the contract they will keep, so moving to Clerk, Auth0, Better Auth, WorkOS or Supabase Auth will be one line in main.yaml. The WhatsApp sender is a driver today and swaps that way, under auth.whatsapp.driver. See swap a driver.

auth.signOut(req) ends this browser's session and returns the Set-Cookie lines that clear its cookies; auth.signOutEverywhere(req) ends every session of the person on every device.

auth.raw() is the escape hatch for provider-only features. With the built-in driver there is no outside provider; it returns { driver: "local" } and logs MAIN-AUTH-0012.

Testing

createTestApp() puts every interface on its in-memory fake, so tests need no provider account. See the testing kit. @mainsdk/testing also ships a fake passkey authenticator (createFakeAuthenticator), a fake OIDC provider (createFakeOidcProvider) and a fake WhatsApp sender (fakeWhatsApp).

app/api/me/route.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { auth, problem } from "mainsdk";

// the handler under test, as in app/api/me/route.ts
async function GET(req: Request) {
  try {
    const session = await auth.require(req);
    return Response.json({ user: session.user });
  } catch (err) {
    return problem(err);
  }
}

describe("GET /api/me", () => {
  it("auth AC1 refuses a request with no session and accepts one", async () => {
    createTestApp({
      yaml: "schema: 1\nproject: shop\nauth:\n  methods: [password]\n",
    });
    const none = await GET(new Request("http://localhost/api/me"));
    assert.equal(none.status, 401);
    assert.equal(((await none.json()) as { code: string }).code, "MAIN-AUTH-0001");

    const issued = await auth.passwords.register(new Request("http://localhost/"), {
      email: "jane@x.com",
      password: "correct horse battery",
    });
    const cookie = issued.cookies.map((c) => c.split(";")[0]).join("; ");
    const ok = await GET(new Request("http://localhost/api/me", { headers: { cookie } }));
    assert.equal(ok.status, 200);
  });
});

The sdk's own tests in packages/sdk/test/auth*.test.ts show the full set: organisations, SCIM, risk, recovery and the fake identity provider.

Security and privacy

  • Sessions are short-lived, HttpOnly, Secure, SameSite cookies. Tokens are never written to localStorage, and the implicit OAuth flow is not offered.
  • A refresh token works once. A reused token revokes the whole token family. Sessions are bound to a device through a signed device cookie and a rotating refresh family; Device Bound Session Credentials (DBSC) are planned.
  • Email and phone are sealed at rest and found by keyed hash.
  • Enterprise sign-in for your customers is a free capability in your app. It is not SSO for the MainSDK Cloud console.

FAQ

Can I keep passwords? Yes, as a fallback. Passkeys are the default; list password in methods to allow them. Admin roles still need a phishing-resistant method.

Does MainSDK store my users? Today, yes, in your own kv driver, sealed at rest. A provider driver will keep them in the provider's tenant instead.

Do I get SAML and SCIM? Yes: SAML 2.0 (see SAML), OIDC single sign-on and SCIM 2.0 all work in your app.

What about native apps and app-to-desktop QR? Native passkeys, app-to-desktop QR and push approval are phase 3.

Which provider drivers ship first? Clerk and Better Auth are proposed to make auth stable in 0.3; that choice is still open.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet