Skip to content

feat(auth): admin.setPassword on the auth module's admin port, so an operator can reset a forgotten password without email #327

Description

@wmadden-electric

At a glance

// in a service wired to `auth.admin`
const { admin } = service.load();
const { user } = await admin.setPassword({ userId, newPassword: 'a-new-long-passphrase' });
// user === null → no such account. Otherwise the password is replaced and every session of that account is signed out.

Today this does not compile: the admin port of @prisma/composer-prisma-cloud/auth has no operation that sets a password.

The commit and versions these paths refer to

  • Source: prisma/composer at commit edaf7b272d7a4d5f1563eb25f5de0072af2b5b10 (main, 2026-09-30, "Merge pull request fix(cli): report the engine's failure cause with DEPLOY.ENGINE_FAILED #326"). The auth package there is at version 0.25.0.
  • Published: @prisma/composer-prisma-cloud 0.21.0, 0.22.0, 0.23.0, 0.24.0 and 0.25.0 (the newest, latest tag on 2026-09-30) were each unpacked and checked. None has a password operation on the admin port.
  • Better Auth: 1.6.24, the version the auth package pins.

All paths below are relative to the repository root at that commit.

Summary

The auth module can create an account with a password (admin.createUser), but nothing lets an operator change the password of an existing account. An application whose users cannot receive a reset email therefore has no supported way to let a person back in after they forget their password. We ask for one new operation on the admin port, admin.setPassword, built the same way as createUser: server to server, authorized by wiring, writing Better Auth's own rows with Better Auth's own hasher.

This hurts every application that runs the module without real email delivery, and any operator who needs to reset a password faster than an email round trip. The concrete case is Asks, an internal Prisma application described below.

Background

How the auth module exposes admin operations today

