|
| 1 | +--- |
| 2 | +title: Better Auth |
| 3 | +--- |
| 4 | + |
| 5 | +import { |
| 6 | +StepByStep, |
| 7 | +Step, |
| 8 | +StepText, |
| 9 | +StepCode, |
| 10 | +} from '@site/src/components/Steps'; |
| 11 | +import Tabs from '@theme/Tabs'; |
| 12 | +import TabItem from '@theme/TabItem'; |
| 13 | + |
| 14 | +[Better Auth](https://www.better-auth.com/) is a TypeScript authentication |
| 15 | +framework that can act as an OAuth 2.1/OIDC provider. SpacetimeDB can authenticate |
| 16 | +Better Auth users when Better Auth issues a JWT with: |
| 17 | + |
| 18 | +- a stable `iss` issuer, |
| 19 | +- a stable `sub` subject, |
| 20 | +- an `aud` audience you check in your module, |
| 21 | +- and a JWKS endpoint SpacetimeDB can use to verify the token signature. |
| 22 | + |
| 23 | +This guide shows the OAuth/OIDC provider pattern. Your application signs users in |
| 24 | +with Better Auth, obtains an OIDC token, and passes that token to the SpacetimeDB |
| 25 | +client connection. |
| 26 | + |
| 27 | +::::warning |
| 28 | +SpacetimeDB verifies JWTs through OIDC discovery and JWKS metadata. Opaque access |
| 29 | +tokens cannot be validated this way. Make sure the token you pass to SpacetimeDB |
| 30 | +is a JWT issued by Better Auth and signed by a key published in Better Auth's |
| 31 | +JWKS. |
| 32 | +:::: |
| 33 | + |
| 34 | +## Prerequisites |
| 35 | + |
| 36 | +We assume you have the following prerequisites in place: |
| 37 | + |
| 38 | +- A working SpacetimeDB project. |
| 39 | +- A Better Auth application with a working sign-in flow. |
| 40 | +- A public URL for your Better Auth server. |
| 41 | +- An OAuth/OIDC client library for your frontend, backend, CLI, or native app. |
| 42 | + |
| 43 | +SpacetimeDB validates the token by fetching OIDC metadata from the token issuer, |
| 44 | +so the issuer URL must be reachable by the SpacetimeDB server. |
| 45 | + |
| 46 | +## OAuth/OIDC flow overview |
| 47 | + |
| 48 | +The integration has four parts: |
| 49 | + |
| 50 | +1. Configure Better Auth as an OAuth/OIDC provider. |
| 51 | +2. Publish Better Auth OIDC metadata and JWKS. |
| 52 | +3. Create an OAuth client for the application that will connect to SpacetimeDB. |
| 53 | +4. Obtain a Better Auth token and pass it to SpacetimeDB with `.withToken(...)`. |
| 54 | + |
| 55 | +The examples below use placeholder URLs: |
| 56 | + |
| 57 | +```txt |
| 58 | +Better Auth issuer: https://app.example.com/api/auth |
| 59 | +OAuth client ID: <YOUR_BETTER_AUTH_CLIENT_ID> |
| 60 | +SpacetimeDB URL: <YOUR_SPACETIMEDB_URL> |
| 61 | +Module name: <YOUR_MODULE_NAME> |
| 62 | +``` |
| 63 | + |
| 64 | +Use the exact same issuer value everywhere. The issuer must match the token's |
| 65 | +`iss` claim and the OIDC discovery document's `issuer` field. |
| 66 | + |
| 67 | +<StepByStep> |
| 68 | + |
| 69 | +<Step title="Install Better Auth OIDC packages"> |
| 70 | +<StepText> |
| 71 | +Install Better Auth and the OAuth Provider plugin on your auth server. |
| 72 | + |
| 73 | +Your client application may use any OAuth/OIDC client library. For browser apps, |
| 74 | +choose a library that supports Authorization Code with PKCE. |
| 75 | + |
| 76 | +</StepText> |
| 77 | +<StepCode> |
| 78 | +<Tabs groupId="package-manager" defaultValue="NPM"> <TabItem value="NPM" label="NPM"> |
| 79 | + |
| 80 | +```bash |
| 81 | +npm add better-auth @better-auth/oauth-provider |
| 82 | +``` |
| 83 | + |
| 84 | +</TabItem> |
| 85 | +<TabItem value="Yarn" label="Yarn"> |
| 86 | +```bash |
| 87 | +yarn add better-auth @better-auth/oauth-provider |
| 88 | +``` |
| 89 | +</TabItem> |
| 90 | +<TabItem value="PNPM" label="PNPM"> |
| 91 | + |
| 92 | +```bash |
| 93 | +pnpm add better-auth @better-auth/oauth-provider |
| 94 | +``` |
| 95 | + |
| 96 | +</TabItem> |
| 97 | +<TabItem value="Bun" label="Bun"> |
| 98 | + |
| 99 | +```bash |
| 100 | +bun add better-auth @better-auth/oauth-provider |
| 101 | +``` |
| 102 | + |
| 103 | +</TabItem> |
| 104 | +</Tabs> |
| 105 | +</StepCode> |
| 106 | +</Step> |
| 107 | + |
| 108 | +<Step title="Configure Better Auth as an OIDC provider"> |
| 109 | +<StepText> |
| 110 | +Add the Better Auth JWT and OAuth Provider plugins. |
| 111 | + |
| 112 | +The OAuth Provider plugin exposes the OAuth/OIDC authorization flow. The JWT |
| 113 | +plugin signs the token that SpacetimeDB will validate. |
| 114 | + |
| 115 | +For new integrations, prefer the OAuth Provider plugin over the older OIDC |
| 116 | +Provider plugin. |
| 117 | + |
| 118 | +</StepText> |
| 119 | +<StepCode> |
| 120 | + |
| 121 | +```typescript title="auth.ts" |
| 122 | +import { betterAuth } from 'better-auth'; |
| 123 | +import { jwt } from 'better-auth/plugins'; |
| 124 | +import { oauthProvider } from '@better-auth/oauth-provider'; |
| 125 | + |
| 126 | +export const auth = betterAuth({ |
| 127 | + // ... your existing Better Auth configuration |
| 128 | + |
| 129 | + // OAuth Provider mode uses its own token endpoint. |
| 130 | + disabledPaths: ['/token'], |
| 131 | + |
| 132 | + plugins: [ |
| 133 | + jwt({ |
| 134 | + jwks: { |
| 135 | + keyPairConfig: { |
| 136 | + // Prefer an asymmetric algorithm whose public keys can be published |
| 137 | + // through JWKS. |
| 138 | + alg: 'ES256', |
| 139 | + }, |
| 140 | + }, |
| 141 | + }), |
| 142 | + |
| 143 | + oauthProvider({ |
| 144 | + loginPage: '/sign-in', |
| 145 | + consentPage: '/consent', |
| 146 | + |
| 147 | + scopes: ['openid', 'profile', 'email'], |
| 148 | + }), |
| 149 | + ], |
| 150 | +}); |
| 151 | +``` |
| 152 | + |
| 153 | +</StepCode> |
| 154 | +</Step> |
| 155 | + |
| 156 | +<Step title="Expose Better Auth OIDC metadata"> |
| 157 | +<StepText> |
| 158 | +SpacetimeDB validates external JWTs by reading: |
| 159 | + |
| 160 | +```txt |
| 161 | +<issuer>/.well-known/openid-configuration |
| 162 | +``` |
| 163 | + |
| 164 | +It then follows the discovery document's `jwks_uri` to fetch the public signing |
| 165 | +keys. |
| 166 | + |
| 167 | +Expose the Better Auth metadata routes using your framework's routing mechanism. |
| 168 | +The example below uses Next.js route handlers, but the same endpoints can be |
| 169 | +served from any framework. |
| 170 | + |
| 171 | +</StepText> |
| 172 | +<StepCode> |
| 173 | + |
| 174 | +```typescript title="app/api/auth/.well-known/openid-configuration/route.ts" |
| 175 | +import { oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider'; |
| 176 | +import { auth } from '@/lib/auth'; |
| 177 | + |
| 178 | +export const GET = oauthProviderOpenIdConfigMetadata(auth); |
| 179 | +``` |
| 180 | + |
| 181 | +```typescript title="app/.well-known/oauth-authorization-server/api/auth/route.ts" |
| 182 | +import { oauthProviderAuthServerMetadata } from '@better-auth/oauth-provider'; |
| 183 | +import { auth } from '@/lib/auth'; |
| 184 | + |
| 185 | +export const GET = oauthProviderAuthServerMetadata(auth); |
| 186 | +``` |
| 187 | + |
| 188 | +</StepCode> |
| 189 | +</Step> |
| 190 | + |
| 191 | +<Step title="Create an OAuth client"> |
| 192 | +<StepText> |
| 193 | +Create an OAuth client for the application that will request the token. |
| 194 | + |
| 195 | +For browser and native applications, use a public client with |
| 196 | +`token_endpoint_auth_method: "none"` and Authorization Code with PKCE. |
| 197 | + |
| 198 | +Run this from trusted server-side code, such as an admin script or admin route. |
| 199 | +Do not create OAuth clients from browser code. |
| 200 | + |
| 201 | +</StepText> |
| 202 | +<StepCode> |
| 203 | + |
| 204 | +```typescript |
| 205 | +const client = await auth.api.adminCreateOAuthClient({ |
| 206 | + headers, |
| 207 | + body: { |
| 208 | + client_name: 'SpacetimeDB App', |
| 209 | + redirect_uris: [ |
| 210 | + 'http://localhost:5173', |
| 211 | + 'https://app.example.com/callback', |
| 212 | + ], |
| 213 | + token_endpoint_auth_method: 'none', |
| 214 | + skip_consent: true, |
| 215 | + }, |
| 216 | +}); |
| 217 | + |
| 218 | +console.log(client.client_id); |
| 219 | +``` |
| 220 | + |
| 221 | +</StepCode> |
| 222 | +</Step> |
| 223 | + |
| 224 | +<Step title="Request an OIDC token"> |
| 225 | +<StepText> |
| 226 | +Use your OAuth/OIDC client library to perform the Authorization Code with PKCE |
| 227 | +flow. |
| 228 | + |
| 229 | +The authorization request should use your Better Auth issuer and client ID, and |
| 230 | +should request at least the `openid` scope. |
| 231 | + |
| 232 | +The exact code depends on your framework and OAuth client library, but the |
| 233 | +configuration usually looks like this: |
| 234 | + |
| 235 | +</StepText> |
| 236 | +<StepCode> |
| 237 | + |
| 238 | +```typescript |
| 239 | +const oidcConfig = { |
| 240 | + authority: 'https://app.example.com/api/auth', |
| 241 | + client_id: '<YOUR_BETTER_AUTH_CLIENT_ID>', |
| 242 | + redirect_uri: 'https://app.example.com/callback', |
| 243 | + |
| 244 | + response_type: 'code', |
| 245 | + scope: 'openid profile email', |
| 246 | + |
| 247 | + // Browser and native clients should use Authorization Code with PKCE. |
| 248 | + // Most OIDC client libraries enable PKCE automatically for public clients. |
| 249 | +}; |
| 250 | +``` |
| 251 | + |
| 252 | +</StepCode> |
| 253 | +</Step> |
| 254 | + |
| 255 | +<Step title="Pass the Better Auth token to SpacetimeDB"> |
| 256 | +<StepText> |
| 257 | +After the OAuth/OIDC flow completes, get the JWT from your OIDC client library |
| 258 | +and pass it to SpacetimeDB with `.withToken(...)`. |
| 259 | + |
| 260 | +For many OIDC clients, this token is exposed as `id_token`. Some OAuth Provider |
| 261 | +flows may instead return a JWT access token. The important requirement is that |
| 262 | +the token is a signed JWT whose `iss` matches your Better Auth issuer and whose |
| 263 | +signing key is available through Better Auth's JWKS. |
| 264 | + |
| 265 | +</StepText> |
| 266 | +<StepCode> |
| 267 | + |
| 268 | +```typescript |
| 269 | +import { DbConnection } from './module_bindings'; |
| 270 | + |
| 271 | +const token = await getBetterAuthOidcToken(); |
| 272 | + |
| 273 | +const conn = DbConnection.builder() |
| 274 | + .withUri('<YOUR_SPACETIMEDB_URL>') |
| 275 | + .withDatabaseName('<YOUR_MODULE_NAME>') |
| 276 | + .withToken(token) |
| 277 | + .onConnect((_conn, identity) => { |
| 278 | + console.log( |
| 279 | + 'Connected to SpacetimeDB with identity:', |
| 280 | + identity.toHexString() |
| 281 | + ); |
| 282 | + }) |
| 283 | + .onDisconnect(() => { |
| 284 | + console.log('Disconnected from SpacetimeDB'); |
| 285 | + }) |
| 286 | + .onConnectError((_ctx, err) => { |
| 287 | + console.error('Error connecting to SpacetimeDB:', err); |
| 288 | + }) |
| 289 | + .build(); |
| 290 | +``` |
| 291 | + |
| 292 | +</StepCode> |
| 293 | +</Step> |
| 294 | + |
| 295 | +<Step title="Validate Better Auth claims in your module"> |
| 296 | +<StepText> |
| 297 | +SpacetimeDB verifies the token signature before your reducers run. Your module |
| 298 | +should still validate the claims that define your trust boundary. |
| 299 | + |
| 300 | +At minimum, check: |
| 301 | + |
| 302 | +- `iss`, to ensure the token came from your Better Auth issuer; |
| 303 | +- `aud`, to ensure the token was meant for the expected client or resource; |
| 304 | +- any custom claim your app uses for authorization, such as a tenant, |
| 305 | + organization, role, scope, or token type. |
| 306 | + |
| 307 | +Do not treat a valid signature as the entire authorization decision. |
| 308 | +</StepText> |
| 309 | +<StepCode> |
| 310 | + |
| 311 | +```typescript title="server/auth.ts" |
| 312 | +import { SenderError } from 'spacetimedb/server'; |
| 313 | + |
| 314 | +const BETTER_AUTH_ISSUER = 'https://app.example.com/api/auth'; |
| 315 | +const BETTER_AUTH_CLIENT_ID = '<YOUR_BETTER_AUTH_CLIENT_ID>'; |
| 316 | + |
| 317 | +function stringClaim( |
| 318 | + payload: Record<string, unknown>, |
| 319 | + name: string |
| 320 | +): string | undefined { |
| 321 | + const value = payload[name]; |
| 322 | + return typeof value === 'string' ? value : undefined; |
| 323 | +} |
| 324 | + |
| 325 | +export const onConnect = spacetimedb.clientConnected(ctx => { |
| 326 | + const jwt = ctx.senderAuth.jwt; |
| 327 | + |
| 328 | + if (jwt == null) { |
| 329 | + throw new SenderError('Unauthorized: JWT is required to connect'); |
| 330 | + } |
| 331 | + |
| 332 | + if (jwt.issuer !== BETTER_AUTH_ISSUER) { |
| 333 | + throw new SenderError('Unauthorized: invalid issuer'); |
| 334 | + } |
| 335 | + |
| 336 | + if (!jwt.audience.includes(BETTER_AUTH_CLIENT_ID)) { |
| 337 | + throw new SenderError('Unauthorized: invalid audience'); |
| 338 | + } |
| 339 | + |
| 340 | + // Optional: validate custom claims if your Better Auth token includes them. |
| 341 | + const tokenType = stringClaim(jwt.fullPayload, 'token_type'); |
| 342 | + if (tokenType != null && tokenType !== 'spacetime-access') { |
| 343 | + throw new SenderError('Unauthorized: invalid token type'); |
| 344 | + } |
| 345 | + |
| 346 | + // Store or refresh any connection/session state your reducers need for |
| 347 | + // module-local authorization decisions. |
| 348 | +}); |
| 349 | +``` |
| 350 | + |
| 351 | +</StepCode> |
| 352 | +</Step> |
| 353 | + |
| 354 | +</StepByStep> |
| 355 | + |
| 356 | +## Checklist |
| 357 | + |
| 358 | +Before deploying, verify the following: |
| 359 | + |
| 360 | +- The OIDC discovery document is available at |
| 361 | + `<issuer>/.well-known/openid-configuration`. |
| 362 | +- The discovery document's `issuer` exactly matches the JWT `iss` claim. |
| 363 | +- The discovery document's `jwks_uri` points to the JWKS containing the token |
| 364 | + signing key. |
| 365 | +- The token you pass to SpacetimeDB is a JWT, not an opaque access token. |
| 366 | +- The module checks `iss` and `aud` on connect. |
| 367 | +- Any tenant, organization, role, scope, or permission claims are treated as |
| 368 | + authorization input, not as a replacement for reducer-level authorization. |
| 369 | + |
| 370 | +You are now set up to use Better Auth authentication with SpacetimeDB. Your app |
| 371 | +signs users in through Better Auth, receives an OIDC-compatible JWT, and connects |
| 372 | +to SpacetimeDB using that token. |
0 commit comments