Sign-in and sessions
authSign people in with passkeys, WhatsApp codes, sign-in links, social login or company SSO, and keep sessions short-lived, device-bound and audited.
authFree in your app, Apache-2.0Status 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.
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 routesimport { auth } from "mainsdk";
export async function POST(req: Request) {
// 401 or 403 with a fix unless an admin signed in with a fresh, phishing-resistant method
const session = await auth.require(req, { role: "admin", aal: "aal2", fresh: "5m" });
return Response.json({ ok: true });
}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);
});
});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.
auth:
driver: githubChange this one line to swap providers; your calls stay the same.GitHub integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-AUTH-0053 | Checkmainsdk check, before deploy | auth.risk.step-up names a method that isn't on, or only methods weaker than the ones it steps up from |
MAIN-AUTH-0055 | Checkmainsdk check, before deploy | The 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-0001 | RuntimeError responses and logs | Not signed in (401 from auth.require()) |
MAIN-AUTH-0006 | RuntimeError responses and logs | WhatsApp code reused, expired or tried too often; the challenge is closed |
MAIN-AUTH-0007 | RuntimeError responses and logs | OIDC SSO callback with a mismatched state or nonce |
MAIN-AUTH-0008 | RuntimeError responses and logs | SAML assertion unsigned, for the wrong audience, expired or replayed |
MAIN-AUTH-0041 | RuntimeError responses and logs | Invite used, expired, revoked, or accepted by a different email (410); a mismatch leaves the invite valid |
MAIN-AUTH-0042 | RuntimeError responses and logs | Domain not verified, already verified by another tenant, or a public email domain |
MAIN-AUTH-0043 | RuntimeError responses and logs | The change would leave the tenant with no owner (409) |
MAIN-AUTH-0044 | RuntimeError responses and logs | The tenant requires its SSO; this session came from another method (403) |
MAIN-AUTH-0045 | RuntimeError responses and logs | Invites, teams and domains are not available in a personal tenant (409) |
MAIN-AUTH-0002 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0003 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0004 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0005 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0010 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0011 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0012 | RuntimeError responses and logs | Check (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-0013 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0014 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0015 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0016 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0017 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0018 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0019 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0020 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0021 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0022 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0023 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0024 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0025 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0026 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0027 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0050 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0051 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0052 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0054 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0056 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0060 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0061 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0080 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0081 | RuntimeError responses and logs | The person cancelled the passkey prompt, or the browser refused it (NotAllowedError or AbortError); not retryable without a new gesture |
MAIN-AUTH-0082 | RuntimeError responses and logs | The auth routes answered 404 or HTML: the adapter isn't mounted or base is wrong |
MAIN-AUTH-0083 | RuntimeError responses and logs | This browser has no WebAuthn, or no conditional mediation when mediation: "conditional" was asked for |
MAIN-AUTH-0084 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0040 | RuntimeError responses and logs | A sign-in challenge was verified a second time; a challenge works once |
MAIN-AUTH-0046 | RuntimeError responses and logs | Sign-in link used, expired, unknown, or opened in a browser other than the one that asked (401; the challenge is closed) |
MAIN-AUTH-0090 | RuntimeError responses and logs | A password is stored with a bare hash instead of passkeys or the auth interface |
MAIN-AUTH-0091 | RuntimeError responses and logs | The sign-in uses the OAuth implicit flow (response_type=token) |
MAIN-AUTH-0100 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0101 | RuntimeError responses and logs | assetlinks.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-0102 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0103 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0104 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0105 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0106 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0200 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0201 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0202 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0203 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0204 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0205 | RuntimeError responses and logs | See the spec |
MAIN-AUTH-0206 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Wallet sign-in by challenge signature | Phase 2 | 0.7 | web3-ready |
| Sessions and require | Phase 1 | 0.3 | auth#session-require |
| Web passkeys and browser QR | Phase 1 | 0.3 | auth#passkeys-web |
| CLI device sign-in | Phase 1 | 0.3 | auth#device-flow |
| WhatsApp one-time codes | Phase 1 | 0.3 | auth#whatsapp-codes |
| OIDC single sign-on | Phase 1 | 0.3 | auth#sso-oidc |
| SAML single sign-on | Phase 1 | 0.3 | auth#sso-saml |
| SAML sign-in request in the redirect binding | Phase 1 | 0.3 | auth#sso-saml-request |
| SCIM provisioning | Phase 1 | 0.3 | auth#scim |
| Native passkeys and app-to-desktop QR | Phase 3 | 1.1 | mobile-auth |
| Organisations | Phase 1 | 0.3 | auth#orgs-members |
| Email-bound | Phase 1 | 0.3 | auth#invites |
| Teams usable as access subjects | Phase 1 | 0.3 | auth#teams |
| Verified email domains and auto-join | Phase 1 | 0.3 | auth#domains |
| Require SSO per organisation | Phase 1 | 0.3 | auth#sso-per-tenant |
| Sign-in routes mounted by every adapter | Phase 1 | 0.4 | auth-client#routes |
| Browser client for passkeys with autofill | Phase 1 | 0.4 | auth-client#client-passkeys |
| Sign-in links and WhatsApp codes from the page | Phase 1 | 0.4 | auth-client#link-whatsapp |
| Step-up | Phase 1 | 0.4 | auth-client#step-up |
Quick start
Turn on the methods you want in main.yaml, then guard a route with auth.require().
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
}
}use mainsdk::prelude::*;
let app = App::test();
let token = app.auth().start_session("tnt_a", "u1").await?;
let session = app.auth().require(Some(&token)).await?;
assert_eq!(session.user, "u1");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:.
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| Key | Type | What it does |
|---|---|---|
key | secret reference | Signs 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 |
methods | list | passkeys, password, whatsapp, oauth, sso, link. Default [passkeys]; a method that is not listed refuses its calls |
signup | string | open (default) or closed; closed refuses new accounts with MAIN-AUTH-0017 |
passkeys.origins | list | The https origins your app is served from. Passkeys and the device flow need one, or fail with MAIN-AUTH-0024 |
passkeys.user-verification | string | required (default) or preferred |
whatsapp.driver | string | Sender for one-time codes, swappable with no code change |
whatsapp.ttl, max-tries | duration, number | Code lifetime (5 minutes) and wrong tries before it closes (5) |
whatsapp.limits | map | Sends per phone (3) and per IP (10) in a window (10 minutes) |
oauth.<id> | map | A social provider: preset, client-id, key (a secret reference), and issuer, tenant or scopes where the preset needs them |
sso.default-role | string | Role of a person provisioned by single sign-on. Default member |
sso.jit | boolean | Create an account on first SSO sign-in. Default true |
session.ttl | duration | Session cookie lifetime, 1 second to 15 minutes. Default 15 minutes |
session.refresh | duration | Refresh token lifetime. Default 7 days; tokens rotate on every use |
recovery.wait | duration | How long recovery waits before access. Default 24 hours |
admin.roles | list | Roles that auth.require() only grants to a phishing-resistant sign-in (a passkey, or SSO that reports MFA) |
admin.require | string | phishing-resistant; mainsdk check fails when an admin role can sign in without it |
orgs.invites.expires | duration | How long an invite lasts. Default 7d |
orgs.domains.auto-join | boolean | Allows auto-join; each organisation still turns it on per verified domain |
risk.mode | string | observe logs what a risk decision would do; enforce does it. A push may only move it to enforce |
risk.step-up | list | Methods a step-up may use, in order |
risk.breached.driver | string | Opt-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:
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.
| Call | What 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.
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.
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.
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 });
}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.
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.
"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.
Sign-in links
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.
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.
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;
}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.
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:
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.
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:
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.
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:
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:
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 isSameSite=Nonebecause 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).
KeyInfoin 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
authfails 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
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
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.
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.
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.
| Call | What 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.
| Code | Status | When | Fix |
|---|---|---|---|
MAIN-AUTH-0001 | 401 | No valid session | Sign in, or call auth.session() where signing in is optional |
MAIN-AUTH-0002 | 403 | The session lacks the role | Ask an owner of the organisation to give the role |
MAIN-AUTH-0003 | 403 | The sign-in is too weak for aal or an admin role | Sign in with a passkey, or complete a second check |
MAIN-AUTH-0006 | 401 | WhatsApp code reused, expired or tried too often | Send a new code |
MAIN-AUTH-0007 | 401 | OIDC or social callback with a bad state, nonce or token | Start sign-in again from your app |
MAIN-AUTH-0010 | 401 | Sign-in older than fresh | Ask the person to sign in again |
MAIN-AUTH-0011 | 500 | auth.key is not set in production | Set auth.key: secret:AUTH_KEY |
MAIN-AUTH-0020 | 503 | The store or database could not be reached | Retry; check the kv and db drivers |
MAIN-AUTH-0008 | 401 | A SAML response was refused; reason says why | Check the IdP metadata, certificate and clock for the organisation |
MAIN-AUTH-0041 to 0045 | 4xx | Invite, domain, last owner, required SSO and personal-tenant refusals | Each problem body carries a fix |
MAIN-AUTH-0046 | 401 | Sign-in link used, expired, unknown or in another browser | Ask for a new link in that browser |
MAIN-AUTH-0050 | 401 | Step-up needed before a session is issued | Complete one of the listed methods within 5 minutes |
MAIN-AUTH-0051 | 403 | Source network is blocked | Sign in from another network |
MAIN-AUTH-0052 | 401 | Step-up wrong, expired or reused | Start the sign-in again |
MAIN-AUTH-0054 | 422 | The new password is in a known breach | Choose another password |
{
"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).
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
- Permissions: decide what a signed-in user can do.
- Data and records: read records scoped to the user.
- Rate limits and quotas: the limits behind sign-in.
- Tenants: what an organisation is.
- API keys for your customers: keys for code, not people.
Was this page helpful?