meteorjs, oss

Understanding Meteor's Authentication System: An In-Depth Look

Meteor’s accounts packages are one of the framework’s most powerful features, streamlining authentication with minimal hassle. This article explores how Meteor’s authentication works internally, following the complete login flow from client-side initiation to server validation and session management.

Table of Contents

Introduction

Meteor’s accounts system is widely recognized as a key factor in the framework’s success. It provides a clean, straightforward approach to implementing user authentication while maintaining security and flexibility. Let’s dive into how this system works behind the scenes. While Meteor supports multiple authentication mechanisms including OAuth and custom strategies, this article will focus primarily on the standard password-based authentication flow provided by the accounts:password package.

The Authentication Flow

1. Client-Side Login Initiation

When a user attempts to log in, the process begins with one of these methods:

  • `Meteor.loginWithPassword(selector, password, callback)` - For password-based authentication
  • `Meteor.loginWithX()` - For OAuth providers (like Facebook, Google, etc.)
  • `Accounts.createUser(options, callback)` - For new user registration

Let’s examine the normal flow of Meteor.loginWithPassword:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-password/password_client.js#L56
Meteor.loginWithPassword = (selector, password, callback) => { 
  return internalLoginWithPassword({ selector, password, callback }); 
};

2. Password Preparation and Method Call

The internalLoginWithPassword function handles several important tasks:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-password/password_client.js#L10
const internalLoginWithPassword = ({ selector, password, code, callback }) => {
  // Determine whether to use username or email for login
  if (typeof selector === 'string')
    if (!selector.includes('@')) selector = { username: selector };
    else selector = { email: selector };
  
  // Call the login method with prepared arguments
  Accounts.callLoginMethod({
    methodArguments: [
      {
        user: selector,
        password: Accounts._hashPassword(password),
        code,
      },
    ],
    userCallback: (error, result) => {
      if (error) {
        reportError(error, callback);
      } else {
        callback && callback(error, result);
      }
    },
  });
  
  return selector;
};

This function:

  • Determines whether to search for the user by username or email based on whether the selector contains an "@" symbol
  • Hashes the password using SHA-256 for secure transmission
  • Calls the login method with the prepared credentials

The password hashing happens with:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-password/password_client.js#L60
Accounts._hashPassword = password => ({
  digest: SHA256(password),
  algorithm: "sha-256"
});

3. Server-Side Login Handling

When the login request arrives at the server, it’s processed by the login method:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L649
methods.login = async function (options) {
  check(options, Object);
  
  const result = await accounts._runLoginHandlers(this, options);
  
  return await accounts._attemptLogin(this, "login", arguments, result);
};

This method performs two critical operations:

3.1. Running Login Handlers

The _runLoginHandlers method iterates through all registered login handlers until it finds one that can process the request. For password authentication, the password handler is registered like this:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-password/password_server.js#L349
Accounts.registerLoginHandler("password", async options => {
  if (!options.password)
    return undefined; // don't handle

  check(options, {
    user: Accounts._userQueryValidator,
    password: passwordValidator,
    code: Match.Optional(NonEmptyString),
  });

  const user = await Accounts._findUserByQuery(options.user, {
    fields: {
      services: 1,
      ...Accounts._checkPasswordUserFields,
    }
  });
  
  if (!user) {
    Accounts._handleError("User not found");
  }

  if (!getUserPasswordHash(user)) {
    Accounts._handleError("User has no password set");
  }

  const result = await checkPasswordAsync(user, options.password);
  // ...

  return result;
});

This handler:

  • Verifies that the request is for password authentication
  • Validates the request format
  • Looks up the user by username or email
  • Checks if the user exists and has a password set
  • Verifies the password hash against the stored hash

3.2. Attempting Login

