Authentication

Players sign in with an indie.fun account or Discord. Your game gets a signed token it can trust.

How it works

  1. The browser SDK mounts a small pill in the top-right corner of your page. Before login it reads Log in; after, it shows the player's avatar and name. Pass login: false to leave it out and drive login from your own button instead.
  2. Clicking it opens a popup on indie.fun, email/password, or Sign in with Discord. Your page never sees a password.
  3. On success the popup hands your page a signed JWT. The SDK stores it, tells indie.fun a player joined, and attaches the account to the session already being measured.
  4. Your game server (if you run one) receives a playerJoin event over a WebSocket, with the player's saved data and permissions already loaded.
The token is a JWT signed by indie.fun, valid for 7 days, and kept in 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.

Set it up

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.

Use your own login button

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 here

Call 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. Pass login: 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.

Verify a player on your server

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 one

Or 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.

Permissions

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.

In the browser
indie.getPermissions() returns a flat map, e.g. { chat: true, vip: false }, and updates through onChange.
On your server
player.permissions on the playerJoin event carries the same map.
When a player lacks a role
The widget tells them which role they need and links your Discord invite, so you don't have to build that UI. With login: false there is no widget, so that message is yours to show; getPermissions() is what to show it from.
A player who joins the server or gains a role mid-session has to sign in again for it to take effect. The widget offers “Re-login to refresh” for exactly that.

Check it worked

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 it

3. 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”.

When it doesn't work

The popup opens and closes, nothing happens
The SDK only accepts the token from indie.fun itself. A custom apiUrl that isn't the host you actually log in on will silently drop it. Leave apiUrl unset unless you know you need it.
The popup never opens
A popup blocker. The widget opens it from a real click, so this usually means the click was intercepted by your own handler. Check for stopPropagation on the page. Calling 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.
Permissions are all false
The player has no Discord linked, isn't in your server, or the Indie bot isn't in it. The widget states which of the three it is.
Login works, but my server never hears about 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.