EmofyEmofy Developers
Auth & Tenancy

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:

PluginWhat 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:

  1. Fetches the public keys with createRemoteJWKSet from BETTER_AUTH_URL/.well-known/jwks.json (cached). No shared secret is needed — verification is public-key.
  2. Verifies the signature, the issuer (BETTER_AUTH_URL), and the audience (convex).
  3. Rejects EMA tokens. A token carrying an appId claim is an EMA token, not a user token, and is refused here — this prevents an EMA from escalating into a full user session.
  4. Loads the user from the auth DB: confirms it exists and is email-verified, reads the isSuperAdmin flag, 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:

TokenAudienceDistinct claimsTTLUsed by
User JWTconvexorgRole, additionalRoles~1 hourWeb/mobile apps
EMA tokenema:{appId}appId, scopes~5 minEmbedded Mini Apps
API key— (opaque key)server-side scopesn/aPartner 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.

Next

On this page