[GH-ISSUE #8901] Can't connect Better Auth Infra to NextJS application #28552

Closed
opened 2026-04-17 20:00:09 -05:00 by GiteaMirror · 16 comments
Owner

Originally created by @KingIronMan2011 on GitHub (Apr 1, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8901

Originally assigned to: @jonathansamines on GitHub.

Im currently making a Web App with Better Auth for the Authenfication and NextJS (with App Router) as the framework, it works perfectly, but when I tried to connect my Web App to the Infrastructure using the dash and dashClient Plugins it says that it could resolve the API key even thought I followed the docs carefully. When I go to the /api/auth/dash/config endpoint it says that the api key is invalid even thought I pasted it in my .env like it was described, Im also using Cloudflare with a Proxy, already tried disabeling that, but didn't work and I can't think of anything else on what to do. Any help would be appriciated, if you have more questions just ask me.

Originally created by @KingIronMan2011 on GitHub (Apr 1, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8901 Originally assigned to: @jonathansamines on GitHub. Im currently making a Web App with Better Auth for the Authenfication and NextJS (with App Router) as the framework, it works perfectly, but when I tried to connect my Web App to the Infrastructure using the dash and dashClient Plugins it says that it could resolve the API key even thought I followed the docs carefully. When I go to the /api/auth/dash/config endpoint it says that the api key is invalid even thought I pasted it in my .env like it was described, Im also using Cloudflare with a Proxy, already tried disabeling that, but didn't work and I can't think of anything else on what to do. Any help would be appriciated, if you have more questions just ask me.
GiteaMirror added the devopsbug labels 2026-04-17 20:00:09 -05:00
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 1, 2026):

This is my next.config.ts if it helps:

import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next";

const EXTRA_SCRIPT_SRCS = [
  "https://www.googletagmanager.com",
  "https://www.google-analytics.com",
  "https://cdn.jsdelivr.net",
].join(" ");

const EXTRA_CONNECT_SRCS = [
  "https://www.google-analytics.com",
  "https://*.analytics.google.com",
  "https://stats.g.doubleclick.net",
  "https://ip-api.com",
  "https://kv.better-auth.com",
  "https://api.scalar.com",
].join(" ");

const CSP = [
  `default-src 'self'`,
  `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${EXTRA_SCRIPT_SRCS}`,
  `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
  `font-src 'self' https://fonts.gstatic.com https://fonts.scalar.com`,
  `img-src 'self' data: blob: https:`,
  `connect-src 'self' ${EXTRA_CONNECT_SRCS}`,
  `media-src 'self' blob:`,
  `worker-src 'self' blob:`,
  `frame-ancestors 'none'`,
  `base-uri 'self'`,
  `form-action 'self'`,
  `upgrade-insecure-requests`,
].join("; ");

const config: NextConfig = {
  serverExternalPackages: ["pg", "archiver", "better-auth", "redis"],
  allowedDevOrigins: ["something.somedomain.tld", "localhost:3002"],

  async headers() {
    return [
      {
        // Apply to every route
        source: "/(.*)",
        headers: [
          { key: "Content-Security-Policy", value: CSP },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-XSS-Protection", value: "1; mode=block" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          {
            key: "Permissions-Policy",
            value: "camera=(), microphone=(), geolocation=()",
          },
          {
            key: "Strict-Transport-Security",
            value: "max-age=63072000; includeSubDomains; preload",
          },
        ],
      },
    ];
  },
};

export default withSentryConfig(config, {
  // For all available options, see:
  // https://www.npmjs.com/package/@sentry/webpack-plugin#options

  org: "kingironmans-org-wu",

  project: "video-downloader",

  // Only print logs for uploading source maps in CI
  silent: !process.env.CI,

  // For all available options, see:
  // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/

  // Upload a larger set of source maps for prettier stack traces (increases build time)
  widenClientFileUpload: true,

  // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
  // This can increase your server load as well as your hosting bill.
  // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
  // side errors will fail.
  tunnelRoute: "/monitoring",

  webpack: {
    // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
    // See the following for more information:
    // https://docs.sentry.io/product/crons/
    // https://vercel.com/docs/cron-jobs
    automaticVercelMonitors: true,

    // Tree-shaking options for reducing bundle size
    treeshake: {
      // Automatically tree-shake Sentry logger statements to reduce bundle size
      removeDebugLogging: true,
    },
  },
});
<!-- gh-comment-id:4172787429 --> @KingIronMan2011 commented on GitHub (Apr 1, 2026): This is my next.config.ts if it helps: ```ts import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; const EXTRA_SCRIPT_SRCS = [ "https://www.googletagmanager.com", "https://www.google-analytics.com", "https://cdn.jsdelivr.net", ].join(" "); const EXTRA_CONNECT_SRCS = [ "https://www.google-analytics.com", "https://*.analytics.google.com", "https://stats.g.doubleclick.net", "https://ip-api.com", "https://kv.better-auth.com", "https://api.scalar.com", ].join(" "); const CSP = [ `default-src 'self'`, `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${EXTRA_SCRIPT_SRCS}`, `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, `font-src 'self' https://fonts.gstatic.com https://fonts.scalar.com`, `img-src 'self' data: blob: https:`, `connect-src 'self' ${EXTRA_CONNECT_SRCS}`, `media-src 'self' blob:`, `worker-src 'self' blob:`, `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`, `upgrade-insecure-requests`, ].join("; "); const config: NextConfig = { serverExternalPackages: ["pg", "archiver", "better-auth", "redis"], allowedDevOrigins: ["something.somedomain.tld", "localhost:3002"], async headers() { return [ { // Apply to every route source: "/(.*)", headers: [ { key: "Content-Security-Policy", value: CSP }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "X-XSS-Protection", value: "1; mode=block" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", }, { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload", }, ], }, ]; }, }; export default withSentryConfig(config, { // For all available options, see: // https://www.npmjs.com/package/@sentry/webpack-plugin#options org: "kingironmans-org-wu", project: "video-downloader", // Only print logs for uploading source maps in CI silent: !process.env.CI, // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ // Upload a larger set of source maps for prettier stack traces (increases build time) widenClientFileUpload: true, // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. // This can increase your server load as well as your hosting bill. // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- // side errors will fail. tunnelRoute: "/monitoring", webpack: { // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) // See the following for more information: // https://docs.sentry.io/product/crons/ // https://vercel.com/docs/cron-jobs automaticVercelMonitors: true, // Tree-shaking options for reducing bundle size treeshake: { // Automatically tree-shake Sentry logger statements to reduce bundle size removeDebugLogging: true, }, }, }); ```
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 1, 2026):

So some more details, I tried setting it up on a brand new project and it worked flawlessly, also tried to remove my custom headers, but that also didn't work and I don't really have any idea what to do now. Any help would be appreciated

<!-- gh-comment-id:4172798072 --> @KingIronMan2011 commented on GitHub (Apr 1, 2026): So some more details, I tried setting it up on a brand new project and it worked flawlessly, also tried to remove my custom headers, but that also didn't work and I don't really have any idea what to do now. Any help would be appreciated
Author
Owner

@jonathansamines commented on GitHub (Apr 1, 2026):

@KingIronMan2011 Do you see any errors in your better-auth application whenever you try to connect it to the dashboard? Alternatively, do you see any error details on the dashboard connect dialog?

<!-- gh-comment-id:4173225701 --> @jonathansamines commented on GitHub (Apr 1, 2026): @KingIronMan2011 Do you see any errors in your better-auth application whenever you try to connect it to the dashboard? Alternatively, do you see any error details on the dashboard connect dialog?
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 1, 2026):

@KingIronMan2011 Do you see any errors in your better-auth application whenever you try to connect it to the dashboard? Alternatively, do you see any error details on the dashboard connect dialog?

Gimme a little bit, so I can try again to set it up and send you all error I can see.

<!-- gh-comment-id:4173514020 --> @KingIronMan2011 commented on GitHub (Apr 1, 2026): > [@KingIronMan2011](https://github.com/KingIronMan2011) Do you see any errors in your better-auth application whenever you try to connect it to the dashboard? Alternatively, do you see any error details on the dashboard connect dialog? Gimme a little bit, so I can try again to set it up and send you all error I can see.
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 1, 2026):

Image

Api Response from the "Create" api call:
{"message":"API key validation failed. Please ensure you've added the API key to your app and the dash plugin is configured.","status":400,"code":"INVALID_API_KEY"}

logs from nextjs:
2026-04-01T23:19:39.253Z WARN [Better Auth]: [Dash] JWT is missing from header

When I access the /api/auth/dash/config endpoint it shows this:
{"message":"Invalid API key"}

but my env is configured just like in the tutorial and in my quick test application I made.

<!-- gh-comment-id:4173531745 --> @KingIronMan2011 commented on GitHub (Apr 1, 2026): <img width="714" height="692" alt="Image" src="https://github.com/user-attachments/assets/8b524330-ec56-49cd-8ff4-05c80c4f24b1" /> Api Response from the "Create" api call: {"message":"API key validation failed. Please ensure you've added the API key to your app and the dash plugin is configured.","status":400,"code":"INVALID_API_KEY"} logs from nextjs: 2026-04-01T23:19:39.253Z WARN [Better Auth]: [Dash] JWT is missing from header When I access the /api/auth/dash/config endpoint it shows this: {"message":"Invalid API key"} but my env is configured just like in the tutorial and in my quick test application I made.
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 1, 2026):

Here my auth.ts plugin section:

plugins: [
    username(),
    haveIBeenPwned(),
    nextCookies(),
    organization(),
    apiKey(),
    twoFactor(),
    passkey(),
    captcha({
      provider: "cloudflare-turnstile",
      secretKey: process.env.TURNSTILE_SECRET_KEY!,
    }),
    dash({
      apiKey: process.env.BETTER_AUTH_API_KEY,
      activityTracking: {
        enabled: true,
        updateInterval: 300000, // Update interval in ms (default: 5 minutes)
      },
    }),
    openAPI(),
  ],
<!-- gh-comment-id:4173538287 --> @KingIronMan2011 commented on GitHub (Apr 1, 2026): Here my auth.ts plugin section: ```ts plugins: [ username(), haveIBeenPwned(), nextCookies(), organization(), apiKey(), twoFactor(), passkey(), captcha({ provider: "cloudflare-turnstile", secretKey: process.env.TURNSTILE_SECRET_KEY!, }), dash({ apiKey: process.env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true, updateInterval: 300000, // Update interval in ms (default: 5 minutes) }, }), openAPI(), ], ```
Author
Owner

@jonathansamines commented on GitHub (Apr 2, 2026):

2026-04-01T23:19:39.253Z WARN [Better Auth]: [Dash] JWT is missing from header

This error means, something is stripping your headers and your application is not seeing our authorization header (which is a JWT). Any chance there is something in your app that is restricting/stripping that header?

Will double-check on our side as well, just in case your exact plugin combination is problematic. It would also be helpful if you share your complete auth configuration (excluding any sensitive info).

<!-- gh-comment-id:4173674767 --> @jonathansamines commented on GitHub (Apr 2, 2026): >2026-04-01T23:19:39.253Z WARN [Better Auth]: [Dash] JWT is missing from header This error means, something is stripping your headers and your application is not seeing our authorization header (which is a JWT). Any chance there is something in your app that is restricting/stripping that header? Will double-check on our side as well, just in case your exact plugin combination is problematic. It would also be helpful if you share your complete auth configuration (excluding any sensitive info).
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 2, 2026):

