Security Best Practices

OAuth is only as secure as your implementation. This guide covers the essential security practices for your Xident integration. Every item here addresses a real-world attack vector.

1. Always use PKCE with S256

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Xident requires PKCE for all clients, including confidential (server-side) clients. The plain method is rejected — you must use S256.

// ALWAYS use PKCE with S256 — plain is rejected
const codeVerifier = generateCodeVerifier(); // 43+ chars, high entropy
const codeChallenge = await sha256base64url(codeVerifier);

const params = new URLSearchParams({
  // ...
  code_challenge: codeChallenge,
  code_challenge_method: 'S256', // MUST be S256, not "plain"
});

Why S256 and not plain? With plain, anyone who intercepts the authorization request can see the verifier. With S256, they only see a hash — they cannot derive the verifier to complete the token exchange.

2. Use the state parameter for CSRF protection

The state parameter prevents cross-site request forgery. Without it, an attacker could trick a user into completing an OAuth flow that links the attacker's Xident account to the victim's session on your site.

// Generate a cryptographically random state parameter
const state = crypto.randomUUID();

// Store it before redirecting
sessionStorage.setItem('oauth_state', state);

// On callback, verify it matches
const returnedState = new URLSearchParams(window.location.search).get('state');
if (returnedState !== sessionStorage.getItem('oauth_state')) {
  throw new Error('State mismatch — possible CSRF attack');
}

3. Validate redirect URIs strictly

Redirect URIs are validated as exact string matches. No wildcards, no query parameters, no fragments. HTTPS is required in production.

// Redirect URI rules:
//
// VALID:
//   https://yoursite.com/callback          ✓  Exact HTTPS match
//   https://app.yoursite.com/auth/callback  ✓  Subdomain allowed if registered
//   http://localhost:3000/callback           ✓  localhost only for development
//   http://127.0.0.1:3000/callback           ✓  loopback for development
//
// INVALID:
//   https://yoursite.com/callback?extra=1   ✗  No query parameters
//   https://yoursite.com/callback#frag      ✗  No fragments
//   https://yoursite.com/*                  ✗  No wildcards
//   http://yoursite.com/callback            ✗  No HTTP (except localhost)
//   https://evil.com/callback               ✗  Must match registered URI exactly

Why exact match? Open redirect vulnerabilities allow attackers to steal authorization codes. If https://yoursite.com/* were allowed, an attacker could use https://yoursite.com/redirect?to=evil.com to capture the code.

4. Store client secrets securely

The client secret must never appear in frontend code, mobile apps, or version control. It belongs on your server, in environment variables or a secrets manager.

// WRONG — client secret in frontend code
const response = await fetch('/oauth/token', {
  body: new URLSearchParams({
    client_secret: 'xcs_NEVER_DO_THIS', // Exposed to anyone
  }),
});

// RIGHT — client secret on your backend only
// .env file (never committed to git)
XIDENT_CLIENT_ID=xc_abc123
XIDENT_CLIENT_SECRET=xcs_secretkey

// server.js
app.post('/api/auth/callback', async (req, res) => {
  const response = await fetch('https://api.xident.io/oauth/token', {
    body: new URLSearchParams({
      client_secret: process.env.XIDENT_CLIENT_SECRET, // Server-side only
    }),
  });
});

5. Handle refresh token rotation correctly

Xident rotates refresh tokens on every use. Each refresh gives you a new refresh token and invalidates the old one. If you reuse an old token — intentionally or due to a bug — Xident treats it as a replay attack and revokes the entire token family.

// Refresh token rotation — handle it correctly

// Step 1: Use the current refresh token
const tokens = await refreshTokens(currentRefreshToken);

// Step 2: ATOMICALLY replace the stored token
// If your app crashes between receiving and storing, you lose the session
// Use a database transaction to make this atomic
await db.transaction(async (tx) => {
  await tx.update(sessions)
    .set({
      refreshToken: tokens.refresh_token,  // Save the NEW token
      accessToken: tokens.access_token,
      expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
    })
    .where(eq(sessions.userId, userId));
});

// Step 3: NEVER reuse the old refresh token
// Xident uses replay detection — reusing an old token revokes ALL tokens
// in the family, forcing the user to log in again

Common pitfall: concurrent refresh requests

If two requests try to refresh the same token simultaneously, the second one will use an already-invalidated token and trigger replay detection. Use a mutex or queue to ensure only one refresh happens at a time, and share the result with all waiting requests.

6. Revoke tokens on logout

When a user logs out, revoke the refresh token to prevent it from being used if it was previously compromised. Revoking a refresh token also invalidates all associated access tokens.

// On logout: always revoke the refresh token
app.post('/api/auth/logout', async (req, res) => {
  const session = await getSession(req);

  if (session?.refreshToken) {
    // Revoke the Xident refresh token
    await fetch('https://api.xident.io/oauth/revoke', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: process.env.XIDENT_CLIENT_ID,
        client_secret: process.env.XIDENT_CLIENT_SECRET,
        token: session.refreshToken,
        token_type_hint: 'refresh_token',
      }),
    });
  }

  // Clear the local session
  await destroySession(req);
  res.json({ success: true });
});

7. Verify JWTs properly

Never just decode a JWT without verifying the signature. An unverified JWT could have been crafted by anyone. Always verify the signature, issuer, audience, and expiration.

// Always verify JWTs — never just decode them
import jwt from 'jsonwebtoken';

// WRONG — decoding without verification
const payload = jwt.decode(token); // Anyone could have created this token!

// RIGHT — verify signature, issuer, audience, and expiration
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],           // Only accept RS256
  issuer: 'https://api.xident.io', // Reject tokens from other issuers
  audience: process.env.XIDENT_CLIENT_ID, // Reject tokens for other clients
  clockTolerance: 30,              // Allow 30s clock skew
});

8. Additional recommendations

  • Use HTTPS everywhere — Not just for OAuth, but for your entire site. Mixed content allows token interception
  • Never log tokens — Access tokens, refresh tokens, and authorization codes should never appear in logs
  • Set short session timeouts — For sensitive operations, re-verify the user's identity
  • Monitor for anomalies — Watch for unusual patterns like rapid token refreshes or access from new locations
  • Keep dependencies updated — JWT libraries and OAuth SDKs receive security patches regularly
  • Use Content Security Policy — Prevent XSS attacks that could steal tokens from the browser

Security checklist

Use this checklist to audit your integration:

// Security checklist for your OAuth integration
//
// [x] PKCE with S256 (mandatory)
// [x] State parameter for CSRF protection
// [x] Redirect URIs: exact match, HTTPS only (except localhost)
// [x] Client secret: server-side only, in environment variables
// [x] JWT verification: signature + iss + aud + exp
// [x] Refresh token rotation: always store the latest token
// [x] Revoke tokens on logout
// [x] Use HTTPS everywhere (your site, not just the OAuth flow)
// [x] Never log tokens (access, refresh, or authorization codes)
// [x] Set short session timeouts for sensitive operations

Reporting security issues

If you discover a security vulnerability in Xident's OAuth implementation, please report it responsibly to security@xident.io. We take all reports seriously and will respond within 24 hours.

Next steps