Better JWT Support (hooks, server-side signing functions, exported API functions) #428

Closed
opened 2026-03-13 07:45:28 -05:00 by GiteaMirror · 6 comments
Owner

Originally created by @daveycodez on GitHub (Dec 18, 2024).

Is this suited for github?

  • Yes, this is suited for github

No response

Describe the solution you'd like

As of right now in order to get JWT in React (or even server side) I have to manually fetch it. I tried using auth.api.getToken({ headers }) directly but it gives ⨯ Error [TypeError]: "alg" argument is required when "jwk.alg" is not present

/auth/api/token is functioning normally, but I would like to be able to easily call some thing like getToken in server side and even have a useToken in React that updates the token when it is expired.

It would also be great if we could get decodeToken and signToken or encodeToken functions exported from auth so we can verify, decode and resign tokens manually as needed in our own custom API's.

Describe alternatives you've considered

My current solution in React is as follows

/lib/get-jwt.ts

REDACTED see use-token hook below

Would love if there was a more streamlined way to use JWT's both in the server and within React.

Additional context

No response

Originally created by @daveycodez on GitHub (Dec 18, 2024). ### Is this suited for github? - [X] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. _No response_ ### Describe the solution you'd like As of right now in order to get JWT in React (or even server side) I have to manually fetch it. I tried using auth.api.getToken({ headers }) directly but it gives `⨯ Error [TypeError]: "alg" argument is required when "jwk.alg" is not present` /auth/api/token is functioning normally, but I would like to be able to easily call some thing like getToken in server side and even have a useToken in React that updates the token when it is expired. It would also be great if we could get decodeToken and signToken or encodeToken functions exported from auth so we can verify, decode and resign tokens manually as needed in our own custom API's. ### Describe alternatives you've considered My current solution in React is as follows /lib/get-jwt.ts ```ts REDACTED see use-token hook below ``` Would love if there was a more streamlined way to use JWT's both in the server and within React. ### Additional context _No response_
Author
Owner

@daveycodez commented on GitHub (Dec 18, 2024):

As for signing tokens, I'm trying to set up RLS with Neon and it works great with the JWKS Endpoint and ES256 token alg. Loving that. Only issue is I can't use anonymous database role without a JWT, and I need to be able to sign an Anonymous JWT really easily without having to create an anonymous user or making extra API requests.

I need to be able to do something like:

const jwt = signToken({ role: "anonymous" })
@daveycodez commented on GitHub (Dec 18, 2024): As for signing tokens, I'm trying to set up RLS with Neon and it works great with the JWKS Endpoint and ES256 token alg. Loving that. Only issue is I can't use anonymous database role without a JWT, and I need to be able to sign an Anonymous JWT really easily without having to create an anonymous user or making extra API requests. I need to be able to do something like: ```ts const jwt = signToken({ role: "anonymous" }) ```
Author
Owner

@daveycodez commented on GitHub (Dec 21, 2024):

@Bekacru Here is the hook I'm using now to keep JWT up to date... Would be great to have a better solution from the plugin

/src/hooks/use-jwt.ts

**REDACTED see below**
@daveycodez commented on GitHub (Dec 21, 2024): @Bekacru Here is the hook I'm using now to keep JWT up to date... Would be great to have a better solution from the plugin /src/hooks/use-jwt.ts ```ts **REDACTED see below** ```
Author
Owner

@daveycodez commented on GitHub (Dec 21, 2024):

@Bekacru

use-token.ts

import { useEffect, useState } from "react"
import { decodeJwt } from "jose"

import { useSession } from "@/lib/auth-client"
import { getURL } from "@/lib/utils"

export const getToken = async (): Promise<string | null> => {
    // Check if token is stored in localStorage
    const storedToken = localStorage.getItem("token")

    // Remove token from localStorage if it's expired, otherwise return it
    if (storedToken) {
        if (isTokenExpired(storedToken)) {
            localStorage.removeItem("token")
        } else {
            return storedToken
        }
    }

    // Fetch the token
    const apiUrl = getURL() + "/api/auth/token"
    const response = await fetchWithRetry(apiUrl, { credentials: "include" })
    if (!response.ok) return null

    // Store token in localStorage and return it
    const data = await response.json()
    if (!data.token) return null

    localStorage.setItem("token", data.token)
    return data.token
}

const isTokenExpired = (token: string) => {
    const payload = decodeJwt(token)
    if (!payload?.exp) return true

    const currentTime = Math.floor(Date.now() / 1000)
    return payload.exp < currentTime
}