Here is my whole auth.ts, will also check if I added a header stripping in some way:

import { betterAuth } from "better-auth";
import { createTransport } from "nodemailer";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import {
  haveIBeenPwned,
  organization,
  username,
  twoFactor,
  captcha,
  openAPI,
} from "better-auth/plugins";
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";
import { dash } from "@better-auth/infra";
import { db } from "@/db/index";
import { maybeSeedSuperadmin } from "@/utils/superadmin";
import * as schema from "@/db/schemas/auth-schema";

// Social providers — only enabled when env vars are set
const socialProviders: Record<
  string,
  { clientId: string; clientSecret: string; redirectURI?: string }
> = {};

const BASE_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3002";
const API_KEY = process.env.BETTER_AUTH_API_KEY;

if (process.env.NEXT_PUBLIC_GITHUB_ENABLED === "true") {
  socialProviders.github = {
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  };
}
if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true") {
  socialProviders.google = {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  };
}
if (process.env.NEXT_PUBLIC_DISCORD_ENABLED === "true") {
  socialProviders.discord = {
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  };
}

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      user: schema.user,
      session: schema.session,
      account: schema.account,
      verification: schema.verification,
    },
  }),

  baseURL: BASE_URL,
  secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production",

  emailAndPassword: {
    enabled: true,
    minPasswordLength: 8,
    requireEmailVerification:
      process.env.EMAIL_VERIFICATION_REQUIRED === "true",
  },

  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["discord", "github", "google"],
      allowDifferentEmails: true,
    },
  },

  user: {
    additionalFields: {
      role: {
        type: "string",
        required: true,
        defaultValue: "user",
      },
      banned: {
        type: "boolean",
        defaultValue: false,
      },
      banReason: {
        type: "string",
      },
      termsAcceptedAt: {
        type: "string",
      },
      theme: {
        type: "string",
        defaultValue: "dark",
      },
    },
  },

  emailVerification: {
    sendOnSignUp: process.env.SMTP_HOST ? true : false,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url }) => {
      if (!process.env.SMTP_HOST) return;
      const transport = createTransport({
        host: process.env.SMTP_HOST,
        port: parseInt(process.env.SMTP_PORT ?? "587"),
        secure: process.env.SMTP_SECURE === "true",
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASS,
        },
      });
      await transport.sendMail({
        from: process.env.SMTP_FROM ?? process.env.SMTP_USER,
        to: user.email,
        subject: "Verify your email — Video Downloader",
        html: `
          <div style="font-family:sans-serif;max-width:480px;margin:0 auto;">
            <h2 style="color:#c8f135;">Verify your email</h2>
            <p>Click the button below to verify your email address.</p>
            <a href="${url}" style="display:inline-block;background:#c8f135;color:#000;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin:16px 0;">
              Verify Email
            </a>
            <p style="color:#888;font-size:12px;">If you didn't create an account, you can ignore this email.</p>
          </div>
        `,
        text: `Verify your email: ${url}`,
      });
    },
  },

  socialProviders,

  plugins: [
    username(),
    haveIBeenPwned(),
    nextCookies(),
    organization(),
    apiKey(),
    twoFactor(),
    passkey(),
    captcha({
      provider: "cloudflare-turnstile",
      secretKey: process.env.TURNSTILE_SECRET_KEY!,
    }),
    dash({
      apiKey: API_KEY,
      activityTracking: {
        enabled: true,
        updateInterval: 300000, // Update interval in ms (default: 5 minutes)
      },
    }),
    openAPI(),
  ],

  session: {
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
    cookieCache: { enabled: true, maxAge: 60 * 5 },
  },

  trustedOrigins: process.env.TRUSTED_ORIGINS
    ? process.env.TRUSTED_ORIGINS.split(",")
    : [],

  databaseHooks: {
    user: {
      create: {
        before: async (userData) => {
          return {
            data: { ...userData, email: userData.email.toLowerCase().trim() },
          };
        },
      },
      update: {
        before: async (userData) => {
          if (userData.email) {
            return {
              data: {
                ...userData,
                email: userData.email.toLowerCase().trim(),
                emailVerified: false,
              },
            };
          }
          return { data: userData };
        },
      },
    },
    session: {
      create: {
        after: async (session) => {
          await maybeSeedSuperadmin(session.userId);
        },
      },
    },
  },
});

