Enabling advanced IAM-based MongoDB Atlas access from a Meteor 3 app

Meteor ships its own wrapper around the official mongodb npm driver in the npm-mongo core package. That works fine for username/password connection strings, but the moment you try to use MongoDB Atlas IAM auth with anything beyond the bare minimum, you run into two separate problems:

  1. Meteor’s npm-mongo package doesn’t depend on @aws-sdk/credential-providers, and you can’t just npm install it into your app — the driver uses require() from inside the npm-mongo package’s node_modules.
  2. The MongoDB Node.js driver 6.x has its own surprising behavior around AWS credentials that intersects badly with how Meteor constructs the client.

This post walks through the workaround we use at Refapp (Meteor 3.3.2, mongodb@6.16.0) to get a properly refreshing STS AssumeRole-based connection to Atlas.

Why you can’t just meteor npm install @aws-sdk/credential-providers

The MongoDB driver resolves AWS support libraries via require("@aws-sdk/credential-providers") from its own installation path. In a Meteor app, mongodb lives inside the npm-mongo Meteor package’s isolated node_modules, not in your app’s node_modules. Node’s module resolution walks up from the requiring file, so installing the package in your app root doesn’t make it visible to mongodb at all.

The only way to make a peer dependency available to the driver is to declare it in the Meteor package’s Npm.depends() — which means you have to repackage npm-mongo into your app.

Vendoring npm-mongo into the app

We keep a local copy of the core npm-mongo package under packages/npm-mongo/ and pin it to the exact version the installed Meteor release ships. A shell script (packages/sync-npm-mongo.sh) downloads the stock source from the Meteor repo and then patches Npm.depends() to add aws4 and @aws-sdk/credential-providers.

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
TARGET_DIR="${SCRIPT_DIR}/npm-mongo"

# Read Meteor release tag (e.g. "METEOR@3.3.2" -> "release/METEOR@3.3.2")
RELEASE=$(cat "${APP_DIR}/.meteor/release" | tr -d '[:space:]')
TAG="release/${RELEASE}"

if [ -d "$TARGET_DIR" ]; then
  rm -rf "$TARGET_DIR"
fi

# Uses `yarn degit` to download a single subtree at a tag
yarn degit "meteor/meteor/packages/npm-mongo#${TAG}" "$TARGET_DIR" --force

AWS4_VERSION="1.13.2"
AWS_CREDENTIAL_PROVIDERS_VERSION="3.1009.0"

cd "$TARGET_DIR"

# Append aws4 and @aws-sdk/credential-providers to Npm.depends()
if ! grep -q "aws4" package.js; then
  sed -i '' "/mongodb:/s/$/,/" package.js
  sed -i '' "/mongodb:.*,$/a\\
\\  aws4: \"${AWS4_VERSION}\"
" package.js
fi

if ! grep -q "credential-providers" package.js; then
  sed -i '' "/aws4:/s/$/,/" package.js
  sed -i '' "/aws4:.*,$/a\\
\\  \"@aws-sdk/credential-providers\": \"${AWS_CREDENTIAL_PROVIDERS_VERSION}\"
" package.js
fi

After running it, packages/npm-mongo/package.js looks like this:

Package.describe({
  summary: "Wrapper around the mongo npm package",
  version: "6.16.1",
  documentation: null,
});

Npm.depends({
  mongodb: "6.16.0",
  aws4: "1.13.2",
  "@aws-sdk/credential-providers": "3.1009.0"
});

Package.onUse(function (api) {
  api.addFiles("wrapper.js", "server");
  api.export(["NpmModuleMongodb", "NpmModuleMongodbVersion"], "server");
  api.addAssets("index.d.ts", "server");
});

Since the vendored package is a local package inside our packages/ folder, Meteor prefers it over the core one automatically. To refresh against a newer Meteor release, just rerun the script — it reads .meteor/release and checks out the matching tag.

The driver’s built-in AWS shim: useful, but limited

