+1 (478) 654-9062

Meteor Accounts: Carrying Login Through an Upgrade or a Migration

Every upgrade plan we write runs into the same wall at about week three. The async conversion is mechanical, the packages have replacements, the database upgrade is boring in the good way — and then someone asks how login works, and nobody on the current team knows. The original engineer added accounts-password and accounts-google in 2016, it worked, and it has not been opened since.

That is not a failure of care. Meteor Accounts is one of the genuinely good parts of the platform: it gave you password hashing, resume sessions, OAuth, and reactive Meteor.userId() on both client and server for the price of two meteor add lines. The cost of that convenience shows up later, when you want to move the app somewhere else and discover that auth is the one subsystem that touches everything.

This is what is actually inside it, and what to do about it in each of the two situations that force the question.

What is actually stored

Open a shell against production and look at one document in Meteor.users. The interesting fields:

  • services.password.bcrypt — the password hash. It is standard bcrypt, in the standard modular crypt format ($2a$10$... or $2b$...). There is no Meteor-specific wrapper around it. That single fact is the reason most auth migrations are far less painful than teams fear.
  • services.resume.loginTokens — an array of { hashedToken, when }. These are the long-lived resume tokens that keep a user logged in across page reloads. The client holds the unhashed token in localStorage under Meteor.loginToken; the server stores only the SHA-256 of it.
  • services.google, services.facebook, etc. — OAuth provider data, including the provider's user id and, depending on the package and flow, access and refresh tokens.
  • emails — an array of { address, verified }. Note the array. Meteor supports multiple addresses per user and code written against user.emails[0].address quietly assumes it does not.
  • services.email.verificationTokens and services.password.reset — transient tokens for verification and reset flows.

One detail that surprises people: the client-side password is not sent in plaintext at the protocol level. accounts-password SHA-256s the password in the browser, sends the digest, and the server bcrypts that digest. So the bcrypt hash in your database is the hash of a SHA-256 digest, not of the raw password. Any other system that wants to verify the same password must reproduce that two-step. It is twelve lines of code, but you have to know to write them.

The other half: DDP login is not an HTTP session

The stored data is only half of it. The runtime model is the part that does not port.

A conventional Node app authenticates per HTTP request: a cookie arrives, middleware validates it, the request is handled. Meteor authenticates a connection. The browser opens a DDP websocket, calls the login method once with a password or a resume token, and the server marks that connection as belonging to a user for its lifetime. Everything afterward — every method call, every subscription — inherits that identity from the connection, which is why this.userId works inside a method without any token being passed.

Three consequences worth naming:

  1. There is no cookie. Nothing in a Meteor app's default auth flow sets one. A reverse proxy, a CDN rule, or a second application cannot see who the user is by looking at request headers, because the identity lives in the socket.
  2. Logout is server-side and immediate. Removing the hashed token from loginTokens invalidates the session everywhere it is used. There is no stateless JWT expiry to wait out — which is a real security advantage, and one you lose by accident if you replace resume tokens with unrevocable tokens during a migration.
  3. Login state is reactive. Meteor.userId() is a reactive data source, and a great deal of Blaze and React code in an older app depends on that: template helpers, route guards, subscription arguments. Any replacement has to provide a change notification, not just a value.

Case one: carrying Accounts through a Meteor 3 upgrade

The good news first. The accounts-* packages are core, maintained, and part of the Meteor 3 line; the data in Meteor.users does not change shape, and nobody has to reset a password. Auth is not the hard part of a Meteor 3 upgrade.