export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;
<!-- gh-comment-id:4177828795 --> @KingIronMan2011 commented on GitHub (Apr 2, 2026): Here is my whole auth.ts, will also check if I added a header stripping in some way: ```ts import { betterAuth } from "better-auth"; import { createTransport } from "nodemailer"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { haveIBeenPwned, organization, username, twoFactor, captcha, openAPI, } from "better-auth/plugins"; import { apiKey } from "@better-auth/api-key"; import { passkey } from "@better-auth/passkey"; import { dash } from "@better-auth/infra"; import { db } from "@/db/index"; import { maybeSeedSuperadmin } from "@/utils/superadmin"; import * as schema from "@/db/schemas/auth-schema"; // Social providers — only enabled when env vars are set const socialProviders: Record< string, { clientId: string; clientSecret: string; redirectURI?: string } > = {}; const BASE_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3002"; const API_KEY = process.env.BETTER_AUTH_API_KEY; if (process.env.NEXT_PUBLIC_GITHUB_ENABLED === "true") { socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }; } if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true") { socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }; } if (process.env.NEXT_PUBLIC_DISCORD_ENABLED === "true") { socialProviders.discord = { clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }; } export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification, }, }), baseURL: BASE_URL, secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production", emailAndPassword: { enabled: true, minPasswordLength: 8, requireEmailVerification: process.env.EMAIL_VERIFICATION_REQUIRED === "true", }, account: { accountLinking: { enabled: true, trustedProviders: ["discord", "github", "google"], allowDifferentEmails: true, }, }, user: { additionalFields: { role: { type: "string", required: true, defaultValue: "user", }, banned: { type: "boolean", defaultValue: false, }, banReason: { type: "string", }, termsAcceptedAt: { type: "string", }, theme: { type: "string", defaultValue: "dark", }, }, }, emailVerification: { sendOnSignUp: process.env.SMTP_HOST ? true : false, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { if (!process.env.SMTP_HOST) return; const transport = createTransport({ host: process.env.SMTP_HOST, port: parseInt(process.env.SMTP_PORT ?? "587"), secure: process.env.SMTP_SECURE === "true", auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }); await transport.sendMail({ from: process.env.SMTP_FROM ?? process.env.SMTP_USER, to: user.email, subject: "Verify your email — Video Downloader", html: ` <div style="font-family:sans-serif;max-width:480px;margin:0 auto;"> <h2 style="color:#c8f135;">Verify your email</h2> <p>Click the button below to verify your email address.</p> <a href="${url}" style="display:inline-block;background:#c8f135;color:#000;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin:16px 0;"> Verify Email </a> <p style="color:#888;font-size:12px;">If you didn't create an account, you can ignore this email.</p> </div> `, text: `Verify your email: ${url}`, }); }, }, socialProviders, plugins: [ username(), haveIBeenPwned(), nextCookies(), organization(), apiKey(), twoFactor(), passkey(), captcha({ provider: "cloudflare-turnstile", secretKey: process.env.TURNSTILE_SECRET_KEY!, }), dash({ apiKey: API_KEY, activityTracking: { enabled: true, updateInterval: 300000, // Update interval in ms (default: 5 minutes) }, }), openAPI(), ], session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 60 * 5 }, }, trustedOrigins: process.env.TRUSTED_ORIGINS ? process.env.TRUSTED_ORIGINS.split(",") : [], databaseHooks: { user: { create: { before: async (userData) => { return { data: { ...userData, email: userData.email.toLowerCase().trim() }, }; }, }, update: { before: async (userData) => { if (userData.email) { return { data: { ...userData, email: userData.email.toLowerCase().trim(), emailVerified: false, }, }; } return { data: userData }; }, }, }, session: { create: { after: async (session) => { await maybeSeedSuperadmin(session.userId); }, }, }, }, }); export type Auth = typeof auth; export type Session = typeof auth.$Infer.Session; ```
Author
Owner

