Skip to content

auth-clerk

Package @theoven/auth-clerk
Adds to context ctx.user, ctx.auth — it is a provider for auth
Endpoints none — Clerk hosts sign-in
Creates files none
Creates tables none
Status shipped
Terminal window
bun add @theoven/auth @theoven/auth-clerk
src/app.ts
import { createApp, env } from '@theoven/core'
import { auth } from '@theoven/auth'
import { clerkAuth } from '@theoven/auth-clerk'
export const app = createApp().use(
auth(clerkAuth({ issuer: env.url('CLERK_ISSUER') })),
)
.env
CLERK_ISSUER=https://tidy-mole-42.clerk.accounts.dev

That is the whole integration. Your frontend signs in with Clerk and sends the session token; this brick verifies it and puts the user on ctx.user.

Verifies Clerk session tokens on every request and puts the result on ctx.user. Nothing else — Clerk hosts sign-in, your frontend talks to Clerk directly, and this brick is the backend half that decides whether to trust what arrives.

Verification is local: the signing keys are fetched once and cached, so a request costs no round trip to Clerk.

This brick adds no endpoints. Clerk hosts sign-in, sign-up, and sign-out; there is nothing for Oven to mount. That is the design, not a gap — see below.

auth-basic owns everything: users, passwords, sessions, eight endpoints. This brick owns nothing. It cannot sign anyone in or out, because sessions belong to Clerk and the browser talks to Clerk directly.

So it declares those capabilities as absent:

capabilities: { routes: false, signOut: false, refresh: false }

This is also why the brick exists in the first place: a contract that only ever met auth-basic would have been shaped around it. auth-clerk and auth-basic sit at opposite extremes and the same AuthProvider interface fits both, unchanged.

Option Default Purpose
issuer — required; your Clerk instance URL
jwksUrl ${issuer}/.well-known/jwks.json where signing keys are fetched
authorizedParties — origins allowed to mint tokens for this backend
clockSkew 5 seconds of tolerance on exp / nbf
jwksTtl 600000 how long a fetched key set is trusted, in ms
cookie __session cookie checked when there is no bearer token
clerkAuth({
issuer: env.url('CLERK_ISSUER'),
authorizedParties: ['https://app.example.com'],
})

A valid signature proves Clerk minted the token. It does not prove the token was minted for you. Without authorizedParties, a token issued to a different frontend of the same instance is accepted here. It is optional because Clerk omits azp for some token types, and backends that never see a browser would break — but set it if a browser is involved.

Guard a route and read the user, the same as with any provider:

src/routes/me.get.ts
export const auth = true
export default async ({ user }) => ({
clerkId: user.id, // the `sub` claim
email: user.email, // only if your JWT template includes it — see Limitations
})

Anything else in the token is on raw, typed as Clerk’s claims:

src/routes/org/dashboard.get.ts
export const auth = true
export default async ({ user }) => {
const orgId = user.raw.org_id
if (!orgId) throw new Forbidden('This endpoint needs an organization token.')
return { orgId }
}

Roles and organizations are claims, not normalised fields (D17), so anything conditional on them belongs in a named policy:

src/app.ts
auth(clerkAuth({ issuer: env.url('CLERK_ISSUER') }), {
policies: {
orgAdmin: (user) => user.raw.org_role === 'admin',
},
})
src/routes/org/settings.patch.ts
export const auth = 'orgAdmin'

Mirroring Clerk users into your own tables is a webhook you write; this brick does not do it.

Nothing. No files, no tables, no migrations. Users live in Clerk.

Authorization: Bearer <token> first, because that is what an API client sends. Then the __session cookie, which Clerk’s browser SDK sets on same-origin requests. Neither present means an anonymous request, not an error — a public route on the same app still has to work.

No Clerk SDK. This brick is about two hundred lines on WebCrypto, because a framework brick that depends on a vendor SDK owns that SDK’s release cadence and its whole transitive tree.

  1. Fetch the instance’s JWKS and import the RSA keys, cached for ten minutes.
  2. Check the token’s header names RS256 and a kid we hold.
  3. Verify the signature.
  4. Check iss matches your issuer exactly, exp/nbf are within skew, and azp is authorized.

Step 2 matters more than it looks: the algorithm is a field in the token, so trusting it is the classic JWT break — alg: "none" means “no signature”, and an implementation that honours it accepts anything. Both that and an RS256-key-as-HMAC-secret attempt have tests.

An unknown kid usually means Clerk rotated its keys, so the key set is refetched immediately rather than after the TTL — but at most once a minute. Without that limit, a stream of forged kids would be a way to make your server hammer Clerk’s key endpoint on an attacker’s behalf.

identify ✓ — local JWT verification, no call to Clerk per request
routes ✗ — declared false; Clerk hosts sign-in
signOut ✗ — declared false; sessions belong to Clerk
refresh ✗ — declared false; Clerk’s SDK refreshes in the browser

What fails at boot: a route asking for something the provider declares it cannot do fails at startup, naming the provider and the capability — rather than at 3am, in a handler.

The signature path is tested against generated RSA key pairs, so the tokens are really signed and really verified rather than compared to a fixture.

The rejections are the point, and each has its own test: a token signed by a different key, alg: "none", an RS256 key offered as an HMAC secret, an unknown kid, a missing kid, and a malformed token — rejected rather than thrown. Then the claim checks: wrong issuer, expired, not yet valid, no expiry at all, authorizedParties enforced when set, and skew tolerated exactly at the edges.

Key handling: keys cached across requests, an unknown kid triggering at most one refetch a minute, a key server that is down surfacing as an error rather than a silent pass, and a key set with no usable keys treated as an error.

End to end through a real app: a bearer token identifies a request, so does the __session cookie, a guarded route refuses an anonymous request, and a forged token is refused.

No Clerk account is needed for any of this, and none is used.

  • No user data beyond the token. ctx.user.email and .name are present only if your Clerk JWT template includes them; the default template carries just sub. Everything the token does carry is on ctx.user.raw. Fetching a full profile would mean a Clerk API call per request.
  • No server-side sign-out. Sign out through Clerk on the frontend.
  • No organizations or roles built in. They arrive as claims on ctx.user.raw; turn them into a policy yourself.
  • No webhook handling. If you mirror Clerk users into your own database, that endpoint is yours to write.