Skip to Content
DocsAdd Connect with Valyd

Add Connect with Valyd

🔑 Auth: client_id + client_secret (server-side) · 👤 Standard OpenID Connect — Connect can also serve as your sign-in · 📖 After connecting: read the account with a Bearer access token

Prefer the concept pages? See Flows for the picture-first walkthrough and Tokens for what each returned token is for.

These raw-HTTP examples demonstrate the protocol. For production, use a maintained OIDC library or @valyd/sdk — they validate issuer, audience, signature, expiry, state, nonce, and PKCE for you.

Prerequisites

  • Client ID and Client Secret (get these from the Developer Portal → your app → Credentials: https://dev.valyd.id  — the portal lists apps as “projects”; same object).
  • A registered redirect/callback URL matching what you send as redirect_uri.
  • Environment variables set on your server:
    • VALYD_CLIENT_ID (get from Developer Portal: https://dev.valyd.id )
    • VALYD_CLIENT_SECRET (get from Developer Portal: https://dev.valyd.id )
    • VALYD_REDIRECT_URI (the exact callback URL you registered, e.g. https://yourapp.com/callback)

Steps

  1. Construct the authorization URL. Redirect users to the OIDC authorization endpoint with your client id, redirect URI, scopes, a PKCE code_challenge, and a freshly generated random state (and nonce). PKCE (S256) is required for every client, including confidential ones: generate a random code_verifier (43–128 chars), keep it server-side, and send code_challenge = BASE64URL(SHA256(code_verifier)):

    https://idp.valyd.id/api/auth/oidc/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope={scopes}&state={state}&nonce={nonce}&code_challenge={code_challenge}&code_challenge_method=S256

    The endpoint accepts GET (query string) or POST (application/x-www-form-urlencoded form).

    Parameters:

    ParameterRequiredDescription
    client_idYesYour application’s Client ID from the Developer Portal (https://dev.valyd.id ).
    redirect_uriYesThe URL to redirect to after authentication. Must match the URL registered on your application.
    response_typeYesMust be code.
    scopeYesSpace-separated list of scopes, URL-encoded. MUST include openid. Example: openid%20profile%20verifications.
    code_challengeYesBASE64URL(SHA256(code_verifier)) — 43–128 base64url characters. Missing → your callback receives error=invalid_request.
    code_challenge_methodYesMust be S256 (plain is not supported).
    stateRecommendedA random value you generate and store. Echoed back unchanged on the callback (only if you sent one) — compare it there (CSRF protection).
    nonceRecommendedA random value bound into the id_token. The ID token carries nonce only if you sent one — verify it after the exchange (replay protection).
    promptNoSpace-separated: none, login, consent, select_account (none can’t be combined). none = silent check — returns error=login_required (no/stale session) or consent_required (third-party app without a prior grant for these scopes). login / select_account force re-authentication; consent always shows the consent screen, even for first-party apps.
    max_ageNoSeconds. If the user’s last real sign-in is older, they must re-authenticate. The ID token always carries auth_time.
    id_token_hintNoA previously issued ID token. With prompt=none, a different signed-in user returns login_required.
    login_hintNoPassed through to the sign-in screen.

    response_type must be code and response_mode (if sent) must be query. request and request_uri are not supported (request_not_supported / request_uri_not_supported).

    Expected output: A fully-formed URL string. Example with encoded scopes openid profile verifications:

    https://idp.valyd.id/api/auth/oidc/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/callback&response_type=code&scope=openid%20profile%20verifications&state=RANDOM_STATE&nonce=RANDOM_NONCE&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256

    Generating the PKCE pair without the SDK:

    import crypto from "node:crypto"; const codeVerifier = crypto.randomBytes(32).toString("base64url"); // keep server-side const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
  2. Create and store one secure transaction (recommended: SDK). The SDK generates strong state, nonce, and S256 PKCE values together. Store the returned object in a server-side session:

    import { ValydClient } from "@valyd/sdk"; const valyd = new ValydClient({ clientId: process.env.VALYD_CLIENT_ID!, clientSecret: process.env.VALYD_CLIENT_SECRET!, redirectUri: "https://yourapp.com/callback", }); const transaction = valyd.createAuthorizationRequest({ scope: ["profile", "verifications", "zkp"], // "openid" is added automatically }); req.session.valydOidc = transaction; // server-side only; includes the PKCE verifier res.redirect(transaction.url);

    Expected output: HTTP 302 to the OIDC authorize endpoint with state, nonce, and code_challenge. The full transaction stays server-side for step 6.

  3. User consents on the Valyd consent screen. Valyd shows the consent screen with the requested scopes and, on approval, issues a one-time authorization code. (Scopes must be enabled on your app in the Developer Portal before they can be requested — the same model as Google’s consent-screen scopes.)

    Expected output: Valyd redirects the user’s browser to your callback URL with the code attached. Codes are single-use and short-lived — exchange immediately.

  4. Receive the callback on your server. The user is redirected to your registered callback URL with the authorization code and your original state:

    https://yourapp.com/callback?code=AUTH_CODE_HERE&state=RANDOM_STATE&iss=https%3A%2F%2Fidp.valyd.id

    If the request failed after your client_id and redirect_uri were verified (user pressed Cancel, missing PKCE, prompt=none couldn’t be satisfied, private org app the user isn’t assigned to, …) the callback carries an error instead of a code:

    https://yourapp.com/callback?error=access_denied&error_description=The+user+denied+the+request&state=RANDOM_STATE&iss=https%3A%2F%2Fidp.valyd.id

    Only an unknown client_id or an unregistered redirect_uri shows an error page on Valyd and never redirects.

    Callback query parameters:

    ParameterDescription
    codeThe one-time authorization code. Single-use; exchange immediately.
    stateThe exact state value you sent on /authorize, echoed back unchanged. Compare it to your stored value.
    issAlways https://idp.valyd.id (RFC 9207). Reject the callback if it differs (mix-up defence).
    error, error_descriptionPresent instead of code when the authorization failed — e.g. access_denied, login_required, consent_required, invalid_request, invalid_scope.

    Expected output: Your /callback route is invoked with code and state (and possibly error) present in the query string.

  5. Handle error, then the CSRF + issuer check. If the callback carries error, stop and show/log it. Otherwise the callback state must strictly equal the value you stored in step 2 and iss must equal the issuer. Reject the request on any mismatch, before touching the code.

    if (req.query.error) { return res.status(400).send(`${req.query.error}: ${req.query.error_description ?? ""}`); } const stored = readStoredOAuthValues(req); // your cookie/session read if (!stored?.state || req.query.state !== stored.state) { return res.status(400).send("state mismatch"); } if (req.query.iss && req.query.iss !== "https://idp.valyd.id") { return res.status(400).send("issuer mismatch"); }

    Expected output: On a legitimate flow the values are identical and processing continues. On a mismatch (missing cookie, forged callback), respond HTTP 400 and stop.

  6. Exchange the code for tokens, then fetch the user (server-side). Exchange the code immediately (it is single-use). The callback handler in your stack — pick your language once and every code block on the page follows:

// Recommended: @valyd/sdk import { ValydClient } from "@valyd/sdk"; const valyd = new ValydClient({ clientId: process.env.VALYD_CLIENT_ID, clientSecret: process.env.VALYD_CLIENT_SECRET, redirectUri: process.env.VALYD_REDIRECT_URI, }); app.get("/callback", async (req, res) => { const transaction = req.session.valydOidc; if (!transaction) return res.status(400).send("login transaction missing"); delete req.session.valydOidc; const callbackUrl = new URL(req.originalUrl, process.env.VALYD_REDIRECT_URI).toString(); try { const { tokens, user } = await valyd.handleCallback(callbackUrl, { transaction }); // ...set your own app session, then redirect to /dashboard } catch (err) { // ValydError: err.code is "access_denied", "invalid_grant", "issuer_mismatch", ... return res.status(400).send(err.code); } });

handleCallback() surfaces a callback error, compares state, checks the RFC 9207 iss, sends the PKCE verifier, exchanges the code, verifies RS256/JWKS plus issuer/audience/expiry/nonce, and fetches UserInfo.

Expected output: exchangeCode(code) returns tokens (accessToken, refreshToken, idToken, expiresIn, scope); getUserInfo(accessToken) returns the user’s profile data. Then set your own app session and redirect (e.g. to /dashboard).

Token exchange without the SDK (raw HTTP)

The token endpoint is POST https://idp.valyd.id/api/auth/oidc/token with a standard application/x-www-form-urlencoded body (a JSON body is also accepted). The response is a standard top-level token JSON — access_token, refresh_token, id_token, expires_in, scope, token_type at the root (no data wrapper).

Request shape:

POST /api/auth/oidc/token HTTP/1.1 Host: idp.valyd.id Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&code=AUTH_CODE_HERE&redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&code_verifier=YOUR_CODE_VERIFIER

Authenticate the client with either client_secret_post (as above) or client_secret_basic (Authorization: Basic base64(urlencode(client_id):urlencode(client_secret)), RFC 6749 §2.3.1) — sending both is rejected with invalid_request. Public first-party clients send only client_id (auth method none) and rely on PKCE.

Send the same redirect_uri you used at /authorize — Valyd validates it against the code — and the code_verifier whose S256 hash you sent as code_challenge. Authorization codes are bound to the client they were issued to and are single-use — exchange them as soon as your callback fires. Replaying a code is rejected and revokes every token it already produced.

Expected output: HTTP 200 with a top-level JSON body:

{ "access_token": "eyJhbGciOi...", "refresh_token": "rfrsh_abc123...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 900, "scope": "openid profile verifications" }

The id_token is an RS256-signed JWT — validate its signature against the JWKS at https://idp.valyd.id/api/auth/oidc/jwks.json and, if you sent a nonce on /authorize, check that its nonce claim equals it.

Errors follow RFC 6749 §5.2 — a flat JSON object (no success/data envelope) with Cache-Control: no-store:

{ "error": "invalid_grant", "error_description": "Invalid or expired authorization code" }

error is one of invalid_request, invalid_client (HTTP 401; plus WWW-Authenticate: Basic when you used HTTP Basic), invalid_grant, invalid_scope, unsupported_grant_type. See Errors.

Renewing an access token

Access tokens are short-lived (expires_in ≈ 900 seconds). Exchange the refresh_token at the same token endpoint — POST https://idp.valyd.id/api/auth/oidc/token — from your backend, with your client credentials:

grant_type=refresh_token&refresh_token=rfrsh_abc123...&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET

Add an optional scope to narrow the new access token to a subset of the granted scopes (asking for more returns invalid_scope).

The refresh token is validated against the client it was issued to, so a token leaked from one app cannot be used by another. Rotation is on: every refresh returns a new refresh_token and revokes the one you sent, so always persist the returned value. Replaying a rotated-away token is treated as theft and revokes every refresh token for that user and app.

With the SDK this is one call — const next = await valyd.auth.refreshToken(stored) — then persist both next.accessToken and next.refreshToken.

In all raw-HTTP examples, replace YOUR_CLIENT_ID and YOUR_CLIENT_SECRET with your real values (get these from the Developer Portal → your app → Credentials: https://dev.valyd.id ). Never expose YOUR_CLIENT_SECRET in client-side code — the token exchange must run on your server.

Auth-flow decision tree

IF the request just hit your login/start route: → const txn = valyd.auth.createAuthorizationRequest({ scope }) (random state + nonce + S256 PKCE verifier/challenge), store txn server-side (httpOnly cookie / server session), and res.redirect(txn.url). (getAuthorizationUrl() without a codeChallenge throws "pkce_required".) IF the request hit your /callback route: → read code, state, iss, error from the query string IF error is set (access_denied, login_required, consent_required, invalid_request, ...): → return HTTP 400 (error + error_description). STOP. IF code is missing: → return HTTP 400 "missing code". STOP. IF code is present: → continue to the CSRF check below. CSRF + issuer check (do this on every callback): → compare req.query.state to the state you stored before the redirect IF they differ (or the stored value is missing): → return HTTP 400 "state mismatch". STOP. IF iss is present and != "https://idp.valyd.id": → return HTTP 400 "issuer mismatch". STOP. IF they match: → proceed to token exchange. Token exchange: → const tokens = await valyd.auth.exchangeCode(code, { codeVerifier: txn.codeVerifier }) // server-side; codes are single-use (or simply: await valyd.auth.handleCallback(callbackUrl, { transaction: txn }) — does all of the above) IF the exchange fails with error "invalid_grant": → the code expired or was already used; restart from the login route. → verify tokens.idToken's `nonce` claim equals the nonce you stored → const user = await valyd.auth.getUserInfo(tokens.accessToken) → clear the stored transaction, set your own app session, redirect to /dashboard. IF you are not using Node / the SDK: → POST https://idp.valyd.id/api/auth/oidc/token (form-encoded) grant_type=authorization_code, client_id, client_secret, code, redirect_uri, code_verifier and read the token from the TOP-LEVEL response field access_token. On failure the body is { "error": "...", "error_description": "..." }.

Verification

  • Confirm the redirect: opening your login route returns HTTP 302 with a Location header beginning https://idp.valyd.id/api/auth/oidc/authorize?client_id=...&redirect_uri=...&response_type=code&scope=openid...&state=... and containing code_challenge=...&code_challenge_method=S256.

  • After consenting, confirm your /callback route receives code, state and iss query parameters, and that state equals the value you sent.

  • Confirm the token exchange succeeds:

    curl -i -X POST https://idp.valyd.id/api/auth/oidc/token \ -d grant_type=authorization_code \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d code=AUTH_CODE_HERE \ --data-urlencode redirect_uri=https://yourapp.com/callback \ -d code_verifier=YOUR_CODE_VERIFIER

    Expected: HTTP 200 and a JSON body containing top-level access_token, refresh_token, and id_token. On failure: HTTP 400/401 and {"error":"invalid_grant","error_description":"..."}.

Common errors

  1. state mismatch on the callback (legitimate logins rejected).

    • Cause: The stored state was lost before the callback — cookie not set, blocked by the browser, expired, or overwritten by a second parallel login attempt.
    • Fix: Store state in an httpOnly, sameSite: "lax" cookie (or server session) on the login route and compare strictly on the callback: req.query.state === storedState. This comparison IS the CSRF protection — do not remove it.
  2. error=access_denied / “missing code” / invalid_grant (HTTP 400 on callback or 4xx from /token).

    • Cause: No code in the callback (the user pressed Cancel → access_denied, the user isn’t assigned to a private org app → access_denied, or another error was returned), the code was already exchanged (single-use), the code_verifier doesn’t match the code_challenge, or the redirect_uri in the token request differs from the authorize request.
    • Fix: When the callback carries error or no code, return HTTP 400 and restart the flow. Exchange the code immediately, exactly once, with the same redirect_uri and the stored code_verifier.
  3. redirect_uri mismatch (authorization rejected by Valyd).

    • Cause: The redirect_uri sent on the authorization request does not exactly match the URL registered for your application.
    • Fix: Set VALYD_REDIRECT_URI (and the SDK redirectUri) to the exact callback URL registered in the Developer Portal (https://dev.valyd.id ), matching scheme, host, and path.
  4. error=invalid_request on the callback (“code_challenge is required”).

    • Cause: The authorization request had no PKCE code_challenge (or used plain). PKCE S256 is required for every client since 2026-09-18.
    • Fix: Use valyd.auth.createAuthorizationRequest() or send code_challenge + code_challenge_method=S256, and the matching code_verifier at the token endpoint.
  5. invalid_scope / missing openid.

    • Cause: The scope parameter omitted openid, or a requested scope is not enabled on your app in the Developer Portal.
    • Fix: Always include openid (the SDK adds it automatically) and enable every requested scope on the app before requesting it.
Last updated on