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_yourappThat 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_yourappThe 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_3b7157c3d69f11a4af17d6c955ae31fd1fe3183e10b329c1ef4220c3badc0c18The 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 data | Verify-only |
Sits behind a load balancer and only checks Authorization headers | Verify-only |
Handles /api/auth/callback and exchanges codes for tokens | Confidential |
| Issues OAuth2 client credentials grants for service-to-service auth | Confidential |
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.
BASE_IDP_CLIENT_ID=sq_live_yourapp
BASE_IDP_CLIENT_SECRET=sqk_3b7157c3d69f11a4af17d6c955ae31fd1fe3183e10b329c1ef4220c3badc0c18Neither variable gets a NEXT_PUBLIC_ prefix. The browser component that
starts a login is a plain anchor tag.
export default function SignInPage() {
// No Base IdP import. No client id. Just a link.
return <a href="/api/auth/start">Sign in</a>;
}import "server-only";
// Reads BASE_IDP_CLIENT_ID — never exported to the browser bundle.
export async function GET(request: NextRequest) { ... }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.
final auth = BaseIdpFlutterAuth(
config: const BaseIdpConfig(
clientId: String.fromEnvironment('BASE_IDP_CLIENT_ID'),
),
redirectUri: 'myapp://auth/callback',
);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 code | Role | Env you set | Secret? |
|---|---|---|---|
| Mobile, SPA, browser-only Next.js | Public frontend | CLIENT_ID | No |
| API behind a mobile / SPA frontend | Verify-only | nothing | No |
| Server-rendered web app login | Confidential | CLIENT_ID + CLIENT_SECRET | Yes |