The JWT and the attestation response are two different things
ThePOST /api/v1/attestation/validate response body and the signed token contain overlapping but different data. Mixing them up is the most common integration bug.
Absent, not null
Claims that do not apply to your plan are omitted from the token entirely — they are not present with anull value. Check for absence, not for null:
Claim reference
Availability by plan. ✓ present · ◐ conditional, see the note for that claim · — omitted from the token entirely.Standard claims
Device identity
Device intelligence
device_model, os_version, and app_version are Enterprise-only in the token. They are collected on every paid plan and are visible in the dashboard and analytics API on Pro and Business — they are simply not projected into the JWT below Enterprise. If your Pro or Business backend needs the device model, read it from the analytics API, not the token.Custom claims
Your configured claims are nested under
custom_claims, preserving their JSON types. Grantiva’s managed subscription object also lives here. See Custom Claims and Subscription Claims.
The key is omitted entirely when no claims are configured on Free, and is an empty object {} on paid plans when no claims evaluate to a value.
Decoded examples
Business plan
risk_category, no device_model, no country, no device_id.
Free plan
deviceIntelligence.riskCategory — your app has to forward it if your backend needs it, and it is not signed.
Issuer and audience
iss and aud are per-organization, not fixed Grantiva strings. The default issuer is app-attest-<your-organization-name-lowercased> and the default audience is your organization name exactly as it was registered. There is no self-serve editor for these today — if you need them changed, contact support@grantiva.io.
Pinning iss and aud matters: it is what stops a token minted for a different organization from being accepted by your backend.
device_id
device_id is not a reliable identifier and is populated inconsistently:
POST /attestation/validate— carries the device id supplied on the challenge request (GET /attestation/challenge?device_id=...). The iOS SDK does not send that parameter, so the claim is absent on the normal SDK attestation path.POST /attestation/refresh— carries Grantiva’s internal device-profile UUID.
sub (the App Attest key id) as your per-device key. It is present on every token on every plan, and it is the identity Apple’s attestation actually binds.
Verifying the token server-side
Grantiva signs with RS256. Publish keys are athttps://api.grantiva.io/.well-known/jwks.json (alias: GET /api/v1/attestation/public-key). Match the kid in the JWT header — not the kid claim in the payload — against the key set. Standard libraries do this for you.
Cache the key set. It is served with Cache-Control: public, max-age=3600 and changes only on rotation.
Node.js
step-up means: ask the client for a fresh attestation (the SDK’s refresh) and re-run the gate, or require a second factor. It does not mean “allow anyway”.
Python
Both examples verify
iss and aud and pin algorithms to RS256. Never call a decode function without an algorithm allowlist — an attacker-supplied alg: none or HMAC-with-the-public-key token would otherwise verify.Claims you should not build on
These are present in some tokens but carry no dependable signal today. They are documented here so you do not discover that the hard way.Related
- Device Attestation — how the token is issued
- Risk Scoring — what moves
risk_scoreand what the ranges mean - Custom Claims — adding your own data to
custom_claims - Backend JWT Verification — more languages (Go, Swift/Vapor)
- JWKS endpoint — public key format