export const useToken = () => {
    const { data: session, isPending: sessionPending } = useSession()
    const [token, setToken] = useState<string | null>(null)
    const [isPending, setIsPending] = useState(sessionPending)

    const refreshToken = async () => {
        const token = await getToken()
        setToken(token)
        setIsPending(false)
    }

    // If the token sub is different from the session user id, clear the token
    useEffect(() => {
        if (!token || !session) return

        const tokenPayload = decodeJwt(token)
        if (tokenPayload?.sub !== session.user.id) {
            setToken(null)
            localStorage.removeItem("token")
        }
    }, [session, token])

    // Initialize the token
    useEffect(() => {
        // Check if token is stored in localStorage on mount
        const storedToken = localStorage.getItem("token")

        if (storedToken) {
            // Remove token from localStorage if it's expired
            if (isTokenExpired(storedToken)) {
                localStorage.removeItem("token")
            } else {
                setToken(storedToken)
                setIsPending(false)
            }
        }

        // Clear Token if there is no session
        if (!session) {
            if (!sessionPending) {
                setToken(null)
                localStorage.removeItem("token")
                setIsPending(false)
            }

            return
        }

        // Refresh the token if there is a session
        refreshToken()
    }, [session, sessionPending])

    // Refresh the token when it expires
    useEffect(() => {
        if (!token) return
        setIsPending(false)

        const payload = decodeJwt(token)
        if (!payload?.exp) return

        const expirationTime = payload.exp * 1000
        const currentTime = Date.now()
        const timeoutDuration = expirationTime - currentTime

        const timeoutId = setTimeout(() => {
            localStorage.removeItem("token")
            setToken(null)
            refreshToken()
        }, timeoutDuration)

        return () => clearTimeout(timeoutId)
    }, [token])

    return { token, isPending }
}
@daveycodez commented on GitHub (Dec 21, 2024): @Bekacru `use-token.ts` ```ts import { useEffect, useState } from "react" import { decodeJwt } from "jose" import { useSession } from "@/lib/auth-client" import { getURL } from "@/lib/utils" export const getToken = async (): Promise<string | null> => { // Check if token is stored in localStorage const storedToken = localStorage.getItem("token") // Remove token from localStorage if it's expired, otherwise return it if (storedToken) { if (isTokenExpired(storedToken)) { localStorage.removeItem("token") } else { return storedToken } } // Fetch the token const apiUrl = getURL() + "/api/auth/token" const response = await fetchWithRetry(apiUrl, { credentials: "include" }) if (!response.ok) return null // Store token in localStorage and return it const data = await response.json() if (!data.token) return null localStorage.setItem("token", data.token) return data.token } const isTokenExpired = (token: string) => { const payload = decodeJwt(token) if (!payload?.exp) return true const currentTime = Math.floor(Date.now() / 1000) return payload.exp < currentTime } export const useToken = () => { const { data: session, isPending: sessionPending } = useSession() const [token, setToken] = useState<string | null>(null) const [isPending, setIsPending] = useState(sessionPending) const refreshToken = async () => { const token = await getToken() setToken(token) setIsPending(false) } // If the token sub is different from the session user id, clear the token useEffect(() => { if (!token || !session) return const tokenPayload = decodeJwt(token) if (tokenPayload?.sub !== session.user.id) { setToken(null) localStorage.removeItem("token") } }, [session, token]) // Initialize the token useEffect(() => { // Check if token is stored in localStorage on mount const storedToken = localStorage.getItem("token") if (storedToken) { // Remove token from localStorage if it's expired if (isTokenExpired(storedToken)) { localStorage.removeItem("token") } else { setToken(storedToken) setIsPending(false) } } // Clear Token if there is no session if (!session) { if (!sessionPending) { setToken(null) localStorage.removeItem("token") setIsPending(false) } return } // Refresh the token if there is a session refreshToken() }, [session, sessionPending]) // Refresh the token when it expires useEffect(() => { if (!token) return setIsPending(false) const payload = decodeJwt(token) if (!payload?.exp) return const expirationTime = payload.exp * 1000 const currentTime = Date.now() const timeoutDuration = expirationTime - currentTime const timeoutId = setTimeout(() => { localStorage.removeItem("token") setToken(null) refreshToken() }, timeoutDuration) return () => clearTimeout(timeoutId) }, [token]) return { token, isPending } } ```
Author
Owner

@daveycodez commented on GitHub (Dec 26, 2024):

Here is how I am re-signing JWT's server side for my Neon proxy. I'm adding "proxy": true to the JWT and checking that in RLS to prevent direct access to the database but still be able to use Drizzle on the client side, this way I can apply rate limiting in my Next.js middleware until Neon releases their official proxy. I can also add options to allow certain roles to ping the database directly from React which is cool (extremely low latency)