The work that does show up is the same async work as everywhere else, concentrated in the hooks you wrote years ago:

  • Accounts.onCreateUser, Accounts.validateLoginAttempt, Accounts.validateNewUser, and Accounts.onLogin callbacks that query collections now need await and the *Async collection API. These functions are short and rarely tested, which is exactly the combination that produces a missed await.
  • Accounts.findUserByEmail, Accounts.createUser, and Accounts.setPassword have async forms. Server code calling the old sync signatures is a straightforward rename, but seeding scripts and admin tooling are easy to miss because they do not run in CI.
  • Custom login handlers registered with Accounts.registerLoginHandler — SSO shims, API-key logins, impersonation tools — are the highest-risk item in the set. They are bespoke, they often predate everyone, and a mistake is an authentication bug rather than a broken page. Write tests for these before you convert them, not after.
  • Anything reading Meteor.user() on the server inside a publication or method: use the async form and check what fields you are actually pulling. Publications that fetch the whole user document are a small performance problem and a larger privacy one.

If you use a third-party accounts package — a community 2FA add-on, an old SAML or LDAP bridge, a UI package like useraccounts — check its Meteor 3 status early. Auth packages tend to be both abandoned and load-bearing, which is the worst quadrant of the triage grid. Finding out in week two is a plan; finding out in week nine is a schedule slip.

Case two: letting a second app read the same logins

This is the harder case, and it is the one that stalls strangler migrations. You are moving route by route to a Next.js app in front of the same MongoDB, and the new route needs to know who the user is. Nobody can be asked to log in twice, and nobody can be asked to reset a password.

The approach we use, in order of how load-bearing each decision is:

Keep Meteor as the identity authority for as long as it is convenient. The new app does not need to own login on day one. It needs to answer "who is this request from." Those are different problems, and conflating them turns a two-week task into a quarter.

Bridge the identity into something HTTP-shaped. The practical pattern: after a successful Meteor login, the client also posts its resume token to a small endpoint the Meteor server exposes; that endpoint verifies the token by hashing it and looking it up in services.resume.loginTokens, then sets an HttpOnly, Secure, SameSite=Lax cookie scoped to the parent domain. Now both applications, served behind one proxy, can identify the user from the same request. The Next.js side verifies the cookie against the same collection — it is a MongoDB read, not an API call to Meteor, so the new app has no runtime dependency on the old one. Keep the cookie's lifetime tied to the token's: when the token is removed, the cookie stops validating.

Verify passwords directly when the new app takes over login. Because the hash is plain bcrypt, the new app can authenticate against the existing Meteor.users documents without a password reset. Reproduce Meteor's two-step: SHA-256 the submitted password, hex-encode it, then bcrypt.compare against services.password.bcrypt. Existing users log in with their existing passwords. New users, if you want, get hashes in whatever format your new stack prefers — a per-document check of which format is present handles the mixed period, and the mixed period can last years without harm.

Move OAuth deliberately. OAuth is the one place where a user-visible seam can appear. The provider identifies users by their own id, stored in services.google.id and friends; your new auth library stores the same id in a different place. Map them explicitly during the migration and test with a real account per provider. Getting this wrong creates duplicate accounts rather than failed logins, which is worse, because it is silent.

Decide on session semantics on purpose. If you replace revocable resume tokens with stateless JWTs, you have traded immediate logout for statelessness. That can be the right trade. It should be a decision someone made out loud, with the support team in the room, not a side effect of a library's default.

What we would tell you not to do

Do not start a migration with the auth system. It is the subsystem with the highest blast radius and the least visible payoff, and doing it first means every subsequent route lands on top of freshly moved ground. Move a boring read-only route first, get the proxy and the deployment pipeline honest, and bring auth across once the seam has carried real traffic for a few weeks.

And do not plan a password reset for all users unless something is genuinely wrong with the stored hashes. It is a large, visible cost to the business — support load, a measurable share of users who simply never come back — paid to avoid perhaps two days of engineering. We have not yet seen a Meteor app where it was the right call. If yours turns out to be one, that is a finding with evidence behind it, not a default.

The general shape holds either way: the data in Meteor.users is ordinary and portable, and the runtime model is the part that needs design. Teams get this backwards, budget for the data, and get surprised by the connection semantics. Look at the socket first.