npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-auth-etoro

v0.1.0

Published

eToro SSO provider for better-auth — Login with eToro in ~5 lines of config

Readme

better-auth-etoro

npm version License: MIT TypeScript Bundle Size

eToro SSO provider for better-auth — add "Login with eToro" to any TypeScript app in ~5 lines of config.

  • OAuth 2.0 + PKCE (S256) out of the box
  • ID token validated via JWKS (RS256)
  • Zero config beyond clientId and clientSecret
  • TypeScript-native with full type exports
  • Works with Next.js, Hono, Express, SvelteKit, Remix, Astro

Install

npm install better-auth-etoro

Requirements:

  • Node.js >= 18
  • better-auth >= 1.0.0 (peer dependency)

Quick Start

Next.js App Router

// lib/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});
// lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [genericOAuthClient()],
});
// Sign in from any component
import { PROVIDER_ID } from "better-auth-etoro";

authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });

Hono

import { Hono } from "hono";
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});

const app = new Hono();
app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw));

Express

import express from "express";
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { toNodeHandler } from "better-auth/node";
import { etoro } from "better-auth-etoro";

const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});

const app = express();
app.all("/api/auth/*", toNodeHandler(auth));

SvelteKit

// src/lib/server/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
import { ETORO_CLIENT_ID, ETORO_CLIENT_SECRET } from "$env/static/private";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: ETORO_CLIENT_ID,
          clientSecret: ETORO_CLIENT_SECRET,
        }),
      ],
    }),
  ],
});
// src/hooks.server.ts
import { svelteKitHandler } from "better-auth/svelte-kit";
import { auth } from "$lib/server/auth";

export async function handle({ event, resolve }) {
  return svelteKitHandler({ event, resolve, auth });
}
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/svelte";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [genericOAuthClient()],
});

Remix

// app/lib/auth.server.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});
// app/routes/api.auth.$.ts
import { auth } from "~/lib/auth.server";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  return auth.handler(request);
}

export async function action({ request }: ActionFunctionArgs) {
  return auth.handler(request);
}
// app/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [genericOAuthClient()],
});

Astro

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: import.meta.env.ETORO_CLIENT_ID,
          clientSecret: import.meta.env.ETORO_CLIENT_SECRET,
        }),
      ],
    }),
  ],
});
// src/pages/api/auth/[...all].ts
import { auth } from "../../../lib/auth";
import type { APIRoute } from "astro";

export const ALL: APIRoute = async (ctx) => {
  return auth.handler(ctx.request);
};
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [genericOAuthClient()],
});

Migrating from passport-etoro

If you're currently using passport-etoro with Passport.js, here's how to migrate to better-auth.

Before (passport-etoro):

import passport from "passport";
import { Strategy as EToroStrategy } from "passport-etoro";

passport.use(
  new EToroStrategy(
    {
      clientID: process.env.ETORO_CLIENT_ID,
      clientSecret: process.env.ETORO_CLIENT_SECRET,
      callbackURL: "https://myapp.com/auth/etoro/callback",
    },
    async (accessToken, refreshToken, profile, done) => {
      // Manual: verify id_token via JWKS, extract sub, find/create user
      const user = await db.users.upsert({ etoroId: profile.id });
      done(null, user);
    },
  ),
);

app.get("/auth/etoro", passport.authenticate("etoro", { scope: ["openid"] }));
app.get("/auth/etoro/callback", passport.authenticate("etoro"), (req, res) => {
  res.redirect("/dashboard");
});

After (better-auth-etoro):

import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});

What changed:

| passport-etoro | better-auth-etoro | |---|---| | clientID | clientId | | clientSecret | clientSecret | | callbackURL | redirectURI (optional — auto-built from baseURL) | | Manual PKCE setup | Automatic (S256) | | Manual JWKS validation in verify callback | Automatic via getUserInfo | | Manual token refresh handling | Automatic via better-auth | | passport.authenticate() middleware | authClient.signIn.oauth2({ providerId: "etoro" }) | | passport.use() + passport.session() | Single betterAuth() config |

Multi-Provider Setup

Most apps use multiple OAuth providers. Here's eToro alongside Google in a single config:

