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 |
Install
Section titled “Install”bun add @theoven/auth @theoven/auth-clerkimport { 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') })),)CLERK_ISSUER=https://tidy-mole-42.clerk.accounts.devThat 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.
What it does
Section titled “What it does”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.
Endpoints
Section titled “Endpoints”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.
The opposite end of the contract
Section titled “The opposite end of the contract”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.
Configuration
Section titled “Configuration”| 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 |
Set authorizedParties
Section titled “Set authorizedParties”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:
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:
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:
auth(clerkAuth({ issuer: env.url('CLERK_ISSUER') }), { policies: { orgAdmin: (user) => user.raw.org_role === 'admin', },})export const auth = 'orgAdmin'Mirroring Clerk users into your own tables is a webhook you write; this brick does not do it.
What it creates
Section titled “What it creates”Nothing. No files, no tables, no migrations. Users live in Clerk.
Where the token comes from
Section titled “Where the token comes from”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.
How verification works
Section titled “How verification works”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.
- Fetch the instance’s JWKS and import the RSA keys, cached for ten minutes.
- Check the token’s header names RS256 and a
kidwe hold. - Verify the signature.
- Check
issmatches your issuer exactly,exp/nbfare within skew, andazpis 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.
Key rotation
Section titled “Key rotation”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.
Capabilities
Section titled “Capabilities”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.
How it is verified
Section titled “How it is verified”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.
Limitations
Section titled “Limitations”- No user data beyond the token.
ctx.user.emailand.nameare present only if your Clerk JWT template includes them; the default template carries justsub. Everything the token does carry is onctx.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.