Even without @aws-sdk/credential-providers present, the mongodb driver has its own minimal AWS credential loader. It handles two cases out of the box:

  • Static credentials from the env vars AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (plus optional AWS_SESSION_TOKEN).
  • The ECS container credentials endpoint — i.e., an HTTP GET against 169.254.170.2 (or whatever AWS_CONTAINER_CREDENTIALS_RELATIVE_URI points at).

If you are running on ECS with a task role and don’t need anything fancier, that’s often enough. But as soon as you need:

  • EC2 IMDSv2 instance profile credentials,
  • Web Identity / IRSA (AssumeRoleWithWebIdentity) on EKS,
  • SSO / SSO-OIDC local dev credentials,
  • Credential Process entries from ~/.aws/config,
  • chained ~/.aws/config profiles with role_arn / source_profile, or
  • explicit sts:AssumeRole into a different role than the ambient one,

you need the real @aws-sdk/credential-providers package — which is exactly what the driver tries to require() lazily when you hand it a custom provider via authMechanismProperties.AWS_CREDENTIAL_PROVIDER.

Assuming a different role and refreshing the credentials

Our stats database lives behind a role our task role is allowed to assume. We don’t want to bake the assumed role into the connection string — we want to call sts:AssumeRole at connect time and refresh the temporary credentials before they expire.

The provider is a function returning a promise of the credentials object the driver expects. We cache the result and re-fetch when it’s within 5 minutes of expiry, and we deduplicate concurrent calls so a flurry of reconnects doesn’t trigger multiple STS calls.

import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { Meteor } from "meteor/meteor";
import ConnectionString from "mongodb-connection-string-url";

type AwsCredentials = Readonly<{
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string | undefined;
  expiration: Date | undefined;
}>;

const REFRESH_BEFORE_EXPIRY_MS = 5 * 60 * 1000;

export const createAssumeRoleCredentialProvider = (
  roleArn: string
): (() => Promise<AwsCredentials>) => {
  const sts = new STSClient({});
  let cached: AwsCredentials | undefined;
  let inflight: Promise<AwsCredentials> | undefined;

  const isExpiringSoon = (creds: AwsCredentials): boolean => {
    if (!creds.expiration) {
      return true;
    }
    return creds.expiration.getTime() - Date.now() < REFRESH_BEFORE_EXPIRY_MS;
  };

  const fetchCredentials = async (): Promise<AwsCredentials> => {
    try {
      const { Credentials } = await sts.send(
        new AssumeRoleCommand({
          RoleArn: roleArn,
          RoleSessionName: `refapp-stats-${Date.now()}`,
          ...(Meteor.isDevelopment && { DurationSeconds: 900 }),
        })
      );

      if (!Credentials?.AccessKeyId || !Credentials.SecretAccessKey) {
        throw new Error("STS AssumeRole returned empty credentials");
      }

      cached = {
        accessKeyId: Credentials.AccessKeyId,
        secretAccessKey: Credentials.SecretAccessKey,
        sessionToken: Credentials.SessionToken,
        expiration: Credentials.Expiration,
      };
      return cached;
    } finally {
      inflight = undefined;
    }
  };

  return async (): Promise<AwsCredentials> => {
    if (cached && !isExpiringSoon(cached)) {
      return cached;
    }
    if (!inflight) {
      inflight = fetchCredentials();
    }
    return inflight;
  };
};

We encode the target role in the connection string as a custom awsRoleArn= query param and strip it before handing the URL to the driver (it rejects unknown options):

const validateAwsAuthConnectionString = (connectionString: string) => {
  const cs = new ConnectionString(connectionString);

  if (cs.searchParams.get("authMechanism") !== "MONGODB-AWS") {
    return { status: "not-aws" as const };
  }
  if (cs.searchParams.get("authSource") !== "$external") {
    return { status: "invalid" as const };
  }

  const roleArn = cs.searchParams.get("awsRoleArn") || undefined;
  cs.searchParams.delete("awsRoleArn");
  cs.username = "";
  cs.password = "";

  return {
    status: "valid" as const,
    roleArn,
    cleanConnectionString: cs.toString(),
  };
};

So MONGO_STATS_URL looks something like:

