Authentication
Predictu supports three authentication flows depending on how the user accesses the platform: email/password login for returning users, email/password registration for new users, and embed initialization for users arriving through a casino operator’s iframe integration. All flows produce a JWT that is used for subsequent API requests.
Flow 1: Email/Password Login
Standard login for users who already have an account. Used in both the standalone Predictu app and operator-branded embed modes (when the operator allows direct login).
/api/auth/loginRequest Body
{
"email": "user@example.com",
"password": "securepassword123"
}Flow Steps
email andpassword are present and properly formatted.users table by email. If no user is found, return a generic error (do not reveal whether the email exists).is_banned = true on the user record, reject the login with a 403 Forbidden response.Success Response (200)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_abc123",
"email": "user@example.com",
"display_name": "JohnD",
"balance": 9850.00,
"tier": "regular",
"operator_id": null,
"created_at": "2026-01-15T10:30:00Z"
}
}Error Responses
| Status | Code | Description |
|---|---|---|
400 | INVALID_INPUT | Missing or malformed email/password. |
401 | AUTH_FAILED | Invalid email or password (generic to prevent enumeration). |
403 | USER_BANNED | Account has been banned by an admin. |
AUTH_FAILED for both cases.Flow 2: Email/Password Registration
Creates a new user account with an initial starting balance. Used for self-service sign-up on the standalone platform.
/api/auth/registerRequest Body
{
"email": "newuser@example.com",
"password": "securepassword123",
"display_name": "NewUser42"
}Flow Steps
409 Conflict error.users withbalance = 10000.00 (the $10k starting balance),tier = 'new', and operator_id = null.Success Response (201)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_xyz789",
"email": "newuser@example.com",
"display_name": "NewUser42",
"balance": 10000.00,
"tier": "new",
"operator_id": null,
"created_at": "2026-03-19T14:22:00Z"
}
}Error Responses
| Status | Code | Description |
|---|---|---|
400 | INVALID_INPUT | Missing fields, weak password, or invalid email format. |
409 | EMAIL_EXISTS | An account with this email already exists. |
Flow 3: Embed Initialization
This is the authentication flow used when Predictu is embedded inside a casino operator’s site via an iframe. The operator sends user information through the PostMessage bridge, and Predictu creates or finds a matching user account scoped to that operator.
/api/auth/embed-initRequest Body
{
"operator_id": "op_example",
"user_id": "player_12345",
"display_name": "HighRoller99",
"token": "operator-signed-jwt-token"
}Flow Steps
s2s_secret. This confirms the request is genuinely from the operator and not forged.{operator_id}-{user_id}@embed.predictu.io. This scopes the user to the operator and avoids collisions with real email addresses.operator_id set to the operator.403 Forbidden.operator_id claim, which scopes all subsequent API calls.Success Response (200)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_emb_456",
"email": "op_example-player_12345@embed.predictu.io",
"display_name": "HighRoller99",
"balance": 10000.00,
"tier": "new",
"operator_id": "op_example",
"created_at": "2026-03-19T14:30:00Z"
},
"branding": {
"primary_color": "#00b341",
"logo_url": "https://cdn.example-casino.com/logo.png",
"platform_name": "Example Casino Predictions",
"hide_predictu_branding": true
}
}Synthetic Email Format
The synthetic email is the key to operator-scoped user isolation. It follows a strict format that is deterministic and collision-free:
// Format: {operator_id}-{user_id}@embed.predictu.io
// Examples:
"op_example-player_12345@embed.predictu.io"
"op_betworld-user_98765@embed.predictu.io"
"op_luckycasino-uid_abc@embed.predictu.io"
// The same operator + user_id always produces the same email,
// so subsequent logins find the existing account.user_id will get different Predictu accounts because the operator ID is part of the synthetic email. There is no cross-operator data leakage.JWT Structure
All three authentication flows produce JWTs with the same structure. The token is signed with HS256 using the platform’s JWT_SECRET environment variable.
JWT Payload
{
"sub": "usr_abc123", // User ID (subject)
"email": "user@example.com", // User email (real or synthetic)
"operator_id": "op_example", // Operator scope (null for direct users)
"role": "user", // "user" | "admin" | "operator_admin"
"tier": "regular", // Current user tier
"iat": 1710856800, // Issued at (Unix timestamp)
"exp": 1710943200 // Expires at (24h from issuance)
}Field Reference
| Claim | Type | Description |
|---|---|---|
sub | string | The Predictu user ID. Primary identifier for all API operations. |
email | string | Real email for direct users, synthetic for embed users. |
operator_id | string | null | Scopes the user to an operator. Null for direct Predictu users. |
role | string | Determines access level. Regular users are always user. |
tier | string | Cached tier for quick access. May be stale if tier changes mid-session. |
iat | number | Unix timestamp when the token was issued. |
exp | number | Unix timestamp when the token expires (24 hours after issuance). |
Session Management
Predictu uses stateless JWT sessions. The token is stored client-side (in memory for the embed, in localStorage for the standalone app) and sent as aBearer token in the Authorization header on every API request.
Request Validation
// Middleware validates every API request
async function authMiddleware(request: NextRequest) {
const token = request.headers
.get("Authorization")
?.replace("Bearer ", "");
if (!token) {
return NextResponse.json(
{ error: "AUTH_REQUIRED" },
{ status: 401 }
);
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
// Check if user is banned (fresh check, not from JWT)
const user = await db.getUser(payload.sub);
if (user.is_banned) {
return NextResponse.json(
{ error: "USER_BANNED" },
{ status: 403 }
);
}
// Attach user context to request
request.user = payload;
return NextResponse.next();
} catch (err) {
return NextResponse.json(
{ error: "INVALID_TOKEN" },
{ status: 401 }
);
}
}is_banned on every request, even though the JWT is otherwise self-contained. This ensures that a banned user is immediately locked out, even if their JWT has not yet expired. This is the one place where the “stateless” design makes a deliberate exception.Token Lifecycle
| Event | Action |
|---|---|
| Login / Register / Embed-init | New JWT issued with 24h expiry |
| Token expires | Client must re-authenticate (login or embed-init) |
| User banned | Existing JWT rejected at middleware on next request |
| Tier changed | JWT tier claim is stale; fresh tier loaded from DB for risk checks |
| Page refresh (embed) | Operator re-sends embed-init via PostMessage; new JWT issued |
Ban Checking
The is_banned flag on the user record is the master switch for account access. When set to true, the user is immediately locked out of the platform.
Effects of Banning
- Login blocked - The login flow checks
is_bannedbefore generating a JWT. Banned users receive403 USER_BANNED. - Embed-init blocked - Same check applies to the embed flow. The iframe will show an error state.
- API requests blocked - The middleware performs a live ban check on every request. An existing JWT is immediately invalidated.
- Open positions preserved - Banning does not close or void a user’s positions. They will still be settled normally when markets resolve. The user simply cannot place new trades or access the platform.
is_banned = false via the admin dashboard immediately restores access. The user can log in again and their account, balance, and positions are all intact.Embed Auth: Operator-Scoped Users
The embed initialization flow is the most complex of the three because it bridges two identity systems: the operator’s (external user IDs) and Predictu’s (internal user IDs).
Identity Mapping
// Operator sends via PostMessage:
{
type: "PREDICTU_INIT",
operator_id: "op_example",
user_id: "player_12345", // Operator's user ID
display_name: "HighRoller99",
token: "operator-jwt"
}
// Predictu maps this to:
{
email: "op_example-player_12345@embed.predictu.io", // Synthetic
operator_id: "op_example",
display_name: "HighRoller99",
}
// On first visit: creates new Predictu user with this mapping
// On return visit: finds existing user by synthetic emailSecurity Considerations
- Origin validation - The PostMessage handler checks the origin of incoming messages against the operator’s registered allowed origins. Messages from unregistered origins are silently ignored.
- Operator JWT verification - The
tokenfield is a JWT signed with the operator’ss2s_secret. Predictu verifies this signature before processing the init request. This prevents unauthorized user creation. - No password - Embed users do not have a password. They cannot log in via the standard login flow. Their only entry point is through the operator’s embed.
- Synthetic emails are not routable - The
@embed.predictu.iodomain does not receive email. Synthetic emails are purely an internal identifier scheme.
The three auth flows are designed to be mutually compatible. A direct Predictu user and an embed user can both trade on the same markets. The only difference is how they authenticate and which wallet adapter handles their balance.
