PromptConduit Auth Hardening
If your company puts an AI coding analytics tool behind SSO or shared accounts, the security questionnaire is only the start. You still need to know what happens when someone is deactivated, when a bot floods sign-in, or when a session token lands in a browser URL.
PromptConduit is a TypeScript API on Cloudflare Workers with D1. Sign-in is OAuth with Google and GitHub on the web, plus an RFC 8628 device flow for promptconduit login. There is no password login, no refresh tokens, and no SAML. I still had gaps worth fixing. Three PRs merged in our private platform repo on October 14, 2026: #244, #245, and #247. I cannot link the repo here. This post is what shipped.
PromptConduit now rejects deactivated or soft-deleted users on every credential check (#244), rate-limits unauthenticated auth endpoints by IP (#245, with exchange added in #247), and uses PKCE on Google/GitHub sign-in plus a one-time exchange code so the JWT never rides in the redirect URL (#247). Ask any vendor in this space for the same behaviors.
Motivation
Engineering teams wire Claude Code, Cursor, or Copilot into their workflow, then add something that aggregates prompt metadata. Procurement asks about SSO. Even when the product only offers Google or GitHub login today, the questions overlap: offboarding, automated sign-in floods, and tokens leaking through URLs or logs.
PromptConduit holds team usage data. It is not the model provider. The failure modes below are the ones I actually hit in code review, not slide-deck hypotheticals.
What
Three PRs, three fixes.
#244 — Account status on every credential check. getUserById used to ignore is_active and deleted_at, so deactivated or soft-deleted users kept working through bearer JWTs, the session cookie, and API keys.
#245 — Per-IP rate limits on unauthenticated auth routes. OAuth sign-in starts, device-code creation, device token polling, and device authorize. OAuth callbacks stay unlimited. A later slice in #247 added the exchange endpoint.
#247 — PKCE on Google/GitHub sign-in, and JWT out of the redirect URL. PromptConduit is the OAuth client of those providers, not an authorization server. The CLI keeps using the device flow; it does not use PKCE. The web callback also stopped putting the session JWT in query parameters.
If you evaluate SaaS that sits next to AI coding tools, ask vendors to explain these three areas in plain language.
How
Deactivated users (#244)
All three credential types go through authenticateRequest. Device-flow approval uses it too, so a deactivated user cannot mint new CLI keys. Soft-deleted users do not need a separate API-key revoke pass.
NULL is_active still counts as active, matching the column default, so legacy rows are not locked out. Either is_active === false or a set deleted_at blocks access. The user row was already loaded on each request; this fix did not add another database read.
Trimmed from the real Drizzle path:
const user = await db.select(/* … */).from(users)
.where(eq(users.id, userId))
.get();
// A soft-deleted or deactivated user loses access through every credential
// (JWTs, session cookies, API keys), not just new sign-ins. NULL is_active is
// treated as active, matching the column default, so legacy rows keep access.
if (!user || user.isActive === false || user.deletedAt) return null;
Tests run eleven cases through fake D1 and the real Drizzle stack: three credential types times active, deactivated, and soft-deleted users, plus a legacy NULL row and an unknown user. Revert the fix and six deactivated-or-deleted cases fail.
Rate limits (#245, exchange in #247)
Limits use Cloudflare Workers rate-limit bindings keyed on cf-connecting-ip, wrapped in a small rateLimited(binding, period, handler, onLimited?) helper.
Covered routes: GET /v1/auth/google, GET /v1/auth/github, device code creation, device token polling, device authorize, and POST /v1/auth/exchange. Callbacks from Google and GitHub are not limited.
The point is flood control. Every sign-in start writes a D1 row; a cheap HTTP flood becomes write amplification. Limits also slow guessing of device-flow user_code values. This is not a credential-stuffing story; there are no passwords.
Over the limit, callers get HTTP 429 with Retry-After. Device token polling is different: RFC 8628 slow_down instead of 429, because the CLI backs off on slow_down but treats other non-400 statuses as a failed login. Limits were sized from production traffic so typical office NATs stay clear. I am not publishing thresholds. Counters are approximate per Cloudflare location; they stop floods, not act as a quota.
Expired, unused OAuth states and device codes are cleaned up on a schedule.
PKCE and JWT exchange (#247)
At sign-in start the server generates a code_verifier, stores it on the pending oauth_states row (five-minute lifetime), and sends code_challenge with S256 to Google or GitHub. On callback the server sends code_verifier with the authorization-code grant. The verifier never leaves the server. Sign-ins already in flight at deploy time had no verifier stored; the five-minute state lifetime closed that window. Deploy smoke tests fail if a provider redirect is missing an S256 challenge.
/** S256 code_challenge = BASE64URL(SHA256(ASCII(code_verifier))). */
export async function codeChallengeS256(verifier: string): Promise<string> {
return base64Url(await sha256(verifier));
}
// sign-in start: the verifier stays server-side with the state
const codeVerifier = generateCodeVerifier(); // 32 random bytes, base64url
// …stored on the oauth_states row…
authUrl.searchParams.set('code_challenge', await codeChallengeS256(codeVerifier));
authUrl.searchParams.set('code_challenge_method', 'S256');
// callback: token exchange with Google/GitHub
grant_type: 'authorization_code',
...(stateRecord.codeVerifier ? { code_verifier: stateRecord.codeVerifier } : {})
The second half fixes the redirect leak. The session JWT used to come back as /oauth/callback?token=<JWT>&provider=…&email=…&isNewUser=…, which shows up in history, logs, and Referer. Now the callback is /oauth/callback?code=<exchange code>. The page strips the code with history.replaceState, then POSTs to /v1/auth/exchange. The exchange code is 32 random bytes, single-use, valid for sixty seconds, stored only as a SHA-256 hash. Redemption uses DELETE … RETURNING so concurrent posts cannot double-spend. Expired codes and deactivated users get 401. The JWT returns in the response body; email and isNewUser are not in the URL.
export async function redeemExchangeCode(db: Database, code: string, now = new Date()) {
const row = await db
.delete(oauthExchangeCodes)
.where(eq(oauthExchangeCodes.codeHash, await hashCode(code)))
.returning()
.get();
if (!row || new Date(row.expiresAt).getTime() <= now.getTime()) return null;
return { userId: row.userId, provider: row.provider, isNewUser: row.isNewUser };
}
Summary
Platform PRs #244, #245, and #247 hardened PromptConduit auth: live rejection of deactivated or soft-deleted users on JWTs, session cookies, and API keys; per-IP limits on unauthenticated auth endpoints (with slow_down on device polling); PKCE on Google and GitHub sign-in; and a one-time exchange code so the JWT never sits in the redirect URL. TypeScript snippets above are trimmed from the real code.
Teams rolling out AI coding analytics behind SSO or shared accounts should expect the same baseline from any vendor, even when login is "just" OAuth today.
Related Posts
- PromptConduit: Building Analytics for AI Coding Assistants — What the platform captures and why
- How to Measure AI Coding Assistant Productivity — Metrics framework for teams rolling out AI assistants