Skip to content

Auth & Accounts ​

apps/api/src/router/authRoutes.ts · the only router that is public by default.

Endpoints ​

MethodPathAuthPurpose
POST/authenticate—Log in, returns { user, token } (plus refreshToken, expiresIn when refreshable)
POST/create-account—Register; same response as /authenticate
POST/token/refresh—Swap a refresh token for a new access + refresh token
POST/token/upgrade✔ legacy tokenMove a pre-refresh app install onto a refresh session
POST/logout—Log out; ends the refresh session when one is sent
POST/check-username—Is a username taken?
POST/check-username-offensive—Does a username contain profanity?
POST/forgot-password—Email a 6-digit reset code
POST/reset-password—Set a new password with the code
POST/verify-identity✔Planned answers 501
PUT/auth/change-password✔Change password (in userRoutes)
POST/auth/logout-all✔Revoke every session (in userRoutes)

Rate limits ​

/authenticate, /create-account, /forgot-password and /reset-password share a Redis-backed limiter (apps/api/src/middleware/authRateLimit.ts): 5 attempts per email and 20 per IP in a 15-minute fixed window, per endpoint. /check-username gets the per-IP limit only, and /token/refresh allows 300 per IP (every app device refreshes about hourly, and many can share one carrier IP). Over the limit answers 429 with a Retry-After header. If Redis is unreachable the limiter lets requests through rather than locking everyone out.

POST /authenticate ​

json
{ "email": "user@example.com", "password": "…", "refreshable": true }
json
{
  "user": { "id": "clx…", "email": "…", "username": "…", "onboarded": true, "userType": "user" },
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "refreshToken": "q3Vt…",
  "expiresIn": 3600
}

Also creates an AuthSession record capturing device name, IP, and user agent. Invalid credentials answer 401 with "Invalid email or password" for both "no such user" and "wrong password", so the endpoint can't be used to find out who has an account. A suspended account answers 403, but only after the password checks out.

The email is matched case-insensitively. user is the caller's own row run through toSelfUser (apps/api/src/lib/selfUser.ts), which strips password, tokenVersion, stripeAccountId, revenueCatUserId, the coin-freeze fields and the suspension/deletion fields. Every endpoint that returns the signed-in user (GET /user, the profile updates, avatar and banner) uses it.

Send the token as Authorization: Bearer <token> on every subsequent request.

  • With refreshable: true (the mobile app): token is valid for expiresIn seconds (JWT_EXPIRATION_MINUTES, default 60). Renew it with refreshToken at POST /token/refresh before it runs out, or when a request answers 401 with code TOKEN_EXPIRED. No cookie is set.
  • Without it (admin panel, web, older app builds): one token valid for 7 days, also set as the httpOnly token cookie. The client signs in again when it expires.

See Authentication for rotation, revocation and the 401 codes.

POST /token/refresh ​

json
{ "refreshToken": "q3Vt…" }
→ { "token": "eyJhbGciOiJIUzI1NiIs…", "refreshToken": "x8Pa…", "expiresIn": 3600 }

Rotates the refresh token: store both new values. The previous refresh token keeps working for 2 minutes (a retry after a lost response). Presented after that, it revokes the whole session. Every failure (unknown, expired, revoked, account not active) answers 401 with code REFRESH_INVALID.

POST /token/upgrade ​

Requires a legacy bearer token (one without sid). Creates a refresh session for it and answers like /token/refresh, so app installs signed in before refresh tokens move over without signing in again. A refreshable token answers 409 ALREADY_REFRESHABLE.

POST /create-account ​

json
{ "name": "Ada Lovelace", "email": "ada@example.com", "password": "…", "acceptedTerms": true }

The body is validated with zod: name is trimmed and required, email is trimmed and lowercased, and password must be 8–128 characters. acceptedTerms must be true: App Review (Guideline 1.2) requires every user to agree to the Terms and Community Guidelines before using a UGC app. The user row records termsAcceptedAt and termsVersion (TERMS_VERSION in apps/api/src/lib/terms.ts, the Terms page's "Last updated" date). Invalid input answers 400. The first word of name becomes first_name and the rest last_name (empty for a one-word name). An email that already has an account, in any casing, answers 409 with code EMAIL_TAKEN.

Registration hashes the password with bcrypt and creates an AuthSession. It does not create a Mux livestream: that's a paid resource, so getOrCreateCredentials (services/livestream.ts) makes one the first time someone opens their streaming settings.

The response matches /authenticate. Note that username is not set at registration — it is chosen during onboarding, so User.username is nullable.

Username checks ​

json
POST /check-username
{ "username": "gamer123" }
→ { "exists": false }

POST /check-username-offensive runs the trie-based filter in apps/api/src/lib/offensive-words-validator/. Call both before letting a user commit to a name. Neither checks the format. Check the name with isValidUsername from @playpals/types first (see Users → Profiles).

PUT /auth/change-password ​

Requires authentication. Body { currentPassword, newPassword }, with newPassword at least 8 characters. A wrong current password answers 400.

The update also increments tokenVersion and ends every other device's refresh session, so they are all signed out. The response carries a fresh access token for the current device, which the client must store. A refreshable caller keeps its session (same sid) and refresh token:

json
{ "message": "Password updated successfully", "token": "eyJhbGciOiJIUzI1NiIs…" }

POST /auth/logout-all ​

The real revocation mechanism:

ts
prisma.user.update({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
prisma.authSession.updateMany({ where: { userId }, data: { isActive: false } });

Incrementing tokenVersion invalidates every JWT ever issued to that user, because the auth middleware compares the token's version against the user's. See Authentication.

POST /logout ​

Body { refreshToken } (optional). Ends that refresh session, so the token can't be refreshed again, and clears the web token cookie. The current access token stays valid until it expires (at most JWT_EXPIRATION_MINUTES); the client discards it. Use /auth/logout-all to revoke everything at once.

POST /verify-identity ​

Reserved for the planned face-verification 2FA check at sign-in. Not built yet: the route answers 501 with code NOT_IMPLEMENTED. The reference selfie it will compare against is already enrolled through /biometrics/face. See Biometrics & Face Enrollment. Creator identity verification doesn't use this endpoint; it uses the ID upload and admin review in Creator Onboarding.

Password reset ​

A two-step flow with a 6-digit code (apps/api/src/services/passwordReset.ts). A code typed into the app works in every mail client, unlike a playpalz:// link.

json
POST /forgot-password
{ "email": "ada@example.com" }
→ 200 { "message": "If an account exists for that email, we've sent a reset code." }

The response is the same whether or not the account exists. For an active account it replaces any outstanding code with a new one and emails it through Resend (apps/api/src/lib/email.ts). Only an HMAC of the code, keyed with RESET_SECRET, is stored in PasswordResetCode. Codes expire after 30 minutes.

json
POST /reset-password
{ "email": "ada@example.com", "code": "042317", "password": "…" }
→ 200 { "message": "Your password has been reset. Log in with your new password." }

A wrong, expired or already-used code answers 400 with code INVALID_RESET_CODE. Each code allows 5 wrong guesses before it is burned, on top of the route's rate limit. On success the code is marked used, the password is replaced, tokenVersion is incremented and every AuthSession is deactivated, the same as /auth/logout-all. The app then signs the user straight back in with the new password.

Without RESEND_API_KEY the email is logged (development) or dropped with an error log (anywhere else). See Environment variables.

Internal documentation — PlayPalz platform