Base IdP

Base IdP

Concepts

How It Works

OAuth2 authorization code with PKCE, PASETO tokens, and offline verification — the full picture, end to end.

Base IdP is a standard OAuth2 authorization server with one or two opinions baked in. The login is the authorization code flow with PKCE. The tokens are PASETO v4.public, not JWT. Verification happens offline against published keys.

This page walks through the whole picture so you know what is actually happening when the SDK does its work. If you understand this page, you can debug almost any integration problem on your own.

The flow at a glance

A successful login is four round trips and one redirect:

  1. The app asks Base IdP for an authorization URL and opens it in a browser.
  2. Base IdP shows the login page; the user authenticates.
  3. Base IdP redirects back to your app with an authorization code.
  4. Your app or your server exchanges that code for tokens.

The PKCE part ties step 1 to step 4: the app generates a secret called the code_verifier, sends a code_challenge (a hash of the verifier) in step 1, and proves possession of the original verifier in step 4. An attacker who intercepts the code cannot use it without the verifier.

Step 1: build the authorize URL

The app constructs the authorize URL. The SDK does this for you, but it is worth knowing what is in there.

https://authlayer.squareexp.com/oauth2/authorize?
  response_type=code&
  client_id=sq_live_yourapp&
  redirect_uri=myapp%3A%2F%2Fauth%2Fcallback&
  scope=openid+profile&
  code_challenge=XQRWuFhA6dT...&
  code_challenge_method=S256&
  state=cs_8a7c3...

What each parameter does

  • response_type=code — you want an authorization code, not a token directly. This is the code flow, not the (deprecated) implicit flow.
  • client_id — your app's public id.
  • redirect_uri — where Base IdP should send the user back. Must exactly match one of the URIs registered for the app.
  • scope — what permissions you are asking for. Resolved against the allowed scopes on your registration.
  • code_challenge and code_challenge_method — the PKCE binding.
  • state — an opaque string the SDK uses to detect tampering and to carry a "return to" URL across the redirect.

Step 2: the user authenticates

The system browser opens the URL. Base IdP shows the hosted login: email and password, magic link, OAuth federation, MFA, password reset — whatever your app's registration allows. None of this UI lives in your code.

This is the moment you do not want to own. Login is hard. Account recovery is harder. Edge cases like password resets that race with magic-link expiry are the kind of bug you spend a week chasing. Base IdP owns all of it.

Step 3: the redirect back

After a successful login Base IdP redirects the browser to your redirect_uri with two parameters appended.

myapp://auth/callback?code=ac_01HRZ...&state=cs_8a7c3...

The SDK checks the state matches what it sent in step 1, then moves on to the exchange.

Step 4: exchange the code for tokens

This is where public and confidential clients differ.

Public client (mobile, SPA)

A public client cannot hold a secret, so it uses the PKCE code_verifier to authenticate.

POST /oauth2/token HTTP/1.1
Host: authlayer.squareexp.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=ac_01HRZ...&
client_id=sq_live_yourapp&
redirect_uri=myapp%3A%2F%2Fauth%2Fcallback&
code_verifier=<the original random string>

Confidential client (server)

A confidential client also authenticates with a secret. The PKCE verifier is optional here but commonly included.

POST /oauth2/token HTTP/1.1
Host: authlayer.squareexp.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=ac_01HRZ...&
client_id=sq_live_yourapp&
client_secret=sqk_3b7157c3d69...&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback

What you get back

{
  "token_type": "PASETO",
  "access_token": "v4.public.eyJ...",
  "refresh_token": "rt_01HRZ...",
  "expires_in": 3600,
  "refresh_token_expires_at": "2026-07-29T14:22:08Z"
}

The access token is short-lived (typically one hour). The refresh token is long-lived and rotates on every use. Store both in secure storage — the Keychain on iOS, the EncryptedSharedPreferences on Android, an HTTP-only cookie on the web.

Verifying a token

Once your app has an access token, your backend verifies it on every request. There are two ways to verify, and they have very different performance characteristics.

Option A: offline verification

The backend caches Base IdP's public keys and verifies the PASETO signature locally. There is no per-request call to Base IdP. This is what you want for real production traffic.

verify in TypeScript
import { BaseIdPServerClient } from "base-idp/server";

const client = new BaseIdPServerClient({
  clientId: process.env.BASE_IDP_CLIENT_ID,
});