pages/api/sql.ts

import { jwks } from "@/db/schema"
import { allowedOrigins, corsOptions } from "@/middleware"
import { neonConfig } from "@neondatabase/serverless"
import { symmetricDecrypt } from "better-auth/crypto"
import { jwtVerify, importJWK, SignJWT } from "jose"
import { NextRequest } from "next/server"
import { db } from "@/db/database"
import { InferSelectModel } from "drizzle-orm"

const databaseUrl = new URL(process.env.DATABASE_AUTHENTICATED_URL!)
const fetchEndpoint = typeof neonConfig.fetchEndpoint === "function"
    ? neonConfig.fetchEndpoint(databaseUrl.host, databaseUrl.port, { jwtAuth: true })
    : neonConfig.fetchEndpoint

export const config = { runtime: "edge" }

let cachedJwks: InferSelectModel<typeof jwks> | undefined

export default async function handler(req: NextRequest) {
    const authorizationHeader = req.headers.get("authorization")
    const token = authorizationHeader?.split("Bearer ")[1]

    if (!token) throw new Error("No token provided")

    if (!cachedJwks) {
        cachedJwks = await db.query.jwks.findFirst()
        if (!cachedJwks) throw new Error("No JWKS found in the database")
    }

    const publicKey = await importJWK(JSON.parse(cachedJwks.publicKey), "ES256")

    // Verify the JWT using the public key extracted from JWKS
    let payload
    try {
        const { payload: verifiedPayload } = await jwtVerify(token, publicKey, { algorithms: ["ES256"] })
        payload = verifiedPayload
    } catch (error) {
        throw new Error("JWT verification failed: " + (error as Error).message)
    }

    payload.proxy = true

    const privateWebKey = await symmetricDecrypt({
        key: process.env.BETTER_AUTH_SECRET!,
        data: JSON.parse(cachedJwks.privateKey),
    })

    const privateKey = await importJWK(JSON.parse(privateWebKey), "ES256")

    const jwt = await new SignJWT(payload)
        .setProtectedHeader({
            alg: "ES256",
            kid: cachedJwks.id,
        })
        .setIssuedAt()
        .sign(privateKey)

    const response = await fetch(fetchEndpoint, {
        method: req.method,
        headers: {
            "content-length": req.headers.get("content-length") || "",
            "content-type": req.headers.get("content-type") || "",
            "authorization": "Bearer " + jwt,
            "neon-array-mode": req.headers.get("neon-array-mode") || "",
            "neon-connection-string": process.env.DATABASE_AUTHENTICATED_URL!,
            "neon-raw-text-output": req.headers.get("neon-raw-text-output") || "",
        },
        body: req.body,
    })

    // Append corsOptions to the response
    const origin = req.headers.get("origin") ?? ""
    if (allowedOrigins.includes(origin)) {
        response.headers.set("Access-Control-Allow-Origin", origin)
    }

    for (const [key, value] of Object.entries(corsOptions)) {
        response.headers.set(key, value)
    }

    return response
}

database-client.ts (Safe to use directly in React client side)

import { neon, neonConfig } from "@neondatabase/serverless"
import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "@/db/schema"
import * as relations from "@/db/relations"
import { getURL } from "@/lib/utils"

if (typeof window !== "undefined") {
    neonConfig.fetchEndpoint = getURL() + "/api/sql"
}

const clientSql = neon(
    "postgres://foo@bar", {
    authToken: () => {
        return localStorage.getItem("token") || ""
    }
})