@KingIronMan2011 commented on GitHub (Apr 2, 2026):

So I did a quick search, but couldn't find any system that strips any of my headers, so its a really weird problem.

<!-- gh-comment-id:4177854920 --> @KingIronMan2011 commented on GitHub (Apr 2, 2026): So I did a quick search, but couldn't find any system that strips any of my headers, so its a really weird problem.
Author
Owner

@caiovernaglia commented on GitHub (Apr 8, 2026):

Right now, i'm facing same problem, with ERROR [Better Auth]: Invalid API key.

Can confirm that the JWT is reaching the server, and received by Better Auth Endpoints.

One thing i've noticed is that, when trying to connect to app, my Prisma logs are showing an query to the apikey table wich, on an initial state, is empty.

Perhaps something with that is causing the problem?

The logs

prisma:query SELECT "public"."apikey"."id", "public"."apikey"."configId", "public"."apikey"."name", "public"."apikey"."start", "public"."apikey"."referenceId", "public"."apikey"."prefix", "public"."apikey"."key", "public"."apikey"."enabled", "public"."apikey"."refillInterval", "public"."apikey"."refillAmount", "public"."apikey"."lastRefillAt", "public"."apikey"."rateLimitEnabled", "public"."apikey"."rateLimitTimeWindow", "public"."apikey"."rateLimitMax", "public"."apikey"."requestCount", "public"."apikey"."remaining", "public"."apikey"."lastRequest", "public"."apikey"."expiresAt", "public"."apikey"."permissions", "public"."apikey"."metadata", "public"."apikey"."createdAt", "public"."apikey"."updatedAt" FROM "public"."apikey" WHERE "public"."apikey"."key" = $1 LIMIT $2 OFFSET $3
2026-04-08T20:17:51.153Z ERROR [Better Auth]: Invalid API key.
<!-- gh-comment-id:4209376594 --> @caiovernaglia commented on GitHub (Apr 8, 2026): Right now, i'm facing same problem, with `ERROR [Better Auth]: Invalid API key.` Can confirm that the JWT is reaching the server, and received by Better Auth Endpoints. One thing i've noticed is that, when trying to connect to app, my Prisma logs are showing an query to the `apikey` table wich, on an initial state, is empty. Perhaps something with that is causing the problem? The logs ``` prisma:query SELECT "public"."apikey"."id", "public"."apikey"."configId", "public"."apikey"."name", "public"."apikey"."start", "public"."apikey"."referenceId", "public"."apikey"."prefix", "public"."apikey"."key", "public"."apikey"."enabled", "public"."apikey"."refillInterval", "public"."apikey"."refillAmount", "public"."apikey"."lastRefillAt", "public"."apikey"."rateLimitEnabled", "public"."apikey"."rateLimitTimeWindow", "public"."apikey"."rateLimitMax", "public"."apikey"."requestCount", "public"."apikey"."remaining", "public"."apikey"."lastRequest", "public"."apikey"."expiresAt", "public"."apikey"."permissions", "public"."apikey"."metadata", "public"."apikey"."createdAt", "public"."apikey"."updatedAt" FROM "public"."apikey" WHERE "public"."apikey"."key" = $1 LIMIT $2 OFFSET $3 2026-04-08T20:17:51.153Z ERROR [Better Auth]: Invalid API key. ```
Author
Owner

