[GH-ISSUE #6650] [OIDC Provider]: Missing OAuth 2.0 Token Revocation Endpoint (RFC 7009) #10584

Closed
opened 2026-04-13 06:49:20 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @inducingchaos on GitHub (Dec 9, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6650

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure BetterAuth with OIDC provider plugin
  2. Issue OAuth access tokens via the /oauth2/token endpoint
  3. Attempt to revoke tokens using the standard OAuth 2.0 Token Revocation endpoint
  4. Observe that /oauth2/revoke endpoint does not exist

Expected endpoint:


POST /api/auth/oauth2/revoke
Content-Type: application/x-www-form-urlencoded

token=<access_token_or_refresh_token>
token_type_hint=access_token # optional
client_id=<client_id>

Current vs. Expected behavior

Current behavior:
The OIDC plugin does not implement the OAuth 2.0 Token Revocation endpoint (RFC 7009). When users log out or need to revoke tokens, there's no standard way to invalidate tokens server-side. Tokens remain valid in the database until they expire naturally.

Expected behavior:
The OIDC plugin should implement /oauth2/revoke endpoint per RFC 7009 that:

  • Accepts token (required), token_type_hint (optional), and client_id (required for public clients)
  • Validates the token and client_id
  • Deletes the token record from the database
  • Returns 200 OK even for invalid/expired tokens (security best practice to prevent token enumeration)

Impact:

  • Users cannot properly log out (tokens remain valid)
  • Security concern: compromised tokens cannot be immediately revoked
  • No way to implement proper token lifecycle management

Reference implementation:
I've created a polyfill plugin that implements the revocation endpoint. Here's the implementation for reference:

// polyfills/oidc-provider.ts
import type { BetterAuthPlugin } from "better-auth"
import { APIError } from "better-auth"
import { createAuthEndpoint } from "better-auth/plugins"
import { eq, or } from "drizzle-orm"
import { db } from "~/server/data/store"
import { oauthAccessTokens } from "~/server/data/store/tables"

export const oidcProviderPolyfill = () => {
    return {
        id: "oidc-polyfill",
        endpoints: {
            revoke: createAuthEndpoint(
                "/oauth2/revoke",
                {
                    method: "POST",
                    metadata: {
                        allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
                    }
                },
                async ctx => {
                    let body = ctx.body
                    if (body instanceof FormData) body = Object.fromEntries(body.entries())

                    if (!body || typeof body !== "object") {
                        throw new APIError("BAD_REQUEST", {
                            error_description: "request body is required",
                            error: "invalid_request"
                        })
                    }

                    const { token, token_type_hint, client_id } = body as {
                        token?: string | string[]
                        token_type_hint?: string | string[]
                        client_id?: string | string[]
                    }

                    if (!token) {
                        throw new APIError("BAD_REQUEST", {
                            error_description: "token is required",
                            error: "invalid_request"
                        })
                    }

                    const tokenValue = Array.isArray(token) ? token[0] : token
                    const clientIdValue = Array.isArray(client_id) ? client_id[0] : client_id
                    const tokenTypeHint = Array.isArray(token_type_hint) ? token_type_hint[0] : token_type_hint

                    if (!clientIdValue) {
                        throw new APIError("BAD_REQUEST", {
                            error_description: "client_id is required",
                            error: "invalid_request"
                        })
                    }

                    // Find token by type hint for optimization, or search both if no hint
                    const tokenRecord = await db
                        .select()
                        .from(oauthAccessTokens)
                        .where(tokenTypeHint === "refresh_token" ? eq(oauthAccessTokens.refreshToken, tokenValue) : tokenTypeHint === "access_token" ? eq(oauthAccessTokens.accessToken, tokenValue) : or(eq(oauthAccessTokens.accessToken, tokenValue), eq(oauthAccessTokens.refreshToken, tokenValue)))
                        .limit(1)
                        .then(rows => rows[0] ?? null)

                    // Verify client_id matches (security: prevent token theft)
                    if (tokenRecord && tokenRecord.clientId !== clientIdValue) {
                        // RFC 7009: Return 200 OK even for invalid tokens
                        return ctx.json({}, { status: 200 })
                    }

                    // Delete the token if found
                    if (tokenRecord) {
                        await db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.id, tokenRecord.id))
                    }

                    // RFC 7009: Always return 200 OK, even if token is invalid/expired
                    return ctx.json({}, { status: 200 })
                }
            )
        }
    } satisfies BetterAuthPlugin
}

