Verification with OpenID Connect
Paradym can act as an OpenID Provider, so an application that already speaks OpenID Connect can verify a credential from the user’s wallet. Use it to sign users in, or for any other check, such as proving their age at checkout. Your application redirects the user to Paradym, the user presents a credential, and your application receives an ID token carrying the verified attributes. No wallet-specific code is needed on your side: any OpenID Connect client library works.
How it works
Register an OAuth client
A client holds a secret, the redirect URIs your application returns to, and the scopes it may request. Each scope verifies with a presentation template, which decides what the user presents.
Your application starts a sign-in
It pushes the authorization request to Paradym, then redirects the user to the authorization
endpoint. PKCE is always required, and so is pushing the request unless the client uses the basic
profile.
The user presents a credential
Paradym hosts the sign-in page, which carries your wallet’s own name and logo, and hands the user to their wallet.
Your application exchanges the code
The authorization code is exchanged at the token endpoint for an ID token with the verified attributes.
Discovery lives at https://api.paradym.id/oauth/.well-known/openid-configuration, and the issuer is
https://api.paradym.id/oauth. Point your OpenID Connect library at either and it will configure
itself.
Registering an OAuth client
Create the client in the dashboard under Settings, OAuth Clients , or through the API. Only the wallet owner can create, change or delete OAuth clients, their secrets and scopes. Other members of the wallet can view them, and can still remove a scope by archiving its presentation template.
{
"name": "Customer Portal",
"redirectUris": [
"https://portal.example.com/auth/callback"
],
"roleAssignments": [
{
"role": "oauthVerifier",
"resource": {
"type": "presentationTemplate",
"id": "clu159ps100013evfbvhz22m2"
},
"oauthScope": "pid"
}
]
}roleAssignments grants the client its scopes (see Scopes). It is optional, a client can have
up to 20 role assignments, and is not part of the client in responses: scopes are managed as their own collection.
redirectUris are matched exactly, so list every URI your application uses. HTTPS is required.
A wallet can have up to 5 OAuth clients.
The client secret is returned once, when it is created, and is stored only as a hash. There is no way to read it back.
Rotating the client secret
A client can have two secrets at a time and accepts either, so you can switch secrets without failed sign-ins:
- Add a secret with
POST /v1/wallets/{walletId}/oauth-clients/{oauthClientId}/secrets, or Add secret on the client in the dashboard. - Switch your application to the new secret.
- Delete the old secret. Each secret on the client shows when it was last used (
lastUsedAt, accurate to five minutes), so you can tell when your application has stopped using it. To stop accepting it at a set time instead, set itsexpiresAt.
For a secret that may have leaked, add a new one and delete the leaked one right away. A client’s last secret cannot be deleted, so add the new one first.
Scopes
A scope is the name your application requests alongside openid, and it selects the presentation
template used to verify. A client asking for openid pid presents whatever the template behind its
pid scope requests.
Scopes belong to the client. Each one is a role assignment of the oauthVerifier role on one
presentation template, which lets the client start an authorization request with that template and
nothing else. A scope that is not assigned to the client is refused with invalid_scope. There is no
wallet-wide grant.
You can view a client’s scopes on its page in the dashboard, or on a presentation template’s overview, which shows the clients that use it. Through the API, scopes are a paginated collection:
{
"role": "oauthVerifier",
"resource": {
"type": "presentationTemplate",
"id": "clu2a8xk000043evf9h1c7d0q"
},
"oauthScope": "age_over_18"
}To move a scope to another template, PATCH the role assignment with the new resource. The change
applies to new requests. A request already in progress keeps the template it started with.
Archiving a template removes the scopes that point at it, so requests for them are refused with
invalid_scope.
To find the clients that use a template, filter the client list with
filter[roleAssignments.resource.id]=<presentationTemplateId>. A client’s role assignments take
filter[resource.id] to narrow them to that template.
openid is reserved and cannot be assigned. A client can assign each template once, under one
scope. Several clients can use the same template. It must request only sd-jwt-vc and mdoc
credentials and define no transactions, since an
authorization request cannot supply their data.
Every request must ask for openid and exactly one scope beside it. Extra scopes are refused
with invalid_scope rather than ignored, because picking one of several templates would ask the
user for a credential nobody requested.
This catches people out, because several OpenID Connect libraries request openid profile email by
default, which is two scopes too many. Set the scope explicitly:
// Auth.js, NextAuth and most libraries take the scope on the provider
authorization: { params: { scope: 'openid pid' } }Client profiles
A client’s profile decides how strictly its authorization requests have to be made. It is set
when you create the client and defaults to secure.
| Profile | How the request reaches the authorization endpoint |
|---|---|
secure | The request must be pushed first (PAR ). The endpoint reads only client_id and request_uri, and ignores anything else. |
basic | The parameters may be sent to the endpoint directly, in the query string or as a posted form, the way a stock OpenID Connect library does. A pushed request is still accepted. |
Reach for basic only when your library cannot push a request, which is what the profile exists
for: neither Better Auth nor Auth.js can. Everything else holds either way, so basic gives up
where the request is carried and nothing more. PKCE with S256, exact redirect URI matching and the
single scope all still apply, and a signed request object (request) is refused on
both routes rather than silently ignored in favour of the query.
Without the push, nothing authenticates a request before it reaches the authorization endpoint, so
anyone who knows a basic client’s ID can start sign-ins for it, and each one does the work of a
real sign-in on Paradym’s side. Requests are rate limited per IP
address, not per client. Use secure wherever your library allows it.
Discovery advertises require_pushed_authorization_requests: false, because one document serves
every client. A secure client that skips the push is still refused.
Request parameters
Besides the parameters every authorization request carries, the provider reads two that OpenID Connect defines:
prompt:login,consentandselect_accountare accepted and always met, because every sign-in is a fresh presentation.noneis answered withlogin_required, since there is never a session to reuse.acr_values: namehttps://paradym.id/acr/same-deviceto accept only a same-device sign-in. Values Paradym does not define are ignored. A request the presentation template cannot meet, because it does not allow that flow, is refused withinvalid_request. It binds only in a pushed request: on abasicclient’s direct route the parameters pass through the browser, where whoever started the sign-in can drop it. There, checkacrin the ID token, or restrict the template’s flows.
When a sign-in fails
If the presentation fails or the sign-in expires, Paradym sends the browser that started it back to
your redirect_uri with error=access_denied and your state, so your application can tell the
user and start over. Any other browser, such as a phone that scanned the code, is told on the
sign-in page instead.
When the presentation fails, error_description gives the reason, the same one the verification
reports in the API: for example that the credential has been revoked or has expired, or that its
issuer is not trusted. You get the reason but none of the credential’s attributes. The text is meant
for your logs and support, not to show to the user as is.
A sign-in expires 15 minutes after it starts. Paradym reports the expiry from the browser that
started it, while the sign-in page is open there, or when the user returns to it within 10 minutes
of the expiry. A user who closes the page never comes back to your redirect_uri, so expire
pending sign-ins on your side too.
The ID token
The verified attributes arrive under credentials, one entry per credential the holder presented.
Each entry is what the verification API returns for the
credential, so id names the credential of your presentation template and type the type that
was presented:
{
"iss": "https://api.paradym.id/oauth",
"sub": "txn_9f2c…",
"aud": "your client id",
"auth_time": 1760000000,
"acr": "https://paradym.id/acr/same-device",
"credentials": [
{
"exchange": "openid4vc",
"format": "sd-jwt-vc",
"id": "pid",
"type": "urn:eudi:pid:1",
"isValid": true,
"issuer": { "certificate": "-----BEGIN CERTIFICATE-----…", "commonName": "My Issuer" },
"holder": "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8…",
"raw": "eyJhbGciOiJFUzI1NiIs…~",
"presentedAttributes": {
"vct": "urn:eudi:pid:1",
"given_name": "Ada",
"birthdate": "1815-12-10"
}
}
]
}Alongside credentials the token carries:
sub, which is new for every sign-in: Paradym keeps no account of the user, so there is no stable identifier to give. Do not use it as a primary key. Match on an attribute fromcredentialsinstead. Discovery lists the subject type aspairwisebecause it has to name one: nosubis shared between clients, and none persists between sign-ins either.auth_time, the moment the credential was verified. Every sign-in is a fresh presentation. Paradym keeps no session of its own and cannot sign anyone in silently.acr, which says how the browser proved itself. See below.
With Auth.js, for example, take the user’s identifier from the presented attributes in the
provider’s profile callback:
profile(claims) {
// `pid` is the id of the credential in your presentation template.
const { presentedAttributes } = claims.credentials.find((credential) => credential.id === 'pid')
// Whichever attribute identifies a user in your application, never `claims.sub`.
return { id: presentedAttributes.personal_administrative_number, name: presentedAttributes.given_name }
}The access token is an opaque value that nothing accepts: there is no userinfo endpoint, and everything is in the ID token. There is no refresh token and no logout either: ask for a new sign-in instead.
The ID token is signed with ES256, the only algorithm the provider offers, and discovery
advertises nothing else. RSA would need a second key backend to hold the key and buys no security
for it, so a client that insists on RS256 has to be reconfigured rather than accommodated. Verify
against the jwks_uri from discovery and let your library pick the key by kid, so a signing key
rotation does not interrupt you.
Signing keys
The key is replaced every 90 days. The next key is published a day before it signs, so a client that caches the JWKS already holds it when the first token names it, and the key it replaces stays published until everything it signed has expired, so a rotation never invalidates a token already issued. A client that pinned a single key rather than reading the JWKS will break at the first rotation.
What Paradym keeps
A sign-in creates an ordinary verification in the wallet the client belongs to, so it appears in the
verification API and in the dashboard’s Activity, marked as
a sign-in and naming the client that started it. It leaves out the authorizationRequest* links:
the request belongs to the sign-in page, and only the user signing in may present to it.
The attributes are released when your application exchanges the code, and only there: the ID token and the verification’s own release happen in one transaction, and the stored data is cleared as part of it. Until the exchange the verification reports each credential’s format, validity and issuer but withholds the attributes, as any redirect-flow verification does. A sign-in that is never completed expires, and the data goes with it.
Because the exchange is the release, the wallet’s verificationDataAccess setting plays no part in
a sign-in. There is no webhook carrying the data and no second read to allow or refuse.
Same-device and cross-device sign-ins
Both work, and they do not give you the same guarantee. Which one you get is a property of the
flow, not a race: only the browser that started a sign-in can complete it, so a second device that
scanned the code is told to go back rather than finishing there. The ID token’s acr tells you
which happened:
acr | What it means |
|---|---|
https://paradym.id/acr/same-device | The wallet returned the holder to the browser that collected the result, so the result is bound to the device that presented. |
https://paradym.id/acr/cross-device | Only the browser that started the request proved itself. Nothing ties the result to the device that presented. |
A cross-device sign-in is open to a phishing attack: someone can start a sign-in, show its QR
code to another person, and have that person’s credential land in the attacker’s session. Refuse
cross-device where the sign-in grants anything that matters: check acr in the ID token, or set
the template’s openId4Vc.allowedFlows to ["sameDevice"] and Paradym refuses it for you.
acr_values naming only the same-device value does the same for one request, but only when it is
pushed: on a basic client’s direct route the attacker controls the browser and can drop it. The
trade-off is that the holder then has to open their wallet on the device they started on. See
assurance and security model.
A template that allows only crossDevice completes every sign-in as cross-device, even when the
wallet happens to be on the same device.
Asking someone who is already signed in
Nothing in the flow requires it to be the user’s first sign-in. An application that has already authenticated someone can send them through it again to have them present a credential: to prove an attribute before a sensitive action, or to collect one you did not ask for earlier. Request a scope whose template asks for what you need, and read the result from the ID token as usual.
The ID token stays the only output, so treat it as the result of that one presentation rather than as
a new session. Each run has its own auth_time, and sub is new every time and says nothing about
who the user is, so tie the result to the user you already know through an attribute in
credentials.