mongodb+srv://cluster.xxxx.mongodb.net/stats?authMechanism=MONGODB-AWS&authSource=%24external&awsRoleArn=arn:aws:iam::123456789012:role/refapp-stats-reader

Wiring the provider into a Meteor RemoteCollectionDriver

This is where it gets ugly — and where two independent bugs/limitations bite you.

Obstacle 1: Meteor’s RemoteCollectionDriver won’t let you pass MongoClientOptions

MongoInternals.RemoteCollectionDriver only accepts (url, { oplogUrl }) and constructs its own MongoClient internally. There’s no public way to pass arbitrary driver options. The only injection point is an internal Mongo._connectionOptions object which MongoConnection spreads into the MongoClient constructor.

Obstacle 2: mongodb@6.x overwrites your custom provider with env vars

In mongodb@6.x, even when you set authMechanismProperties.AWS_CREDENTIAL_PROVIDER to a function, the MongoCredentials constructor still copies AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY out of process.env into the credential’s username / password fields. The auth flow then sees a non-empty username, skips the provider entirely, and uses static env-var creds. This is fixed in the 7.0 line (see node-mongodb-native#4656, NODE-7047) — but 7.0 isn’t on the menu for Meteor 3.x yet.

The fix is to temporarily remove those env vars while new MongoClient() runs. The whole RemoteCollectionDriver → MongoConnection → new MongoClient() chain is synchronous, so nothing else can observe the missing env vars between set and restore.

import { Mongo, MongoInternals } from "meteor/mongo";

const awsAuth = validateAwsAuthConnectionString(connectionString);
if (awsAuth.status !== "valid" || !awsAuth.roleArn) {
  return new MongoInternals.RemoteCollectionDriver(connectionString);
}

const credentialProvider =
  createAssumeRoleCredentialProvider(awsAuth.roleArn);

const prevOptions = Mongo._connectionOptions;
const prevAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
const prevSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;

Mongo._connectionOptions = {
  ...prevOptions,
  appName: "refapp-stats",
  authMechanismProperties: {
    AWS_CREDENTIAL_PROVIDER: credentialProvider,
  },
};
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;

const driver = new MongoInternals.RemoteCollectionDriver(
  awsAuth.cleanConnectionString
);

Mongo._connectionOptions = prevOptions;
process.env.AWS_ACCESS_KEY_ID = prevAccessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = prevSecretAccessKey;

The type extension we use for the meteor/mongo internals looks like this:

import type { Db, MongoClient, MongoClientOptions } from "mongodb";

declare module "meteor/mongo" {
  namespace MongoInternals {
    class MongoConnection {
      client: MongoClient;
      db: Db;
    }
    class RemoteCollectionDriver {
      constructor(
        mongoUrl: string,
        options?: { oplogUrl?: string } & Record<string, unknown>
      );
      mongo: MongoConnection;
      open(name: string): Record<string, (...args: unknown[]) => unknown>;
    }
  }
  namespace Mongo {
    // Spread into MongoClient options by MongoConnection constructor.
    let _connectionOptions: MongoClientOptions | undefined;
  }
}

Summary

If you want to use MongoDB Atlas IAM auth from Meteor beyond the simplest “just set env vars” case, three things need to be true:

  1. Vendor npm-mongo into your app (or maintain a fork) so you can add @aws-sdk/credential-providers to Npm.depends(). Installing it at the app level doesn’t work — the driver resolves from inside its own package. A small script that degits the source at the right release tag and patches package.js keeps this painless.
  2. Assume the role yourself using @aws-sdk/client-sts and expose an async function as authMechanismProperties.AWS_CREDENTIAL_PROVIDER, caching and proactively refreshing the temporary credentials. The driver’s built-in AWS shim is fine for static env-var creds and the ECS relative-URI endpoint but doesn’t cover IRSA, SSO, credential_process, chained profiles, or cross-account AssumeRole.
  3. Work around two Meteor/driver quirks when constructing the client: inject options via Mongo._connectionOptions, and clear AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY during the synchronous window in which new MongoClient() runs, so mongodb@6.x doesn’t clobber your provider with static credentials.

Hopefully this saves someone a few afternoons of squinting at MongoAuthenticationError logs.