export const clientDb = drizzle({ client: clientSql, schema: { ...schema, ...relations } })
@daveycodez commented on GitHub (Dec 26, 2024): Here is how I am re-signing JWT's server side for my Neon proxy. I'm adding "proxy": true to the JWT and checking that in RLS to prevent direct access to the database but still be able to use Drizzle on the client side, this way I can apply rate limiting in my Next.js middleware until Neon releases their official proxy. I can also add options to allow certain roles to ping the database directly from React which is cool (extremely low latency) pages/api/sql.ts ```ts import { jwks } from "@/db/schema" import { allowedOrigins, corsOptions } from "@/middleware" import { neonConfig } from "@neondatabase/serverless" import { symmetricDecrypt } from "better-auth/crypto" import { jwtVerify, importJWK, SignJWT } from "jose" import { NextRequest } from "next/server" import { db } from "@/db/database" import { InferSelectModel } from "drizzle-orm" const databaseUrl = new URL(process.env.DATABASE_AUTHENTICATED_URL!) const fetchEndpoint = typeof neonConfig.fetchEndpoint === "function" ? neonConfig.fetchEndpoint(databaseUrl.host, databaseUrl.port, { jwtAuth: true }) : neonConfig.fetchEndpoint export const config = { runtime: "edge" } let cachedJwks: InferSelectModel<typeof jwks> | undefined export default async function handler(req: NextRequest) { const authorizationHeader = req.headers.get("authorization") const token = authorizationHeader?.split("Bearer ")[1] if (!token) throw new Error("No token provided") if (!cachedJwks) { cachedJwks = await db.query.jwks.findFirst() if (!cachedJwks) throw new Error("No JWKS found in the database") } const publicKey = await importJWK(JSON.parse(cachedJwks.publicKey), "ES256") // Verify the JWT using the public key extracted from JWKS let payload try { const { payload: verifiedPayload } = await jwtVerify(token, publicKey, { algorithms: ["ES256"] }) payload = verifiedPayload } catch (error) { throw new Error("JWT verification failed: " + (error as Error).message) } payload.proxy = true const privateWebKey = await symmetricDecrypt({ key: process.env.BETTER_AUTH_SECRET!, data: JSON.parse(cachedJwks.privateKey), }) const privateKey = await importJWK(JSON.parse(privateWebKey), "ES256") const jwt = await new SignJWT(payload) .setProtectedHeader({ alg: "ES256", kid: cachedJwks.id, }) .setIssuedAt() .sign(privateKey) const response = await fetch(fetchEndpoint, { method: req.method, headers: { "content-length": req.headers.get("content-length") || "", "content-type": req.headers.get("content-type") || "", "authorization": "Bearer " + jwt, "neon-array-mode": req.headers.get("neon-array-mode") || "", "neon-connection-string": process.env.DATABASE_AUTHENTICATED_URL!, "neon-raw-text-output": req.headers.get("neon-raw-text-output") || "", }, body: req.body, }) // Append corsOptions to the response const origin = req.headers.get("origin") ?? "" if (allowedOrigins.includes(origin)) { response.headers.set("Access-Control-Allow-Origin", origin) } for (const [key, value] of Object.entries(corsOptions)) { response.headers.set(key, value) } return response } ``` database-client.ts _(Safe to use directly in React client side)_ ```ts import { neon, neonConfig } from "@neondatabase/serverless" import { drizzle } from "drizzle-orm/neon-http" import * as schema from "@/db/schema" import * as relations from "@/db/relations" import { getURL } from "@/lib/utils" if (typeof window !== "undefined") { neonConfig.fetchEndpoint = getURL() + "/api/sql" } const clientSql = neon( "postgres://foo@bar", { authToken: () => { return localStorage.getItem("token") || "" } }) export const clientDb = drizzle({ client: clientSql, schema: { ...schema, ...relations } }) ```
Author
Owner

@daveycodez commented on GitHub (Dec 26, 2024):

It would be so nice to have a verifyToken & signToken functions in the plugin that did this for me (including caching the JWKS server side to prevent additional queries). I copied a lot of the code from getToken endpoint to re-sign it here.

@daveycodez commented on GitHub (Dec 26, 2024): It would be so nice to have a verifyToken & signToken functions in the plugin that did this for me (including caching the JWKS server side to prevent additional queries). I copied a lot of the code from getToken endpoint to re-sign it here.
Author
Owner

@dosubot[bot] commented on GitHub (Jun 14, 2025):

Hi, @daveycodez. I'm Dosu, and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale.

Issue Summary:

  • You requested enhanced JWT support to improve server-side token fetching and React integration.
  • Suggested functions include getToken, useToken, decodeToken, and signToken.
  • Shared a workaround using React hooks and server-side JWT re-signing for a Neon proxy.
  • Community interest shown by positive reactions from users like @Infiee and @Alex-ray.

Next Steps:

  • Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here.
  • If no updates are provided, the issue will be automatically closed in 7 days.

Thank you for your understanding and contribution!

@dosubot[bot] commented on GitHub (Jun 14, 2025): Hi, @daveycodez. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale. **Issue Summary:** - You requested enhanced JWT support to improve server-side token fetching and React integration. - Suggested functions include `getToken`, `useToken`, `decodeToken`, and `signToken`. - Shared a workaround using React hooks and server-side JWT re-signing for a Neon proxy. - Community interest shown by positive reactions from users like @Infiee and @Alex-ray. **Next Steps:** - Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here. - If no updates are provided, the issue will be automatically closed in 7 days. Thank you for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#428