Implementing LinkedIn OAuth 2.0 Authorization Code Flow with PKCE in a Web App
A practical guide to wiring LinkedIn OAuth 2.0 Authorization Code Flow with PKCE into a web app, covering code‑verifier generation, token exchange, refresh handling, and a ready‑to‑run Node.js example.
17 Sept 2026, 21:13 UTC

The problem: securing a public client without a secret
When a single‑page application (SPA) or mobile app calls LinkedIn APIs, you cannot safely embed a client secret. The Authorization Code Flow with Proof Key for Code Exchange (PKCE) solves this by replacing the secret with a one‑time cryptographic challenge that only the legitimate client can answer.
Why PKCE matters for LinkedIn
LinkedIn requires a registered app (client ID + secret) and enforces HTTPS on every endpoint. Access tokens expire after ~60 minutes and must be refreshed with a refresh token that lives up to 90 days. The response also returns a state parameter that must be validated to prevent CSRF. PKCE adds a code_verifier / code_challenge pair so the token endpoint can verify the caller without ever seeing the secret.
Step‑by‑step flow
- Generate a code verifier – a high‑entropy random string (43‑128 characters, URL‑safe).
- Derive the code challenge – SHA‑256 hash of the verifier, base64‑url encoded (no padding).
- Build the authorization URL with
response_type=code,client_id,redirect_uri,scope(e.g.r_liteprofile r_emailaddress),state(random),code_challenge, andcode_challenge_method=S256. - Redirect the user to that URL. LinkedIn prompts for consent and redirects back to
redirect_uriwithcodeand the originalstate. - Validate state – compare the returned value with the one you stored (e.g. in a secure, same‑site cookie).
- Exchange the code for tokens – POST to
https://www.linkedin.com/oauth/v2/accessTokenwithgrant_type=authorization_code,code,redirect_uri,client_id, andcode_verifier. No client secret is sent. - Store access & refresh tokens securely (HttpOnly, Secure, SameSite=Strict cookies or encrypted server‑side store).
- Refresh when needed – POST
grant_type=refresh_tokenwith the refresh token andcode_verifier(same verifier used in the original exchange).
Worked example (Node.js + Express)
// 1. Generate verifier & challenge
const crypto = require('crypto');
function base64URLEncode(buf) {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const verifier = base64URLEncode(crypto.randomBytes(32));
const challenge = base64URLEncode(crypto.createHash('sha256').update(verifier).digest());
// 2. Store verifier & state in a secure cookie
res.cookie('pkce_verifier', verifier, { httpOnly: true, secure: true, sameSite: 'strict' });
res.cookie('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'strict' });
// 3. Redirect
const authUrl = new URL('https://www.linkedin.com/oauth/v2/authorization');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', process.env.LI_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', process.env.LI_REDIRECT_URI);
authUrl.searchParams.set('scope', 'r_liteprofile r_emailaddress');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
res.redirect(authUrl.toString());
// 4. Callback handler
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.cookies.oauth_state) return res.status(400).send('Invalid state');
const verifier = req.cookies.pkce_verifier;
const tokenResp = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.LI_REDIRECT_URI,
client_id: process.env.LI_CLIENT_ID,
code_verifier: verifier
})
});
const tokens = await tokenResp.json();
// store tokens securely …
res.redirect('/app');
});
Run the code on a server you control (Node ≥ 18). Required permissions: ability to set HTTPS cookies and make outbound HTTPS requests. Placeholders (process.env.LI_CLIENT_ID, LI_REDIRECT_URI) must be replaced with values from your LinkedIn developer console.
Trade‑offs and limitations
- Refresh‑token rotation – LinkedIn may rotate refresh tokens on each use. Your storage must handle the new token atomically; otherwise a race condition can invalidate the session.
- PKCE only for public clients – If you later add a confidential backend, you can switch to the classic client‑secret flow, but you must keep both implementations in sync.
- Scope creep – Requesting
w_member_social(write) requires a separate review. Only ask for scopes you actually use to avoid extra compliance steps.
Verification checklist
- Register a test app in the LinkedIn Developer Portal; note the
client_idand configure a valid HTTPSredirect_uri. - Run the full flow in a browser; confirm the redirect returns a
codeand the samestateyou sent. - Exchange the code with
curl(or the example code) and verify the JSON response containsaccess_token,expires_in≈ 3600,refresh_token, and the requested scopes. - Wait > 60 minutes (or manually expire the token) and perform a refresh request; ensure a new access token is issued and the refresh token is either unchanged or rotated as documented.
Next steps
Add the PKCE flow to your authentication library, store tokens in a secure HttpOnly cookie, and set up a background job that refreshes tokens before they expire. Monitor LinkedIn’s developer changelog for any endpoint or token‑lifetime changes, and run the verification checklist after each deploy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.