@jonathansamines commented on GitHub (Apr 13, 2026):

Hey @caiovernaglia can you share your auth.ts configuration? Still trying to track this issue down, and this could really help 🙏🏻

<!-- gh-comment-id:4239927915 --> @jonathansamines commented on GitHub (Apr 13, 2026): Hey @caiovernaglia can you share your auth.ts configuration? Still trying to track this issue down, and this could really help 🙏🏻
Author
Owner

@caiovernaglia commented on GitHub (Apr 14, 2026):

@jonathansamines

As requested

import { apiKey } from '@better-auth/api-key'
import { dash } from '@better-auth/infra'
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { admin, openAPI } from 'better-auth/plugins'
import { database } from './database'
import { env } from './env'

export const auth = betterAuth({
  database: prismaAdapter(database, {
    provider: 'postgresql',
  }),
  appName: env.APP_NAME,
  baseURL: env.API_URL,
  basePath: '/api/auth',
  trustedOrigins: [env.API_URL, env.FRONTEND_URL, env.ADMIN_PANEL_URL],
  logger: {
    level: env.ENV === 'production' ? 'error' : 'debug',
    log: async (level, message, ...args) => {
      // TODO: implantação de logger para o Better-Auth
      console.log(`[${level}] ${message}`, ...args)
    },
  },
  session: {
    cookieCache: {
      enabled: true,
      maxAge: env.AUTH_COOKIE_CACHE_MAX_AGE,
    },
  },
  advanced: {
    database: {
      generateId: false,
    },
    cookiePrefix: 'x-axo',
    crossSubDomainCookies: {
      enabled: true,
      domain: env.APP_DOMAIN,
    },
  },
  user: {
    additionalFields: {
      phone: {
        type: 'string',
        required: false,
        input: true,
        returned: true,
      },
    },
    changeEmail: {
      enabled: true,
      sendChangeEmailConfirmation: async ({ newEmail, token, url, user }) => {
        // TODO: implementar envio de email para atualização de email
        console.log('Change Email Request')
        console.log('User: ', user.email)
        console.log('NewEmail: ', newEmail)
        console.log('Token: ', token)
        console.log('URL: ', url)
      },
    },
    deleteUser: {
      enabled: true,
      sendDeleteAccountVerification: async ({ token, url, user }) => {
        // TODO: implementar envio de email para confirmação da exclusão
        console.log('Delete User Request')
        console.log('User: ', user.email)
        console.log('Token: ', token)
        console.log('URL: ', url)
      },
      beforeDelete: async (user) => {
        console.log('Before delete validations for ', user.email)
      },
      afterDelete: async (user) => {
        console.log('After delete actions for ', user.email)
      },
    },
  },
  emailAndPassword: {
    enabled: true,
    disableSignUp: true,
    requireEmailVerification: true,
    minPasswordLength: env.AUTH_PASSWORD_MIN_LENGTH,
    maxPasswordLength: env.AUTH_PASSWORD_MAX_LENGTH,
    resetPasswordTokenExpiresIn: env.SEC_RESET_PASSWORD_TOKEN_EXPIRATION,
    revokeSessionsOnPasswordReset: true,
    sendResetPassword: async ({ token, url, user }) => {
      // TODO: implementar envio de email para reset de senha
      console.log('Password Reset Request')
      console.log('User: ', user.email)
      console.log('Token: ', token)
      console.log('URL: ', url)
    },
    customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
      ...coreFields,
      role: 'user',
      banned: false,
      banReason: null,
      banExpires: null,
      ...additionalFields,
      id,
    }),
  },
  socialProviders: {
    google: {
      enabled: env.OAUTH_GOOGLE_ENABLED,
      clientId: env.OAUTH_GOOGLE_CLIENT_ID as string,
      clientSecret: env.OAUTH_GOOGLE_CLIENT_SECRET as string,
      mapProfileToUser: (profile) => {
        return {
          name: `${profile.given_name} ${profile.family_name}`,
        }
      },
    },
    facebook: {
      enabled: env.OAUTH_FACEBOOK_ENABLED,
      clientId: env.OAUTH_FACEBOOK_CLIENT_ID as string,
      clientSecret: env.OUATH_FACEBOOK_CLIENT_SECRET as string,
      // TODO: implementar mapProfileUser (ie: Google)
    },
    microsoft: {
      enabled: env.OAUTH_MICROSOFT_ENABLED,
      clientId: env.OAUTH_MICROSOFT_CLIENT_ID as string,
      clientSecret: env.OAUTH_MICROSOFT_CLIENT_SECRET as string,
      // TODO: implementar mapProfileUser (ie: Google)
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, token, url }, _request) => {
      //TODO: implementar envio de senha para verificação de email em novos cadastros
      console.log('Email Verification')
      console.log('User: ', user.email)
      console.log('Token: ', token)
      console.log('URL: ', url)
    },
  },
  plugins: [
    openAPI({
      disableDefaultReference: env.ENV !== 'development',
      path: '/documentation',
    }),
    admin({
      defaultRole: 'user',
      adminRoles: ['admin'],
      impersonationSessionDuration: env.ADMIN_IMPERSONATION_SESSION_DURATION,
      defaultBanReason: env.ADMIN_DEFAULT_BAN_REASON,
      defaultBanExpiresIn: env.ADMIN_DEFAULT_BAN_EXPIRATION,
      bannedUserMessage: env.ADMIN_DEFAULT_BAN_MESSAGE,
    }),
    apiKey({
      references: 'user',
      apiKeyHeaders: 'Authorization',
      defaultKeyLength: 32,
      requireName: true,
      minimumNameLength: 3,
      rateLimit: {
        enabled: env.ENV !== 'development',
        timeWindow: env.API_RATE_LIMIT_TIME_WINDOW,
        maxRequests: env.API_RATE_LIMIT_MAX_REQUESTS,
      },
      enableSessionForAPIKeys: true,
    }),
    dash({
      apiKey: env.BETTER_AUTH_API_KEY,
    }),
  ],
})
<!-- gh-comment-id:4245019003 --> @caiovernaglia commented on GitHub (Apr 14, 2026): @jonathansamines As requested ``` import { apiKey } from '@better-auth/api-key' import { dash } from '@better-auth/infra' import { betterAuth } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' import { admin, openAPI } from 'better-auth/plugins' import { database } from './database' import { env } from './env' export const auth = betterAuth({ database: prismaAdapter(database, { provider: 'postgresql', }), appName: env.APP_NAME, baseURL: env.API_URL, basePath: '/api/auth', trustedOrigins: [env.API_URL, env.FRONTEND_URL, env.ADMIN_PANEL_URL], logger: { level: env.ENV === 'production' ? 'error' : 'debug', log: async (level, message, ...args) => { // TODO: implantação de logger para o Better-Auth console.log(`[${level}] ${message}`, ...args) }, }, session: { cookieCache: { enabled: true, maxAge: env.AUTH_COOKIE_CACHE_MAX_AGE, }, }, advanced: { database: { generateId: false, }, cookiePrefix: 'x-axo', crossSubDomainCookies: { enabled: true, domain: env.APP_DOMAIN, }, }, user: { additionalFields: { phone: { type: 'string', required: false, input: true, returned: true, }, }, changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ newEmail, token, url, user }) => { // TODO: implementar envio de email para atualização de email console.log('Change Email Request') console.log('User: ', user.email) console.log('NewEmail: ', newEmail) console.log('Token: ', token) console.log('URL: ', url) }, }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ token, url, user }) => { // TODO: implementar envio de email para confirmação da exclusão console.log('Delete User Request') console.log('User: ', user.email) console.log('Token: ', token) console.log('URL: ', url) }, beforeDelete: async (user) => { console.log('Before delete validations for ', user.email) }, afterDelete: async (user) => { console.log('After delete actions for ', user.email) }, }, }, emailAndPassword: { enabled: true, disableSignUp: true, requireEmailVerification: true, minPasswordLength: env.AUTH_PASSWORD_MIN_LENGTH, maxPasswordLength: env.AUTH_PASSWORD_MAX_LENGTH, resetPasswordTokenExpiresIn: env.SEC_RESET_PASSWORD_TOKEN_EXPIRATION, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ token, url, user }) => { // TODO: implementar envio de email para reset de senha console.log('Password Reset Request') console.log('User: ', user.email) console.log('Token: ', token) console.log('URL: ', url) }, customSyntheticUser: ({ coreFields, additionalFields, id }) => ({ ...coreFields, role: 'user', banned: false, banReason: null, banExpires: null, ...additionalFields, id, }), }, socialProviders: { google: { enabled: env.OAUTH_GOOGLE_ENABLED, clientId: env.OAUTH_GOOGLE_CLIENT_ID as string, clientSecret: env.OAUTH_GOOGLE_CLIENT_SECRET as string, mapProfileToUser: (profile) => { return { name: `${profile.given_name} ${profile.family_name}`, } }, }, facebook: { enabled: env.OAUTH_FACEBOOK_ENABLED, clientId: env.OAUTH_FACEBOOK_CLIENT_ID as string, clientSecret: env.OUATH_FACEBOOK_CLIENT_SECRET as string, // TODO: implementar mapProfileUser (ie: Google) }, microsoft: { enabled: env.OAUTH_MICROSOFT_ENABLED, clientId: env.OAUTH_MICROSOFT_CLIENT_ID as string, clientSecret: env.OAUTH_MICROSOFT_CLIENT_SECRET as string, // TODO: implementar mapProfileUser (ie: Google) }, }, emailVerification: { sendOnSignUp: true, sendVerificationEmail: async ({ user, token, url }, _request) => { //TODO: implementar envio de senha para verificação de email em novos cadastros console.log('Email Verification') console.log('User: ', user.email) console.log('Token: ', token) console.log('URL: ', url) }, }, plugins: [ openAPI({ disableDefaultReference: env.ENV !== 'development', path: '/documentation', }), admin({ defaultRole: 'user', adminRoles: ['admin'], impersonationSessionDuration: env.ADMIN_IMPERSONATION_SESSION_DURATION, defaultBanReason: env.ADMIN_DEFAULT_BAN_REASON, defaultBanExpiresIn: env.ADMIN_DEFAULT_BAN_EXPIRATION, bannedUserMessage: env.ADMIN_DEFAULT_BAN_MESSAGE, }), apiKey({ references: 'user', apiKeyHeaders: 'Authorization', defaultKeyLength: 32, requireName: true, minimumNameLength: 3, rateLimit: { enabled: env.ENV !== 'development', timeWindow: env.API_RATE_LIMIT_TIME_WINDOW, maxRequests: env.API_RATE_LIMIT_MAX_REQUESTS, }, enableSessionForAPIKeys: true, }), dash({ apiKey: env.BETTER_AUTH_API_KEY, }), ], }) ```
Author
Owner