const principal = await client.verifyAccessToken(token);
verify in Go
client := baseidp.MustNew(baseidp.ConfigFromEnv())

principal, err := client.VerifyAccessToken(ctx, token, baseidp.VerifyOptions{})
verify in Rust
let client = Client::new(Config::from_env()?)?;
let principal = client.verify_access_token(token, VerifyOptions::default()).await?;

Option B: ask Base IdP directly

If local verification is inconvenient (a serverless function, a one-off script), hand the token to Base IdP and let it tell you who the user is.

GET /v1/me HTTP/1.1
Host: authlayer.squareexp.com
Authorization: Bearer v4.public.eyJ...
{
  "sub": "usr_01HRZ...",
  "gid": "gid_01HRZ...",
  "email": "emai@domain.com",
  "name": "Ajmal Leonard"
}

This is the equivalent of OpenID Connect's /userinfo. It is fine at login time. It is the wrong choice on every authenticated request — every call adds a network round trip to Base IdP and a single point of failure for your service.

Refresh

When the access token expires, you exchange the refresh token for a new pair.

POST /oauth2/token HTTP/1.1
Host: authlayer.squareexp.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
client_id=sq_live_yourapp&
refresh_token=rt_01HRZ...

The response is the same shape as the original token response, with a new access token and a new refresh token. The old refresh token is invalidated — this is rotation, and it limits the blast radius if a token leaks.

If the refresh fails, the user has been signed out (revoked, expired, or rotated). Drop the tokens, show the sign-in screen, and start over.

Mobile to backend handoff

For native apps the common pattern is to share the verified user between the device and your own backend, then issue your own session tokens.

The four-step pattern

  1. The mobile SDK runs the full login flow and gets an access token.
  2. The app POSTs the access token and a payload (the principal from session.toServerPayload()) to your backend's exchange endpoint.
  3. Your backend verifies the token, upserts a user row, and issues your own JWT or cookie session for your product API.
  4. The app uses your session token for all subsequent product API calls.

This keeps Base IdP as the source of truth for identity while your backend controls its own sessions. It also means your product API does not have to talk to Base IdP on every request.

backend exchange endpoint
app.post("/auth/idp-exchange", requireBaseIdpToken, async (req, res) => {
  const principal = req.principal;          // verified by the middleware
  const user = await upsertUser(principal); // your own users table
  const session = await issueSession(user); // your own session tokens
  res.json({ session, user });
});

Logout

A logout has two parts: drop your local tokens, and tell Base IdP to revoke the session. The SDKs expose a logout helper that does both.

await auth.logout();

Under the hood this calls POST /oauth2/revoke with the refresh token, then clears local storage. The next time the user wants to sign in, the full flow starts from scratch.

Why PASETO and not JWT

If you have done identity work before, you may be wondering why the tokens look strange — why v4.public. and not the eyJ you are used to.

PASETO is a token format designed to remove the categories of bugs that haunt JWT. JWT lets the token pick its own signing algorithm at runtime, which created an entire family of attacks (alg: none, algorithm substitution, RS256-vs-HS256 confusion). PASETO does not. A v4.public token is Ed25519, period. The format is the security contract.

Practically, this means:

  • There is no alg header field you can attack.
  • There is no library that "supports" PASETO but parses it wrong.
  • The signature is verifiable offline, exactly like JWT.

The SDK handles the format. You never see the difference at the call site.

What could go wrong

This list covers the bugs that come up most often. Most of them are not bugs in your code — they are mismatches between what your code sends and what your registration says.

"redirect_uri does not match"

The redirect URI you pass in step 1 is not in the registered list, or differs by a slash, scheme, or port. The Base IdP error message tells you which URI it received and which it expected.

"invalid client"

The client id is wrong or the app is disabled. Run npx base-idp test --client-id <id> to confirm.

"invalid grant"

The authorization code expired, was already used, or the PKCE verifier does not match the challenge. Codes are single-use and short-lived — if you see this in dev because you reloaded the page, just start over.

"token signature failed"

You are verifying with the wrong implicit assertion or the wrong public key. This is almost never possible when you use the SDK — it is the kind of bug you get from hand-rolling PASETO verification. Use the SDK.

Browser opens and never returns

Almost always a redirect-URI scheme mismatch. The URI in your code says myapp://auth/callback but myapp is not registered on iOS / Android. Or the scheme registered is myapp-debug but the code uses myapp.

Where to go next

On this page