What version of Better Auth are you using?

1.4.5

System info

{                                                       "system": {                                             "platform": "darwin",                                 "arch": "arm64",                                      "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:38:03 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T8112",                                              "release": "25.0.0",                                  "cpuCount": 8,                                        "cpuModel": "Apple M2",                               "totalMemory": "8.00 GB",                             "freeMemory": "0.16 GB"
  },                                                    "node": {                                               "version": "v22.20.0",                                "env": "development"                                },
  "packageManager": {                                     "name": "pnpm",                                       "version": "10.20.0"                                },
  "frameworks": [                                         {                                                       "name": "next",                                       "version": "16.0.8"                                 },                                                    {                                                       "name": "react",                                      "version": "catalog:react"                          }                                                   ],                                                    "databases": [                                          {                                                       "name": "postgres",                                   "version": "^3.4.7"                                 },                                                    {                                                       "name": "drizzle",                                    "version": "catalog:drizzle"                        }                                                   ],                                                    "betterAuth": {                                         "version": "catalog:better-auth",                     "config": {                                             "appName": "ALTERED",                                 "baseURL": "http://localhost:258",                    "secret": "[REDACTED]",                               "socialProviders": {                                    "google": {                                             "clientId": "[REDACTED]",                             "clientSecret": "[REDACTED]"                        }                                                   },                                                    "plugins": [                                            {                                                       "name": "next-cookies",                               "config": {                                             "id": "next-cookies",                                 "hooks": {                                              "after": [                                              {}                                                  ]                                                   }                                                   }                                                   },                                                    {                                                       "name": "oidc",                                       "config": {                                             "id": "oidc",                                         "hooks": {                                              "after": [                                              {}                                                  ]                                                   },                                                    "endpoints": {},                                      "schema": {                                             "oauthApplication": {                                   "modelName": "oauthApplication",                      "fields": {                                             "name": {                                               "type": "string"                                    },                                                    "icon": {                                               "type": "string",                                     "required": false                                   },                                                    "metadata": {                                           "type": "string",                                     "required": false                                   },                                                    "clientId": {
                    "type": "string",                                     "unique": true                                      },                                                    "clientSecret": {
                    "type": "string",
                    "required": false                                   },                                                    "redirectUrls": {                                       "type": "string"                                    },                                                    "type": {                                               "type": "string"                                    },                                                    "disabled": {
                    "type": "boolean",                                    "required": false,                                    "defaultValue": false                               },                                                    "userId": {                                             "type": "string",                                     "required": false,                                    "references": {                                         "model": "user",                                      "field": "id",                                        "onDelete": "cascade"                               },                                                    "index": true                                       },                                                    "createdAt": {                                          "type": "date"                                      },                                                    "updatedAt": {                                          "type": "date"                                      }                                                   }                                                   },                                                    "oauthAccessToken": {                                   "modelName": "oauthAccessToken",                      "fields": {
                  "accessToken": {                                        "type": "string",
                    "unique": true                                      },                                                    "refreshToken": {
                    "type": "string",                                     "unique": true                                      },                                                    "accessTokenExpiresAt": {                               "type": "date"                                      },                                                    "refreshTokenExpiresAt": {                              "type": "date"                                      },                                                    "clientId": {                                           "type": "string",                                     "references": {                                         "model": "oauthApplication",
                      "field": "clientId",
                      "onDelete": "cascade"                               },
                    "index": true
                  },
                  "userId": {                                             "type": "string",
                    "required": false,                                    "references": {
                      "model": "user",
                      "field": "id",
                      "onDelete": "cascade"
                    },
                    "index": true
                  },
                  "scopes": {                                             "type": "string"
                  },
                  "createdAt": {
                    "type": "date"                                      },
                  "updatedAt": {                                          "type": "date"
                  }
                }
              },
              "oauthConsent": {
                "modelName": "oauthConsent",
                "fields": {                                             "clientId": {
                    "type": "string",                                     "references": {
                      "model": "oauthApplication",
                      "field": "clientId",
                      "onDelete": "cascade"
                    },
                    "index": true                                       },
                  "userId": {                                             "type": "string",
                    "references": {
                      "model": "user",
                      "field": "id",
                      "onDelete": "cascade"
                    },                                                    "index": true
                  },                                                    "scopes": {
                    "type": "string"
                  },
                  "createdAt": {
                    "type": "date"
                  },                                                    "updatedAt": {
                    "type": "date"                                      },
                  "consentGiven": {
                    "type": "boolean"
                  }
                }
              }                                                   },
            "options": {                                            "codeExpiresIn": 600,
              "defaultScope": "openid",
              "accessTokenExpiresIn": 3600,
              "refreshTokenExpiresIn": 604800,                      "allowPlainCodeChallengeMethod": true,
              "storeClientSecret": "[REDACTED]",                    "loginPage": "/sign-in",
              "trustedClients": [
                {
                  "clientId": "[REDACTED]",
                  "name": "ALTERED for Raycast",
                  "type": "public",
                  "clientSecret": "[REDACTED]",
                  "icon": "icon.png",                                   "redirectUrls": [
                    "https://raycast.com/redirect?packageName=Extension"
                  ],
                  "disabled": false,
                  "skipConsent": true,                                  "metadata": {
                    "platform": "raycast"
                  }                                                   }
              ],                                                    "scopes": [
                "openid",                                             "profile",
                "email",
                "offline_access"
              ]
            }
          }
        },
        {
          "name": "oidc-polyfill",                              "config": {
            "id": "oidc-polyfill",                                "endpoints": {}
          }
        }
      ],                                                    "advanced": {                                           "database": {}                                      }
    }
  }
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { oidcProvider } from "better-auth/plugins"
import { db } from "~/server/data/store"

export const auth = betterAuth({
    database: drizzleAdapter(db, {
        provider: "pg",
        usePlural: true
    }),
    plugins: [
        oidcProvider({
            loginPage: "/sign-in",
            trustedClients: [
                {
                    clientId: "altered-launcher",
                    name: "ALTERED for Raycast",
                    type: "public",
                    clientSecret: "placeholder-secret",
                    redirectUrls: ["https://raycast.com/redirect?packageName=Extension"],
                    disabled: false,
                    skipConsent: true,
                    metadata: { platform: "raycast" }
                }
            ]
        })
    ]
})

Additional context

RFC 7009 Compliance:
The OAuth 2.0 Token Revocation specification (RFC 7009) is a standard that should be implemented by OAuth 2.0 providers. It's particularly important for:

  • Native/mobile applications that need to log users out
  • Security: ability to immediately revoke compromised tokens
  • Token lifecycle management

Current OIDC endpoints:

  • /oauth2/authorize - Authorization endpoint
  • /oauth2/token - Token endpoint
  • /oauth2/userinfo - UserInfo endpoint
  • /oauth2/consent - Consent endpoint
  • /oauth2/register - Client registration
  • /oauth2/endsession - RP-Initiated Logout
  • /oauth2/revoke - Missing Token Revocation endpoint

Related:

Originally created by @inducingchaos on GitHub (Dec 9, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6650 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure BetterAuth with OIDC provider plugin 2. Issue OAuth access tokens via the `/oauth2/token` endpoint 3. Attempt to revoke tokens using the standard OAuth 2.0 Token Revocation endpoint 4. Observe that `/oauth2/revoke` endpoint does not exist **Expected endpoint:** ``` POST /api/auth/oauth2/revoke Content-Type: application/x-www-form-urlencoded token=<access_token_or_refresh_token> token_type_hint=access_token # optional client_id=<client_id> ``` ### Current vs. Expected behavior **Current behavior:** The OIDC plugin does not implement the OAuth 2.0 Token Revocation endpoint (RFC 7009). When users log out or need to revoke tokens, there's no standard way to invalidate tokens server-side. Tokens remain valid in the database until they expire naturally. **Expected behavior:** The OIDC plugin should implement `/oauth2/revoke` endpoint per RFC 7009 that: - Accepts `token` (required), `token_type_hint` (optional), and `client_id` (required for public clients) - Validates the token and client_id - Deletes the token record from the database - Returns `200 OK` even for invalid/expired tokens (security best practice to prevent token enumeration) **Impact:** - Users cannot properly log out (tokens remain valid) - Security concern: compromised tokens cannot be immediately revoked - No way to implement proper token lifecycle management **Reference implementation:** I've created a polyfill plugin that implements the revocation endpoint. Here's the implementation for reference: ```typescript // polyfills/oidc-provider.ts import type { BetterAuthPlugin } from "better-auth" import { APIError } from "better-auth" import { createAuthEndpoint } from "better-auth/plugins" import { eq, or } from "drizzle-orm" import { db } from "~/server/data/store" import { oauthAccessTokens } from "~/server/data/store/tables" export const oidcProviderPolyfill = () => { return { id: "oidc-polyfill", endpoints: { revoke: createAuthEndpoint( "/oauth2/revoke", { method: "POST", metadata: { allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"] } }, async ctx => { let body = ctx.body if (body instanceof FormData) body = Object.fromEntries(body.entries()) if (!body || typeof body !== "object") { throw new APIError("BAD_REQUEST", { error_description: "request body is required", error: "invalid_request" }) } const { token, token_type_hint, client_id } = body as { token?: string | string[] token_type_hint?: string | string[] client_id?: string | string[] } if (!token) { throw new APIError("BAD_REQUEST", { error_description: "token is required", error: "invalid_request" }) } const tokenValue = Array.isArray(token) ? token[0] : token const clientIdValue = Array.isArray(client_id) ? client_id[0] : client_id const tokenTypeHint = Array.isArray(token_type_hint) ? token_type_hint[0] : token_type_hint if (!clientIdValue) { throw new APIError("BAD_REQUEST", { error_description: "client_id is required", error: "invalid_request" }) } // Find token by type hint for optimization, or search both if no hint const tokenRecord = await db .select() .from(oauthAccessTokens) .where(tokenTypeHint === "refresh_token" ? eq(oauthAccessTokens.refreshToken, tokenValue) : tokenTypeHint === "access_token" ? eq(oauthAccessTokens.accessToken, tokenValue) : or(eq(oauthAccessTokens.accessToken, tokenValue), eq(oauthAccessTokens.refreshToken, tokenValue))) .limit(1) .then(rows => rows[0] ?? null) // Verify client_id matches (security: prevent token theft) if (tokenRecord && tokenRecord.clientId !== clientIdValue) { // RFC 7009: Return 200 OK even for invalid tokens return ctx.json({}, { status: 200 }) } // Delete the token if found if (tokenRecord) { await db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.id, tokenRecord.id)) } // RFC 7009: Always return 200 OK, even if token is invalid/expired return ctx.json({}, { status: 200 }) } ) } } satisfies BetterAuthPlugin } ``` ### What version of Better Auth are you using? 1.4.5 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:38:03 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T8112", "release": "25.0.0", "cpuCount": 8, "cpuModel": "Apple M2", "totalMemory": "8.00 GB", "freeMemory": "0.16 GB" }, "node": { "version": "v22.20.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.20.0" }, "frameworks": [ { "name": "next", "version": "16.0.8" }, { "name": "react", "version": "catalog:react" } ], "databases": [ { "name": "postgres", "version": "^3.4.7" }, { "name": "drizzle", "version": "catalog:drizzle" } ], "betterAuth": { "version": "catalog:better-auth", "config": { "appName": "ALTERED", "baseURL": "http://localhost:258", "secret": "[REDACTED]", "socialProviders": { "google": { "clientId": "[REDACTED]", "clientSecret": "[REDACTED]" } }, "plugins": [ { "name": "next-cookies", "config": { "id": "next-cookies", "hooks": { "after": [ {} ] } } }, { "name": "oidc", "config": { "id": "oidc", "hooks": { "after": [ {} ] }, "endpoints": {}, "schema": { "oauthApplication": { "modelName": "oauthApplication", "fields": { "name": { "type": "string" }, "icon": { "type": "string", "required": false }, "metadata": { "type": "string", "required": false }, "clientId": { "type": "string", "unique": true }, "clientSecret": { "type": "string", "required": false }, "redirectUrls": { "type": "string" }, "type": { "type": "string" }, "disabled": { "type": "boolean", "required": false, "defaultValue": false }, "userId": { "type": "string", "required": false, "references": { "model": "user", "field": "id", "onDelete": "cascade" }, "index": true }, "createdAt": { "type": "date" }, "updatedAt": { "type": "date" } } }, "oauthAccessToken": { "modelName": "oauthAccessToken", "fields": { "accessToken": { "type": "string", "unique": true }, "refreshToken": { "type": "string", "unique": true }, "accessTokenExpiresAt": { "type": "date" }, "refreshTokenExpiresAt": { "type": "date" }, "clientId": { "type": "string", "references": { "model": "oauthApplication", "field": "clientId", "onDelete": "cascade" }, "index": true }, "userId": { "type": "string", "required": false, "references": { "model": "user", "field": "id", "onDelete": "cascade" }, "index": true }, "scopes": { "type": "string" }, "createdAt": { "type": "date" }, "updatedAt": { "type": "date" } } }, "oauthConsent": { "modelName": "oauthConsent", "fields": { "clientId": { "type": "string", "references": { "model": "oauthApplication", "field": "clientId", "onDelete": "cascade" }, "index": true }, "userId": { "type": "string", "references": { "model": "user", "field": "id", "onDelete": "cascade" }, "index": true }, "scopes": { "type": "string" }, "createdAt": { "type": "date" }, "updatedAt": { "type": "date" }, "consentGiven": { "type": "boolean" } } } }, "options": { "codeExpiresIn": 600, "defaultScope": "openid", "accessTokenExpiresIn": 3600, "refreshTokenExpiresIn": 604800, "allowPlainCodeChallengeMethod": true, "storeClientSecret": "[REDACTED]", "loginPage": "/sign-in", "trustedClients": [ { "clientId": "[REDACTED]", "name": "ALTERED for Raycast", "type": "public", "clientSecret": "[REDACTED]", "icon": "icon.png", "redirectUrls": [ "https://raycast.com/redirect?packageName=Extension" ], "disabled": false, "skipConsent": true, "metadata": { "platform": "raycast" } } ], "scopes": [ "openid", "profile", "email", "offline_access" ] } } }, { "name": "oidc-polyfill", "config": { "id": "oidc-polyfill", "endpoints": {} } } ], "advanced": { "database": {} } } } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { oidcProvider } from "better-auth/plugins" import { db } from "~/server/data/store" export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", usePlural: true }), plugins: [ oidcProvider({ loginPage: "/sign-in", trustedClients: [ { clientId: "altered-launcher", name: "ALTERED for Raycast", type: "public", clientSecret: "placeholder-secret", redirectUrls: ["https://raycast.com/redirect?packageName=Extension"], disabled: false, skipConsent: true, metadata: { platform: "raycast" } } ] }) ] }) ``` ### Additional context **RFC 7009 Compliance:** The OAuth 2.0 Token Revocation specification (RFC 7009) is a standard that should be implemented by OAuth 2.0 providers. It's particularly important for: - Native/mobile applications that need to log users out - Security: ability to immediately revoke compromised tokens - Token lifecycle management **Current OIDC endpoints:** - ✅ `/oauth2/authorize` - Authorization endpoint - ✅ `/oauth2/token` - Token endpoint - ✅ `/oauth2/userinfo` - UserInfo endpoint - ✅ `/oauth2/consent` - Consent endpoint - ✅ `/oauth2/register` - Client registration - ✅ `/oauth2/endsession` - RP-Initiated Logout - ❌ `/oauth2/revoke` - **Missing** Token Revocation endpoint **Related:** - RFC 7009: https://datatracker.ietf.org/doc/html/rfc7009 - BetterAuth OIDC plugin source: https://github.com/better-auth/better-auth/blob/5f89bfa076dbb71959a2582b43d13e49dddf0856/packages/better-auth/src/plugins/oidc-provider/index.ts
GiteaMirror added the locked label 2026-04-13 06:49:20 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 9, 2025):

No reply.

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3634685149 --> @better-auth-agent[bot] commented on GitHub (Dec 9, 2025): No reply. _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@dvanmali commented on GitHub (Dec 15, 2025):

Same as #4624, addressed in #4163

<!-- gh-comment-id:3656690061 --> @dvanmali commented on GitHub (Dec 15, 2025): Same as #4624, addressed in #4163
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10584