Authentication
How Better Auth issues tokens and how the backend verifies them — the JWT, JWKS, and the identity strategy.
Authentication is split across a boundary that is worth internalizing early:
- the account app (
apps/account) issues identity using Better Auth; - the backend (
apps/backend) consumes identity by verifying JWTs.
The backend does not serve auth endpoints. Better Auth's routes
(/api/auth/*) are mounted in the account app; the backend only validates the
tokens it receives.
Better Auth (the issuer)
The Better Auth instance is configured in packages/auth/src/index.ts (built
lazily via a getAuth() proxy so DB clients aren't materialized at import time).
Key plugins:
| Plugin | What it provides |
|---|---|
jwt() | Mints RS256 JWTs with iss = BETTER_AUTH_URL, aud = "convex". |
organization() | Multi-tenancy: orgs, members, and a custom 9-role access-control model. |
customSession() | Enriches the session with org membership data. |
twoFactor() | TOTP 2FA (issuer "Emofy"). |
magicLink() | Passwordless email links. |
expo() | Native app support. |
nextCookies() | Set-Cookie handling for server actions. |
Sessions use a 60-second cookie cache and cross-subdomain cookies for SSO across the app family (ADR-002 covers the RS256 choice).
What's in the token
On sign-in, a session.create.before hook resolves the user's active org and
stamps org claims into the session. The JWT payload the backend cares about:
{
"sub": "user_q3m8x...", // user id
"orgId": "org_8f2k...", // active organization (may be null)
"orgRole": "teacher", // primary role in that org
"additionalRoles": ["staff"],// secondary roles (union of permissions applies)
"orgSlug": "sunshine-daycare",
"isSuperAdmin": false,
"iss": "https://auth.emofy.net",
"aud": "convex"
}The fixed audience "convex" is a historical name, not a Convex-specific
claim — every internal user JWT shares it, and the backend verifies it on
every request. Other surfaces use a different audience (an EMA token's
audience is ema:{appId}), which is what keeps a token issued for one surface
from being accepted on another.
Backend verification (the consumer)
Every request (except @Public() routes) passes through JwtAuthGuard, a
Passport AuthGuard("jwt") that delegates to the strategy in
apps/backend/src/modules/identity/strategies/jwt.strategy.ts. The strategy:
- Fetches the public keys with
createRemoteJWKSetfromBETTER_AUTH_URL/.well-known/jwks.json(cached). No shared secret is needed — verification is public-key. - Verifies the signature, the issuer (
BETTER_AUTH_URL), and the audience (convex). - Rejects EMA tokens. A token carrying an
appIdclaim is an EMA token, not a user token, and is refused here — this prevents an EMA from escalating into a full user session. - Loads the user from the auth DB: confirms it exists and is
email-verified, reads the
isSuperAdminflag, and for org-scoped users checks a Redis-cached org status (a suspended or deleted org is rejected).
The verified identity is attached to the request:
interface UserPayload {
userId: string;
orgId: string | null;
orgRole: OrganizationRole | null;
additionalRoles: OrganizationRole[];
orgSlug: string | null;
isSuperAdmin: boolean;
}From here, authorization and multi-tenancy take over.
Token types at a glance
The platform issues more than one kind of token. They share the JWKS but differ by audience and claims:
| Token | Audience | Distinct claims | TTL | Used by |
|---|---|---|---|---|
| User JWT | convex | orgRole, additionalRoles | ~1 hour | Web/mobile apps |
| EMA token | ema:{appId} | appId, scopes | ~5 min | Embedded Mini Apps |
| API key | — (opaque key) | server-side scopes | n/a | Partner integrations |
The user-JWT strategy explicitly refuses anything with an appId, keeping the
surfaces from bleeding into each other.
How clients get a token
The web apps sign in through the account app, then exchange the Better Auth session for a JWT used on API and Convex calls. Partner developers use an API key instead — see Developer authentication.