Authentication
Your application's own users — how they sign in, what a session is, and where a second factor is enforced.
Base316 holds your application's users, not just the people who log into your dashboard. They are a separate population from your own team, with their own tenants, roles and sign-in.
Read How the model fits together first if you have not. Everything here sits under Environment, the same isolation boundary as the billing half.
The shape
Environment
├── RuntimeUser a person who signs in to your product
│ ├── credential a password, if they have one
│ ├── passkeys registered authenticators
│ ├── mfaFactors second factors
│ └── sessions where they are signed in
├── RuntimeOrganization a tenant — one of your customers' companies
│ └── RuntimeMembership a user's place in a tenant, holding one role
└── RuntimeRole what a membership may doTwo things follow from this shape, and both catch people out.
A user is not a tenant. Signing up creates both — a person, and a tenant of one for them to belong to — because billing hangs off the tenant and a user with no membership reads as "not a member" to every permission check.
The exception is somebody signing up to accept an invitation. Pass the
token from the invitation link as invitationToken on POST /v1/auth/signup
(auth.signUp({ …, invitationToken }); <SignUp /> reads ?invitation= from
the page for you) and the account is created straight into the inviting
tenant instead:
- no tenant of one is opened, and so no billing account beside it;
- the address is marked verified — the token reached that mailbox, the same
proof a magic link is — so
verificationTokencomes backnulland an environment that requires a confirmed address lets them sign in at once; - the invitation is accepted in the same transaction, with the role it named.
The token must be live and sent to the same address (compared without case).
Otherwise the sign-up is refused — INVITATION_INVALID for a token that is
unknown, withdrawn, used or expired, INVITATION_EMAIL_MISMATCH for one sent
to somebody else — rather than quietly opening the workspace you were trying to
avoid. A tenant with no free seat refuses with 409 SEAT_LIMIT_REACHED and
creates nothing. An invitation also admits its address to an invitation-only
environment; a blocked address stays blocked. The response always says where
the person landed: organizationId and invitationAccepted.
A tenant nobody needs any more — including a personal one opened before this
existed — is removed with DELETE /v1/organizations/:id
(organizations.delete(id, { exceptUserId })). It is refused while anybody but
exceptUserId is a member, while a paid subscription is active, past due or
paused, or while the tenant manages others, has a connected payment account or
sells its own plans. A trial is cancelled as part of it. Memberships,
invitations, branding and tenant-scoped records go with it; its billing
account is unlinked and kept, because it holds invoices. A repeat answers
alreadyDeleted: true.
A membership holds exactly one role. Somebody in two groups at their identity provider has whichever role was applied last. See Directory sync for why that is the honest consequence rather than a limitation to work around.
Ways in
Which of these your users see is set per environment, on the Authentication tab.
| Method | Default | What it proves |
|---|---|---|
| Password | on | Something they know |
| Social sign-in | off | An account at Google, GitHub, and others |
| Magic link | off | Control of the mailbox |
| Passkey | off | Possession of a registered device |
| Enterprise SSO | off | Whatever the customer's own IdP checked |
Twelve social providers are supported, from Google and Microsoft to Discord and Roblox. Social sign-in covers registering each one, which accounts are joined by email, and restricting Microsoft to your customers' directories.
You can present these through hosted pages Base316 renders and brands, or
build your own screens on the SDK, or use the components in
@base316/react. All three reach the same endpoints.
Signing in without a browser
A CLI, a television, or anything with no browser uses the device flow (RFC 8628). The device shows a short code, the person approves it on their phone, and the device learns the outcome by polling.
Enable it per client by adding the Device code grant under OIDC clients.
Approval can happen on a page in your own application — <DeviceApproval /> in
@base316/react is that page — or on Base316's hosted /hosted/device if you
would rather not build one.
The scopes a device asks for are narrowed to the ones its client is registered
for, exactly as /authorize narrows them. A device asking for more than its
client holds is granted the overlap rather than refused, so register the client
for everything it legitimately needs.
Sessions
A session is an opaque token. Only its digest is stored, so a leaked database yields no usable sessions.
Sessions are per environment. A sandbox password can never open a production session, even for the same person and the same address.
Single sign-on, and single sign-out
Signing in on the hosted pages also opens a session with Base316 itself, so a
second application's trip through /oidc/{env}/authorize goes straight through
without a form. prompt=login and max_age ask for credentials again anyway,
and prompt=none answers login_required rather than showing a page.
Signing out works the other way round. Give an OIDC client a back-channel
logout URL (Settings → Applications → Edit) and Base316 POSTs it a signed
logout_token whenever somebody who signed in to that application:
- signs out at
/oidc/{env}/logout, - signs out of every device, changes or resets their password,
- is disabled or erased,
- or disconnects that application.
Only applications the person actually signed in to are told. The token names
the person (sub), not a session, so end every session you hold for them.
Verify it with createLogoutTokenVerifier from @base316/sdk:
import { createLogoutTokenVerifier } from "@base316/sdk";
const logout = createLogoutTokenVerifier({
issuer: "https://api.base316.com/oidc/env_1a2b3c",
clientId: "oidc_…",
});
app.post("/auth/backchannel-logout", async (c) => {
const form = await c.req.parseBody();
const result = await logout.verify(String(form.logout_token ?? ""));
if (!result.ok) {
return c.body(null, 400);
}
await endSessionsFor(result.subject);
return c.body(null, 200);
});A notice that cannot be delivered is retried over about two and a half hours, then given up on; the application's row in the dashboard says how many were delivered and why the last one failed.
Signing algorithms
Tokens are signed RS256 unless a client asks for ES256, per client — an application that never said is assumed to expect RS256, as OIDC requires. Both keys are published in the environment's JWKS. Rotate keys on the Applications page replaces every key in use; the old ones stay published for a week so nothing already issued stops verifying.
Second factors
A user can enrol an authenticator app (TOTP) or a code to their inbox.
Both are offered by <TwoFactorManager />, and both produce single-use recovery
codes shown exactly once.
Where a second factor is enforced is worth knowing precisely:
| Signing in with | Second factor asked for |
|---|---|
| Password | Yes |
| Magic link | Yes |
| Social sign-in | Yes |
| Passkey | No |
| Enterprise SSO | No |
The two exceptions are deliberate. A passkey is already possession of a registered authenticator, usually behind a biometric, and cannot be phished — a code on top buys little and costs enough friction that people stop using the stronger thing. Enterprise SSO exists so the identity provider is the only door, and MFA policy belongs to the enterprise that bought it.
See Second factors for enrolment and recovery.
What a person can ask for about themselves
Under GDPR — and as a matter of course for anyone else — a user may ask for a copy of what you hold, and ask to be forgotten. Both have endpoints, and both take the user's own session so your server never has to be trusted with somebody else's account.
See Privacy requests.

