Login with ZOREAL for React: a ZOREAL Verified Proof-of-Human behind every sign-in.
The API mirrors @react-oauth/google one to one, renamed, so a team already
integrated with Google ports by find and replace.
npm install @zoreal/oauth2-reactreact and react-dom (18 or 19) are peer dependencies.
Everything <ZorealOAuthProvider> and the hooks need starts from a ZOREAL
asset.
- Create an account at https://zoreal.com and open Assets.
- Create an asset — a website (a domain you own) or an app bundle (a
reverse-DNS bundle id). An asset is the thing users log in to; its token is
your
clientIdand it looks likeast_.... It is public and ships in your frontend. - On the asset, open the OAuth2 tab and register:
- the JavaScript origins your pages run on and the redirect URIs you use — the pairing start and its poll are checked against your authorized origins, and requests from anything not registered are rejected (this is the core control);
- the scopes the client may request (see the catalogue below) — a
request for a scope you did not register is refused at
/pair; - client authentication for the backend half of the flow: a client
secret for
client_secret_basic, or a JWKS forprivate_key_jwt. A public client (the browser button) authenticates with PKCE alone and no secret — so it never touches this, and there is noclientSecretprop here, by design.
- A website asset must verify its domain (a DNS or meta-tag proof shown in
the dashboard) before it can request personal-data scopes or sign users in;
the verified domain is what your users' pairwise
subis derived against.
The clientId is the only credential this package holds, and it is public. The
client secret is your backend's, never the browser's.
ZOREAL never issues fake or sandbox humans: a pool of test identities would be a fraud vector against the exact thing the product proves. So you always authenticate real ZOREAL IDs.
To develop and test, create a free ZOREAL ID for yourself (enrol in the
ZOREAL ID app) and sign in with it. Mark your asset's environment sandbox
in the dashboard while building — a sandbox asset may register http://localhost
origins and redirect URIs that a production asset may not — and flip it to
production when you ship. The identities are real either way; only the allowed
origins differ.
The issuer defaults to https://id.zoreal.com and rarely changes: it must
match the iss inside the tokens exactly (an exact string comparison, not a
normalization), and there is no public sandbox issuer to point at. Override the
issuer prop only when you were explicitly given a non-production provider
origin.
- You have a backend and want the user's email or name (most apps): use the
auth-code flow. Your backend gets the email, name, and verification
details from
/userinfo. Start here. - You have no backend and only need to know "this is a verified, unique human,
and the same one as last time": use the
<ZorealLogin>button. It returns a stable per-user identifier and proof of verification, but no email or name. Email and other personal details are never placed in a browser-side token; that is what the auth-code flow and your backend are for.
Two pieces: the provider once, and a button wherever you sign people in. The QR, its live status, the countdown and the cancel wiring are the SDK's job.
// app.tsx — once, around the part of your app that signs in.
import { ZorealOAuthProvider } from '@zoreal/oauth2-react';
<ZorealOAuthProvider clientId={ZOREAL_CLIENT_ID} locale="en" theme="auto">
<App />
</ZorealOAuthProvider>;// ZorealSignIn.tsx
import { useZorealLogin } from '@zoreal/oauth2-react';
function ZorealSignIn() {
// `email` (and profile.name, etc.) are returned from /userinfo on your backend.
const login = useZorealLogin({
flow: 'auth-code',
scope: 'openid email profile.name',
onSuccess: async ({ code, code_verifier, nonce }) => {
// Send ALL THREE to your backend over TLS. It calls POST /token with the
// code and verifier plus its client authentication, verifies the ID
// token's nonce is this one, then reads the email and name from
// /userinfo. That is where personal data is delivered.
await fetch('/api/auth/zoreal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier, nonce }),
});
},
onError: (e) => console.error(e.description ?? e.error),
onNonOAuthError: (e) => {
// The holder closing or ignoring the request is not an error to surface.
if (e.type === 'request_denied' || e.type === 'request_expired') return;
console.error(e.description ?? e.type);
},
});
return <button onClick={login}>Continue with ZOREAL</button>;
}That is the whole integration. When login() runs, the provider puts the
pairing modal on screen; on a phone it skips the QR and opens the ZOREAL ID
app instead. See The pairing modal for what it does and
how to theme, translate, time out or replace it.
import { ZorealOAuthProvider, ZorealLogin } from '@zoreal/oauth2-react';
<ZorealOAuthProvider clientId="ast_your_asset_id">
<ZorealLogin
onSuccess={({ credential }) => {
// `credential` is an ID token carrying a stable per-user identifier (`sub`)
// and proof the person is a verified, unique human. No email, no name:
// use the auth-code flow above for those. Verify it on your server against
// the JWKS before trusting it.
}}
onError={(e) => console.warn(e.type, e.description)}
/>
</ZorealOAuthProvider>On desktop the provider opens the pairing modal; the user
scans the QR with their phone and approves in the ZOREAL ID app. On a phone it
skips the QR and opens the app directly. Either way your page just receives
onSuccess.
There is a sibling library for every major backend — use one instead of hand-rolling the exchange (the family table at the bottom lists them all). With the Ruby one, the whole thing is:
login = ZOREAL_OAUTH.authenticate(code:, code_verifier:, nonce:)
login.sub # your stable account key
login.email # from /userinfo, with the email scopeOn the wire, what every one of them does:
POST https://id.zoreal.com/token
grant_type=authorization_code
code=<code>
code_verifier=<code_verifier>
client_id=ast_your_asset_id
<your client authentication> # HTTP Basic client secret, or a private_key_jwt assertion
-> { "id_token": "...", "access_token": "...", "expires_in": 600 }
GET https://id.zoreal.com/userinfo
Authorization: Bearer <access_token>
-> { "sub": "...", "email": "...", "email_verified": true, "given_name": "...", ... }
Which fields come back depends on the scopes the user consented to.
Things a backend implementer needs to know, learned the concrete way:
- The
id_tokenis ES256, only ever ES256. Verify against the JWKS at{issuer}/jwks; a library defaulting to RS256 rejects every real token. - Check
issby exact string comparison against your configured issuer,audagainst your client id,exp, and thenonceyour frontend posted against the token's nonce claim — without that last check you cannot tell a substituted token from the real one, which is whyonSuccesshands the nonce over. - The ID token carries no personal data, by design. Email and profile
claims exist only at
/userinfo. An integration that only decodes the ID token and looks for an email finds none, and that is not a bug. - The access token lives 10 minutes. Call
/userinfowhile handling the login; do not store the token. subis pairwise per verified domain. It is the right account key, and changing your asset's domain rotates everysubyou have stored — treat a domain change as a data migration, not a settings edit.- The provider publishes no
authorization_endpoint. The flow starts at/pair(this SDK's job) and finishes at/token(your backend's); a generic OIDC relying-party library that wants to build an authorize URL has nothing to point at. Use the family libraries.
ux_mode: 'redirect' is not supported in v1: it would put the PKCE verifier
in a URL, which is a credential in every access log on the path.
On desktop, a QR sign-in cannot complete unless something puts the pairing code
on screen. Since 0.2.0 that something is this package: ZorealOAuthProvider
renders the modal for both flows, so useZorealLogin integrators get the
whole pairing UI without writing (or styling, or translating) a line of it.
<ZorealOAuthProvider clientId={CLIENT_ID} locale={i18n.language} theme="auto">
<App />
</ZorealOAuthProvider>That is the entire integration. Your button stays a button:
const login = useZorealLogin({ flow: 'auth-code', scope: 'openid email', onSuccess });
return <button onClick={login}>Continue with ZOREAL</button>;What the modal does:
| Mobile | No QR. The SDK opens the pairing link, which the ZOREAL ID app claims; the modal never appears. Force one or the other with display: 'qr' / 'link'. |
| Live status | The copy and the title follow the pairing: waiting for a scan, then waiting for approval once the holder has claimed the code (the spent QR blurs out behind a phone glyph). |
| Countdown | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
| Timeout | Closes and cancels at zero. Defaults to 120s; override with pairingTimeoutMs. The provider's own expiry wins when it is shorter. |
| Cancel | The X, the Cancel button, Escape, clicking outside and the timeout are one behaviour: abort the poll, close the modal. An orphaned poll is exactly how a request gets cancelled for over-polling. |
| No ZOREAL ID yet | A footer says the same code also installs the app. Without it the panel reads as "scan this with something I do not have", and the flow dead-ends at the one moment it can still be recovered. |
| Themes | theme="auto" (default) follows prefers-color-scheme; "light" and "dark" force it. The QR well stays white in dark mode on purpose — an inverted QR fails on a good number of phone cameras. |
| Language | Ships its own copy in 39 locales (listed below). Pass locale and the modal matches the page it opened on; omit it and it follows the browser's preference list, English as the floor. Arabic, Hebrew and Urdu flip the dialog to RTL. |
| Accessibility | role="dialog", aria-modal, labelled by its title, focus moved in on open, scroll locked, visible focus rings, and a prefers-reduced-motion fallback. |
Arabic · Bengali · Bosnian · Bulgarian · Chinese (Simplified) · Chinese (Traditional) · Croatian · Czech · Danish · Dutch · English · Filipino/Tagalog · Finnish · French · German · Greek · Hebrew · Hindi · Hungarian · Indonesian · Italian · Japanese · Korean · Malay · Norwegian · Polish · Portuguese · Portuguese (Brazil) · Romanian · Russian · Serbian · Spanish · Spanish (Latin America) · Swedish · Thai · Turkish · Ukrainian · Urdu · Vietnamese
Resolution handles the cases that usually get missed: zh splits by script rather
than region, es-MX and the other Latin American regions resolve to Latin American
Spanish instead of peninsular, pt-BR stays out of European Portuguese, and the
superseded codes (iw, in) plus nb/nn and fil reach the right table.
Styling is a single <style> tag injected once, every selector prefixed zrl-
and scoped to the dialog. There is no CSS file to import and nothing to add to
your build, because a required import step is a required support ticket.
If you already have a design system you want the QR to live inside, opt out:
<ZorealOAuthProvider clientId={CLIENT_ID} pairingUI="none">onPairingStateChange then carries everything you need on every state:
qrUrl, pairUrl, status, expiresIn and cancel. Render qrUrl in an
<img>; do not draw your own. PairingModal is also exported if you want the
real dialog but on your own terms.
Request scopes in the scope string, space-separated, always starting with
openid. They are consented to by the holder and must be pre-authorized on
your asset. What each grants, where it is delivered, and what it needs:
| Scope | Claims | Delivered in | Tier | Requires |
|---|---|---|---|---|
openid |
sub, iss, aud, exp, iat, nonce, auth_time, acr, amr, and the assurance block |
ID token | A | any client |
zoreal.age |
age_over_13/16/18/21/65 booleans — only the thresholds you registered, never an age or birthdate |
ID token | A | any client |
zoreal.nationality |
nationality (ISO 3166-1 alpha-3) |
ID token | A | any client |
email |
email, email_verified |
/userinfo |
B | confidential client + verified domain |
profile.name |
name, given_name, family_name |
/userinfo |
B | confidential client + verified domain |
profile.birthdate |
birthdate (full ISO 8601 date) |
/userinfo |
B | confidential client + verified domain |
profile.document |
document_type, document_number, issuing_country, document_expires_on |
/userinfo |
B | confidential client + verified domain |
profile.portrait |
portrait (the chip's facial image; GDPR Article 9 data) |
/userinfo |
C | confidential client + verified domain — registrable but not served yet |
- Tier A rides in the ID token and is available to every client, so the
no-backend button can use it. Tier B is personal data, served only from
/userinfoto a confidential client on a domain you have verified in the dashboard, and never placed in a browser-side token — which is why those scopes requireflow: 'auth-code'and a backend. - Tier C (
profile.portrait) is registrable but the provider does not serve the claim yet; a backend reads it as absent until it does. - Age thresholds are a fixed set — 13, 16, 18, 21, 65 — that you register on
the asset. Only a registered threshold mints an
age_over_Nclaim; an unregistered one produces no claim at all, which a backend reads as absent (distinct from afalse). Registering all five would recover an age band, so register only the thresholds you actually gate on.
To receive a person's government-document-verified identity, request the
document scopes together and read them from /userinfo:
const login = useZorealLogin({
flow: 'auth-code',
scope: 'openid profile.name profile.birthdate profile.document profile.portrait',
onSuccess: async ({ code, code_verifier }) => {
await fetch('/api/auth/zoreal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier }),
});
},
});Your backend then holds the name, date of birth, document type and number, issuing country and expiry, all verified at enrolment against a genuine government identity document by a live person, plus the portrait if you requested it. That is the raw material for a KYC check. You remain the controller of that data and responsible for how you store, use, and justify collecting it; ZOREAL provides the verified attributes, not a regulatory determination.
acr is an OpenID Connect standard claim — Authentication Context Class
Reference. It is a string in the ID token that says how strongly this login
was authenticated. Every ZOREAL login carries one. sub tells you who (a
stable, pairwise identifier for this person at your site); acr tells you how
sure ZOREAL is that the person is really there for this login. A stolen,
unlocked phone can still produce a sub; it cannot produce a fresh
zoreal.live.
This SDK is the request side of acr: it is where you ask for a level, which
decides what the holder's ZOREAL ID app makes them do. Whether that level was
actually reached is decided by the signed token and checked on your backend
(below).
Weakest to strongest. acr reports what actually happened, never what was asked.
acr |
What the holder did | amr |
Proves | Does not prove |
|---|---|---|---|---|
zoreal.session |
Nothing — a returning holder resumed silently from an existing ZOREAL session, no phone interaction | [] |
Continuity | Presence |
zoreal.device |
Approved on their enrolled phone: a secure-element key signature released by a local biometric/passcode unlock | ["hwk","user"] |
Possession of the enrolled device and a local unlock | That a live face was captured for this login |
zoreal.live |
The above plus a fresh face capture this login — a flash-plus-zoom video scored for presentation attacks and screen replay, matched 1:1 to the government document read at enrolment | ["hwk","face","user"] |
A live, real, unique human, verified to be the enrolled person, at the moment of this login | — (strongest) |
amr (Authentication Methods References) lists the factors: hwk a hardware
key, user a presence/unlock gesture, face a face biometric. zoreal.live is
zoreal.device with face added. The default is zoreal.device — a login that
asks for nothing still requires the enrolled phone and a local unlock.
zoreal.device(the default): a forum, a community, a normal login. Pass noacr_valuesat all.zoreal.live: a bank onboarding, a high-value transaction, an age-gated purchase, a first login, a "confirm it is really you" step. Anywhere a fresh, unforgeable proof of the live, right human is worth the few seconds a capture costs.zoreal.sessionis never something you request; it is the silent convenience re-auth (prompt: 'none') a returning holder gets at a site they have already consented to.
acr_values is a request option on useZorealLogin, useZorealAutoLogin and
<ZorealLogin>. Its type is AcrValue | AcrValue[] where
AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session'.
const login = useZorealLogin({
flow: 'auth-code',
acr_values: 'zoreal.live', // the app now makes the holder pass a face capture
onSuccess: async ({ code, code_verifier, nonce }) => {
// Post all three to your backend, which verifies the signed acr claim.
},
onNonOAuthError: (e) => {
// A device that cannot meet the requirement (e.g. no capture on the
// platform, or the holder declines) arrives here as
// { type: 'request_denied' }, with the provider's reason in e.description.
},
});In browser-direct mode the resolved level comes back on the credential
response as response.acr, parsed from the ID token; the token stays the
authority.
acr_values here is advisory. It shapes what the holder is asked to do; it
is not a security control, because a browser is attacker-controlled and a value
that only travels through it proves nothing. The proof is the signed acr
claim in the ID token, minted by ZOREAL, and it must be verified on your
backend:
- Auth-code flow: your backend receives the code and, after exchanging it, reads
the token's
acr. The ZOREAL backend libraries (zoreal-oauth2for Ruby and its siblings for Node, Python, PHP, Go, JVM and .NET) take a required-acr argument at exchange for exactly this, refusing a token below the level. - Browser-direct flow: the
credentialis an ID token you still verify server-side against the JWKS,acrincluded.
With the Ruby backend library the required-acr argument is acr:; every sibling
takes the same argument in its own idiom:
login = ZOREAL_OAUTH.authenticate(
code:, code_verifier:, nonce:,
acr: 'zoreal.live' # refuses the login unless the signed token says zoreal.live
)
login.acr # "zoreal.live" — what actually happened, read from the signed tokenA relying party that requests zoreal.live but never verifies the claim has
checked nothing. It has only asked the holder nicely.
The requirement you hand a backend library is satisfied upward:
zoreal.session < zoreal.device < zoreal.live. Requiring zoreal.device
therefore accepts a zoreal.live token — the holder gave you more assurance
than you demanded. A token whose acr is below the requirement, missing, or
outside the vocabulary is refused (in Ruby, a VerificationError); an unknown
required value — a typo like 'zoreal.liveness' — is a bug in your code rather
than a bad token, so the libraries raise a configuration error instead and fail
loudly rather than rejecting every login in silence.
If you would rather branch than have the exchange refuse, require nothing and
inspect the result — the backend libraries expose a predicate for the same upward
comparison (in Ruby, login.satisfies_acr?, with login.live? as shorthand):
login = ZOREAL_OAUTH.authenticate(code:, code_verifier:, nonce:)
unless login.satisfies_acr?('zoreal.live')
# step the user up, or refuse the sensitive action
endThe frontend's only job is the request; each of these checks happens where verification means something, which is your server.
acr grades this login event. The assurance block in the token (uniqueness
basis, verification month, chip-liveness, trust tier, key protection) describes
the identity behind it — how the person was proofed at enrolment. One is about
now; the other about who they are. A high-value flow wants both.
The ID token carries a zoreal claim — the assurance block — describing the
strength of the identity behind this login, distinct from acr, which grades
the login event. In browser-direct mode it rides inside the credential you
verify server-side; in auth-code mode your backend reads it from the ID token
after the exchange. Its keys and their value sets:
| Key | Values | Meaning |
|---|---|---|
uniqueness |
personal_number | document | none |
The anchor the holder is deduplicated on. personal_number (a national number from the chip) is strongest; none means no reliable anchor |
verified_on |
"YYYY-MM" |
The month the underlying document was verified. Quantised to a month on purpose — a day-precision date is a cross-site correlator |
chip_liveness_proven |
true | false |
Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone) |
trust_tier |
high | standard |
high when chip_liveness_proven, else standard |
key_protection |
secure_enclave | strongbox | tee | software |
How the holder's device key is protected. software means no hardware attestation |
A high-value flow usually pairs acr_values: 'zoreal.live' (fresh presence,
requested here and verified on your backend) with a check on the assurance block
(identity strength) — for example requiring uniqueness === 'personal_number'
and trust_tier === 'high'.
This package routes every failure to one of two callbacks, matching
@react-oauth/google:
onError(error)— an OAuth error the provider returned.error.erroris anErrorCode;error.descriptionis the provider's own reason string, surfaced verbatim (never rewrite it).onNonOAuthError(error)— the flow never reached a provider OAuth error: the holder declined, the window elapsed, or the request could not start.error.typeis aNonOAuthError['type'];error.descriptioncarries the provider's reason when there is one.
The drop-in <ZorealLogin> button funnels both into its single
onError(NonOAuthError); the hooks give you the two callbacks separately.
The code exchange can fail with these. In browser-direct mode this package
makes the exchange, so they arrive at your onError. In auth-code mode your
backend makes the exchange, so its library surfaces them there — but the causes
are the same:
error |
Cause | Retryable? |
|---|---|---|
invalid_grant |
The code is spent — unknown, expired (60s), already used, PKCE mismatch, or the asset's domain verification lapsed mid-flow | No. Start a new login; the code cannot be reused |
invalid_request |
Client authentication failed — wrong secret, a bad private_key_jwt assertion, or tls_client_auth (not accepted at /token yet) |
No. Fix the client configuration |
unsupported_grant_type |
Something other than authorization_code reached /token |
No. A bug |
These reach your callbacks before any backend is involved — handle them here:
| Where | Reaches | Code / type | Meaning |
|---|---|---|---|
/pair |
onError |
invalid_scope |
A scope not on the asset's allowed list, or a Tier B scope from a public client |
/pair |
onError |
invalid_request |
Missing PKCE/nonce, an unverified sector, an unregistered redirect_uri, or an unknown acr_values |
/pair |
onError |
login_required |
prompt: 'none' with no silent session to resume — the expected quiet outcome, not a failure (useZorealAutoLogin turns it into onUnavailable) |
| pairing | onNonOAuthError |
request_denied |
The holder declined in their ZOREAL ID app — not an error to alarm on; offer to try again |
| pairing | onNonOAuthError |
request_expired |
The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
ErrorCode (what onError carries):
invalid_request, access_denied, unauthorized_client,
unsupported_response_type, invalid_scope, server_error,
temporarily_unavailable, login_required, consent_required,
interaction_required.
NonOAuthError['type'] (what onNonOAuthError carries):
popup_failed_to_open, popup_closed, request_expired, request_denied,
enrolment_abandoned, platform_unsupported, unknown.
The user-cancel path is not a real error. request_denied (the holder
tapped decline) and request_expired (they walked away, or the device could
not meet a required liveness) are human outcomes, not faults: clear the pairing
UI and offer the button again. Do not log them as errors or paint a red banner.
enrolment_abandoned (someone started creating a ZOREAL ID and did not finish)
and platform_unsupported (a platform the ZOREAL ID app has not shipped to yet)
are the same shape and want the same gentle "try again / not available here"
treatment. Only unknown — a network or JavaScript fault — is worth a genuine
error path.
A full sign-in component, end to end — the shape a real auth-code integration
takes. It renders the button, renders its own pairing UI (the hook renders
none), hands { code, code_verifier, nonce } to your backend on success, and
treats the decline/expiry path as the non-events they are.
import { useState } from 'react';
import { ZorealOAuthProvider, useZorealLogin } from '@zoreal/oauth2-react';
import type { PairingState } from '@zoreal/oauth2-react';
function ZorealSignIn() {
const [pairing, setPairing] = useState<PairingState | null>(null);
const [note, setNote] = useState<string | null>(null);
const login = useZorealLogin({
flow: 'auth-code',
scope: 'openid email profile.name',
// acr_values: 'zoreal.live', // request a fresh liveness for a step-up / high-value login
onPairingStateChange: setPairing,
onSuccess: async ({ code, code_verifier, nonce }) => {
setPairing(null);
// Post ALL THREE to YOUR backend over TLS. Your backend does the /token
// exchange with its client authentication, verifies the ID token
// (ES256 against the JWKS, iss/aud/exp, and this nonce), checks the acr
// floor, then reads email and name from /userinfo. NONE of that can
// happen here: the browser is attacker-controlled, so the backend is the
// only place verification means anything. THE BACKEND MUST VERIFY.
const res = await fetch('/api/auth/zoreal', {
method: 'POST',
// Your login route needs its own CSRF / same-origin protection, exactly
// as any login endpoint does — the ZOREAL nonce protects the token, not
// your route. Send whatever token your framework expects here.
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code, code_verifier, nonce }),
});
if (res.ok) window.location.assign('/');
else setNote('Sign-in could not be completed. Please try again.');
},
// An OAuth error from the provider (e.g. a scope not on your allow list).
onError: (e) => {
setPairing(null);
setNote(e.description ?? e.error); // the provider's words, verbatim
},
// The human outcomes: declined, expired, cancelled. Not faults — clear the
// pairing UI and let them try again. Do not alarm on these.
onNonOAuthError: (e) => {
setPairing(null);
if (e.type === 'request_denied') setNote('Login was declined. Try again when ready.');
else if (e.type === 'request_expired') setNote('That took too long. Try again.');
else setNote('Something went wrong. Try again.');
},
});
return (
<div>
<button onClick={login}>Continue with ZOREAL</button>
{/* The hook renders nothing, so the pairing UI is yours. On desktop the
login cannot complete until something shows pairing.qrUrl to scan. */}
{pairing && !pairing.appLink && ['pending', 'claimed'].includes(pairing.status) && (
<div role="dialog" aria-label="Log in with ZOREAL">
<img src={pairing.qrUrl} alt="Log in with ZOREAL" width={200} height={200} />
<p>
{pairing.status === 'claimed'
? 'Approve the login in your ZOREAL ID app.'
: 'Scan with your phone camera or the ZOREAL ID app.'}
</p>
<button onClick={pairing.cancel}>Cancel</button>
</div>
)}
{note && <p role="status">{note}</p>}
</div>
);
}
export default function App() {
return (
<ZorealOAuthProvider clientId="ast_your_asset_id">
<ZorealSignIn />
</ZorealOAuthProvider>
);
}The backend must verify. This component only starts the flow and forwards a code; on its own it proves nothing. The security is your backend exchanging the code with its client authentication and verifying the signed ID token — use a family library (below) rather than hand-rolling it.
- Always pass the
noncethrough — and it is not your CSRF token. The SDK generates the nonce and hands it toonSuccess; forwarding it lets your backend confirm the ID token was minted for this login rather than substituted. Two things it does not do: it is not your login endpoint's CSRF token (protect that route with your framework's normal CSRF / same-origin defence, exactly as you would any login endpoint), and it is not what binds the code to whoever started the flow. - PKCE is what binds the code to the initiator. The verifier is generated in
the browser and never leaves it except to your backend; without it the
exchange cannot complete, which is why
onSuccesshands overcode_verifier. PKCE is mandatory for every client and there is no plain fallback.ux_mode: 'redirect'is refused in v1 precisely because it would put the verifier in a URL, i.e. in every access log on the path. - The
issuermust match the token'sissexactly. It is compared as a string, not normalized. Production ishttps://id.zoreal.com; override theissuerprop only when pointing at a non-production provider origin you were explicitly given. Your backend verifiesissthe same way. - No secret lives in the browser. The provider takes no
clientSecretprop and never will. The client secret is your backend's; the browser holds only the publicclientId.
The package loads no third-party script, no stylesheet, no font, and has zero runtime dependencies. Two things touch the network, both on the ZOREAL origin:
| CSP directive | Value | Why |
|---|---|---|
connect-src |
https://id.zoreal.com |
starting the pairing, polling it, and (button mode) the code exchange |
img-src |
https://id.zoreal.com |
the QR image, served by the provider so it stays correct and current |
- No secret has a home here. The provider takes no
clientSecretprop and never will; a pull request adding one is a security bug regardless of its documentation. - The ID token in browser mode carries no personal data. Personal claims
exist only at
/userinfo, behind an access token browser mode is never issued for those scopes. There is no configuration that changes this. - Server errors are shown, not rewritten. Whatever reason the provider
gives,
descriptioncarries it verbatim. - The button copy is neutral. "Continue with ZOREAL" and variants; there is no "verified human" button text and there will not be one. The assertion lives in the token, where it is verifiable.
Every version is published from GitHub Actions with npm provenance: the package page on npmjs.com carries a Provenance panel linking the exact commit and workflow run that built the tarball, signed through Sigstore and recorded in its public transparency log. No long-lived npm token stands behind it — the workflow authenticates by OIDC (trusted publishing), so a leaked CI secret cannot cut a release.
Check the signatures on what you actually installed:
npm install @zoreal/oauth2-react
npm audit signatures| Repository | Package | Role |
|---|---|---|
| zoreal-oauth2-react | @zoreal/oauth2-react (npm) | React frontend: the button, the QR, the polling |
| zoreal-oauth2-js | @zoreal/oauth2-js (npm) | Framework-free browser core |
| zoreal-oauth2-react-native | @zoreal/oauth2-react-native (npm) | React Native frontend |
| zoreal-oauth2-node | @zoreal/oauth2-node (npm) | Node.js backend |
| zoreal-oauth2-ruby | zoreal-oauth2 (RubyGems) | Ruby backend |
| zoreal-oauth2-python | zoreal-oauth2 (PyPI) | Python backend |
| zoreal-oauth2-php | zoreal/oauth2 (Packagist) | PHP backend |
| zoreal-oauth2-go | github.com/Bynn-Intelligence/zoreal-oauth2-go | Go backend |
| zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
| zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
Every backend library supports all four registered client authentication
methods: none (public client, PKCE alone), client_secret_basic,
private_key_jwt (a fresh 60-second single-use assertion per exchange), and
tls_client_auth (configurable now; the provider itself does not accept it at
/token yet and answers 501, which the libraries surface rather than hide).
MIT