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.work — 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.work )VALYD_CLIENT_SECRET(get from Developer Portal: https://dev.valyd.work )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, and a freshly generated random
state(andnonce):https://idp.valyd.work/api/auth/oidc/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope={scopes}&state={state}&nonce={nonce}Parameters:
Parameter Required Description client_idYes Your application’s Client ID from the Developer Portal (https://dev.valyd.work ). 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.stateYes A random value you generate and store. Echoed back unchanged on the callback — compare it there (CSRF protection). nonceRecommended A random value bound into the id_token. Verify thenonceclaim after the exchange (replay protection).Expected output: A fully-formed URL string. Example with encoded scopes
openid profile verifications:https://idp.valyd.work/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 -
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_STATECallback 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.Expected output: Your
/callbackroute is invoked withcodeandstate(and possiblyerror) present in the query string. -
CSRF check — compare the
state. The callbackstatemust strictly equal the value you stored in step 2. Reject the request on any mismatch, before touching the code.const stored = readStoredOAuthValues(req); // your cookie/session read if (!stored?.state || req.query.state !== stored.state) { return res.status(400).send("state 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();
const { tokens, user } = await valyd.handleCallback(callbackUrl, { transaction });
// ...set your own app session, then redirect to /dashboard
});handleCallback() compares state, 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.work/api/auth/oidc/token with a JSON body. 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.work
Content-Type: application/json
{
"grant_type": "authorization_code",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTH_CODE_HERE",
"redirect_uri": "https://yourapp.com/callback"
}Send the same redirect_uri you used at /authorize — Valyd validates it against the code.
Authorization codes are bound to the client they were issued to and are single-use — exchange
them as soon as your callback fires.
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.work/api/auth/oidc/jwks.json and check that its nonce claim equals the
nonce you sent on /authorize.
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.work/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"
}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.work ). 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:
→ generate random state + nonce (crypto.randomBytes), store them
(httpOnly cookie / server session),
build the URL with valyd.auth.getAuthorizationUrl({ state, nonce, scope }),
and res.redirect(url).
IF the request hit your /callback route:
→ read code, state, error from the query string
IF error is set OR code is missing: → return HTTP 400 (error ?? "missing code"). STOP.
IF code is present: → continue to the CSRF check below.
CSRF 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 they match: → proceed to token exchange.
Token exchange:
→ const tokens = await valyd.auth.exchangeCode(code) // must run server-side; codes are single-use
IF the exchange returns 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 state/nonce, set your own app session, redirect to /dashboard.
IF you are not using Node / the SDK:
→ POST https://idp.valyd.work/api/auth/oidc/token with JSON
{ grant_type: "authorization_code", client_id, client_secret, code, redirect_uri }
and read the token from the TOP-LEVEL response field access_token.Verification
-
Confirm the redirect: opening your login route returns HTTP 302 with a
Locationheader beginninghttps://idp.valyd.work/api/auth/oidc/authorize?client_id=...&redirect_uri=...&response_type=code&scope=openid...&state=.... -
After consenting, confirm your
/callbackroute receivescodeandstatequery parameters, and thatstateequals the value you sent. -
Confirm the token exchange succeeds:
curl -i -X POST https://idp.valyd.work/api/auth/oidc/token \ -H "Content-Type: application/json" \ -d '{"grant_type":"authorization_code","client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET","code":"AUTH_CODE_HERE","redirect_uri":"https://yourapp.com/callback"}'Expected: HTTP 200 and a JSON body containing top-level
access_token,refresh_token, andid_token.
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
-
“missing code” /
invalid_grant(HTTP 400 on callback or 4xx from /token).- Cause: No
codein the callback (user denied or anerrorwas returned), the code was already exchanged (single-use), 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_uri.
- 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.work ), matching scheme, host, and path.
- Cause: The
-
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