If a login handler successfully returns a userId, the _attemptLogin method takes over:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L441
async _attemptLogin(methodInvocation, methodName, methodArgs, result) {
  if (!result)
    throw new Error("result is required");

  if (!result.userId && !result.error)
    throw new Error("A login method must specify a userId or an error");

  let user;
  if (result.userId)
    user = await this.users.findOneAsync(result.userId, {
      fields: this._options.defaultFieldSelector
    });

  const attempt = {
    type: result.type || "unknown",
    allowed: !!(result.userId && !result.error),
    methodName: methodName,
    methodArguments: Array.from(methodArgs)
  };
  
  if (result.error) {
    attempt.error = result.error;
  }
  if (user) {
    attempt.user = user;
  }

  // Run login validation hooks
  await this._validateLogin(methodInvocation.connection, attempt);

  if (attempt.allowed) {
    const o = await this._loginUser(
      methodInvocation,
      result.userId,
      result.stampedLoginToken
    );
    
    const ret = {
      ...o,
      ...result.options
    };
    ret.type = attempt.type;
    
    await this._successfulLogin(methodInvocation.connection, attempt);
    return ret;
  }
  else {
    await this._failedLogin(methodInvocation.connection, attempt);
    throw attempt.error;
  }
}

This method:

  • Creates an "attempt" object with information about the login attempt
  • Runs validation hooks via `_validateLogin`, allowing developers to implement custom logic
  • If allowed, calls `_loginUser` to complete the login process
  • Triggers success or failure hooks accordingly

4. Token Generation and Association

The _loginUser method is responsible for creating and managing login tokens:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L405
async _loginUser(methodInvocation, userId, stampedLoginToken) {
  if (!stampedLoginToken) {
    stampedLoginToken = this._generateStampedLoginToken();
    await this._insertLoginToken(userId, stampedLoginToken);
  }

  // Set the login token on the connection
  Meteor._noYieldsAllowed(() =>
    this._setLoginToken(
      userId,
      methodInvocation.connection,
      this._hashLoginToken(stampedLoginToken.token)
    )
  );

  await methodInvocation.setUserId(userId);

  return {
    id: userId,
    token: stampedLoginToken.token,
    tokenExpires: this._tokenExpiration(stampedLoginToken.when)
  };
}

This method:

  • Generates a new login token if one wasn't provided
  • Associates the token with the user in the database
  • Sets the token on the DDP connection
  • Associates the user ID with the method invocation
  • Returns the token and its expiration to the client

A token is generated using:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L649
_generateStampedLoginToken() {
  return {
    token: Random.secret(),
    when: new Date
  };
}

5. Client-Side Token Storage

Back on the client side, the login token is stored in local storage:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_client.js#L530
_storeLoginToken(userId, token, tokenExpires) {
  this.storageLocation.setItem(this.USER_ID_KEY, userId);
  this.storageLocation.setItem(this.LOGIN_TOKEN_KEY, token);
  if (!tokenExpires)
    tokenExpires = this._tokenExpiration(new Date());
  this.storageLocation.setItem(this.LOGIN_TOKEN_EXPIRES_KEY, tokenExpires);

  // Update the last polled token
  this._lastLoginTokenWhenPolled = token;
}

Meteor supports both localStorage (default) and sessionStorage:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_client.js#L577
initStorageLocation(options) {
  // Determine whether to use local or session storage
  this.storageLocation = (options?.clientStorage === 'session' || 
    Meteor.settings?.public?.packages?.accounts?.clientStorage === 'session') 
    ? window.sessionStorage : Meteor._localStorage;
}

6. DDP Connection Association

To associate a user’s authentication token with their DDP connection:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L115
_setLoginToken(userId, connection, newToken) {
  // Remove any existing token from this connection
  this._removeTokenFromConnection(connection.id);
  
  // Associate the new token with this connection
  this._setAccountData(connection.id, 'loginToken', newToken);

  if (newToken) {
    // Set up observation for this token
    const myObserveNumber = ++this._nextUserObserveNumber;
    this._userObservesForConnections[connection.id] = myObserveNumber;
    
    Meteor.defer(async () => {
      // Monitor this token in the database
      const observe = await this.users.find({
        _id: userId,
        'services.resume.loginTokens.hashedToken': newToken
      }).observeChanges({
        added: () => { foundMatchingUser = true; },
        removed: connection.close, // Close connection if token is removed
      });
      
      this._userObservesForConnections[connection.id] = observe;
    });
  }
}

