Summary
When ALLOW_REGISTRATION=false, every OAuth sign-in attempt is rejected with Registrations are not allowed — including for users who already have a fully populated User + Account record.
Self-hosted installs that want to lock down sign-ups end up locking out their entire existing user
base the moment those users' sessions expire. There is no documented escape hatch.
This is not just "new sign-ups are blocked" — it is a hard lockout for the whole install.
Why this is severe
The current population of OAuth users on any locked-down self-hosted instance is on borrowed time:
- Can't sign back in via OAuth —
signInOAuth rejects them before the IdP redirect (see Root
cause below).
- Can't sign in via email/password — they have no
Account row with provider='email';
signInEmail calls getUserAccount(email, provider: 'email')
(packages/db/src/services/user.service.ts) and returns "User does not exist".
- Can't recover via password reset —
requestResetPassword (packages/trpc/src/routers/auth.ts,
~L526) also looks up provider='email' only. For OAuth-only users it silently returns true
without sending an email (anti-enumeration). The user just sees "if your email exists, we sent a
link" — but no link ever arrives.
Result: the only thing keeping any existing OAuth user signed in is their session cookie. The moment
it expires (or they log out), they are permanently locked out without manual DB intervention by an
operator.
Repro
- Self-host with
ALLOW_REGISTRATION=false, ALLOW_INVITATION=true.
- Have at least one user who signed up via OAuth (GitHub or Google).
- Sign that user out.
- Click "Sign in with Google" / "Sign in with GitHub".
- tRPC immediately returns:
{
"message": "Registrations are not allowed",
"code": "UNAUTHORIZED",
"path": "auth.signInOAuth"
}
No redirect to the IdP happens.
This also reproduces for invited users post-first-login: they accept the invite once, get an account,
sign out, and can never come back — the invite link is consumed/one-time, so on return their
signInOAuth call has no inviteId and is blocked too.
Root cause
packages/trpc/src/routers/auth.ts (signInOAuth, ~L95) runs getIsRegistrationAllowed() upfront, but at
this point the server has no identity for the caller — the user hasn't hit the IdP yet, so we can't
distinguish a returning user from a new sign-up. The gate is binary and rejects everyone equally.
signInOAuth: publicProcedure
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
.mutation(async ({ input, ctx }) => {
const isRegistrationAllowed = await getIsRegistrationAllowed(input.inviteId);
if (!isRegistrationAllowed) {
throw TRPCAccessError('Registrations are not allowed'); // <-- blocks existing users too
}
...
The check must happen after we've fetched the OAuth profile and looked up whether a user already
exists.
Proposed fix
Defer the registration check to the OAuth callback, where we already branch on handleExistingUser vs
handleNewUser.
- In packages/trpc/src/routers/auth.ts, remove the getIsRegistrationAllowed call from signInOAuth.
(Keep it in signUpEmail — that's an explicit sign-up.)
- In apps/api/src/controllers/oauth-callback.controller.tsx, inside handleNewUser (just before
db.user.create), gate creation on the same policy:
if (!(await isNewUserRegistrationAllowed(inviteId))) {
throw new LogError(
'Registrations are not allowed. Ask an admin for an invite.',
{ oauthUser, providerName, inviteId },
);
}
- isNewUserRegistrationAllowed is the existing getIsRegistrationAllowed logic (env-driven,
invite-aware) — either export it from @openpanel/db (preferred, since it does db.user.count() /
db.invite.findUnique) or inline a copy in the controller.
This keeps existing semantics for the ALLOW_REGISTRATION / ALLOW_INVITATION env vars but moves
enforcement to the only point where we actually know whether a user is new.
Acceptance criteria
With ALLOW_REGISTRATION=false, ALLOW_INVITATION=true:
- Existing OAuth user clicks "Sign in with Google/GitHub" → completes OAuth → signed back in (no
error).
- Invited user signs up via OAuth once, signs out, and can sign back in afterwards without holding
the invite link.
- New user with valid invite cookie → user created, attached to inviting org.
- New user with no invite → redirected to /login?error=Registrations+are+not+allowed.... No User row
created.
- First-time bootstrap (zero users in DB) still works: the first OAuth sign-in creates the seed user.
With ALLOW_REGISTRATION=true: behavior unchanged (anyone can sign up).
Workarounds (for operators stuck on this today)
None of these are good. Listing for completeness:
- Manually INSERT Account rows with provider='email' + a hashed password for every existing user via
direct DB access, then issue password resets. Painful and per-user.
- Temporarily flip ALLOW_REGISTRATION=true to let existing users back in — but this also lets any
internet stranger sign up while the flag is on.
- Keep extending session cookie TTL indefinitely (just delays the inevitable).
Notes
- Email sign-up (signUpEmail) is unaffected — the upfront check there is correct because the request
itself declares "I'm creating an account".
- Same fix applies to GitHub OAuth and Google OAuth — both share signInOAuth and the callback
dispatcher.
- Adjacent UX nit (separate issue worth filing): the OAuth buttons on _login.login.tsx are
unconditionally rendered. Self-hosters who want to disable an OAuth provider have to unset the env
vars, which leaves the buttons visible and they fail at the IdP with invalid_client_id instead of
being hidden.
Summary
When
ALLOW_REGISTRATION=false, every OAuth sign-in attempt is rejected withRegistrations are not allowed— including for users who already have a fully populatedUser+Accountrecord.Self-hosted installs that want to lock down sign-ups end up locking out their entire existing user
base the moment those users' sessions expire. There is no documented escape hatch.
This is not just "new sign-ups are blocked" — it is a hard lockout for the whole install.
Why this is severe
The current population of OAuth users on any locked-down self-hosted instance is on borrowed time:
signInOAuthrejects them before the IdP redirect (see Rootcause below).
Accountrow withprovider='email';signInEmailcallsgetUserAccount(email, provider: 'email')(
packages/db/src/services/user.service.ts) and returns "User does not exist".requestResetPassword(packages/trpc/src/routers/auth.ts,~L526) also looks up
provider='email'only. For OAuth-only users it silently returnstruewithout sending an email (anti-enumeration). The user just sees "if your email exists, we sent a
link" — but no link ever arrives.
Result: the only thing keeping any existing OAuth user signed in is their session cookie. The moment
it expires (or they log out), they are permanently locked out without manual DB intervention by an
operator.
Repro
ALLOW_REGISTRATION=false,ALLOW_INVITATION=true.{ "message": "Registrations are not allowed", "code": "UNAUTHORIZED", "path": "auth.signInOAuth" } No redirect to the IdP happens.This also reproduces for invited users post-first-login: they accept the invite once, get an account,
sign out, and can never come back — the invite link is consumed/one-time, so on return their
signInOAuth call has no inviteId and is blocked too.
Root cause
packages/trpc/src/routers/auth.ts (signInOAuth, ~L95) runs getIsRegistrationAllowed() upfront, but at
this point the server has no identity for the caller — the user hasn't hit the IdP yet, so we can't
distinguish a returning user from a new sign-up. The gate is binary and rejects everyone equally.
signInOAuth: publicProcedure
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
.mutation(async ({ input, ctx }) => {
const isRegistrationAllowed = await getIsRegistrationAllowed(input.inviteId);
if (!isRegistrationAllowed) {
throw TRPCAccessError('Registrations are not allowed'); // <-- blocks existing users too
}
...
The check must happen after we've fetched the OAuth profile and looked up whether a user already
exists.
Proposed fix
Defer the registration check to the OAuth callback, where we already branch on handleExistingUser vs
handleNewUser.
(Keep it in signUpEmail — that's an explicit sign-up.)
db.user.create), gate creation on the same policy:
if (!(await isNewUserRegistrationAllowed(inviteId))) {
throw new LogError(
'Registrations are not allowed. Ask an admin for an invite.',
{ oauthUser, providerName, inviteId },
);
}
invite-aware) — either export it from @openpanel/db (preferred, since it does db.user.count() /
db.invite.findUnique) or inline a copy in the controller.
This keeps existing semantics for the ALLOW_REGISTRATION / ALLOW_INVITATION env vars but moves
enforcement to the only point where we actually know whether a user is new.
Acceptance criteria
With ALLOW_REGISTRATION=false, ALLOW_INVITATION=true:
error).
the invite link.
created.
With ALLOW_REGISTRATION=true: behavior unchanged (anyone can sign up).
Workarounds (for operators stuck on this today)
None of these are good. Listing for completeness:
direct DB access, then issue password resets. Painful and per-user.
internet stranger sign up while the flag is on.
Notes
itself declares "I'm creating an account".
dispatcher.
unconditionally rendered. Self-hosters who want to disable an OAuth provider have to unset the env
vars, which leaves the buttons visible and they fail at the IdP with invalid_client_id instead of
being hidden.