import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        etoro({
          clientId: process.env.ETORO_CLIENT_ID!,
          clientSecret: process.env.ETORO_CLIENT_SECRET!,
        }),
        {
          providerId: "google",
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration",
          scopes: ["openid", "email", "profile"],
        },
      ],
    }),
  ],
});

On the client, let the user choose a provider:

import { PROVIDER_ID } from "better-auth-etoro";

// Sign in with eToro
authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });

// Sign in with Google
authClient.signIn.oauth2({ providerId: "google", callbackURL: "/dashboard" });

Account linking

better-auth automatically links accounts when a user signs in with a new provider and their email matches an existing account. For example, if a user first signs in with Google ([email protected]) and later signs in with eToro using the same email, both providers are linked to the same user record. No extra configuration is needed — this works out of the box with genericOAuth.

API

PROVIDER_ID

A typed constant ("etoro") for referencing the eToro provider without hardcoded strings:

import { PROVIDER_ID } from "better-auth-etoro";

// Client-side sign-in
authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });

// Conditional logic
if (session.provider === PROVIDER_ID) { /* eToro-specific logic */ }

etoro(options)

Returns a config object compatible with better-auth's genericOAuth plugin.

| Option | Type | Required | Default | |---|---|---|---| | clientId | string | Yes | — | | clientSecret | string | Yes | — | | scopes | string[] | No | ["openid"] | | redirectURI | string | No | Auto-built by better-auth | | clockTolerance | number | No | 120 (seconds) | | onError | (error: unknown) => void | No | — | | endpoints | EToroEndpoints | No | Production URLs |

endpoints

| Field | Type | Default | |---|---|---| | authorization | string? | https://www.etoro.com/sso | | token | string? | https://www.etoro.com/api/sso/v1/token | | jwks | string? | https://www.etoro.com/.well-known/jwks.json | | issuer | string? | https://www.etoro.com |

getUserInfo(tokens, clientId, options?)

Standalone function to extract a normalized user profile from an eToro id_token. Validates the token via JWKS and returns { id, name, email, image, emailVerified } or null if validation fails. Use this when you need eToro user info outside of a better-auth integration.

| Param | Type | Required | Default | |---|---|---|---| | tokens | { idToken?: string } | Yes | — | | clientId | string | Yes | — | | options.clockTolerance | number | No | 120 (seconds) | | options.jwksUrl | string | No | https://www.etoro.com/.well-known/jwks.json | | options.issuer | string | No | https://www.etoro.com | | options.onError | (error: unknown) => void | No | — |

import { getUserInfo } from "better-auth-etoro";

const user = await getUserInfo(
  { idToken: rawIdToken },
  "my-client-id",
);
// user = { id: "12345", name: "Jane Smith", email: "[email protected]", image: null, emailVerified: true }
// or null if validation fails

With custom options (e.g. staging environment):

const user = await getUserInfo(
  { idToken: rawIdToken },
  "my-client-id",
  {
    clockTolerance: 300,
    jwksUrl: "https://staging.etoro.com/.well-known/jwks.json",
    issuer: "https://staging.etoro.com",
    onError: (err) => console.error(err),
  },
);

Returns null (without throwing) when:

  • idToken is missing or not a string
  • Token signature, issuer, or audience validation fails
  • JWKS fetch fails

The etoro() provider delegates to this function internally, so the behavior is identical.

Debugging Silent Auth Failures

By default, getUserInfo returns null when token validation fails — no error is surfaced. Pass an onError callback to get visibility into why authentication failed:

etoro({
  clientId: process.env.ETORO_CLIENT_ID!,
  clientSecret: process.env.ETORO_CLIENT_SECRET!,
  onError: (err) => console.error("[etoro] auth failed:", err),
})

