Base IdP

Base IdP

Concepts

Client Roles

Public frontend, verify-only backend, and confidential server — what each one is, what each one needs, and how to choose.

Every piece of code that touches Base IdP plays exactly one of three roles. The role decides which SDK you use, which env you set, and whether a secret is ever in the picture. There is no fourth role. There is no in-between.

If you have spent any time confused about which BASE_IDP_* variable goes where, that confusion almost always traces back to mistaking the role of one part of your code. Once you know which role each part plays, the env block writes itself.

The three roles

Public frontend

Code that runs the login on a user's device or in a browser, using PKCE, without ever holding a secret.

This is your Flutter app. Your React Native app. Your SwiftUI iOS app. Your Expo app. A browser-only Next.js app that calls a separate API. Anything that ships in a bundle.

A public frontend has exactly one Base IdP value in env:

BASE_IDP_CLIENT_ID=sq_live_yourapp

That is the whole story. Public clients use PKCE to authenticate — the code_verifier they generate at the start of the flow is what proves the token exchange is legitimate. No secret is needed.

The reason no secret is allowed here is simple: a secret in a bundle is a leaked secret. App bundles can be unpacked. JS in a browser can be read. Anything that ships to a user is public.

Verify-only backend

Code that receives a token and only needs to know who the user is. It does not run the login. It does not call the token endpoint. It only verifies incoming tokens.

This is the most common backend role and it is the easiest one to set up. Examples include a Go service sitting behind a Flutter app, a Rust gateway fronting your microservices, an Express API that React Native talks to, an internal service that trusts upstream tokens from another service in your fleet.

A verify-only backend needs nothing in env. Nothing.

# Verify-only — nothing required.
# Optional: pin the audience.
# BASE_IDP_CLIENT_ID=sq_live_yourapp

The reason is that everything a verifier needs is either in the token itself (the audience, the user claims) or public (the signing keys). The SDK fetches the keys at startup, caches them, and verifies each request offline. There is no point in this flow where the service authenticates as the app, so there is no secret.

You can optionally set BASE_IDP_CLIENT_ID so the SDK pins the audience to your app. Without it the SDK accepts any token from the issuer. With it the SDK rejects tokens minted for a different app, which is what you want in multi-tenant environments.

Confidential server

Code that runs the login itself — the token exchange happens on your server, and the server authenticates to Base IdP with a secret. The browser hits a callback URL on your server, your server hands the code to Base IdP, your server receives the tokens.

This is a Next.js fullstack app where the same server renders pages and handles /api/auth/callback. A NestJS web app. A Laravel app. A server-rendered Rails app.

A confidential server holds the full pair:

BASE_IDP_CLIENT_ID=sq_live_yourapp
BASE_IDP_CLIENT_SECRET=sqk_3b7157c3d69f11a4af17d6c955ae31fd1fe3183e10b329c1ef4220c3badc0c18

The secret is what tells Base IdP "this token request really is coming from your registered server, not from someone forging requests." The secret never leaves the server.

Choosing your role

If you are unsure which role applies, walk through this checklist for each piece of code separately.

If your code…Then its role is…
Runs in a browser tab and calls loginWithRedirect()Public frontend
Runs in a mobile app and calls BaseIdpFlutterAuth.login()Public frontend
Receives a token from a mobile or SPA, verifies it, serves dataVerify-only
Sits behind a load balancer and only checks Authorization headersVerify-only
Handles /api/auth/callback and exchanges codes for tokensConfidential
Issues OAuth2 client credentials grants for service-to-service authConfidential

The same project can have multiple roles. A Next.js fullstack project is public frontend (the browser code) and confidential server (the API routes) at the same time. A Flutter app with a Go backend is public frontend on the mobile side and verify-only on the Go side.

Fullstack: entirely server-side

A Next.js or Remix fullstack app that handles the login on its own server is not split between two roles — it is fully server-side. The browser navigates to /api/auth/start, the server builds the authorize URL and redirects to Base IdP, Base IdP redirects to /api/auth/callback, the server exchanges the code for tokens and stores them as httpOnly cookies. The browser code never knows the client id, the issuer, or the secret.

.env.local (server-only)
BASE_IDP_CLIENT_ID=sq_live_yourapp
BASE_IDP_CLIENT_SECRET=sqk_3b7157c3d69f11a4af17d6c955ae31fd1fe3183e10b329c1ef4220c3badc0c18

Neither variable gets a NEXT_PUBLIC_ prefix. The browser component that starts a login is a plain anchor tag.

app/sign-in/page.tsx
export default function SignInPage() {
  // No Base IdP import. No client id. Just a link.
  return <a href="/api/auth/start">Sign in</a>;
}
app/api/auth/start/route.ts
import "server-only";
// Reads BASE_IDP_CLIENT_ID — never exported to the browser bundle.
export async function GET(request: NextRequest) { ... }
app/api/auth/callback/route.ts
import "server-only";
// Reads BASE_IDP_CLIENT_SECRET — never exported to the browser bundle.
export async function GET(request: NextRequest) { ... }

Everything Base IdP touches is server-only. Tokens land in httpOnly cookies that browser JS cannot read. This is the architecture used by Square's own console. It is the correct shape for any SSR web app.

Mobile plus a backend

For a Flutter or React Native app that talks to a Go, Rust, or Node API, you also have two roles, but in different processes.

The mobile app is a public frontend. It does the login, gets the tokens, stores them in secure storage, and sends them on each API call.

The API is a verify-only backend. It checks the token, reads the user, and serves the request. It needs nothing in env.

mobile (public frontend)
final auth = BaseIdpFlutterAuth(
  config: const BaseIdpConfig(
    clientId: String.fromEnvironment('BASE_IDP_CLIENT_ID'),
  ),
  redirectUri: 'myapp://auth/callback',
);
api (verify-only)
var client = baseidp.MustNew(baseidp.ConfigFromEnv())

func protect(next http.Handler) http.Handler {
    return client.RequireAuth(next, baseidp.MiddlewareOptions{})
}

No secret anywhere. The mobile app cannot hold one. The API does not need one. This is the most common production shape across Square products.

Anti-patterns to avoid

These are the patterns that look reasonable but cause real bugs.

Putting the secret in your mobile app

Never. A mobile app bundle is public — anyone can extract it. If you find yourself wanting to put the secret in --dart-define or in a Swift constant, stop. Your mobile app is a public frontend. If you need a secret-only operation, do it on a confidential server and have the mobile app call your server.

Treating a verify-only backend as confidential

A backend that only verifies tokens does not need a secret. Adding one does not make the verifier more secure — it just means you have a secret to leak. If your server is doing nothing but checking Authorization headers, it is verify-only.

Treating a confidential server as public

If your server handles /api/auth/callback and exchanges codes for tokens, it must authenticate with the secret. Some libraries let you call the token endpoint without one for public clients, but that requires your app to be registered as a public client — and a server-rendered web app should be registered as confidential. The right move is to register correctly, not to skip the secret.

Sharing the same registration across environments

Production gets its own app registration. Staging gets its own. Local development gets its own. They have different redirect URIs, different secrets, and different audit logs. Sharing one registration across environments is how a dev token ends up authorizing a production action.

At a glance

Your codeRoleEnv you setSecret?
Mobile, SPA, browser-only Next.jsPublic frontendCLIENT_IDNo
API behind a mobile / SPA frontendVerify-onlynothingNo
Server-rendered web app loginConfidentialCLIENT_ID + CLIENT_SECRETYes

Where to go next

On this page