This function:

  • Maintains a mapping of connection IDs to login tokens
  • Sets up an observer to watch for token removal in the database
  • Closes the connection if the token is removed (e.g., during logout)

7. Automatic Token Resume

When a user returns to a Meteor application, their session is automatically resumed:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_client.js#L577
_initLocalStorage() {
  // Set up storage keys
  this.LOGIN_TOKEN_KEY = "Meteor.loginToken";
  this.LOGIN_TOKEN_EXPIRES_KEY = "Meteor.loginTokenExpires";
  this.USER_ID_KEY = "Meteor.userId";
  
  // Namespace keys if needed
  // ...

  let token;
  if (this._autoLoginEnabled) {
    // Try to log in via local storage
    this._unstoreLoginTokenIfExpiresSoon();
    token = this._storedLoginToken();
    
    if (token) {
      // Optimistically present as logged in during the request
      const userId = this._storedUserId();
      userId && this.connection.setUserId(userId);
      
      this.loginWithToken(token, err => {
        if (err) {
          Meteor._debug(`Error logging in with token: ${err}`);
          this.makeClientLoggedOut();
        }

        this._pageLoadLogin({
          type: "resume",
          allowed: !err,
          error: err,
          methodName: "login",
          methodArguments: [{resume: token}]
        });
      });
    }
  }

  // Set up polling for login changes in other tabs
  this._lastLoginTokenWhenPolled = token;
  
  if (this._pollIntervalTimer) {
    clearInterval(this._pollIntervalTimer);
  }

  this._pollIntervalTimer = setInterval(() => {
    this._pollStoredLoginToken();
  }, 3000);
}

On the server, the resume login handler validates the token:

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_server.js#L1573
registerLoginHandler("resume", function(options) {
  if (!options.resume)
    return undefined;

  const hashedToken = this._hashLoginToken(options.resume);
  
  // Look for a user with this token
  const user = this.users.findOne({
    'services.resume.loginTokens.hashedToken': hashedToken
  });
  
  if (!user)
    throw new Meteor.Error("Invalid resume token");
    
  // Check if token is expired
  const tokenRecord = user.services.resume.loginTokens.find(
    token => token.hashedToken === hashedToken
  );
  
  if (this._tokenExpiration(tokenRecord.when) < new Date())
    throw new Meteor.Error("Expired token");
  
  return {
    userId: user._id,
    stampedLoginToken: {
      token: options.resume,
      when: tokenRecord.when
    }
  };
});

Summary

The Meteor authentication flow can be summarized in these steps:

  1. Client Initiation: User attempts to log in via Meteor.loginWithPassword
  2. Credential Preparation:
    • Determine whether to use username or email for identification
    • Hash the password using SHA-256
  3. Server Processing:
    • Run appropriate login handlers (password, OAuth, etc.)
    • Find and validate the user’s credentials
    • Apply login validation hooks
  4. Token Management:
    • Generate a secure login token
    • Store the hashed token in the user’s database record
    • Associate the token with the DDP connection
  5. Client Storage:
    • Store the token in localStorage or sessionStorage
    • Set up polling to detect logins in other tabs
  6. Automatic Resume:
    • Attempt to resume the session on page load
    • Validate the stored token with the server

This elegant, modular architecture is what makes Meteor’s authentication system so powerful yet easy to use, allowing developers to implement secure authentication with minimal effort while providing hooks for customization at every step.

Meteor Authentication Flow

Design Considerations and Trade-offs

Now that we understand how Meteor’s authentication system works, it’s worth examining some of the design choices and their implications. These aren’t necessarily strengths or weaknesses but trade-offs that need to be considered when using the system.

1. Tight Integration with DDP & MongoDB

Meteor’s authentication is deeply integrated with both DDP (Distributed Data Protocol) and MongoDB. All database calls assume MongoDB’s API, and DDP is directly interwoven throughout the system.

