---
title: Sign in users from a browser app
description: Log Bolder users into a single-page app with the authorization code flow and PKCE, keep the session alive with refresh tokens and prompt=none, and call the API from the browser.
---

Goal: a page with no backend of its own, such as a staff dashboard or a dealer portal, where people sign in with their Bolder account and the page calls the v2 API with their token. Nothing in the browser can keep a secret, so the app uses PKCE instead of a `client_secret`. Each user only gets the scopes their role carries.

If your app has a server, the code grant with a secret in [Build an app for other merchants](/guides/build-an-app) is simpler to operate.

1. **Register the application**

    Go to [start.onbolder.com/dev/credentials](https://start.onbolder.com/dev/credentials), create an application and set its redirect URI to the page that will receive the login, for example `https://yourapp.com/callback`. You only need the `CLIENT_ID`; the secret is never used.

    The token endpoint accepts browser requests from the origin of that redirect URI only (`https://yourapp.com`). For local development, register an `http://localhost` URI: any port works.

2. **Send the user to sign in**

    Generate a random `code_verifier`, keep it for the callback, and send its SHA-256 hash as the `code_challenge`. The `state` protects the callback from forged redirects.

    ```js
    const AUTH = 'https://auth.onbolder.com'
    const CLIENT_ID = 'YOUR_CLIENT_ID'
    const REDIRECT_URI = 'https://yourapp.com/callback'
    const SCOPES = 'orders.read,products.read'

    const base64url = (bytes) =>
      btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
    const randomString = (bytes) => base64url(crypto.getRandomValues(new Uint8Array(bytes)))

    async function challengeFor(verifier) {
      const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
      return base64url(new Uint8Array(hash))
    }

    export async function login({ silent = false } = {}) {
      const verifier = randomString(32)
      const state = randomString(16)
      sessionStorage.setItem('bolder_pkce', JSON.stringify({ verifier, state }))

      const query = new URLSearchParams({
        client_id: CLIENT_ID,
        response_type: 'code',
        redirect_uri: REDIRECT_URI,
        scope: SCOPES,
        state,
        code_challenge: await challengeFor(verifier),
        code_challenge_method: 'S256'
      })
      if (silent) query.set('prompt', 'none')
      location.assign(`${AUTH}/oauth/authorize?${query}`)
    }
    ```

    Ask only for scopes the users' role has: a request for any other scope is refused with `invalid_scope`, not trimmed. A *vendedor*, for example, gets `orders.read_own` and `orders.write_own` (only the orders of their own customers), never `orders.read`. The table is in the [API reference](/api/v2).

3. **Exchange the code on the callback page**

    Bolder redirects to your redirect URI with `code` and `state`. Check the `state`, then trade the code for tokens with the verifier, without any secret:

    ```js
    async function tokenRequest(params) {
      const res = await fetch(`${AUTH}/oauth/token`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams(params)
      })
      const body = await res.json()
      if (!res.ok) throw new Error(body.error) // invalid_grant, invalid_scope...
      return body
    }

    function save({ access_token, refresh_token, expires_in }) {
      localStorage.setItem('bolder_tokens', JSON.stringify({
        access_token,
        refresh_token,
        expires_at: Date.now() + expires_in * 1000
      }))
    }

    export async function handleCallback() {
      const query = new URLSearchParams(location.search)
      const saved = JSON.parse(sessionStorage.getItem('bolder_pkce') || 'null')
      sessionStorage.removeItem('bolder_pkce')

      if (query.get('error')) throw new Error(query.get('error')) // access_denied, login_required, consent_required
      if (!saved || query.get('state') !== saved.state) throw new Error('invalid_state')

      save(await tokenRequest({
        grant_type: 'authorization_code',
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        code: query.get('code'),
        code_verifier: saved.verifier
      }))
    }
    ```

    The response has `access_token`, `expires_in` (3600: access tokens last one hour), `scopes` and, if your app has refresh tokens enabled, `refresh_token`. Anything in `localStorage` can be read by every script on your page: don't load scripts you don't trust there.

4. **Keep the session alive**

    Refresh tokens are off by default for applications. Write to [help@onbolder.com](mailto:help@onbolder.com) with your `CLIENT_ID` to enable them. Once enabled, the code exchange also returns a `refresh_token` valid for 24 hours, and you renew with just the `client_id`. Each refresh returns a new refresh token, so the session lasts while the user is active.

    When there is no refresh token, or it has expired, send the user back to `/oauth/authorize` with `prompt=none`. If they are still signed in to Bolder and have already authorized your app, the code comes back without any page shown. If not, the callback receives `error=login_required` or `error=consent_required`: show a "Sign in" button that calls `login()`.

    ```js
    export async function accessToken() {
      const tokens = JSON.parse(localStorage.getItem('bolder_tokens') || 'null')
      if (tokens && tokens.expires_at - Date.now() > 60_000) return tokens.access_token

      if (tokens?.refresh_token) {
        try {
          const fresh = await tokenRequest({
            grant_type: 'refresh_token',
            client_id: CLIENT_ID,
            refresh_token: tokens.refresh_token
          })
          save(fresh)
          return fresh.access_token
        } catch {
          localStorage.removeItem('bolder_tokens') // expired, revoked, or the user's role changed
        }
      }
      await login({ silent: true }) // leaves the page
    }
    ```

    Every refresh checks the user again: someone whose role was removed, downgraded or disabled gets `invalid_scope`, and someone who revoked your app gets `invalid_grant`. An access token already issued keeps working until it expires.

5. **Call the API**

    The API answers CORS requests from any origin, so the page calls it directly with the token in the `Authorization` header:

    ```js
    const res = await fetch('https://api.onbolder.com/v2/orders?per_page=10', {
      headers: { Authorization: `Bearer ${await accessToken()}` }
    })
    const { _embedded } = await res.json()
    ```

    If a call answers `401`, the token has expired or been revoked: get a new one with `accessToken()` and retry once.

6. **Sign out**

    Delete the stored tokens. The user stays signed in to Bolder itself, so a later `prompt=none` login would succeed without asking; to end that session too, send them to `https://auth.onbolder.com/oauth/logout`, which ends on the Bolder sign-in page.

    ```js
    export function logout() {
      localStorage.removeItem('bolder_tokens')
    }
    ```

## Next

- [Authentication reference](/api/v2): every grant type, the token response and the scopes by role.
- [Create an order and take payment](/guides/create-an-order).
