Identity & access management
Authentication methods, sessions, API keys, and the organization-scoped authorization model: one decision point, the Better Auth / Nest split, where the guards live, and what is still open.
openJII separates three concerns that are easy to conflate:
- Identity answers which account is acting.
- Authentication proves that identity with an email code, OAuth provider, passkey, browser session, or personal API key.
- Authorization decides what that account may do to an organization-owned resource.
An authentication method never creates a second permission model. A browser session and an API key for the same user reach the same authorization service and receive the same resource decision.
This page is about mechanism. For what the product does — roles, teams, transfers, the directory — read Organizations and Who can access your work? in the user guide.
The model shipped in four phases: the organization substrate, the authorization substrate, grants becoming the only source of resource access, and the product surface that made organizations reachable at all. That sequence is history now — everything below describes the current system, not a direction of travel.
Authentication paths
Interactive browser sessions
The web client uses the shared Better Auth server in packages/auth.
Interactive sign-in supports:
- a six-digit email one-time code;
- GitHub;
- ORCID when configured; and
- a passkey on browsers with WebAuthn support.
The last-login-method plugin records the successful method in a browser cookie. The login page uses it only to show a Last used hint; it is not an account setting or an authorization signal.
Browser sessions use a 30-day sliding lifetime. Activity renews the session at most once per day, and the cookie cache is valid for one day. After roughly 30 days without activity, the user must authenticate again.
Passkey ceremonies run on the web origin. The relying-party ID is derived from the shared cookie domain in deployed environments and falls back to the web hostname in local development. Passkeys can be created, renamed, and deleted; deleting one does not invalidate other sessions or sign-in methods.
Signing in accepts nothing. A pending organization invitation survives every
sign-in and sign-up untouched: it is claimed only when the recipient opens
/platform/account/invitations and accepts, through Better Auth's own
accept-invitation endpoint. A membership reaches everything the organization
owns, and an admin or owner role makes its holder answerable for other
people's work, so joining is a decision rather than a side effect of logging in.
Signing in does accept pending resource invitations — a share of one
experiment, macro, protocol, workbook or device. That hook lives in the users
module and is attached to the OAuth callback paths as well as the email paths,
because /sign-in/social only hands back a redirect URL and the session is
created when the provider redirects back. It is written to heal rather than fire
once: no early exit, retried on every sign-in, and it never fails the auth flow.
There is no active organization
Better Auth's organization plugin supports an active organization on the
session, and openJII never sets one. session.activeOrganizationId is read
in two places as a fallback — because Better Auth's own routes resolve their
target that way — and is always empty in practice.
Organization selection is per action instead: a picker on each create form, a target picker on transfer. This is deliberate. An active organization is ambient state that has to be kept correct, and every read that trusted it would be a read that could be wrong after a role change.
The same reasoning is why memberships are not embedded in the session. The
session cookie is cached for a day, so an embedded membership map would go
stale on every role change and inflate every request;
GET /users/me/organizations is the read instead.
Personal API keys
A personal API key is a non-browser credential for REST clients:
x-api-key: jii_...The key:
- is shown once, then stored as a hash;
- must have a name and may expire after up to 365 days or have no expiration;
- is independently rate-limited to 100 requests per minute by default;
- records its prefix, creation time, last request, and expiration for the account UI; and
- can be revoked immediately.
Keys are deliberately not accepted by Better Auth account-management
routes. The custom key reader returns a key only for the internal
/get-session check performed by the Nest authentication guard. Better Auth
then supplies a mocked session for that request, allowing ordinary protected
REST handlers to identify the user without allowing the key to create other
keys, register passkeys, or change organization settings through auth routes.
API keys currently have no independent scopes. A key acts as its owner and receives whatever that user is allowed to do. Treat it like a password, use an expiration where practical, and revoke it immediately if it leaks.
One decision point
AuthorizationService.can(userId, request) in
apps/backend/src/authorization/authorization.service.ts is the only place a
per-resource access question is answered.
resource exists and ownership loads
-> role in the resource's owning organization
-> direct user grant
-> grant to one of the user's teams
-> grant to one of the user's organizations
-> public visibility, for read actions only
-> denyThat list is attribution order, not first-match, and the distinction is the
single most important thing on this page. A source whose tier does not cover the
requested action falls through rather than denying, so effective access is
the strongest that any source grants. The order decides which source an allowed
decision is credited to — org-role, a grant tier, or public — not which
source gets to refuse.
Two consequences follow. A grant can only ever raise access, never lower it: an organization member handed a higher grant gets the higher one, and an admin holding a stale low grant keeps their admin access. And there is no platform-administrator tier anywhere in the chain.
Denials distinguish not-found from forbidden, and can() also returns the
owning organization it resolved against. That return value matters: a caller
that authorizes and then separately re-reads the resource's organization has a
window in which a transfer can land, leaving it acting on an organization
nobody authorized for.
can() takes an optional executor so a caller can re-ask the same question
inside its own transaction, on rows it already holds locked, rather than
trusting an answer read before the locks. There is deliberately no second
implementation of the decision for that path.
Two matrices, because the middle tier means two things
packages/auth/src/access.ts is the source of truth for the five
organization-owned resource types — experiment, protocol, macro, workbook,
device — and the five actions read, contribute, update, share,
manage. contribute sits between read and update so that seeing an
experiment never implies writing measurements or annotations into it. Only
experiments have data to contribute to, but the verb stays in the statement for
every type because Better Auth needs one literal action list.
The file exports two role matrices:
| Matrix | Role stored on | Read by | Middle tier |
|---|---|---|---|
roles | organization_members.role | orgRoleCan() | member → read, plus contribute on an experiment |
grantRoles | resource_grants.role | grantRoleCan() | viewer → read, plus contribute on an experiment |
owner and admin mean full control in both, so grantRoles reuses
roles.owner and roles.admin directly; only the middle tier is redefined.
On an experiment the two middle tiers now agree, and that is the point:
being handed the lowest grant tier must not beat belonging to the organization
that owns the experiment. They still disagree nowhere else — viewer and
member are both read-only on every other type — which is why the matrices
stay separate rather than collapsing into one. A public experiment's
passer-by still gets read alone; that comes from the visibility branch in
can(), not from either matrix.
grantRoleCan() resolves a role by exact match and refuses anything it does not
recognise. resource_grants.role is a plain text column, so what holds it to
that set is GRANT_ROLES in @repo/database, which types every grant write
path and is cross-checked against the matrix in
packages/auth/src/access.spec.ts. One spelling per tier is what makes both the
matrix and a WHERE role = 'viewer' trustworthy.
Both matrices read a possibly multi-valued column. organization_members.role
holds Better Auth's role string, which can be comma-joined ("member,admin"),
and both helpers split, trim and accept the row if any token grants. Unknown
tokens are ignored, so a stale or renamed role confers nothing.
organizations.base_permission remains in the schema with a read default and
is read nowhere in the access path. The member role's baseline is folded into
the matrix instead; a configurable per-organization dial is deferred.
The hybrid split: Better Auth writes, Nest reads
Two systems own the organization surface, and the boundary is not arbitrary.
Better Auth owns the writes on its own models — creating and updating an
organization, invitations, adding and removing members, role changes, and team
CRUD. Reimplementing those would mean reimplementing its permission statements,
its last-owner counters and its invitation lifecycle, and then keeping three
copies of each in agreement. So the plugin in
packages/auth/src/organization/plugin.ts is configuration and hooks around
Better Auth's endpoints rather than a replacement for them.
The Nest organizations module owns every read, plus the join-request
domain and the lifecycle rules. Reads are where openJII's own access questions
live — an access-scoped resource showcase, a members list with profile joins,
an outside-collaborators view derived from resource_grants — and none of them
are questions Better Auth models.
One write is Nest's, and it is worth knowing about.
addOrganizationMember writes an organization_members row directly through
OrganizationRepository, bypassing Better Auth entirely. It exists because an
invitation is for reaching an address that may have nobody behind it, while somebody
picked out of the platform's own user search already has an account.
The consequence for a reader of this code: the canonical-role guard in Better Auth's
beforeAddMember hook does not protect this path. What holds the role to one
spelling here is the contract's enum plus canGrantOrganizationRole in
apps/backend/src/organizations/core/organization-access.ts. Two paths write the same
table under two different guards.
Better Auth's own validation is weaker than the product needs in three places, which is what the plugin's hooks exist to correct:
- Slugs. Better Auth validates only non-empty and unique. openJII requires
lowercase alphanumeric with single interior hyphens, capped at the column
width, and reserves the
personal-prefix. Without that guard,create({ slug: "personal-lab" })mints an organization the whole personal-workspace treatment misclassifies — undeletable, memberless, permanently unmanageable. - Role spelling. Better Auth validates a role after trimming but gates who
may hand out the creator role on the untrimmed string, then stores it
verbatim. So
" owner"or"member, owner"passes its check and lands in the column; every reader trims, so such a row reads as a full owner that Better Auth's own permission check and last-owner counters do not recognise. All three membership writes now refuse anything but one exact spelling. - Admin scope. Better Auth's default
adminAccarriesorganization: ["update"], so an admin could rename the organization and change its slug. That statement is emptied in our admin role, so settings are owner-only.
Two smaller configuration facts have visible consequences. visibility is
registered as an organization additional field, because Better Auth silently
strips unknown keys from its update body — without it the directory toggle
would return 200 and change nothing. And the auto-created default team is off:
with teams.enabled, Better Auth creates a team named after the organization on
every create, which would appear in the teams list and the grantee picker
having been made by nobody.
Where the guards live
There are three placements, and which one a rule needs is decided by what Better Auth actually fires — not by preference.
| Placement | Where | Use it for |
|---|---|---|
organizationHooks | packages/auth/src/organization/plugin.ts | The plugin's own writes: create, update, invitation, add member, role change, team create, delete |
Plugin hooks.before / hooks.after | same file | Narrowing reads, which no organizationHooks entry covers |
Nest @BeforeHook / @AfterHook | apps/backend/src/*/presentation/hooks/ | Paths Better Auth fires no organization hook for at all |
Three details make this concrete:
organizationHooks cover writes only. There is no read hook among them, so
the two reads that needed narrowing are handled by hooks.before and
hooks.after on the plugin's own request pipeline —
before to refuse ahead of Better Auth's endpoint, after to withhold part of
what it answered. get-full-organization returned every invitation row to any
plain member and answered a missing organization differently from a non-member,
so any signed-in caller could enumerate which ids and slugs exist.
Those read gates are attached to the plugin, not to the auth instance or to a
Nest hook. Anything mounting openJiiOrganization() — the server config and
the tests alike — carries the gates with it.
Path matching is exact string equality, with no wildcards.
/organization/leave fires no organization hook at all, so the
personal-workspace shield for it is a Nest @BeforeHook("/organization/leave")
naming the endpoint verbatim. That shield is the whole of the organization module's
Nest hook: organization invitations never auto-accept. Whatever role one carries,
it is claimed on /platform/account/invitations through Better Auth's own
accept-invitation endpoint, because a membership reaches everything the
organization owns and joining is the recipient's decision to make.
The same exact-path mechanism still carries the resource invitation auto-accept in
the users module, which is why its OAuth callback hooks are declared as
/callback/:id and /oauth2/callback/:providerId — those are Better Auth's own
route strings, compared whole, rather than patterns being matched.
For per-resource decisions inside the Nest app, controllers declare policy instead of reproducing membership checks:
@CanAccess({ resource, action })authorizes an existing resource — includingaction: "contribute"on the measurement, upload, and annotation routes, which keeps data entry stricter than generic read access; and@CanCreateInOrg()checks organization membership before a resource exists.
The global authentication guard first populates request.session. Resource
guards then use only its user ID and delegate to can(). Malformed resource IDs
fail as 400, missing resources as 404, denied actions as 403.
resource_grants is polymorphic, so nothing cascades
One table carries every explicit grant. Its grantee is a user, a team, or an
organization, discriminated by grantee_type:
granteeType: granteeTypeEnum("grantee_type").notNull(),
granteeId: uuid("grantee_id").notNull(), // no .references()grantee_id and resource_id are bare UUIDs with no foreign key — a
polymorphic column cannot have one, because it points into different tables
depending on a sibling column. Only created_by has an FK.
The consequence is that nothing cleans up after itself. Delete a team or an
organization and the grants naming it survive as access nobody can see or
revoke, ready to be re-associated with a future team or organization that reuses
the id. So teardown is explicit: afterDeleteOrganization and afterDeleteTeam
sweep the rows. They are after hooks on purpose — a refused delete must leave
the grants it would have torn down exactly where they were.
Any new grantee kind, or any new way to delete one, has to add its own teardown. The database will not remind you.
Lifecycle: husks, transfer, and deletion
The husk
A husk is an organization nobody can be held answerable for: one whose living owners are all gone. Most of the rules below exist to prevent or escape it. Two definitions of "living membership" are in play, deliberately:
| Test | Counts | Read by | Swapping it in would |
|---|---|---|---|
| Answerability | Owners only (livingOrgOwnerIdsSql) | The staffing invariant, both account-deletion blockers | Let an organization with admins but no owner read as answerable, though nobody in it can grant the owner role again |
| Operability | Owners and admins (orgHasLivingFullControlMember) | Transfer's husk escape | Treat an organization that merely lost its owner as abandoned |
Ownership is what another member cannot take away, which is why answerability hangs on it alone.
Transfer has an organization gate on top of can(manage)
Moving a resource between organizations is gated twice: can(manage) on the
resource, and authority over the organization losing it — owner or admin
there (mayTransferOutOfOrganization in transfer-authority.ts).
The second half is not redundant. Grant roles carry manage and revoke is
share-gated, so without it any outside collaborator holding an edit tier could
move somebody else's work into their own personal workspace and lock the owning
organization out of it.
A husk is the exception, having nobody to expropriate from: where every owner and admin has closed their account, whoever still holds control through a grant may carry the resource out, or it is stranded. That is the operability test.
The transaction re-asks both halves on its own handle once its locks are held,
using the same can() evaluator rather than a second reading of the grant
tables. A resource that moved since the decision was made is refused. Devices
are excluded by the type rather than by a runtime refusal.
Two account-deletion blockers, each enforced twice
Account deletion is refused while either is true:
- The user is the last person answerable for a resource — two prongs in
blockingResourcesQuery(resource-staffing.ts): the owning organization's sole living owner is this user, or the organization is already a husk and this user holds the only full-control grant. Prong one needs no grant at all. Both escape when somebody else holds anadmin/ownergrant. - The user is the sole living owner of a non-personal organization, whether or not it owns anything. Personal workspaces are excluded and have to be: everyone permanently and solely owns their own, so counting them would refuse every deletion on the platform.
Each is checked twice — pre-flight and unlocked to drive the dialog, then again
inside the deletion transaction under FOR UPDATE. The pre-flight answer is
raceable by construction, which is why nothing acts on it.
The locks are on owner-membership rows, not grant rows, and that is load-bearing: a resource owned outright has no grant rows, so locking those would lock nothing and two concurrent deletions would each conclude the other's owner would still be there. Lock order is user → organization → resource → grants everywhere.
Organization deletion is blocked, never cascaded
An organization cannot be deleted while it owns any resource of the six owned types, and deletion never cascades — where GitHub cascade-deletes an organization's repositories. Devices own live AWS Things and certificates that only their own delete path can tear down, and a platform whose visibility transitions are monotonic should not vaporize public resources behind one confirmation dialog. A device group holds no work at all, but its grants are polymorphic and cleaned only by the group's own delete path, so cascading one away would leave access behind naming a group that no longer exists. What the six share is that dropping a row by SQL leaves something behind. The way out is to transfer or delete each resource.
Implementation map
| Concern | Source |
|---|---|
| Better Auth server, sessions, plugins | packages/auth/src/server.ts |
| Organization plugin, hooks, read gates | packages/auth/src/organization/plugin.ts |
| Organization lifecycle and grant teardown | packages/auth/src/organization/lifecycle.ts |
| Browser auth client | packages/auth/src/clients/client.web.ts |
| API-key session boundary | packages/auth/src/api-key-session.ts |
| Shared role/action matrices | packages/auth/src/access.ts |
| Personal organization provisioning | packages/database/src/organizations.ts |
| Resource ownership and grants | packages/database/src/schema.ts, packages/database/src/resource-grants.ts |
| Central resource decisions | apps/backend/src/authorization/authorization.service.ts |
| Controller policy guards | apps/backend/src/authorization/*guard.ts |
| Organization reads, join requests | apps/backend/src/organizations/ |
| Nest-side auth hooks | apps/backend/src/organizations/presentation/hooks/ |
| Organization role authority checks | apps/backend/src/organizations/core/organization-access.ts |
| Grant read/write surface | apps/backend/src/sharing/ |
| Staffing invariants and row locks | apps/backend/src/sharing/core/resource-staffing.ts |
| Transfer authority gate | apps/backend/src/sharing/core/transfer-authority.ts |
| One-way visibility transitions | apps/backend/src/visibility/ |
| List-query access scoping | apps/backend/src/common/utils/resource-access-scope.ts |
| Account security UI | apps/web/components/account-settings/, apps/web/components/auth/ |
For the user-facing workflow, read Account security & API keys and Organizations. For the generated endpoint contract, use the REST API reference.