Common errors surfaced via onError:

  • JWKS fetch failure (network issue reaching eToro's endpoint)
  • Expired id_token (clock skew or stale token)
  • Wrong issuer or audience (misconfigured clientId)
  • Malformed JWT

The callback is synchronous and fire-and-forget — if it throws, the error is swallowed and getUserInfo still returns null.

Troubleshooting

Library errors

| Error Message | Cause | Fix | |---|---|---| | clientId is required | Empty or undefined clientId passed to etoro() | Ensure ETORO_CLIENT_ID env var is set and non-empty | | clientSecret is required | Empty or undefined clientSecret passed to etoro() | Ensure ETORO_CLIENT_SECRET env var is set and non-empty | | id_token is missing or empty | validateIdToken() called with an empty string | Ensure the token endpoint returned an id_token — check that openid is in your scopes | | clientId is required for token validation | validateIdToken() called with an empty clientId | Pass the same clientId you used in etoro() | | id_token is missing the 'sub' claim | The JWT payload doesn't contain a sub field | Contact eToro support — the id_token is malformed |

JWT validation errors (from jose)

These errors are thrown by the underlying jose library during token validation. If you're using the etoro() provider, they'll appear in your onError callback rather than being thrown directly.

| Error | Cause | Fix | |---|---|---| | JWSSignatureVerificationFailed | Token signature doesn't match any key in the JWKS | Verify endpoints.jwks points to the correct JWKS URL for your environment | | JWTExpired | Token's exp claim is past now + clockTolerance | Increase clockTolerance (default: 120s), or check server clock sync | | JWTClaimValidationFailed: unexpected "iss" claim value | Token issuer doesn't match expected issuer | If using staging, set endpoints.issuer to match your environment | | JWTClaimValidationFailed: unexpected "aud" claim value | Token audience doesn't match your clientId | Verify clientId matches what's registered with eToro | | ERR_JWKS_NO_MATCHING_KEY | JWKS endpoint returned keys but none match the token's kid | Likely a key rotation in progress — wait and retry, or clear your process cache | | Network errors (e.g. fetch failed) | Cannot reach eToro's JWKS endpoint | Check network connectivity, firewalls, and DNS resolution for www.etoro.com |

getUserInfo returns null

If getUserInfo returns null, one of these scenarios applies:

  1. idToken is missing or not a string — The token endpoint didn't include an id_token. Ensure openid is in your scopes (it's the default).
  2. Token validation failed — Any of the JWT errors above will cause null. Add an onError callback to see the actual error.
  3. JWKS fetch failed — Network issue reaching eToro's JWKS endpoint. Check connectivity.

Quick debug template:

etoro({
  clientId: process.env.ETORO_CLIENT_ID!,
  clientSecret: process.env.ETORO_CLIENT_SECRET!,
  onError: (err) => {
    console.error("[etoro] Token validation failed:");
    console.error("  Error:", err instanceof Error ? err.message : err);
    console.error("  Name:", err instanceof Error ? err.constructor.name : typeof err);
  },
})

Staging / Custom Endpoints

Override the default eToro SSO URLs to target staging or sandbox environments:

etoro({
  clientId: process.env.ETORO_CLIENT_ID!,
  clientSecret: process.env.ETORO_CLIENT_SECRET!,
  endpoints: {
    authorization: "https://staging.etoro.com/sso",
    token: "https://staging.etoro.com/api/sso/v1/token",
    jwks: "https://staging.etoro.com/.well-known/jwks.json",
    issuer: "https://staging.etoro.com",
  },
})

All fields are optional — omitted fields fall back to production values. You can override just one:

etoro({
  clientId: process.env.ETORO_CLIENT_ID!,
  clientSecret: process.env.ETORO_CLIENT_SECRET!,
  endpoints: {
    jwks: "https://staging.etoro.com/.well-known/jwks.json",
  },
})

The issuer field controls the expected iss claim during id_token validation. If your staging environment issues tokens with a different issuer, set this to match.

validateIdToken(idToken, clientId, options?)

Standalone utility to validate an eToro id_token via JWKS. Returns the decoded EToroProfile payload or throws on invalid tokens.

| Option | Type | Required | Default | |---|---|---|---| | idToken | string | Yes | — | | clientId | string | Yes | — | | options.clockTolerance | number | No | 120 (seconds) | | options.jwksUrl | string | No | https://www.etoro.com/.well-known/jwks.json | | options.issuer | string | No | https://www.etoro.com |

import { validateIdToken } from "better-auth-etoro";

const profile = await validateIdToken(idToken, clientId);
// profile.sub → eToro user ID

With custom clock tolerance:

const profile = await validateIdToken(idToken, clientId, {
  clockTolerance: 300, // allow up to 5 minutes of clock skew
});

Testing

The package includes test utilities for writing integration tests without manually crafting JWTs or mocking JWKS endpoints.

# The testing subpath is included in the package — no extra install needed
import { createTestIdToken, createTestJWKS, resetJWKSCache, DEFAULT_TEST_CLIENT_ID } from "better-auth-etoro/testing";

