[▲ Vercel Community](/) · [Categories](/categories) · [Latest](/latest) · [Top](/top) · [Live](/live)

[Help](/c/help/9)

# Custom OAuth implementation fails in production with Fastify and Bun

34 views · 0 likes · 3 posts


Pancakes Rock (@fgo37333-2673) · 2026-02-21

Hello everyone,

Recently I decedided to try and roll my own auth for a project because I wanted to learn how to do such. I was toying with `OAuth` and `OIDC` and seen that my `OAuth` works entirely and completely fine locally, but breaks in prod.

\## Behavior

Expected And Received Below. Both are the same commit:![image](https://global.discourse-cdn.com/vercel/original/3X/f/1/f150fdb0e25383b406b8002b824e368f934a51e1.png)![image](https://global.discourse-cdn.com/vercel/original/3X/d/d/dd47c276aacb63e9301fadd64476c9cf451cdfa8.png)

Code:

OAuth.ts

\`\`\`typescript

import { Axios } from "axios";

import { eq } from "drizzle-orm";

import { oauthTable } from "./db/schema";

import { createRemoteJWKSet, jwtVerify } from "jose";

import { drizzle } from 'drizzle-orm/neon-http';

import { neon } from "@neondatabase/serverless";



const dbURL = process.env.DATABASE_URL as string



const neonSQL = neon(dbURL)



const db = drizzle(neonSQL)





export async function callback(provider: string): Promise<(() => OAuth) | undefined> {

    const providerInfo = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]

    switch (provider) {

        case "discord":

            if (!providerInfo) {

                throw new Error("No Provider Info")

            }



            return () => {

                const secret = process.env.DISCORD_SECRET

                if (!secret) {

                    console.log("No Discord Secret")

                    process.emitWarning("No Discord Secret")

                    process.exit(1)

                }

                return new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, (data) => {

                    return { userId: data.id, email: data.email }

                })

            }

        case "google":

            if (!providerInfo) {

                throw new Error("No Provider Info")

            }

            return () => {

                const secret = process.env.GOOGLE_SECRET

                if (!secret) {

                    console.log("No GOOGLE Secret")

                    process.emitWarning("No GOOGLE Secret")

                    process.exit(1)

                }

                const auth = new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, async (data, axios) => {

                    const jwt = await jwtVerify(data.id_token, createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")), {

                        issuer: 'https://accounts.google.com',

                        audience: providerInfo.clientId

                    })

                    if (!jwt.payload.sub || !jwt.payload.email) throw new Error("Invalid JWT Payload");

                    return { userId: jwt.payload.sub, email: String(jwt.payload.email) }

                })

                auth.skipUserEndpoint = true
                return auth

            }



        case "auth0":

            if (!providerInfo) {

                throw new Error("No Provider Info")

            }



            return () => {

                const secret = process.env.AUTH0_SECRET

                if (!secret) {

                    console.log("No AUTH0 Secret")

                    process.emitWarning("No AUTH0 Secret")

                    process.exit(1)

                }

                const auth = new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, async (data, axios) => {

                    return { userId: data.sub, email: data.email }

                })

                return auth

            }

        default:

            return undefined



    };

}



type parser = (data: any, axios: Axios) => { userId: string; email: string } | Promise<{ userId: string; email: string }>;



type OAuthUser = { userId: string, email: string } | { err: string, error?: any }



export class OAuth {

    #parser: parser;

    readonly clientId: string;

    #clientSecret: string;

    #tokenUrl: string;

    #userInfoUrl: string;

    #redirectUri: string;

    #stateSecret: string = "";

    #baseUrl: string;

    #axiosInstance: Axios;

    skipUserEndpoint: boolean = false;

    readonly requestId: string = crypto.randomUUID();



    constructor(

        axios: Axios,

        authorizationUrl: string,

        clientId: string,

        clientSecret: string,

        tokenUrl: string,

        userInfoUrl: string,

        redirectUri: string,

        parser: parser,

    ) {

        this.#axiosInstance = axios;

        this.#parser = parser;

        this.clientId = clientId;

        this.#clientSecret = clientSecret;

        this.#tokenUrl = tokenUrl;

        this.#userInfoUrl = userInfoUrl;

        this.#redirectUri = redirectUri;

        this.#baseUrl = authorizationUrl;

    }



    private async generatePKCE(): Promise<{

        verifier: string;

        challenge: string;

    }> {

        const verifier =

            crypto.randomUUID().replace(/-/g, "") +

            crypto.randomUUID().replace(/-/g, "");



        const encoder = new TextEncoder();

        const data = encoder.encode(verifier);

        const digest = await crypto.subtle.digest("SHA-256", data);



        const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))

            .replace(/\\+/g, "-")

            .replace(/\\//g, "\_")

            .replace(/=+$/, "");



        return { verifier, challenge };

    }



    public verifyState(state: string): boolean {

        return state === this.#stateSecret

    }



    public async generateURL(scope: string | string\[\]): Promise<string> {

        this.#stateSecret = crypto.randomUUID();

        const params = new URLSearchParams({

            client_id: this.clientId,

            redirect_uri: this.#redirectUri,

            response_type: "code",

            state: this.#stateSecret,

            scope: Array.isArray(scope) ? scope.join(" ") : scope,

        });

        return \`${this.#baseUrl}?${params}\`;

    }



    public async getUserInfo(code: string): Promise<OAuthUser> {

        const body = new URLSearchParams({

            grant_type: "authorization_code",

            client_id: this.clientId,

            client_secret: this.#clientSecret,

            code,

            redirect_uri: this.#redirectUri,

        });



        try {

            const tokenRes = await this.#axiosInstance.post(this.#tokenUrl, body.toString());

            const token = JSON.parse(tokenRes.data);

            console.log("Token request body:", body.toString());

            console.log("Token response:", token);

            if (!token.access_token) return { err: "Error Requesting Token", error: token };



            if (!this.skipUserEndpoint) {

                const userRes = await this.#axiosInstance.get(this.#userInfoUrl, {

                    headers: {

                        Authorization: \`Bearer ${token.access_token}\`,

                        "Content-Type": "application/x-www-form-urlencoded"

                    },

                });

                return this.#parser(JSON.parse(userRes.data), this.#axiosInstance as any);

            } else {

                return this.#parser(token, this.#axiosInstance as any);

            }

        } catch (err: any) {

            return { err: "Auth Request Failed", error: err.response?.data || err.message };

        }

    }



    public async saveState(redis: Bun.RedisClient) {

        try {

            await redis.set("STATE\_" + this.requestId, this.#stateSecret, "EX", 300)

        } catch (e) {

            console.error("Failed to save OAuth state to Redis:", e)

            throw e

        }

    }



    public async retrieveState(redis: Bun.RedisClient, id: string): Promise<boolean> {

        const state = await redis.get("STATE\_" + id)

        if (!state) {

            return false

        }

        this.#stateSecret = state

        return true

    }

}
\`\`\`

Routes:

\`\`\`typescript

api.get("/oauth/:provider/callback", async (req, res) => {

    const { code, state } = req.query as { code: string; state: string }

    const { provider } = req.params as { provider: string }

    const { requestId } = req.cookies as { requestId: string }



    const providerData = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]



    if (!providerData) {

        return res.code(400).send({ message: "Invalid Provider" })

    }



    const oauthFunction = callback(providerData?.provider ?? "")



    if (oauthFunction === undefined) {

        return res.code(400).send({ message: "Invalid Provider" })

    }

    try {

        const authFunction = (await oauthFunction)



        if (!authFunction) {

            return res.code(400).send({ message: "Invalid Provider" })

        }



        const auth = authFunction()



        if (!state) {

            return res.send({ err: "No State" }).code(400)

        }



        const restored = await auth.retrieveState(redis, requestId)



        if (!restored) {

            console.log("Restored?: " + restored)

            console.log("Fetched Redis Response: " + await redis.get("STATE\_" + requestId))

            console.log("State Provided: " + state)

            return res.code(400).send({ err: "Invalid OAuth Session" })

        }



        if (!auth.verifyState(state)) {

            return res.code(400).send({ err: "State Mismatch" })

        }



        const userInfo = await auth.getUserInfo(code)



        if ("err" in userInfo) {

            return res.send(userInfo).code(400)

        }



        let targetUser = (await db.select().from(userTable).where(sql\`${userTable.oauthId} @> ${JSON.stringify(\[{ providerId: providerData.id, userId: userInfo.userId }\])}::jsonb\`))\[0\]



        if (!targetUser) {

            const emailUser = (await db.select().from(userTable).where(eq(userTable.username, userInfo.email)))\[0\]

            if (emailUser) {

                const updatedOauthIds = \[...(emailUser.oauthId ?? \[\]), { providerId: providerData.id, userId: userInfo.userId }\];

                await db.update(userTable)

                    .set({ oauthId: updatedOauthIds })

                    .where(eq(userTable.id, emailUser.id));

                targetUser = emailUser;

            } else {

                const newUserId = crypto.randomUUID();

                await db.insert(userTable).values({

                    id: newUserId,

                    username: userInfo.email,

                    passwordHash: "OAUTH",

                    oauthId: \[{ providerId: providerData.id, userId: userInfo.userId }\]

                });

                targetUser = (await db.select().from(userTable).where(eq(userTable.id, newUserId)))\[0\];

            }

        }



        if (!targetUser) return res.code(500).send({ message: "Failed to create user" });




        // Fetch the user safely

        const getChannelNames = async (userId: string, table: any) => {

            const ids = (await db.select().from(table).where(eq(table.userId, userId)))\[0\]?.channelIds ?? \[\]

            const names: string\[\] = \[\]

            for (const id of ids) {

                const data = (await db.select().from(channelTable).where(eq(channelTable.id, id)))\[0\]

                if (data) names.push(data.name)

            }

            return names

        }



        const sendChannelNames = await getChannelNames(targetUser.id, userSend);

        const receiveChannelNames = await getChannelNames(targetUser.id, userRecieve);



        res.setCookie("token", await createJWT(targetUser.id, receiveChannelNames, sendChannelNames, "12h"), {

            path: "/",

            domain: domain,

            secure: isSecure,

            sameSite: "lax"

        })

    } catch (e) {

        console.error(e)

        return res.code(500).send({ message: "Internal Server Error" })

    }

    res.clearCookie("requestId", { path: "/" })

    res.redirect("/cue")



})



api.get("/oauth/:provider", async (req, res) => {

    const { provider } = req.params as { provider: string }

    const providerData = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]



    if (!providerData) {

        return res.code(400).send({ message: "Invalid Provider" })

    }



    const axios = new Axios()



    const auth = new OAuth(axios, providerData.authorizeUrl, providerData.clientId, "", providerData.tokenUrl, "", providerData.redirectUri, () => { return { userId: "", email: "" } })



    const uri = await auth.generateURL(providerData?.scopes)



    await auth.saveState(redis)



    res.clearCookie("requestId", { path: "/" })

    res.setCookie("requestId", auth.requestId, {

        path: "/",

        domain: domain,

        secure: isSecure,

        sameSite: "lax",

    })

    return res.status(302).redirect(uri)

})

\`\`\`

I know my implementation is bad. But if you could help that’d be stellar!

And In Vercel. I can see 2 requests, the one that gets sent to the client is the Invalid one, the other is proper and valid.


Swarnava Sengupta (@swarnava) · 2026-02-21

Can you redeploy and try again? We recently fixed a similar bug.


Pancakes Rock (@fgo37333-2673) · 2026-02-21

Just redeployed and now it’s fixed! Thanks for the help!