This guide follows the implemented IP verification flow: enter a phone number, redirect the browser to IPification, receive an authorization code, fetch userinfo, and display the result in a browser session.
The UI calls this feature PNV, while its configured flow identifier is pvn_ip. Keep that identifier when configuring clients or calling the routes.
This describes the current Showcase implementation. A completed browser session and a successful phone-verification result are separate concepts; see Verification results and current limitations.
The primary walkthrough is the mobile web flow, where the browser follows redirects directly. Desktop IP verification uses a QR code and the same backend callback; see Desktop QR variant. SIM/TS43, SMS OTP, native mobile login, and Android Play Integrity have separate entry points and are not part of this walkthrough.
A configured client must have user_flow: "pvn_ip" and the appropriate scope. The following is an illustrative configuration fragment; replace every placeholder with your own values:
{
"realm": "ipification",
"auth_servers": [
{ "id": "stage", "url": "https://auth.example.test/auth" }
],
"client_id": "<client-id>",
"client_secret": "<client-secret>",
"clients": [
{
"user_flow": "pvn_ip",
"title": "IP Phone Number Verification",
"scope": "openid ip:phone_verify"
}
]
}Client-specific credentials override the shared credentials. Otherwise, request locals supply the shared values. Register the exact callback URL for your application origin and selected server, for example:
https://showcase.example.test/auth/callback/pvn_ip/stage
Sources: sample configuration, request locals, configuration reference, and local setup.
sequenceDiagram
actor User
participant Browser
participant Showcase
participant IPification
participant Cache as In-memory result cache
User->>Browser: Enter phone number and choose IP
Browser->>Showcase: GET /auth/start?user_flow=pvn_ip&phone=...&state=...&server_id=stage
Showcase-->>Browser: 302 to authorization URL with login_hint and redirect_uri
Browser->>IPification: Follow authorization redirect
IPification-->>Browser: Redirect to callback with code and state
Browser->>Showcase: GET /auth/callback/pvn_ip/stage?code=...&state=...
Showcase->>IPification: POST /token (authorization_code grant)
IPification-->>Showcase: access_token
Showcase->>IPification: POST /userinfo (access_token)
IPification-->>Showcase: Userinfo claims
Showcase->>Cache: Store formatted result, then raw userinfo
Showcase-->>Browser: 302 /auth/complete?state=...
Browser->>Showcase: GET /auth/complete?state=...
Showcase->>Cache: Read formatted result by state
Showcase->>Showcase: Populate browser session
Showcase-->>Browser: 302 /user/info
Browser->>Showcase: GET /user/info
Showcase-->>Browser: Render userinfo page
The carrier-side verification itself is outside this repository. The code here builds the authorization request and consumes the token/userinfo responses; it does not implement the provider's internal verification process.
The PNV section renders a phone input and an IP button with data-user-flow="pvn_ip". The browser click handler:
- Reads and validates the number using
intl-tel-input(the existing999test dial code bypasses invalid-number rejection). - Removes spaces and the leading
+from the submitted number. - Reads the selected auth server.
- Generates a state value and navigates to
/auth/starton mobile.
For example, an entered +1 202-555-0123 is submitted as 12025550123 after the input library formats it. An illustrative request is:
GET /auth/start?user_flow=pvn_ip&server_id=stage&phone=12025550123&state=example-stateexample-state is a placeholder for explanation, not a recommended production state value. The current frontend generates state with randomstring(10). The UI requires a phone number for this PNV flow; the backend /auth/start handler itself does not enforce a phone-number requirement or repeat the frontend number validation.
Sources: PNV input and buttons in login.pug, button handler and randomstring in login.js.
The start handler resolves server_id and finds the client by user_flow. If server_id is omitted, it uses the first configured server. The auth service then builds this endpoint:
{auth_server.url}/realms/{realm}/protocol/openid-connect/auth
The request includes response_type=code, client_id, scope, redirect_uri, the browser's state, a generated nonce, and the existing locale/consent/environment fields. The phone parameter becomes login_hint:
// Excerpts from services/authService.js
redirect_uri: `${baseUrl}/auth/callback/${userFlow}/${server.id}`,
// ...
if (phone) params.login_hint = phone;The backend sends HTTP 302, and the browser follows the authorization URL. For pvn_ip, the scope comes from configuration, normally openid ip:phone_verify.
The callback origin is derived from the incoming request's protocol and Host header through res.locals.baseUrl. Use the intended public origin when testing redirects and configuring the provider.
Sources: start in webHandlers.js, buildAuthorizationUrl in authService.js, createAppLocalsMiddleware.
After the provider flow, the browser reaches the callback URL with an authorization code and state:
GET /auth/callback/pvn_ip/stage?code=example-code&state=example-stateBoth path segments, userFlow and serverId, are required by the registered route. The callback resolves the selected server and configured client again. If the provider returns error or error_description, it stores a fixed user-facing error message in the session and redirects to /auth/login.
The code is exchanged by the backend in the following steps; the browser does not perform the token request.
Sources: route registration, callback and CALLBACK_ERROR_MESSAGES in webHandlers.js.
The callback calls exchangeAuthorizationCode with the selected server, realm, client credentials, code, and the callback URI. The token endpoint is:
{auth_server.url}/realms/{realm}/protocol/openid-connect/token
The backend POSTs form-encoded fields:
code=example-code
redirect_uri=https://showcase.example.test/auth/callback/pvn_ip/stage
grant_type=authorization_code
client_id=<client-id>
client_secret=<client-secret>
The fields above are shown one per line for readability; the actual body is URL-encoded. For example, this reproduces the request shape with placeholders:
curl 'https://auth.example.test/auth/realms/ipification/protocol/openid-connect/token' \
--data-urlencode 'code=<authorization-code>' \
--data-urlencode 'redirect_uri=https://showcase.example.test/auth/callback/pvn_ip/stage' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=<client-id>' \
--data-urlencode 'client_secret=<client-secret>'These calls are normally made by Showcase. The example is for inspecting the contract, not an additional step after Showcase has exchanged the same code.
Sources: exchangeAuthorizationCode, exchangeCodeForToken.
The HTTP client takes access_token from the token response, then POSTs it as a form field to the userinfo endpoint:
POST {auth_server.url}/realms/{realm}/protocol/openid-connect/userinfo
Content-Type: application/x-www-form-urlencoded
access_token=<access-token>
This implementation passes the token in the form body. An illustrative userinfo response is:
{
"sub": "example-subject",
"phone_number": "+12025550123",
"phone_number_verified": "true"
}Actual claims and value types depend on the provider response. Showcase stores and displays the returned userinfo without normalizing phone_number_verified.
Source: exchangeCodeAndGetUserInfo and getUserInfo.
If you are integrating your application with IPification, a successful userinfo request is not enough to mark the phone number as verified. After retrieving userinfo, check phone_number_verified before completing your application's authentication flow:
true: Phone verification succeeded; continue the successful authentication flow.false: Phone verification failed. Return a verification error or direct the user to another authentication method, such as SMS OTP, according to your application's flow.- Missing or an unrecognized value: Verification is not confirmed; handle it as an unsuccessful result rather than granting access.
Perform this decision in your integrating backend before creating an authenticated application session or issuing an application token. Match the claim's representation to your provider contract. This illustrative function explicitly accepts boolean true and the string 'true', while rejecting boolean false, the string 'false', and missing values:
// Example integration logic; this is not currently applied by the Showcase web flow.
function evaluatePnvResult(userInfo) {
const verified = userInfo?.phone_number_verified === true
|| userInfo?.phone_number_verified === 'true';
if (!verified) {
return { success: false, error: 'PHONE_NUMBER_NOT_VERIFIED' };
}
return { success: true, phoneNumber: userInfo.phone_number };
}The caller can turn the unsuccessful result into an error response or start the alternative authentication flow. Avoid a truthiness check such as if (userInfo.phone_number_verified): the string 'false' is truthy in JavaScript.
This is the integration rule for deciding PNV success. The following steps describe how Showcase currently caches and displays the response; its ordinary web flow does not apply this claim check before setting the demo session flag.
The callback creates a display result and saves two entries, in this order:
| Cache key | Value | Consumer |
|---|---|---|
{state} |
{ userInfo: prettyHtml(userInfo), client_id, client_title, state } |
Browser session completion |
{state}-user-info |
Raw userinfo object | /auth/s2s/signin |
Both writes finish before the callback redirects or notifies a QR client. The current store is process-local memory with a default TTL of one hour. Reads do not consume the entries; a process restart loses them.
An ordinary web callback then redirects to:
/auth/complete?state=example-state
Sources: callback, saveResult, cache initialization.
The complete handler looks up the formatted result by state. It polls up to five times and waits one second after every miss, including the final miss. When a result is found, it executes:
req.session.isAuthenticated = true;
req.session.userData = response;
res.redirect('/user/info');If no result is found, it redirects to /. The root route redirects to /auth/login.
GET /user/info checks req.session.isAuthenticated, then renders the stored HTML-formatted userinfo. It is a demo result page, not a separate phone-verification decision.
Sources: waitForResult, complete, root redirect, user info route, result template.
On desktop, selecting IP displays a QR code instead of immediately redirecting that browser. The QR link contains the same /auth/start parameters, with state shaped as <random>-qrcode.
The phone scans the link and completes the provider flow. The backend callback still exchanges the code, fetches userinfo, and writes both cache entries. It then emits:
// Socket.IO room: auth:<state>; event: messages
{ event_name: 'url', url: '<baseUrl>/auth/complete?state=<state>' }The desktop receives the URL, navigates to /auth/complete, and adopts the result into its own browser session. The phone is redirected to /auth/qrcode/error only when userInfo.phone_number_verified === 'false'; otherwise it goes to /auth/qrcode/complete. That comparison is specifically against the string 'false', not boolean false.
A callback carrying ip-backchannel-im-auth: true takes precedence over the QR branch and returns an empty HTTP 200 after storing the result. That branch supports the backchannel flow rather than the ordinary IP browser walkthrough.
Sources: showQrcodeWithLink and click handling, desktop socket listener, socket room setup, handleQrCodeCallback and callback.
These points describe the current standard web flow, not the separate signed-state mechanism used by Android Play Integrity:
- State correlates results, but is not checked against a saved browser-session value. The frontend generates it;
/auth/startchecks that it is present and forwards it. The callback does not compare the returned state to an original value saved in the session. If callback state is absent, it generates a UUID. The result cache is then keyed by that state. Do not describe this implementation as completed CSRF state validation. - Code exchange and userinfo retrieval do not by themselves constitute the application's phone-verification decision. The provider returns the claims, but the ordinary web callback does not require
phone_number_verifiedto be true, compare the returned phone to the submitted phone, or validate an ID token locally before storing the result. isAuthenticatedmarks the demo browser session./auth/completesets it when a cached display result is found. It does not independently checkphone_number_verified. The result page can therefore display a failed or missing verification claim even though the session flag is true.- The QR result-page check is narrower. It checks the string
'false'only; it is not a general normalization or validation of the provider's verification claim.
An integrating application needs an explicit decision about the returned verification claims before granting access or treating a phone number as verified. This guide documents existing behavior and does not change it.
| Stage | Current response or behavior |
|---|---|
| Phone missing/invalid in the PNV UI | Show the relevant input modal; do not start the redirect |
Unknown server_id at /auth/start |
HTTP 400 JSON { "error": "INVALID_SERVER_ID" } |
No server configured at /auth/start |
HTTP 400 JSON { "error": "NO_AUTH_SERVERS_CONFIGURED" }; normal app startup also validates configuration |
Unknown user_flow at /auth/start or callback |
Plain-text Client not found with the existing default HTTP 200 |
State missing at /auth/start after server/client resolution |
Redirect to /auth/login |
Provider reports callback error or error_description |
Save a fixed session error message and redirect to /auth/login |
| Unknown callback server | HTTP 400 text describing the invalid server ID |
| Token exchange/userinfo request fails in the callback | HTTP 400 containing the caught error message |
| Completion cache misses on all attempts | Redirect to /, then the login page |
The fixed provider-error mapping and the token-request failure response are different paths. The callback does not redirect every error to login.
| Step | Implementation | Regression coverage |
|---|---|---|
| Phone input, number formatting, mobile/QR branch | login.pug, login.js |
Frontend behavior described from source; not covered by the backend tests below |
| Resolve credentials/server and build authorization URL | appLocals.js, authService.js |
appLocals.test.js, authService.test.js, authFlows.test.js |
| Callback, cache writes, QR notification, session completion | webHandlers.js, authResultStore.js |
authFlows.test.js, authResultStore.test.js |
| Provider error mapping | webHandlers.js |
authSecurity.test.js |
The backend tests use local provider fixtures. They establish request/response behavior and ordering, not live carrier verification or a deployed end-to-end browser flow.