Setup (Vitest example)

import { beforeAll, afterEach, vi } from "vitest";
import { createTestJWKS, resetJWKSCache } from "better-auth-etoro/testing";

beforeAll(async () => {
  const jwks = await createTestJWKS();
  vi.stubGlobal(
    "fetch",
    vi.fn(() =>
      Promise.resolve(new Response(JSON.stringify(jwks), {
        headers: { "Content-Type": "application/json" },
      })),
    ),
  );
});

afterEach(() => {
  resetJWKSCache(); // Ensures clean JWKS state between tests
});

Creating test tokens

import { createTestIdToken, DEFAULT_TEST_CLIENT_ID } from "better-auth-etoro/testing";
import { validateIdToken } from "better-auth-etoro";

// Minimal token (sub defaults to "etoro-test-user")
const token = await createTestIdToken();

// With custom claims
const token = await createTestIdToken({
  sub: "user-42",
  email: "[email protected]",
  given_name: "Test",
  family_name: "User",
  email_verified: true,
});

// Validate the token in your tests
const profile = await validateIdToken(token, DEFAULT_TEST_CLIENT_ID);
// profile.sub → "user-42"

Test utility API

| Export | Type | Description | |---|---|---| | createTestIdToken(claims?, options?) | (claims?, options?) => Promise<string> | Create an RS256-signed JWT matching eToro's id_token format | | createTestJWKS() | () => Promise<{ keys: Array<...> }> | Generate a JWKS containing the test signing key | | resetJWKSCache() | () => void | Clear the internal JWKS cache — call in afterEach for clean test isolation | | DEFAULT_TEST_CLIENT_ID | string | Default aud claim value for test tokens | | DEFAULT_TEST_ISSUER | string | Default iss claim value (https://www.etoro.com) |

createTestIdToken options:

| Option | Type | Default | |---|---|---| | audience | string? | DEFAULT_TEST_CLIENT_ID | | issuer | string? | https://www.etoro.com | | expiresIn | string? | "1h" |

Types

import type {
  EToroEndpoints,
  EToroOptions,
  EToroProfile,
  EToroProviderConfig,
  EToroUserInfo,
  GetUserInfoOptions,
} from "better-auth-etoro";

EToroUserInfo

Normalized user profile returned by getUserInfo.

| Field | Type | Description | |---|---|---| | id | string | eToro user ID (from sub claim) | | name | string \| null | Full name (constructed from given_name + family_name) | | email | string \| null | Email (if available in id_token) | | image | null | Always null (eToro doesn't provide avatars via SSO) | | emailVerified | boolean | true only if email_verified is explicitly true in the token |

EToroProfile

| Field | Type | Description | |---|---|---| | sub | string | eToro user ID | | iss | string | Issuer (https://www.etoro.com) | | aud | string | Audience (your clientId) | | iat | number | Issued-at timestamp | | exp | number | Expiry timestamp | | given_name | string? | First name (if available) | | family_name | string? | Last name (if available) | | email | string? | Email (if available) | | email_verified | boolean? | Whether the email is verified (if available) |

Token Lifetimes

| Token | Lifetime | |---|---| | Access token | ~10 minutes | | Refresh token | ~30 days | | ID token | ~10 minutes |

better-auth handles token refresh automatically via the genericOAuth plugin.

Security

  • ID tokens are always validated via eToro's JWKS endpoint (RS256)
  • Issuer (iss) and audience (aud) claims are verified on every token
  • PKCE (S256) is enforced — no authorization code interception
  • JWKS keys are fetched and cached by jose with automatic rotation handling

Checklist

  • [ ] Store ETORO_CLIENT_ID and ETORO_CLIENT_SECRET in environment variables, not in code
  • [ ] Use HTTPS in production (redirectURI must be HTTPS)
  • [ ] Enable CSRF protection in your better-auth config
  • [ ] Set secure: true on session cookies in production

eToro SSO Endpoints

| Endpoint | URL | |---|---| | Authorization | https://www.etoro.com/sso | | Token | https://www.etoro.com/api/sso/v1/token | | JWKS | https://www.etoro.com/.well-known/jwks.json | | Discovery | https://www.etoro.com/.well-known/openid-configuration |

License

MIT