diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx
index abeed84217..bec6db6265 100644
--- a/docs/content/docs/concepts/plugins.mdx
+++ b/docs/content/docs/concepts/plugins.mdx
@@ -592,3 +592,50 @@ If you need to use better fetch plugins, you can pass them to the `fetchPlugins`
This is only useful if you want to provide `hooks` like `useSession` and you want to listen to atoms and re-evaluate them when they change.
You can see how this is used in the built-in plugins.
+
+## Plugin Extension Protocol
+
+Some plugins act as **platforms** — they provide shared infrastructure that other plugins build on. The `oauth-provider` plugin is the primary example.
+
+The **Plugin Extension Protocol** enables typed, declarative composition between plugins via two fields on `BetterAuthPlugin` (`extensions`, `dependencies`) and one `AuthContext` method (`getExtensions`):
+
+### `extensions`
+
+Contribute capabilities to a host plugin. Each key is a host plugin ID registered in `BetterAuthExtensionRegistry`, and the value must satisfy the host's extension contract:
+
+```ts
+{
+ id: "my-plugin",
+ extensions: {
+ "oauth-provider": {
+ grantTypes: { "urn:custom:grant": myHandler },
+ } satisfies OAuthProviderExtension,
+ },
+}
+```
+
+TypeScript provides autocomplete for registered hosts and type-checking for the contract.
+
+### `dependencies`
+
+Declare which plugins your plugin requires. Validated at startup before any `init` runs:
+
+```ts
+{
+ id: "my-plugin",
+ dependencies: ["oauth-provider"],
+}
+```
+
+### `getExtensions`
+
+Host plugins call `ctx.getExtensions(hostId)` in their `init` to collect all typed contributions from guest plugins:
+
+```ts
+init(ctx) {
+ const extensions = ctx.getExtensions("oauth-provider");
+ // extensions is OAuthProviderExtension[]
+}
+```
+
+See the Plugins documentation for more details on creating plugins.
diff --git a/packages/better-auth/src/context/create-context.test.ts b/packages/better-auth/src/context/create-context.test.ts
index b88646aecc..d13f49bf0c 100644
--- a/packages/better-auth/src/context/create-context.test.ts
+++ b/packages/better-auth/src/context/create-context.test.ts
@@ -1908,4 +1908,90 @@ describe("base context creation", () => {
expect(ctx.hasPlugin("plugin-4")).toBe(false);
});
});
+
+ describe("getExtensions", () => {
+ it("should return empty array when no plugins have extensions", async () => {
+ const ctx = await initBase({
+ plugins: [{ id: "plain-plugin" }],
+ });
+ const result = ctx.getExtensions("nonexistent" as never);
+ expect(result).toEqual([]);
+ });
+
+ it("should collect extensions from multiple plugins", async () => {
+ const ext1 = { grantTypes: { "custom:a": () => {} } };
+ const ext2 = { grantTypes: { "custom:b": () => {} } };
+ const ctx = await initBase({
+ plugins: [
+ {
+ id: "plugin-a",
+ extensions: { "test-host": ext1 } as never,
+ },
+ {
+ id: "plugin-b",
+ extensions: { "test-host": ext2 } as never,
+ },
+ ],
+ });
+ const result = ctx.getExtensions("test-host" as never);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe(ext1);
+ expect(result[1]).toBe(ext2);
+ });
+
+ it("should skip plugins without extensions", async () => {
+ const ext = { metadata: () => ({}) };
+ const ctx = await initBase({
+ plugins: [
+ { id: "no-ext" },
+ {
+ id: "with-ext",
+ extensions: { "test-host": ext } as never,
+ },
+ { id: "also-no-ext" },
+ ],
+ });
+ const result = ctx.getExtensions("test-host" as never);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe(ext);
+ });
+ });
+
+ describe("plugin dependencies", () => {
+ it("should throw when a dependency is missing", async () => {
+ await expect(
+ initBase({
+ plugins: [
+ {
+ id: "child-plugin",
+ dependencies: ["missing-parent"],
+ },
+ ],
+ }),
+ ).rejects.toThrow(
+ 'Plugin "child-plugin" requires plugin "missing-parent" which is not installed',
+ );
+ });
+
+ it("should pass when all dependencies are present", async () => {
+ const ctx = await initBase({
+ plugins: [
+ { id: "parent-plugin" },
+ {
+ id: "child-plugin",
+ dependencies: ["parent-plugin"],
+ },
+ ],
+ });
+ expect(ctx).toBeDefined();
+ expect(ctx.hasPlugin("child-plugin")).toBe(true);
+ });
+
+ it("should pass when plugin has no dependencies field", async () => {
+ const ctx = await initBase({
+ plugins: [{ id: "standalone-plugin" }],
+ });
+ expect(ctx).toBeDefined();
+ });
+ });
});
diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts
index 2680c65414..4d2869e082 100644
--- a/packages/better-auth/src/context/create-context.ts
+++ b/packages/better-auth/src/context/create-context.ts
@@ -247,6 +247,13 @@ Most of the features of Better Auth will not work correctly.`,
const hasPluginFn = (id: string) => pluginIds.has(id);
+ const getExtensionsFn = (hostId: string) =>
+ (options.plugins ?? [])
+ .map(
+ (p) => (p.extensions as Record | undefined)?.[hostId],
+ )
+ .filter((ext): ext is NonNullable => ext != null);
+
const trustedOrigins = await getTrustedOrigins(options);
const trustedProviders = await getTrustedProviders(options);
@@ -398,6 +405,7 @@ Most of the features of Better Auth will not work correctly.`,
},
getPlugin: getPluginFn,
hasPlugin: hasPluginFn as never,
+ getExtensions: getExtensionsFn as never,
};
const initOrPromise = runPluginInit(ctx);
diff --git a/packages/better-auth/src/context/helpers.ts b/packages/better-auth/src/context/helpers.ts
index 3daa641fdb..1120189326 100644
--- a/packages/better-auth/src/context/helpers.ts
+++ b/packages/better-auth/src/context/helpers.ts
@@ -5,6 +5,7 @@ import type {
BetterAuthPlugin,
} from "@better-auth/core";
import { env } from "@better-auth/core/env";
+import { BetterAuthError } from "@better-auth/core/error";
import { defu } from "defu";
import { createInternalAdapter } from "../db";
import { isPromise } from "../utils/is-promise";
@@ -13,6 +14,18 @@ import { getBaseURL, isDynamicBaseURLConfig } from "../utils/url";
export async function runPluginInit(context: AuthContext) {
let options = context.options;
const plugins = options.plugins || [];
+
+ const pluginIds = new Set(plugins.map((p) => p.id));
+ for (const plugin of plugins) {
+ for (const dep of plugin.dependencies ?? []) {
+ if (!pluginIds.has(dep)) {
+ throw new BetterAuthError(
+ `Plugin "${plugin.id}" requires plugin "${dep}" which is not installed. Add it to your plugins array.`,
+ );
+ }
+ }
+ }
+
const pluginTrustedOrigins: NonNullable<
BetterAuthOptions["trustedOrigins"]
>[] = [];
diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts
index 14718a7997..8f01033e57 100644
--- a/packages/core/src/types/context.ts
+++ b/packages/core/src/types/context.ts
@@ -77,6 +77,23 @@ export type BetterAuthPluginRegistryIdentifier = keyof BetterAuthPluginRegistry<
unknown
>;
+/**
+ * Extension contracts for platform plugins.
+ *
+ * Host plugins augment this interface to declare the shape
+ * of contributions they accept from other plugins.
+ *
+ * @example
+ * ```ts
+ * declare module "@better-auth/core" {
+ * interface BetterAuthExtensionRegistry {
+ * "oauth-provider": OAuthProviderExtension;
+ * }
+ * }
+ * ```
+ */
+export interface BetterAuthExtensionRegistry {}
+
export type GenericEndpointContext<
Options extends BetterAuthOptions = BetterAuthOptions,
> = EndpointContext>;
@@ -257,6 +274,18 @@ export type PluginContext = {
hasPlugin: (
pluginId: ID,
) => ID extends InferPluginID ? true : boolean;
+ /**
+ * Collect all extension contributions targeting a host plugin.
+ *
+ * @example
+ * ```ts
+ * const extensions = ctx.getExtensions("oauth-provider");
+ * // extensions is OAuthProviderExtension[]
+ * ```
+ */
+ getExtensions: (
+ hostId: ID,
+ ) => BetterAuthExtensionRegistry[ID][];
};
export type InfoContext = {
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 3545c194ea..2df50cfa96 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -1,6 +1,7 @@
export type { StandardSchemaV1 } from "@standard-schema/spec";
export type {
AuthContext,
+ BetterAuthExtensionRegistry,
BetterAuthPluginRegistry,
BetterAuthPluginRegistryIdentifier,
GenericEndpointContext,
diff --git a/packages/core/src/types/plugin.ts b/packages/core/src/types/plugin.ts
index 8f0f52a525..4ca364d34b 100644
--- a/packages/core/src/types/plugin.ts
+++ b/packages/core/src/types/plugin.ts
@@ -8,7 +8,7 @@ import type { Migration } from "kysely";
import type { AuthMiddleware } from "../api";
import type { BetterAuthPluginDBSchema } from "../db";
import type { RawError } from "../utils/error-codes";
-import type { AuthContext } from "./context";
+import type { AuthContext, BetterAuthExtensionRegistry } from "./context";
import type { Awaitable, LiteralString } from "./helper";
import type { BetterAuthOptions } from "./init-options";
@@ -172,4 +172,30 @@ export type BetterAuthPlugin = BetterAuthPluginErrorCodePart & {
adapter?: {
[key: string]: (...args: any[]) => Awaitable;
};
+ /**
+ * Typed contributions to host plugins.
+ *
+ * Each key is a host plugin ID registered in
+ * BetterAuthExtensionRegistry. The value must satisfy
+ * the host's extension contract.
+ *
+ * @example
+ * ```ts
+ * extensions: {
+ * "oauth-provider": {
+ * grantTypes: { "urn:custom:grant": myHandler },
+ * } satisfies OAuthProviderExtension,
+ * }
+ * ```
+ */
+ extensions?:
+ | {
+ [K in keyof BetterAuthExtensionRegistry]?: BetterAuthExtensionRegistry[K];
+ }
+ | undefined;
+ /**
+ * Plugin IDs this plugin requires.
+ * Validated at startup before any init runs.
+ */
+ dependencies?: string[] | undefined;
};