Tokens & Verification
PASETO v4.public, Ed25519 signatures, key discovery, and exactly what is inside a verified principal.
Base IdP issues PASETO v4.public tokens. Not JWT. The format change is the biggest difference from other identity providers, so this page covers what PASETO is, what a Base IdP token looks like inside, how the SDK verifies it, and what you get back when verification succeeds.
If you have used JWT before, the mental model carries over almost completely. There is a header, a payload of claims, and a signature. The differences are in the details, and the SDK hides most of them.
What a token looks like
A PASETO v4.public token is a single string with four parts separated by periods.
v4.public.eyJpc3MiOiJodHRwczov...<base64url message>...AAAAA.eyJraWQiOiJrXzAxSFJaIn0The four parts are:
- Version —
v4. - Purpose —
public(asymmetric signing, Ed25519). - Payload — base64url-encoded JSON claims, with the Ed25519 signature appended to the end of the binary message.
- Footer — base64url-encoded JSON, contains the key id (
kid).
You will never have to read this by hand. The CLI has a token command that
decodes a token into structured JSON, and the SDK does it as part of
verification.
npx base-idp token v4.public.eyJ...{
"header": { "version": "v4", "purpose": "public" },
"footer": { "kid": "k_01HRZ...", "alg": "v4.public", "typ": "paseto" },
"claims": {
"iss": "https://authlayer.squareexp.com",
"sub": "usr_01HRZ...",
"aud": "sq_live_yourapp",
"exp": "2026-06-29T15:22:08.000Z",
"nbf": "2026-06-29T14:21:38.000Z",
"iat": "2026-06-29T14:22:08.000Z",
"jti": "tok_01HRZ...",
"gid": "gid_01HRZ...",
"email": "emai@domain.com",
"name": "First Name",
"token_use": "access",
"sid": "ses_01HRZ...",
"scopes": ["openid", "profile"],
"role": "operator"
}
}Why PASETO
The short version: JWT has an alg field that picks the signing algorithm at
runtime, which has historically led to an entire family of bugs.
The most famous one is alg: none — a JWT library that respects the header
field will happily accept a token with no signature if the attacker says
"this token is unsigned." There are also algorithm-confusion attacks where an
RS256 verifier is tricked into using HS256 with the public key as the HMAC
secret.
PASETO removes the choice. A v4.public token is Ed25519, full stop. There is
no alg field that can be lied about. The format itself is the security
contract.
This is the kind of thing you do not need to think about as long as you use
the SDK. The SDK only parses tokens that say v4.public. and only verifies
them with Ed25519. There is no configuration that can make it do otherwise.
Do not use a JWT library
Never try to parse a Base IdP token with a JWT library. It will fail at best, or silently misbehave at worst. Use the Base IdP SDK for your language. The SDKs are tiny — there is no real cost to including one.
The verification chain
When the SDK verifies a token it runs four checks in order. If any check fails, the token is rejected and the SDK throws or returns an error.
Parse the footer
The footer contains the kid (key id), alg, and typ. The SDK parses these
without doing any cryptography — it only needs them to look up the right
public key. If alg is not v4.public or typ is not paseto, the token is
rejected immediately.
Verify the Ed25519 signature
The SDK looks up the public key for the token's kid in its cache. If the key
is not in the cache (because keys have rotated), it re-fetches the key set
from GET /v1/keys/paseto-v4-public and tries again.
The signature is verified against the message, footer, and an implicit
assertion of square-experience:idp:access:v1. If the signature does not
match, the token is rejected.
Check the claims
After the signature passes, the SDK validates the claims.
token_usemust beaccess(refresh tokens and ID tokens have different values; the access-token verifier only accepts access tokens).iss,sub,aud, andjtimust all be non-empty.gid(the global user id) andsid(the session id) must be non-empty.expmust be in the future. There is a small clock-skew tolerance.nbf(not before) must not be more than 30 seconds in the future.- The active context must be a valid kind and tenant id.
Check the optional blocklist
If the SDK is configured with a blocklist (used for logout and admin
revocation), it checks the jti against it. If the token is revoked, it is
rejected even if the signature is valid.
What a verified principal contains
When verification succeeds, you get a typed principal. The exact field names
vary by SDK (subject vs sub, globalId vs gid) but the values are the
same.
| Field | Description |
|---|---|
sub / subject | The subject claim — the user id within the issuer. |
gid / globalId | The global user id — stable across products. |
email | The user's email, if granted. |
name / displayName | The user's display name, if granted. |
scopes | The scopes granted on this token. |
role | The user's role within the active context. |
sid / sessionId | The session id, useful for logout. |
aal | Authentication assurance level (1 = password, 2 = MFA). |
amr | Auth methods used to authenticate this session. |
ent / entitlementRefs | References to active entitlements. |
aud / audience | The audience the token was minted for. |
iss / issuer | The issuer that minted the token. |
exp / expiresAt | The expiration time. |
In code:
const principal = await client.verifyAccessToken(token);
console.log(principal.subject, principal.email, principal.scopes);p, _ := client.VerifyAccessToken(ctx, token, baseidp.VerifyOptions{})
fmt.Println(p.Subject, p.Email, p.Scopes)let p = client.verify_access_token(token, VerifyOptions::default()).await?;
println!("{} {} {:?}", p.subject, p.email, p.scopes);Key discovery and rotation
Base IdP rotates its signing keys regularly. The SDK handles this for you, but it is useful to know how.
The keys endpoint
GET /v1/keys/paseto-v4-public HTTP/1.1
Host: authlayer.squareexp.com{
"keys": [
{
"kid": "k_01HRZ...",
"alg": "v4.public",
"crv": "Ed25519",
"x": "base64url-encoded-public-key"
},
{
"kid": "k_01HRY...",
"alg": "v4.public",
"crv": "Ed25519",
"x": "base64url-encoded-public-key"
}
]
}The set always contains the current key and the previous key (so tokens
minted just before rotation still verify). When a token references a kid
the SDK has never seen, the SDK re-fetches the set before failing.
What you should never do
Never hardcode a public key into your application. Keys rotate. A hardcoded key works until the day it does not, and then every request fails until someone redeploys.
const PUBLIC_KEY: &[u8] = &[/* hardcoded bytes */]; // wrongThe SDK has a key cache with a TTL. Trust it.
The /v1/me shortcut
If you do not want to verify locally, you can ask Base IdP directly.
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": "First Name",
"scopes": ["openid", "profile"],
"role": "operator"
}The token is validated server-side — signature, audience, expiry — and the principal is returned. This is the easiest possible integration. It is also the slowest, because it adds a round trip to Base IdP on every authenticated request that uses it.
Use /v1/me for:
- Serverless functions where caching public keys is awkward.
- One-off scripts and CLI tools.
- The initial login-exchange step (where a single extra round trip is fine).
Use offline verification for:
- High-traffic APIs.
- Anything where Base IdP being slow should not slow your service.
Token lifetimes
Defaults are sensible, not negotiable.
| Token | Lifetime | Rotates? |
|---|---|---|
| Access | 1 hour | No, expires |
| Refresh | 30 days | Yes, on every use |
| IdP session | 30 days | Yes, on every refresh |
If you find yourself wanting to bump the access token's lifetime, you almost certainly want a refresh-token-based flow instead. The SDK already handles refresh; you do not need to extend the access lifetime to avoid logins.