Feature Specification: Auth (JWT, Sessions, OAuth, API Keys)
Feature ID: auth-jwt-oauth
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The auth feature is the platform's identity surface. It backs four authentication shapes:
- Email + password sign-up / sign-in via the Better Auth runtime,
mirrored into the platform's
Usertable for FK-safe joins. - Bearer session tokens issued either by Better Auth or by the
platform's own
auth_sessiontable (used after OAuth callbacks and after email-verification). - OAuth social login for GitHub, Google, Facebook, and LinkedIn.
The platform exchanges the authorization code, fetches the social
profile, upserts a
User+AuthAccountrecord, and issues a session token. - API keys (
ew_live_*prefix) for programmatic access. Stored as SHA-256 digests; validated on every request alongside the session-token path.
A single guard (AuthSessionGuard) handles all four shapes. Endpoints
that should be open are opted out with the @Public() decorator.
This feature deliberately does not include:
- The GitHub-App installation flow
(covered by
integrations-github-app). - Plugin-scoped OAuth (Google Drive, Notion, etc.) — those live under
apps/api/src/plugins-capabilities/oauth/. - The transactional email delivery itself
(covered by
mail-providers— auth only emitsUserCreatedEvent/UserConfirmedEvent/UserForgotPasswordEvent).
2. User Scenarios
2.1 Primary scenarios
- Given I do not have an account, when I
POST /api/auth/registerwith a unique email, valid username (≥3 chars), and password matching the policy regex, then Better Auth creates the credential, the platform mirrors the password hash intousers.password, setsregistrationProvider='local', returns{access_token, user}, and emitsUserCreatedEventso the verification email goes out. - Given my email is unverified, when I
POST /api/auth/loginwith valid credentials, then Better Auth issues a session token,AuthServicerecords auser.loginactivity row (USER_LOGIN,summary='Signed in',ipAddress+userAgentfrom the request), and the response is{access_token, user}. - Given I want to sign in with GitHub, when I
GET /api/oauth/github/url?state=<csrf>, then the response is{url}whereurlishttps://github.com/login/oauth/authorize?…withclient_id,redirect_uri,response_type=code,scope(the fullGITHUB_SCOPESset joined by space), andstate. - Given GitHub redirects back, when I
GET /api/oauth/github/callback?code=…, then the platform exchanges the code viaPOST https://github.com/login/oauth/access_tokenWITHOUT agrant_typefield (GitHub-specific quirk), fetches/userand/user/emails, picks the primary verified email viaresolveGitHubAccountEmail, upserts the user, issues a session, and recordsuser.login.githubactivity withsummary='Signed in via GitHub'. - Given I sign in with Google, when I hit
/api/oauth/google/url, then the URL containsaccess_type=offline+prompt=consentso we receive a refresh-token on the first consent. - Given I sign in with LinkedIn, when the userinfo response
has
given_name=Jane,family_name=Doeandnameis missing, thendisplayNamefalls back to'Jane Doe'. - Given I forgot my password, when I
POST /api/auth/forgot-passwordwith my email, then a 32-byte hex reset token is stored onusers.passwordResetTokenwith a 1-hour expiry, andUserForgotPasswordEventis emitted with the resolved callback URL (the body'sresetPasswordCallbackUrl + ?token=if provided, else${webAppUrl}/api/auth/reset-password?token=). - Given I
POST /api/auth/reset-passwordwith a fresh token, when the token validates, then the platform sets the new password via the Better Auth context'spassword.hash, mirrors the hash intousers.password, deletes the reset token viaclearPasswordResetToken(atomic), and signs me out of every device viasignOutAll. - Given I
POST /api/auth/verify-emailwith the token from the signup email, when the token is fresh (≤24h), thenusers.emailVerified=true, the verification token+expiry are cleared,UserConfirmedEventis emitted, and a fresh session token is issued so the client can drop the email-verification gate. - Given I have at most 9 active API keys, when I
POST /api/auth/api-keyswith{name}and an optional ISOexpiresAtin the future, then the platform generates 32 bytes of entropy, prefixes withew_live_, stores the SHA-256 digest, and returns the raw key once ({id, name, key, prefix, expiresAt, createdAt}). - Given I own a key, when I send
Authorization: Bearer ew_live_…(orx-api-key: ew_live_…), thenAuthSessionGuardresolves the user via the SHA-256 digest, refuses ifisActive=falseorexpiresAt < now, firesapiKeyRepository.updateLastUsed(...)fire-and-forget, and treats my request as fully authenticated.
2.2 Edge cases & failures
- Given I
POST /api/auth/registerwith an email that already exists, whenassertCanRegisterruns, then it throwsConflictException('User with this email already exists')BEFORE hitting Better Auth — no orphan credential is created. - Given the verification email send fails (mail provider down),
when the registration handler logs the error, then the
registerresponse still resolves successfully — verification delivery never blocks signup. The handler useslogger.warn('Failed to send verification email for user <id>: <msg>'). - Given my account was suspended (
users.isActive=false), when I try to sign in, thenAuthProviderService.assertActiveUsercallssignOutAll(userId)(purges any lingering sessions) and throwsUnauthorizedException('User account is suspended'). The same check runs on every authenticated request, so an admin flippingisActivemid-session terminates the user. - Given my session token expired, when the guard reads it
from the DB, then the row is deleted via
deleteSessionRecord(token)and the request returns 401 — the client is forced to re-login. Session lifetime is 7 days from issuance (expiresAt = now + 7 daysatcreateSessionRecord). - Given I forgot my password but my email is not in the user
table, when
forgotPasswordreturns, then the response is{message: 'If the email exists, a reset link has been sent'}WITHOUT revealing whether the email exists. No event is emitted, no token is generated. - Given I submit a reset token that expired, when
getUserByPasswordResetTokencheckspasswordResetExpires, then it throwsBadRequestException('Reset token expired'). Same shape for verification:BadRequestException('Verification token expired'). - Given I submit a reset/verification token that is already
consumed (token field is
null), when the lookup runs, then it throwsBadRequestException('Invalid <reset|verification> token'). - Given two windows race to consume the same reset token, when
both call
clearPasswordResetToken, then only one returnsconsumed=true; the loser throwsBadRequestException('Invalid reset token')because the token row was already cleared. - Given I register with social-login but my OAuth provider does
NOT mark the email verified (Facebook always returns
emailVerified=false; Google explicitly returnsemail_verified: false), when I have NO existingauth_accountlink to that provider, thenvalidateSocialUserthrowsUnauthorizedException('Unable to link this social account because the provider email is not verified'). An existing link bypasses this check. - Given GitHub returns no email on
/userand no primary verified email on/user/emails, whenresolveGitHubAccountEmailreturnsemail=null, thengetGitHubUserthrowsBadRequestException('No email found in GitHub profile'). Same shape for Google/Facebook/LinkedIn. - Given I request an OAuth URL for a provider that is not
configured (missing
clientIdenv var), whengetAuthorizationUrlresolves the client id, then it throwsBadRequestException('<provider> client id is not configured'). - Given I hit a callback for a provider whose
clientSecretis missing, whenexchangeCodeForTokensreads it, then the call throwsBadRequestException('<provider> client secret is not configured')BEFORE making any HTTP request. - Given the OAuth provider returns a token response without
access_token, whenreadStringchecks the field, then it throwsBadRequestException('Missing access_token from OAuth provider response'). - Given I use a social provider that the platform does not
support (
/api/oauth/twitter/url), whengetSocialAuthProviderConfigruns, then it throwsBadRequestException('Unsupported OAuth provider: twitter'). - Given I want to log out, when I
POST /api/auth/logoutwith a Bearer token, thenAuthProviderService.signOutdetects the token, deletes only that session row, and the call resolves with{message: 'Logged out successfully'}. Other devices for the same user remain signed in. - Given I want to terminate every device, when I
POST /api/auth/logout-all, thenauth_sessionrows foruserIdare bulk-deleted via TypeORMrepository.delete({userId}). - Given I update my profile with
committerName: null, whenupdateUserProfileruns, then the field is cleared in the database (the explicit-nullsemantics are preserved —undefinedleaves the field untouched). - Given I update my profile and my
usernamefield isnullin the body, whenupdateUserProfileruns, then the username field is NOT cleared —null/undefinedare both treated as "leave alone" forusername/avatar(onlycommitter*fields support explicit clearing). - Given I
GET /api/auth/profile/fresh, when my GitHub OAuth link's access token has expired OR lacks thereposcope, then the GitHub provider does NOT appear inoauthTokens— the same endpoint returns connected providers filtered byisAccessTokenExpired+ (for GitHub)hasRequiredScopes(['repo']). - Given the user already has 10 API keys, when they POST a
new one, then
createKeythrowsBadRequestException('Maximum of 10 API keys allowed per user')before any entropy is generated. - Given the user submits
expiresAtthat is in the past, whencreateKeyvalidates it, then it throwsBadRequestException('Expiration date must be in the future'). - Given an attacker tries
Authorization: Bearer not-an-api-key, whenextractApiKeychecks the prefix, then the bearer token is treated as a session token (NOT an API key) — theew_live_prefix is the gate. - Given a request supplies BOTH
x-api-keyandAuthorization: Bearer …, whenextractApiKeyruns, then thex-api-keyheader wins (checked first). The session-token fallback is only reached when neither header carries anew_live_-prefixed value.
3. Functional Requirements
- FR-1: The auth surface MUST expose three controllers:
AuthControllerat/api/auth,OAuthControllerat/api/oauth,ApiKeysControllerat/api/auth/api-keys. All three are wired inAuthModuleviacontrollers: [OAuthController, AuthController, ApiKeysController]. - FR-2:
AuthControllerMUST expose 14 endpoints:GET providers(public),POST register(public),POST login(public),POST logout(guarded),POST logout-all(guarded),GET profile(guarded, JWT-only),GET profile/fresh(guarded, fresh DB read),POST update-password(guarded),PUT profile(guarded),POST send-verification(guarded),POST verify-email(public),POST forgot-password(public),POST reset-password(public),GET validate-email-token(public),GET validate-reset-token(public). - FR-3:
OAuthControllerMUST expose 2 endpoints:GET /api/oauth/:providerId/url?state=…returning{url}andGET /api/oauth/:providerId/callback?code=…returning the Better-Auth-style{access_token, user}envelope. Both are@Public(). - FR-4:
ApiKeysControllerMUST expose 3 guarded endpoints:POST /(create),GET /(list user's keys),DELETE /:id(revoke). Revoke MUSTthrow NotFoundException('API key not found')whendeleteByIdAndUserId(...)returnsfalse. - FR-5: Email/password flows MUST run through the Better Auth
runtime instance (
createAuthRuntimeInstance(dataSource)) for credential creation, password hashing, and session issuance, and MUST mirror the password hash +lastLoginAt+registrationProviderback intousersviaUserRepository.updateso cross-table joins see a single source of truth. - FR-6:
AuthSessionGuard.canActivateMUST: (a) short-circuittruewhen the route is@Public(); (b) checkx-api-keyandAuthorization: Bearer …for anew_live_-prefixed value, validate viaApiKeyService.validateKey, hydraterequest.userfromUserRepository.findById, and reject with 401 if the key is invalid/expired or the user is inactive; (c) fall back toauthProvider.authenticate(headers)for session tokens / Better Auth cookies; (d) throwUnauthorizedExceptionwhen both shapes fail. - FR-7:
AuthSessionGuardMUST resolveApiKeyServiceandUserRepositorylazily viaModuleRef.get(..., {strict:false})to avoid circular DI between the guard module andAuthModule. - FR-8:
ApiKeyService.createKeyMUST: (a) reject whencountByUserId(userId) >= 10; (b) reject whenexpiresAt <= now; (c) generate 32 bytes of entropy, prefix withew_live_, hash with SHA-256, store the digest plus a 12-char prefix ('ew_live_' + 4 hex chars) for human display; (d) return the raw key in the response payload once, never again. - FR-9:
ApiKeyService.validateKeyMUST: (a) hash the supplied raw key with SHA-256; (b) returnnullif no match, returnnullifexpiresAt < now; (c) fire-and-forgetupdateLastUsed(id)(the.catch(() => {})swallow ensures a DB hiccup never causes a 401 cascade). - FR-10:
AuthService.assertCanRegister(email)MUST throwConflictException('User with this email already exists')whenuserRepository.findByEmail(email)returns a row. - FR-11:
AuthService.sendVerificationEmail(userId, callbackUrl?)MUST: (a) rejectBadRequestException('User not found')when no user; (b) rejectBadRequestException('Email already verified')whenuser.emailVerified === true; (c) generate 32 hex bytes, persist withexpires = now + 24h; (d) whencallbackUrlis provided AND does NOT already containtoken=, append?token=<token>; otherwise default to${webAppUrl}/api/auth/verify-email?token=<token>; (e) emitUserCreatedEvent(user, verificationToken, callbackUrl); (f) return{message, verificationToken, expiresAt}(the token is echoed in the response — pinned for backwards compat with the current dev-mode UI; production deployments should rely on the email). - FR-12:
AuthService.verifyEmail(token)MUST: lookup byemailVerificationToken, reject expired tokens, setemailVerified=true+ null both token fields, refetch the user, emitUserConfirmedEvent(updatedUser, '${webAppUrl}/works/new'), and return the updated user. The controller (POST verify-email) then issues a session viaauthProvider.issueSession(user.id). - FR-13:
AuthService.forgotPassword(dto)MUST silently no-op on unknown email (return the generic message without emitting an event), and on known email persist a fresh 32-hex-byte token with a 1-hour expiry and emitUserForgotPasswordEvent(user, resetToken, callbackUrl, '1 hour'). Callback resolution mirrors FR-11 — append?token=only if the body's URL doesn't already includetoken=, otherwise default to${webAppUrl}/api/auth/reset-password?token=. - FR-14:
AuthService.consumePasswordResetToken(token)MUST calluserRepository.clearPasswordResetToken(user.id, token)and raiseBadRequestException('Invalid reset token')when the atomic clear returnsfalse(someone else consumed it first). - FR-15:
AuthService.validateSocialUser(socialUser)MUST: (a) treatemailVerified !== falseas "trusted email" (soundefinedandtrueboth qualify,falsedoes not); (b) when no user exists, create with random hashed password (bcrypt.hash(randomBytes(16).hex, authConstants.bcryptSaltRounds)) and emitUserConfirmedEventONLY when the email is trusted; (c) when the user exists, refuse to link if email is untrusted AND no existingAuthAccountrow for that provider exists; (d) updatelastLoginAt,registrationProvider, optionallyusername(preserving non-empty existing value), andavatar; (e) upsertAuthAccountwithaccessToken/refreshToken ?? null/tokenType ?? 'Bearer'/accessTokenExpiresAt ?? null/scope ?? nullandmetadata = {providerUserId, ...incoming.metadata}. - FR-16:
AuthService.getUserProfile(userId)MUST strippassword,emailVerificationToken,emailVerificationExpires,passwordResetToken,passwordResetExpiresfrom the returned payload AND attachoauthTokens: ProviderAccount[](filtered to non-expired tokens; for GitHub specifically, only those with thereposcope). - FR-17:
AuthService.updateUserProfile(userId, dto)MUST updateusername/avataronly when the value is non-null/non-undefined, but MUST treat explicitnull(notundefined) oncommitterName/committerEmailas a clear instruction (stored asnullin the database). Empty-string committer values are also treated as clear via thevalue || nullcoalesce. - FR-18:
SocialAuthService.getAuthorizationUrl(providerId, callbackUrl?, state?)MUST: resolve the provider config (or throw for unsupported providers); usecallbackUrlif provided elseprovider.callbackUrl(); build URLSearchParams withclient_id,redirect_uri,response_type='code',scopejoined byprovider.scopeSeparator || ' '; appendstatewhen provided; appendaccess_type=offline+prompt=consentfor Google ONLY. - FR-19:
SocialAuthService.authenticate(providerId, code, callbackUrl?)MUST exchange the code (omittinggrant_typefor GitHub, sendinggrant_type=authorization_codefor everyone else), fetch the provider's user-info, then callauthService.validateSocialUser({...socialUser, provider: provider.id, accessToken, refreshToken, tokenType, scope, expiresAt}). - FR-20:
SocialAuthService.exchangeCodeForTokensMUST send the request asapplication/x-www-form-urlencodedwithAccept: application/json. The response body'sexpires_in(number) MUST be converted toexpiresAt = new Date(Date.now() + expires_in * 1000); missing/non-numeric values yieldexpiresAt=null. - FR-21:
SocialAuthService.getSocialUserMUST dispatch onproviderIdto one ofgetGitHubUser/getGoogleUser/getFacebookUser/getLinkedInUser. Each reader MUST:- Set
displayNamefrom a provider-specific fallback chain (GitHub:data.name || data.login || email-localpart; Google:data.name || email-localpart; Facebook:data.name || email-localpart; LinkedIn:data.name || '<given> <family>' || email-localpart). - Set
emailVerifiedper provider: GitHub usesresolveGitHubAccountEmail's verified flag, Googledata.email_verified !== false(default-true), LinkedIndata.email_verified !== false, Facebook alwaysfalse. - Throw
BadRequestException('No email found in <Provider> profile')when the email is missing.
- Set
- FR-22:
SocialAuthService.getConfiguredProviders()MUST return an array of provider IDs whoseclientId()ANDclientSecret()env-readers both return truthy strings.GET /api/auth/providersreturns{emailPassword: true, socialProviders: [...]}to the client. - FR-23:
AuthProviderService.signInEmailMUST: (a) when the user already has a local password, runauthSyncService.ensureCredentialAccount(userId, passwordHash)so Better Auth's credential table stays in sync; (b) callauth.api.signInEmail({headers, body: {email, password, rememberMe: true}}); (c) re-fetch the user viaassertActiveUser, mirror the new password hash +lastLoginAt+registrationProvider='local'intousers; (d) throwUnauthorizedException('Failed to establish authenticated session')when Better Auth returns notoken. - FR-24:
AuthProviderService.signUpEmailMUST mirror the password hash +registrationProvider='local'+isActive=trueintousers. When Better Auth issues a token in the response, return{access_token, user}; otherwise fall through toissueSession(result.user.id)(which writes a row to the platform'sauth_sessiontable). - FR-25:
AuthProviderService.issueSession(userId)MUST create a row inauth_sessionwith:id = randomUUID();token = randomBytes(24).toString('base64url')(32 chars, URL-safe);expiresAt = now + 7 days;ipAddress: null,userAgent: null(callers can populate later via the activity-log path).
- FR-26:
AuthProviderService.changePasswordMUST require an authenticated user (Bearer token OR Better Auth session), fetch the current credential password hash, refuse withUnauthorizedException('Password login is not configured for this account')when no hash exists (e.g. user signed up via OAuth only), and refuse withUnauthorizedException('Current password is incorrect')onbcrypt.comparemismatch. - FR-27:
AuthProviderService.setPassword(userId, newPassword)MUST hash via the Better Auth runtime context'spassword.hash, sync viaauthSyncService.syncCredentialPassword, and mirror the hash intousers.password. The reset-password controller calls this thensignOutAll(user.id)to terminate every active session. - FR-28:
AuthProviderService.signOut(headers)MUST detect a bearer token viagetBearerTokenand, when present, delete only that session row fromauth_session. When the token is absent (cookie-based Better Auth), it MUST callauth.api.signOut. Both shapes resolve cleanly. - FR-29:
AuthProviderService.signOutAll(userId)MUST bulk-delete everyauth_sessionrow foruserIdvia the TypeORM repository (delete({userId})). Better Auth's own session table is NOT touched here — it expires on its own schedule (by design, per the runtime instance config). - FR-30:
AuthProviderService.authenticate(headers)MUST first check for a bearer token inauth_session; if found and unexpired, hydrate the user. If expired, delete the row and returnnull. Falling back to Better Auth's cookie session, the method MUST reject (UnauthorizedException('User account is suspended')) and callsignOutAllwhen the runtime user hasisActive === false. - FR-31:
mapAuthenticatedUser(Better Auth path) MUST setiss='auth-runtime',aud='ever-works-users',iat=floor(now/1000).mapAuthenticatedUserFromUser(bearer-token path) MUST use the sameiss/aud. The API-key path in the guard usesiss='ever-works',aud='ever-works'. These three values are pinned because downstream code (e.g. logging/analytics) keys off them. - FR-32: The
RegisterDtoMUST requireusername(string,MinLength(3)),email(@IsEmail()), andpassword(string,MinLength(6), regex^[^.\n](?=.*[a-z])(?=.*[\d\w]).*$).UpdatePasswordDto/ResetPasswordDtoMUST require the same regex onnewPasswordwithMinLength(8).LoginDtoMUST requireemail(@IsEmail) andpassword(@IsString @IsNotEmpty). - FR-33: The four supported social providers MUST be exactly
GitHub, Google, Facebook, LinkedIn — pinned in
SOCIAL_AUTH_PROVIDERSwith their authorization URLs, token URLs, scopes, and (Facebook only)scopeSeparator: ','. Adding a fifth provider requires a code change to that map. - FR-34: GitHub's scope set is sourced from
auth/config/github-scopes.config.ts(GITHUB_SCOPES) — the same scope set used by the GitHub-App onboarding, so OAuth and the App flow grant the same repo+org+gist surface. Changing it changes both call sites. - FR-35: Login MUST emit a
user.loginactivity log row withactionType=USER_LOGIN,status=COMPLETED, the resolvedipAddress(req.ip || req.headers['x-forwarded-for']) anduserAgent(req.headers['user-agent']), and the activity-log call MUST be fire-and-forget (.catch(() => {})) so an audit failure does NOT 500 the login. Same shape for OAuth callback, withaction='user.login.<providerId>'andsummary='Signed in via <displayName>'.
4. Non-Functional Requirements
- NFR-1 (security): Password storage uses bcrypt at the
authConstants.bcryptSaltRoundscost (sourced fromBCRYPT_ROUNDSenv var; default 10). The Better Auth runtime owns the canonical hash; the platform'susers.passwordis a synchronized mirror so app-level joins/queries don't need a cross-table fetch. - NFR-2 (security): API keys are stored ONLY as SHA-256 digests.
The raw key is returned exactly once at creation time. The 12-char
prefixis the only fragment kept for human display in the keys list. - NFR-3 (security): All token-bearing fields are random:
- Verification tokens / reset tokens:
randomBytes(32).hex(64 chars). - API keys:
randomBytes(32).hexafter theew_live_prefix. - Session tokens:
randomBytes(24).base64url(32 chars). All three use Node's crypto module — noMath.random().
- Verification tokens / reset tokens:
- NFR-4 (privacy):
getUserProfileMUST never return the password hash, the verification/reset tokens, or their expiry fields. The exclusion is implemented as a destructuring strip (const { password, …, ...userProfile } = user) so adding a new sensitive field requires updating that list. - NFR-5 (resilience): Failure to deliver the verification email
MUST NOT block registration. Failure to write an activity-log row
MUST NOT block login or OAuth callback. Failure to update
lastUsedAtfor an API key MUST NOT 401 the request. - NFR-6 (observability): Each path emits a single warn/info log
line on failure with enough context to pin the user
(
'Failed to send verification email for user <id>: <err>'). The registration controller logs atwarn. The activity-log fire-and-forget paths swallow silently — by design, the activity-log service itself owns its own error logging. - NFR-7 (compatibility): The auth surface presents two response
envelopes (
{access_token, user}for register/login/oauth, and{message, …}for password resets / logout). Existing clients pin both shapes — changing either is a breaking change. - NFR-8 (testability): All four services are covered by Jest unit tests:
5. Out of Scope
- Plugin-scoped OAuth (Notion, Google Drive, Slack, etc.). Those
flows live under
apps/api/src/plugins-capabilities/oauth/and store tokens againstPluginentities, not the user-levelAuthAccounttable. - GitHub-App installation flow (PRs, repo dispatch, webhooks).
See
integrations-github-appwhen that spec is authored. - MFA / TOTP — not implemented yet. Better Auth supports it via a plugin; out of scope until the platform decides on a UX.
- Session refresh-token rotation — Better Auth handles its own rotation for cookie-based sessions; the platform's bearer-token sessions are fixed-7-day with no refresh, by design (clients re-login on expiry).
- OAuth state-validation / CSRF — the
statequery param is currently passed through to the OAuth provider but the callback handler does NOT verify it against a stored value. This is a documented follow-up (OQ-2 below). - Rate-limiting — handled by
@nestjs/throttlerat the application level, not by this feature; the named tiers (short/medium/long) are pinned byconfig/throttler.config.spec.ts.
6. Open Questions / Follow-ups
- OQ-1:
AuthSessionGuardissues an API-key-shapedAuthenticatedUserwithiss='ever-works'/aud='ever-works'while the session-token paths useiss='auth-runtime'/aud='ever-works-users'. Downstream code readsissto decide whether to apply session-only behaviors (e.g. require fresh login for sensitive ops). The split is load-bearing — but it's also undocumented outside this spec. Decide whether to consolidate to a singleiss='ever-works'value and gate sensitive ops on a separate flag (grant: 'api-key' | 'session'). Out of scope for this spec; document the split clearly so refactors don't silently break it. - OQ-2: OAuth
stateparam is passed through to the provider but never verified on callback. A determined attacker who tricks a user into clicking a crafted callback URL could associate the attacker's social account with the victim's session (CSRF). The fix is to writestateto a short-TTL Redis key on URL request and verify-and-delete on callback. Tracked here; implementation belongs in a separate change so thatvalidateSocialUserdoes not need to grow a stateful side-channel for this spec. - OQ-3:
AuthService.sendVerificationEmailechoes theverificationTokenin the response body. Same forforgotPassword(resetToken). This is convenient in dev (no need to read the inbox) but a token leak in prod logs / referer headers / third-party JS. The "Remove this in production" comment in the source acknowledges the hazard. Decision: gate the echo behind aNODE_ENV !== 'production'check in a follow-up — won't change the API shape (the field becomesundefined). - OQ-4: When a user has only an OAuth provider and zero local
credentials, calling
POST /api/auth/update-passwordreturns 401 with'Password login is not configured for this account'. Better UX would be to explain "set a password first" with a separate endpoint. Not blocking. - OQ-5:
signOutAlldeletes only the platform'sauth_sessionrows. If the user authenticated via Better Auth's cookie session and is also subscribed to that session via the runtime, the cookie session is NOT terminated bysignOutAll. Today that's only reachable via the same browser, so the Set-Cookie expiration on next response handles it — but a determined client with a stale cookie could keep the session alive pastsignOutAll. Verify whether Better Auth'sauth.api.deleteSessionsshould also be called.