@jonathansamines commented on GitHub (Apr 14, 2026):

Thanks @caiovernaglia, this is really helpful. Will report back.

<!-- gh-comment-id:4247056300 --> @jonathansamines commented on GitHub (Apr 14, 2026): Thanks @caiovernaglia, this is really helpful. Will report back.
Author
Owner

@jonathansamines commented on GitHub (Apr 14, 2026):

@caiovernaglia I have investigated this issue, and we found the root cause. Your particular configuration for apiKey is problematic for @better-auth/infra because they both use the Authorization header for authentication.

We will evaluate whether to introduce custom headers support on @better-auth/infra, but meanwhile, I'd suggest one of two changes:

  • Use a different header for apiKey. Not sure if this is an option for you, but wanted to call this out just in case.
  • Use a customAPIKeyGetter to skip all dash routes:
apiKey({
  customAPIKeyGetter: (ctx) => {
	if (ctx.path?.includes("dash")) return null;
	const raw = ctx.headers?.get("Authorization");
	if (!raw?.startsWith("Bearer ")) return null;
	return raw.slice(7).trim() || null;
  },
}),
<!-- gh-comment-id:4247246821 --> @jonathansamines commented on GitHub (Apr 14, 2026): @caiovernaglia I have investigated this issue, and we found the root cause. Your particular configuration for `apiKey` is problematic for `@better-auth/infra` because they both use the `Authorization` header for authentication. We will evaluate whether to introduce custom headers support on `@better-auth/infra`, but meanwhile, I'd suggest one of two changes: - Use a different header for `apiKey`. Not sure if this is an option for you, but wanted to call this out just in case. - Use a `customAPIKeyGetter` to skip all dash routes: ```ts apiKey({ customAPIKeyGetter: (ctx) => { if (ctx.path?.includes("dash")) return null; const raw = ctx.headers?.get("Authorization"); if (!raw?.startsWith("Bearer ")) return null; return raw.slice(7).trim() || null; }, }), ```
Author
Owner

@caiovernaglia commented on GitHub (Apr 15, 2026):

Hmm... That's makes sense!

Using another header for apiKey is not a problem since this a brand new application.

I will try it and come back with results.

<!-- gh-comment-id:4248168188 --> @caiovernaglia commented on GitHub (Apr 15, 2026): Hmm... That's makes sense! Using another header for apiKey is not a problem since this a brand new application. I will try it and come back with results.
Author
Owner

@jonathansamines commented on GitHub (Apr 15, 2026):

@caiovernaglia Hope that works for you, I'll close this issue but feel free to re-open/create a new one if you are still facing issues!

<!-- gh-comment-id:4256450040 --> @jonathansamines commented on GitHub (Apr 15, 2026): @caiovernaglia Hope that works for you, I'll close this issue but feel free to re-open/create a new one if you are still facing issues!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28552