Authentication Architecture
Understanding how authentication works in @terreno/api — Better Auth sessions for new apps, with JWT/Passport documented for legacy consumers.
Overview
Better Auth is the default path for new Terreno apps. It provides session-based authentication with MongoDB storage, built-in social OAuth (Google, GitHub, Apple), and clean integration with @terreno/syncdb (betterAuthAdapter, RealtimeApp socket sessions).
Set AUTH_PROVIDER=better-auth and register BetterAuthApp on the server. On the client, use createBetterAuthClient + generateBetterAuthSlice from @terreno/rtk for session Redux state, then wire betterAuthAdapter into createSyncDb.
JWT/Passport (legacy) remains supported through the current major release line for existing deployments. It uses stateless tokens, Passport strategies (email/password, GitHub OAuth, anonymous), and manual token refresh via @terreno/rtk. New projects should not start on JWT unless they have a specific requirement (custom token contracts, non-cookie clients, or a phased migration).
Both systems can run in parallel during migration (AUTH_PROVIDER selects the primary path; legacy JWT routes stay available).
When to choose which
| Choose Better Auth | Stay on JWT (legacy) |
|---|---|
| New app or greenfield screen | Existing production JWT deployment |
| Social login (Google, GitHub, Apple) | Custom JWT payload requirements |
@terreno/syncdb local-first data | Non-cookie API clients only |
Socket sessions via RealtimeApp | Gradual migration in progress |
Setup: Configure Better Auth. Data layer: migrate auth before or with syncdb — see Migrate from RTK to syncdb §7.
Authentication Strategies
Better Auth (default)
Modern session-based authentication with built-in social OAuth support.
Flow:
- Configure Better Auth with
AUTH_PROVIDER=better-authand registerBetterAuthApp - User chooses social provider (Google, GitHub, Apple) or email/password
- Backend redirects to OAuth provider or validates credentials
- Better Auth creates session in MongoDB
- Frontend receives session cookie (web) or bearer session token (native)
- Session middleware populates
req.userfor subsequent requests
Key properties:
- Session-based (cookies / bearer session) vs. stateless JWT
- Built-in OAuth providers with PKCE
betterAuthAdapterfor syncdb socket authsync:auth-expiredsocket event when the session is no longer valid
Endpoints (when enabled):
POST /api/auth/signup/email— Email/password signupPOST /api/auth/signin/email— Email/password signinGET /api/auth/signin/{provider}— Initiate OAuth flow (google, github, apple)GET /api/auth/callback/{provider}— OAuth callback handlerPOST /api/auth/signout— Sign out sessionGET /api/auth/session— Get current session
Learn more: Configure Better Auth
Email/Password (JWT / Local Strategy — legacy)
Traditional username/password authentication using passport-local-mongoose.
Flow:
- User signs up with email and password
- Password is hashed with pbkdf2 (via passport-local-mongoose)
- User logs in with credentials
- Backend validates password and issues JWT tokens
- Frontend includes JWT in
Authorizationheader for subsequent requests
Endpoints:
POST /auth/signup— Create new user accountPOST /auth/login— Authenticate and receive tokens
GitHub OAuth Strategy (JWT — legacy)
OAuth 2.0 authentication with GitHub.
Flow:
- User clicks "Sign in with GitHub"
- Frontend redirects to
GET /auth/github?returnTo=<url> - Backend redirects to GitHub authorization page
- User grants permissions on GitHub
- GitHub redirects back to
GET /auth/github/callback - Backend verifies authorization code with GitHub
- Backend finds/creates user, issues JWT tokens
- Backend redirects to
returnToURL with tokens as query params
Account Linking:
- Authenticated users can link their GitHub account via
GET /auth/github/link - Multiple authentication methods can be attached to one user account
- Users must have a password set before unlinking GitHub
Learn more: How to add GitHub OAuth
Anonymous Strategy (JWT — legacy)
Allows limited access without authentication.
Use case: Public read access to certain resources while requiring authentication for writes.
import {Permissions} from "@terreno/api";
modelRouter(Model, {
permissions: {
list: [Permissions.IsAuthenticatedOrReadOnly],
read: [Permissions.IsAuthenticatedOrReadOnly],
create: [Permissions.IsAuthenticated],
},
});
JWT Token System (legacy)
JWT/Passport auth is legacy. It remains supported through the current major line but is not the recommended path for new apps. Prefer Better Auth above.
Token Types
Access Token (short-lived)
- Default expiration: 15 minutes (
TOKEN_EXPIRES_IN) - Used for API requests
- Included in
Authorization: Bearer <token>header - Contains user ID and permissions in payload
Refresh Token (long-lived)
- Default expiration: 30 days (
REFRESH_TOKEN_EXPIRES_IN) - Used only to obtain new access tokens
- Stored securely on client
- Cannot be used for API requests
Token Payload
Access tokens contain:
{
"sub": "507f1f77bcf86cd799439011", // User ID
"admin": false, // Admin status
"iat": 1709000000, // Issued at (timestamp)
"exp": 1709000900, // Expires at (timestamp)
"iss": "your-app-name" // Issuer (from TOKEN_ISSUER env var)
}
Customize the payload with authOptions.generateJWTPayload:
setupServer({
authOptions: {
generateJWTPayload: (user) => ({
sub: user._id,
admin: user.admin,
role: user.role, // Custom field
}),
},
});
Token Refresh Flow
- Access token expires (after 15 minutes)
- API request returns
401 Unauthorized - Frontend middleware detects 401
- Frontend calls
POST /auth/refresh_tokenwith refresh token - Backend validates refresh token
- Backend issues new access token and refresh token
- Frontend retries original request with new token
This is handled automatically by @terreno/rtk's emptyApi configuration.
Frontend Integration
Redux Store Setup (with @terreno/rtk)
import {generateAuthSlice} from "@terreno/rtk";
import {configureStore} from "@reduxjs/toolkit";
import {openapi} from "./openApiSdk";
const {authReducer, middleware, logout} = generateAuthSlice(openapi);
export const store = configureStore({
reducer: {
auth: authReducer,
[openapi.reducerPath]: openapi.reducer,
},
middleware: (getDefault) =>
getDefault().concat(openapi.middleware, ...middleware),
});
What this provides:
- Automatic token storage (SecureStore on mobile, AsyncStorage on web)
- Token refresh middleware
- Login/logout state management
- Auth header injection for all API requests
Token Storage
Mobile (iOS/Android):
- Uses
expo-secure-storefor encrypted storage - Tokens stored in device keychain
Web:
- Uses
@react-native-async-storage/async-storage - Falls back to localStorage
- SSR-safe (checks
typeof window)
Storage keys:
AUTH_TOKEN— Access tokenREFRESH_TOKEN— Refresh token
Permission System
Permissions control access to modelRouter endpoints.
Built-in Permissions
| Permission | Description |
|---|---|
IsAny | Always allows (public access) |
IsAuthenticated | Requires valid JWT (non-anonymous) |
IsAdmin | Requires user.admin === true |
IsOwner | Requires admin or obj.ownerId === user.id |
IsAuthenticatedOrReadOnly | Auth required for writes, anyone can read |
IsOwnerOrReadOnly | Owner or admin for writes, anyone can read |
Permission Evaluation
Permissions are evaluated as an AND operation — all permissions in the array must return true:
permissions: {
update: [Permissions.IsAuthenticated, Permissions.IsOwner],
// Both conditions must be true
}
Custom Permissions
Create custom permission functions:
const IsPremiumUser = (user, obj, method) => {
return user?.subscription === "premium";
};
modelRouter(Model, {
permissions: {
create: [Permissions.IsAuthenticated, IsPremiumUser],
},
});
Security Best Practices
Backend
✅ Do:
- Use environment variables for secrets (
TOKEN_SECRET,REFRESH_TOKEN_SECRET) - Set strong, unique secrets in production (at least 32 characters)
- Use HTTPS in production
- Validate token issuer (
TOKEN_ISSUER) - Set appropriate token expiration times
- Implement rate limiting on auth endpoints
- Log authentication failures
❌ Don't:
- Commit secrets to version control
- Use the same secret for tokens and refresh tokens
- Store sensitive data in JWT payload (it's base64, not encrypted)
- Allow infinite token lifetimes
Frontend
✅ Do:
- Use SecureStore on mobile for token storage
- Clear tokens on logout
- Handle token expiration gracefully
- Show user feedback during auth flows
- Validate tokens before making authenticated requests
❌ Don't:
- Store tokens in localStorage on web (use httpOnly cookies in production)
- Log tokens to console
- Send tokens in URL query parameters
- Ignore token refresh failures
Environment Variables
Required for authentication:
# JWT Configuration
TOKEN_SECRET=your-secret-key-min-32-chars
TOKEN_ISSUER=your-app-name
REFRESH_TOKEN_SECRET=different-secret-key-min-32-chars
SESSION_SECRET=session-secret-min-32-chars
# Optional: Custom expiration times
TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=30d
# GitHub OAuth (if using)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_CALLBACK_URL=http://localhost:4000/auth/github/callback
# Optional: Disable user registration
SIGNUP_DISABLED=false
Authentication Middleware
Protecting Routes
Use authenticateMiddleware() to require authentication:
import {authenticateMiddleware, asyncHandler} from "@terreno/api";
router.get("/protected", [
authenticateMiddleware(),
asyncHandler(async (req, res) => {
// req.user is populated with authenticated user
const userId = req.user?._id;
return res.json({userId});
}),
]);
Anonymous Access
Allow unauthenticated requests but populate req.user if token is present:
router.get("/public", [
authenticateMiddleware({anonymous: true}),
asyncHandler(async (req, res) => {
// req.user is populated if token provided, undefined otherwise
const isLoggedIn = !!req.user;
return res.json({isLoggedIn});
}),
]);
Troubleshooting
"Invalid token"
- Token has expired — frontend should refresh
- Token signature invalid — check
TOKEN_SECRETmatches - Token issuer mismatch — verify
TOKEN_ISSUERis correct
"No auth token provided"
- Missing
Authorizationheader - Header format incorrect (should be
Bearer <token>)
Token refresh fails
- Refresh token expired — user must log in again
REFRESH_TOKEN_SECRETmismatch between token creation and validation- Refresh token revoked (user logged out)
User logged out unexpectedly
- Access token expired and refresh failed
- Backend
TOKEN_SECRETchanged (invalidates all tokens) - Token storage cleared (app reinstall, cache clear)
Advanced Topics
Custom User Fields in Token
Add custom fields to JWT payload:
setupServer({
authOptions: {
generateJWTPayload: (user) => ({
sub: user._id,
admin: user.admin,
organizationId: user.organizationId,
roles: user.roles,
}),
},
});
Multi-Tenant Authentication
Scope users to organizations:
const queryFilter = (user, _query) => ({
organizationId: user?.organizationId,
});
modelRouter(Model, {
permissions: {list: [Permissions.IsAuthenticated]},
queryFilter,
});
Webhook Authentication
Verify webhook signatures instead of JWT:
import crypto from "crypto";
router.post("/webhook", asyncHandler(async (req, res) => {
const signature = req.headers["x-signature"];
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(payload)
.digest("hex");
if (signature !== expected) {
throw new APIError({status: 401, title: "Invalid signature"});
}
// Process webhook
}));