While this tight coupling provides seamless integration for standard Meteor applications, it can make extending the system to support other databases or transport layers challenging. This is an example of Meteor’s “convention over configuration” philosophy, which streamlines the common case but can make customization more difficult.

Alternative Approaches: The accounts-js project was an attempt to create a more flexible authentication system inspired by Meteor’s approach. It offered multiple transport options (GraphQL & REST), database agnosticism, and support for various authentication strategies. The project also provided a meteor-adapter for integration with existing Meteor apps.

2. Two-Stage Password Hashing

Meteor uses a two-stage password hashing approach:

  1. Client-side hashing with SHA-256 before transmission
  2. Server-side hashing with bcrypt/argon2

The client-side hashing was historically intended to protect passwords during transmission, but with TLS now being standard practice (and free with services like Let’s Encrypt), this approach has been debated. There’s an ongoing discussion in the Meteor community about potential vulnerabilities like “password shucking” with this approach.

3. Client-Side Storage Mechanism

Meteor chose localStorage as its primary token storage mechanism, as explained in this Meteor blog post. This approach has some notable trade-offs:

Potential Vulnerabilities:

  • Susceptibility to XSS attacks (attackers can access localStorage via injected JavaScript)
  • Accessibility to all JavaScript in the same origin, including third-party libraries
  • No built-in expiration mechanism
  • Limited to string data, requiring serialization/deserialization for complex structures

Security Best Practices:

  • Always use HTTPS to prevent token interception
  • Implement a strong Content Security Policy (CSP) to mitigate XSS risks: ``` Content-Security-Policy: script-src 'self' https://trusted.cdn.com ```
  • Use shorter token expiration times: ```js Accounts._options.loginExpirationInDays = 7; // Instead of 90 ```
  • Consider using sessionStorage for higher security (which Meteor supports): ```json { "packages": { "accounts": { "clientStorage": 'session' } } } ```

The community has been discussing alternative approaches like HttpOnly cookies or the Web Authentication API (WebAuthn) in various forums and GitHub discussions.

4. Token Lifetime Considerations

Meteor uses relatively long-lived tokens (90 days by default), which improves user experience but increases security risks. Thankfully, this duration is configurable.

// https://github.com/meteor/meteor/blob/b155192b39168c86965e65e4bf92022a855773f8/packages/accounts-base/accounts_common.js#L363
_getTokenLifetimeMs() {
  // When loginExpirationInDays is set to null, we'll use a really high
  // number of days (LOGIN_UNEXPIRABLE_TOKEN_DAYS) to simulate an
  // unexpiring token.
  const loginExpirationInDays =
    this._options.loginExpirationInDays === null
      ? LOGIN_UNEXPIRING_TOKEN_DAYS
      : this._options.loginExpirationInDays;
  return (
    this._options.loginExpiration ||
    (loginExpirationInDays || DEFAULT_LOGIN_EXPIRATION_DAYS) * 86400000
  );
}

5. Simple Tokens vs. JWT

Meteor uses a simple random string token system rather than JSON Web Tokens (JWTs). The tokens are generated with Random.secret():

_generateStampedLoginToken() {
  return {
    token: Random.secret(),
    when: new Date
  };
}

Trade-offs compared to JWT:

  • **Database Dependency**: Every authenticated request requires a database lookup
  • **No Built-in Claims**: JWTs can contain claims (user roles, permissions, etc.) that can be verified without a database lookup
  • **No Standard Format**: JWTs follow a standard format with broad library support across languages
  • **No Built-in Expiration**: Meteor manually tracks expiration rather than using JWT's built-in expiration claims

Conclusion

The accounts package is much more than what I’ve explained in this article, but I can only cram so much into a single piece. There are many more fascinating aspects to explore, including OAuth integration, two-factor authentication, password reset flows, and account merging.

I hope I’ve intrigued you enough to start diving into the accounts package yourself. It’s truly a sophisticated piece of engineering that can teach you a lot about authentication systems, security considerations, and well-designed API surfaces. The thoughtful approaches to user experience, security trade-offs, and flexible architecture make it worth studying even beyond the Meteor ecosystem.

Till next time, happy coding!