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
-
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 randomstate(andnonce). PKCE (S256) is required for every client, including confidential ones: generate a randomcode_verifier(43–128 chars), keep it server-side, and sendcode_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=S256The endpoint accepts
GET(query string) orPOST(application/x-www-form-urlencodedform).Parameters:
Parameter Required Description client_idYes Your application’s Client ID from the Developer Portal (https://dev.valyd.id ). redirect_uriYes The URL to redirect to after authentication. Must match the URL registered on your application. response_typeYes Must be code.scopeYes Space-separated list of scopes, URL-encoded. MUST include openid. Example:openid%20profile%20verifications.code_challengeYes BASE64URL(SHA256(code_verifier))— 43–128 base64url characters. Missing → your callback receiveserror=invalid_request.code_challenge_methodYes Must be S256(plainis not supported).stateRecommended A random value you generate and store. Echoed back unchanged on the callback (only if you sent one) — compare it there (CSRF protection). nonceRecommended A random value bound into the id_token. The ID token carriesnonceonly if you sent one — verify it after the exchange (replay protection).promptNo Space-separated: none,login,consent,select_account(nonecan’t be combined).none= silent check — returnserror=login_required(no/stale session) orconsent_required(third-party app without a prior grant for these scopes).login/select_accountforce re-authentication;consentalways shows the consent screen, even for first-party apps.max_ageNo Seconds. If the user’s last real sign-in is older, they must re-authenticate. The ID token always carries auth_time.id_token_hintNo A previously issued ID token. With prompt=none, a different signed-in user returnslogin_required.login_hintNo Passed through to the sign-in screen. response_typemust becodeandresponse_mode(if sent) must bequery.requestandrequest_uriare 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=S256Generating 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"); -
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, andcode_challenge. The full transaction stays server-side for step 6. -
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.
-
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.idIf the request failed after your
client_idandredirect_uriwere verified (user pressed Cancel, missing PKCE,prompt=nonecouldn’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.idOnly an unknown
client_idor an unregisteredredirect_urishows an error page on Valyd and never redirects.Callback query parameters:
Parameter Description codeThe one-time authorization code. Single-use; exchange immediately. stateThe exact statevalue 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 codewhen the authorization failed — e.g.access_denied,login_required,consent_required,invalid_request,invalid_scope.Expected output: Your
/callbackroute is invoked withcodeandstate(and possiblyerror) present in the query string. -
Handle
error, then the CSRF + issuer check. If the callback carrieserror, stop and show/log it. Otherwise the callbackstatemust strictly equal the value you stored in step 2 andissmust 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.
-
Exchange the code for tokens, then fetch the user (server-side). Exchange the
codeimmediately (it is single-use). The callback handler in your stack — pick your language once and every code block on the page follows:
Node (SDK)
// 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_VERIFIERAuthenticate 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_SECRETAdd 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
Locationheader beginninghttps://idp.valyd.id/api/auth/oidc/authorize?client_id=...&redirect_uri=...&response_type=code&scope=openid...&state=...and containingcode_challenge=...&code_challenge_method=S256. -
After consenting, confirm your
/callbackroute receivescode,stateandissquery parameters, and thatstateequals 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_VERIFIERExpected: HTTP 200 and a JSON body containing top-level
access_token,refresh_token, andid_token. On failure: HTTP 400/401 and{"error":"invalid_grant","error_description":"..."}.
Common errors
-
state mismatchon the callback (legitimate logins rejected).- Cause: The stored
statewas lost before the callback — cookie not set, blocked by the browser, expired, or overwritten by a second parallel login attempt. - Fix: Store
statein anhttpOnly,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.
- Cause: The stored
-
error=access_denied/ “missing code” /invalid_grant(HTTP 400 on callback or 4xx from /token).- Cause: No
codein the callback (the user pressed Cancel →access_denied, the user isn’t assigned to a private org app →access_denied, or anothererrorwas returned), the code was already exchanged (single-use), thecode_verifierdoesn’t match thecode_challenge, or theredirect_uriin the token request differs from the authorize request. - Fix: When the callback carries
erroror nocode, return HTTP 400 and restart the flow. Exchange the code immediately, exactly once, with the sameredirect_uriand the storedcode_verifier.
- Cause: No
-
redirect_urimismatch (authorization rejected by Valyd).- Cause: The
redirect_urisent on the authorization request does not exactly match the URL registered for your application. - Fix: Set
VALYD_REDIRECT_URI(and the SDKredirectUri) to the exact callback URL registered in the Developer Portal (https://dev.valyd.id ), matching scheme, host, and path.
- Cause: The
-
error=invalid_requeston the callback (“code_challenge is required”).- Cause: The authorization request had no PKCE
code_challenge(or usedplain). PKCE S256 is required for every client since 2026-09-18. - Fix: Use
valyd.auth.createAuthorizationRequest()or sendcode_challenge+code_challenge_method=S256, and the matchingcode_verifierat the token endpoint.
- Cause: The authorization request had no PKCE
-
invalid_scope/ missingopenid.- Cause: The
scopeparameter omittedopenid, 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.
- Cause: The