The module is packages/1-prisma-cloud/2-shared-modules/auth (published inside @prisma/composer-prisma-cloud as the ./auth export). It exposes three ports: api (Better Auth's public HTTP surface), session (rpc) and admin (rpc).

The admin contract is authAdminContract in src/contract.ts, lines 197 to 252. Its operations are findUser, listUsers, listSessions, revokeSession, revokeUserSessions, banUser, unbanUser, createUser, setEmailVerified and removeUser. The last three look like this:

// packages/1-prisma-cloud/2-shared-modules/auth/src/contract.ts, lines 233-251
  createUser: rpc({
    input: type({
      email: 'string.email',
      name: 'string',
      'password?': '8 <= string <= 128',
      'emailVerified?': 'boolean',
    }),
    output: type({ user: userRecord }),
  }),
  setEmailVerified: rpc({
    input: type({ userId: 'string', emailVerified: 'boolean' }),
    output: type({ user: userRecord.or('null') }),
  }),
  removeUser: rpc({
    input: type({ userId: 'string' }),
    output: type({ removed: 'boolean' }),
  }),

The admin handlers do not call Better Auth's admin plugin. They write the auth database directly through an AuthStore, because the ports are authorized by wiring (a service key), not by a Better Auth admin session:

// src/handlers.ts, lines 2-4
 * The `session` + `admin` rpc handler maps, DB-direct over an `AuthStore`
 * (Better Auth's admin plugin authorizes via admin sessions; our ports
 * authorize via wiring, so the handlers never call `auth.api.*`).

src/execution/auth-entrypoint.ts lines 27 to 31 say the same and wire the handlers into serve(). createUser already hashes a password with Better Auth's own hasher (hashPassword from better-auth/crypto, src/handlers.ts lines 10 and 140 to 155) and inserts the credential account row (src/pg-auth-store.ts lines 228 to 255). banUser already shows the pattern for "change a row and delete the user's sessions in one transaction" (src/pg-auth-store.ts lines 194 to 214).

The README describes the admin port as "the tier-1 admin path" and says to wire it only into a back office (README.md lines 28 to 37). It documents provisioning (README.md line 94 onward) and deletion (line 140 onward), and says a deployed stack's rpc ports are reachable only by consumers in its graph (line 121).

The Better Auth configuration is src/auth-options.ts. It enables email and password with requireEmailVerification: true, sends reset mail through the email module (sendResetPassword, line 125), sets revokeSessionsOnPasswordReset: true (line 126), enables self-service /delete-user (line 134), and installs Better Auth's admin() plugin (line 157).

How Asks uses the module

Asks is an internal Prisma application that runs on Composer with @prisma/composer-prisma-cloud 0.21.0 (its pnpm-lock.yaml, and "@prisma/composer-prisma-cloud": "0.21.0" in apps/api/package.json, apps/web/package.json and deploy/package.json). Its wiring, from deploy/module.ts:

  const mail = provision(email(), {
    secrets: { deliveryCredential: envSecret('ASKS_EMAIL_CREDENTIAL') },
    params: {
      deliveryMode: envParam('ASKS_EMAIL_DELIVERY_MODE'),
      from: envParam('ASKS_EMAIL_FROM'),
    },
  });

  const sessions = provision(auth({ signUp: 'closed' }), {
    deps: { db: authDatabase, email: mail.send },
    params: { baseUrl: envParam('ASKS_WEB_URL') },
  });

  const api = provision(app, {
    deps: { database, auth: sessions.api, authAdmin: sessions.admin },
    // ...
  });

  provision(web, {
    deps: { api: api.api, session: sessions.session, authApi: sessions.api },
    // ...
  });
  • Sign-up is closed. Every account is made by an operator through the Asks API, which calls admin.createUser over rpc(authAdminContract) (apps/api/service.ts line 17). Asks stores the auth module's user id in its own database as the account an authorization belongs to.
  • The web app forwards every path under /api/auth to the module with authProxy(authApi) (apps/web/server.ts lines 6, 81 and 99). Signing out and changing your own password are server actions that post to /api/auth/sign-out and /api/auth/change-password with the browser's cookie and Origin (apps/web/app/server/auth-surface.ts, signedInAuthPost). So a signed-in person can change their own password today.
  • The very first account of an environment is written by scripts/grant-first-authorization.mjs, which connects to the auth database with SQL and carries its own copy of Better Auth's scrypt hash. It runs once per environment, by a person, only when the account does not exist yet. It is a bootstrap, not a reset tool.

Why reset mail is off

Asks production has no outgoing mail. It sets the email module's delivery mode to none (deploy/.env.example: ASKS_EMAIL_DELIVERY_MODE=none). Its apps/api/README.md says: "Set the mode to none and nothing is ever delivered, which is what production runs". Better Auth's /request-password-reset still answers, but the email never arrives. So a person who forgets their password cannot reset it themselves, and an operator has no supported way to set it for them.

Getting the code

From CONTRIBUTING.md and the root package.json (Node 24.16.0 and bun 1.3.13 from .tool-versions; pnpm 10.27.0 from packageManager). Note that the clone URL in CONTRIBUTING.md still reads prisma/compose.git and cd app; the commands below use the current repository name.

git clone https://github.com/prisma/composer.git
cd composer
git checkout edaf7b272d7a4d5f1563eb25f5de0072af2b5b10   # or current main
corepack enable
pnpm install --frozen-lockfile
pnpm build

# The auth package is the workspace package @internal/auth
pnpm --filter @internal/auth test        # bun test src
pnpm --filter @internal/auth typecheck   # tsc --noEmit, which also checks src/__tests__/contract.test-d.ts
pnpm lint

The integration tests (*.integration.test.ts) need Postgres. src/__tests__/postgres-harness.ts uses STATE_TEST_DATABASE_URL when it is set, otherwise starts a throwaway cluster with initdb and pg_ctl from PATH, and otherwise skips locally (it fails on CI instead).

Reproduction

A consumer project with only the published package (npm install @prisma/composer-prisma-cloud@0.25.0 typescript@6.0.3).

At runtime, the contract's method list:

// admin-ops.mjs
import { authAdminContract } from '@prisma/composer-prisma-cloud/auth';
import pkg from '@prisma/composer-prisma-cloud/package.json' with { type: 'json' };

const operations = Object.keys(authAdminContract.__cmp);
console.log(`@prisma/composer-prisma-cloud ${pkg.version}`);
console.log('admin operations:', operations.join(', '));
console.log('any password operation:', operations.some((name) => /password/i.test(name)));
$ node admin-ops.mjs
@prisma/composer-prisma-cloud 0.25.0
admin operations: findUser, listUsers, listSessions, revokeSession, revokeUserSessions, banUser, unbanUser, createUser, setEmailVerified, removeUser
any password operation: false

At the type level, the call a consumer would write:

// admin-ops.ts
import type { authAdminContract } from '@prisma/composer-prisma-cloud/auth';
import type { Client } from '@prisma/composer/service-rpc';

declare const admin: Client<typeof authAdminContract>;

await admin.setPassword({ userId: 'u1', newPassword: 'a-long-passphrase' });
$ npx tsc -p .
admin-ops.ts(6,13): error TS2339: Property 'setPassword' does not exist on type '{ findUser: (input: { id?: string | undefined; email?: string | undefined; }) => Promise<...>; ... 8 more ...; removeUser: (input: { ...; }) => Promi...'.

The same list of operations is in the dist of 0.21.0 (without removeUser), 0.22.0, 0.23.0 and 0.24.0.

Expected behaviour

A new operation on authAdminContract:

  setPassword: rpc({
    input: type({ userId: 'string', newPassword: '8 <= string <= 128' }),
    output: type({ user: userRecord.or('null') }),
  }),
  • What it does. Hashes newPassword with Better Auth's own hasher and stores it on the user's credential account row. When the user has no credential account (a magic-link-only account), it creates one, exactly as createUser does and as Better Auth's own setUserPassword does. In the same transaction it deletes every session of the user. It sends no mail.
  • Authorization. Only through the admin rpc port, which serve() already checks against the service key, so only services the root wires to admin can call it. It is not added to the public /api/auth surface and needs no Better Auth role.
  • Result. { user } with the updated record, or { user: null } when no user has that id. This matches setEmailVerified, and means a retried call is not an error.
  • Errors. A newPassword shorter than 8 or longer than 128 characters, or a missing field, is refused by the contract input: a 400 at the rpc boundary, thrown by a typed client. These are the same bounds createUser and Better Auth's sign-up apply. A database failure is a 500, as for every other handler.
  • Ban state is untouched. A banned user stays banned; setting a password does not unban.

Design questions, with recommendations

  1. Name. Recommend setPassword. It reads like the existing setEmailVerified and like Better Auth's setUserPassword, and "set" says the operator chooses the value, which "reset" (a flow with a token and an email) does not.
  2. Account id or email. Recommend userId, and not an email. Every other admin operation that changes a user takes userId, and a caller who has only an email resolves it with findUser({ email }) first. The word "account" should be avoided in the field name: in Better Auth's schema an "account" is a row in the account table (one per sign-in method), and accountId is a column there, so accountId would be ambiguous.
  3. Whether it signs the user out everywhere. Recommend yes, always, in the same transaction, as banUser does. The module already sets revokeSessionsOnPasswordReset: true for the email reset, and the usual reason for an operator reset is that the old password may be known to someone else. Better Auth's own setUserPassword does not revoke sessions (Better Auth 1.6.24, dist/plugins/admin/routes.mjs lines 818 to 846), so this is a deliberate difference and the README should say so. If the maintainers want it optional, a revokeSessions?: boolean defaulting to true is the smallest addition; we do not need it.
  4. Limited to the server-side admin credential. Recommend yes: rpc only, on the admin port. It must not become a Better Auth route on the public surface, and it must not require a Better Auth admin role. This follows the module's existing rule that "ports authorize via wiring, never via Better Auth admin sessions".
  5. Audit logging. The module keeps no audit log for any admin operation today. Recommend one structured log line per call with the operation name and userId (never the password), and leave a durable audit record to the calling application, which knows which operator acted. Also check that nothing logs the request body: the rpc idempotency store keeps the answer, not the input, and the answer here holds no secret.

Implementation outline

  1. src/contract.ts: add setPassword to authAdminContract with the input and output above.
  2. src/auth-store.ts: add setPassword(userId: string, credential: { id: string; passwordHash: string }): Promise<UserRecord | null> to AuthStore, documented as "replaces the credential password or inserts the credential row, and deletes all the user's sessions, atomically; null when the user is absent".
  3. src/pg-auth-store.ts: implement it in one this.sql.begin transaction. Lock the user row (select ... for update) and return null when it is absent. Update password and updatedAt on the account row where "userId" = $1 and "providerId" = 'credential'. If no row was updated, insert one the way createUser does ("accountId" is the user id). Delete the user's sessions. Update the user's updatedAt and return the record. The lock matters because the account table has no unique index on ("userId", "providerId") (src/pack/contract.prisma lines 51 to 69), so two concurrent calls could otherwise both insert.
  4. src/handlers.ts: add setPassword to AdminHandlers; hash with hashPassword from better-auth/crypto, mint the row id with the existing generateId(), and call the store.
  5. apps-facing surface: nothing else. The port, wiring and public exports already carry every admin operation.
  6. Optionally add a /admin/set-password route to examples/auth/src/ops/app.ts and a step to examples/auth/scripts/smoke.ts, so the deployed example exercises it.

Acceptance criteria

Tests to add, all in packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/:

  • contract.test.ts, in describe('authAdminContract — the provisioning methods'): the input needs userId and newPassword; 7 and 129 characters are refused; 8 and 128 are accepted; the output accepts { user: null }.
  • contract.test-d.ts, next to "the admin client types createUser and setEmailVerified": Parameters<Admin['setPassword']>[0] equals { userId: string; newPassword: string } and the result is { user: UserRecord | null }.
  • handlers.test.ts: the store receives a hash that is not the plaintext and that Better Auth's verifyPassword accepts; null from the store comes back as { user: null } without a throw.
  • pg-auth-store.integration.test.ts, a new describe('setPassword'): replaces the hash of an existing credential row and leaves exactly one such row; inserts a credential row for a user without one; deletes all of that user's sessions and none of another user's; returns null and writes nothing for an unknown id; leaves the ban columns unchanged.
  • local-server.integration.test.ts: create a user with password A through admin.createUser, sign in through the real /sign-in/email, call admin.setPassword with password B, then check that the old session no longer resolves through session.getSession, sign-in with A is refused, and sign-in with B succeeds. Also: a user created without a password can sign in with a password after setPassword.

Commands that must pass: pnpm build && pnpm typecheck && pnpm lint && pnpm test (the gate in CONTRIBUTING.md), with the auth integration tests actually running against Postgres rather than skipping.

Documents to update:

  • packages/1-prisma-cloud/2-shared-modules/auth/README.md: add setPassword to the list of admin operations (lines 28 to 35), and a short section "Setting a password" after "Provisioning accounts", with the example at the top of this issue, the session revocation, the difference from Better Auth's setUserPassword, and that it sends no mail.
  • skills/prisma-composer-core-concepts/SKILL.md line 421, the auth row, which lists the admin operations a consumer uses.
  • The auth module spec under .drive/projects/auth-module/, if it is still the source of truth for the contract.

Alternatives considered

These are the three other ways Asks could let an operator reset a password today. Each is worse than a supported admin operation.

  1. Remove the account and create it again. admin.removeUser (0.22.0 and later) followed by admin.createUser with the new password. It uses only supported operations, but the new account has a new user id. The application must then repoint its own rows that name the old id, in a second database, so the change is not atomic: a failure between the two steps leaves a person with no account or with an account nothing points at. Upgrading to a version with removeUser also turns on Better Auth's self-service /delete-user (src/auth-options.ts line 134), which an application proxying all of /api/auth then exposes unless it blocks that path.
  2. Update the hash with SQL. Write a new scrypt hash into auth.account.password directly, as the Asks bootstrap script does for the first account. It works, but the application's API has no credential for the auth database (only the module's own service does), it copies Better Auth's hash format into application code where it silently breaks if Better Auth changes it, and it bypasses the module that owns those tables.
  3. Use Better Auth's admin plugin. The module installs admin() (src/auth-options.ts line 157), so POST /api/auth/admin/set-user-password exists on the public surface. It requires a signed-in session whose user has the admin role, is in adminUserIds, or has a custom role with the user:set-password permission (Better Auth admin plugin documentation; the check is in dist/plugins/admin/routes.mjs lines 819 to 824 of Better Auth 1.6.24). The module has no operation that sets a role or passes admin plugin options, so the role would be written into the user table by hand. With the default admin role, that person's browser session could also ban users, remove users, set roles and impersonate users through the application's proxy. A custom role with only user:set-password narrows that, but it still needs module configuration that does not exist, a hand-written role, and a human browser session for what is a server-to-server operation. It also contradicts the module's own design, in which admin ports are authorized by wiring and not by Better Auth admin sessions.

Out of scope

  • Self-service password reset by email (Better Auth's /request-password-reset and /reset-password). It already exists and works wherever the email module delivers mail.
  • Changing your own password while signed in (Better Auth's /change-password). It already works through authProxy().
  • An admin web UI, role management, and exposing more of Better Auth's admin plugin.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions