Players sign in with an indie.fun account or Discord. Your game gets a signed token it can trust.
login: false to leave it out and drive login from your own button instead.indie.fun, email/password, or Sign in with Discord. Your page never sees a password.playerJoin event over a WebSocket, with the player's saved data and permissions already loaded.localStorage under moddio_access_token. Anything acting on it, awarding items, saving progress, should verify it server-side rather than trusting a user id sent from the browser.Hosted on indie.fun? The player is already signed in
When your game is played at indie.fun/play/your-game, a visitor who is signed in to indie.fun arrives signed in to your game too. The page hands the SDK the same token the popup would have, so there is no second login. The pill shows their name from the first frame and onChange fires with a user straight away.
So don't assume getUser() is null at startup. Gate on the onChange state rather than on “has the player clicked Log in yet”, and your game works the same whether it is hosted here or on your own domain, where the popup is still how someone signs in.
Login needs nothing beyond the App ID. Creating the client is the whole setup:
<script src="https://indie.fun/js/indie.js"></script>
<script>
const indie = new Indie({ appId: 'your-app-id' });
</script>Read the signed-in player, and react to changes:
indie.getUser(); // { id, name, picture } | null
indie.getToken(); // the JWT, or null
indie.getPermissions(); // { chat: true, vip: false }
// Fires immediately with the current state, then on every login/logout
indie.onChange(({ user, token, permissions }) => {
if (!user) return showLoginPrompt();
startGameAs(user.name);
});Don't trust the browser
getUser() is for your UI. For anything that matters, send the token to your server and verify it there. The browser can claim to be anyone.login: false leaves the pill unmounted, and indie.login() opens the same popup from a button of your own:
const indie = new Indie({ appId: 'your-app-id', login: false });
myButton.onclick = () => indie.login(); // open the popup yourself
indie.onChange(({ user }) => { ... }); // the result arrives hereCall it from a real click. A popup opened outside a user gesture is blocked by the browser.
The flag hides the SDK's own UI and nothing else. A token already stored is still restored, and sessions, session length, frame rate, progression and crash reporting all run exactly as before, none of them needs a signed-in player. The guest analytics notice is not part of login either and still appears.
Embedded somewhere that signs players in itself?
On a portal like CrazyGames or Poki, our popup is unusable and the site has its own account. Passlogin: false and keep the SDK for the analytics: play sessions, how long people played, where they stopped and what crashed are all recorded for anonymous players, identified by device, with nobody signed in. Analytics has the whole recipe, including how to turn the consent bar off and what taking that on means.With the server SDK (needs your App Secret, never the browser):
const { Indie } = require('indie-sdk/server');
const indie = new Indie({
appId: 'your-app-id',
appSecret: process.env.INDIE_APP_SECRET,
});
const player = await indie.verifyToken(tokenFromBrowser);
if (!player) return reject('not signed in');
player.userId; // trust this oneOr straight over HTTP:
curl -X POST https://indie.fun/api/auth/verify-token \
-H 'Content-Type: application/json' \
-H 'X-App-Id: your-app-id' \
-H 'X-App-Secret: your-app-secret' \
-d '{"token":"<player token>"}'
# { "valid": true, "userId": "...", "name": "...", "email": "...",
# "discordId": "...", "solanaWallet": null }An invalid or expired token answers 401. The response also carries the player's verified Solana wallet when they have linked one. See Tokens.
A permission is a name your game checks (chat, vip) mapped to a Discord role. Configure them under Permissions on your game's page: pick your Discord server, add the Indie bot so we can read roles, then map each role to a permission name.
indie.getPermissions() returns a flat map, e.g. { chat: true, vip: false }, and updates through onChange.player.permissions on the playerJoin event carries the same map.login: false there is no widget, so that message is yours to show; getPermissions() is what to show it from.Check it worked
1. Open the sandbox with your App ID and click Log in on the widget. The Player login check turns green once the session we're recording carries an account.
2. In your own game, sign in and run this in the console:
await fetch('https://indie.fun/api/sdk/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + indie.getToken(),
},
body: JSON.stringify({ appId: indie.getAppId(), sessionId: indie.getSessionId() }),
}).then(r => r.json());
// auth: { valid: true, userId: '...' } ← the token is one we issued
// session: { identity: 'account' } ← the session is attributed to it3. Your game's Settings → Setup panel counts signed-in sessions across all your players, so you can tell “login is broken” from “nobody has tried yet”.
apiUrl that isn't the host you actually log in on will silently drop it. Leave apiUrl unset unless you know you need it.indie.login() yourself has the same rule: it has to run in the click, not after an await or a timer, or the browser treats it as unrequested and blocks it.playerJoin arrives over a WebSocket authenticated with your App Secret. A wrong secret closes the socket with code 4003 and logs invalid appId or appSecret.