A complete OAuth 2.0 PKCE (Proof Key for Code Exchange) authentication flow with Auth0, featuring:
- Frontend: Single Page Application with login button that initiates PKCE flow
- Backend: Express API with JWT token validation
- Security: No client secrets exposed in frontend; PKCE protects against authorization code interception
oauth-demo/
├── backend/
│ ├── middleware/
│ │ └── checkJwt.js # express-jwt middleware for token validation
│ ├── routes/
│ │ └── hello.js # Protected API route (returns user info)
│ ├── server.js # Express server
│ ├── package.json
│ └── .env # Auth0 credentials (git-ignored)
├── frontend/
│ ├── js/
│ │ ├── pkce.js # PKCE helper functions (verifier, challenge, token exchange)
│ │ └── config.js # Auth0 client config (CLIENT_ID, DOMAIN, REDIRECT_URI, API_AUDIENCE)
│ ├── css/
│ │ └── styles.css # Basic styling
│ ├── index.html # Login page with Auth0 button
│ └── callback.html # Receives authorization code, exchanges for tokens, displays user info
├── README.md
└── .gitignore
- Node.js (v14+) and npm
- Auth0 account with an application created
- Local ports available: 3000 (frontend), 3001 (backend)
git clone https://github.com/YOUR_USERNAME/oauth-demo.git
cd oauth-demo
cd backend
npm install
cd ../frontend
# No npm install needed for frontend (uses npx serve for static hosting)
cd ..- Go to your Auth0 Dashboard
- Create a new application (type: Single Page Application)
- In Settings, add to Allowed Callback URLs:
http://localhost:3000/callback - Add to Allowed Web Origins (for CORS):
http://localhost:3000 - Create or select an API and note its identifier (e.g.,
https://api.example.com)
Create backend/.env:
AUTH0_DOMAIN=your-auth0-domain.us.auth0.com
AUTH0_CLIENT_ID=your-client-id
API_AUDIENCE=https://api.example.com
BACKEND_PORT=3001
Get these values from your Auth0 Application Settings.
Edit frontend/js/config.js with your Auth0 credentials:
export const CLIENT_ID = "YOUR_CLIENT_ID";
export const DOMAIN = "your-auth0-domain.us.auth0.com";
export const API_AUDIENCE = "https://api.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";Terminal 1 — Backend:
cd backend
npm startExpected output: API running on http://localhost:3001
Terminal 2 — Frontend:
cd frontend
npx serve . -l 3000Expected output: Serving on http://localhost:3000
- Open
http://localhost:3000in your browser - Click Login with Auth0
- Sign in with your Auth0 credentials
- You'll be redirected to
http://localhost:3000/callback - The page will:
- Exchange the authorization code for tokens
- Display the token response (access_token, id_token, etc.)
- Show a "Call API" button
- Click Call API to invoke
http://localhost:3001/hello:- The request includes
Authorization: Bearer <access_token> - Backend validates the token with express-jwt
- Response shows:
{ message: "Hello! Your token is valid.", user: {...} } - User Info displays the decoded JWT payload (claims like
sub,aud, etc.)
- The request includes
- Frontend generates a random
code_verifier(stored in localStorage) - Frontend derives a
code_challenge(SHA-256 hash of verifier) and sends it to Auth0 - Auth0 authenticates the user and returns an authorization
code - Frontend exchanges the
code+code_verifierfor tokens with Auth0 - Auth0 verifies that the
code_verifiermatches thecode_challenge(security checkpoint) - Tokens are returned and used to call the protected backend API
This prevents attackers from intercepting the authorization code and using it (they'd need the verifier too).
.envfile: Contains Auth0 credentials — never commit to version control. Already in.gitignore.frontend/js/config.js: Contains CLIENT_ID and REDIRECT_URI — these are public (frontend) but REDIRECT_URI must match Auth0 settings exactly.- No client secret in frontend: The PKCE flow doesn't require a client secret (unlike the Authorization Code flow with backend exchange).
- HTTPS in production: Always use HTTPS in production to protect tokens in transit.
- Check that
REDIRECT_URIinfrontend/js/config.jsexactly matches "Allowed Callback URLs" in Auth0 Application Settings (including protocol and path). - Verify the app is set as Single Page Application (not Regular Web Application) in Auth0.
- Confirm the frontend Call API request includes the
Authorization: Bearer <token>header. - Check browser DevTools Network tab to see the request headers.
- Verify
API_AUDIENCEinfrontend/js/config.jsmatches the API identifier in Auth0. - Verify
AUTH0_DOMAINinbackend/.envis correct (should match the domain in frontend config). - In Auth0 API settings, confirm "Signing Algorithm" is RS256.
- Backend already has
cors()enabled with default settings (allow all origins). - If you see 403 errors, check that the Authorization header is being sent correctly.
- Display and decode the ID token to show user profile info (name, picture, etc.)
- Add refresh token handling for long-lived sessions
- Add logout button to clear localStorage
- Deploy frontend to Vercel/Netlify and backend to Heroku/Railway
- Add role-based access control (RBAC) using Auth0 roles
- Store tokens in secure HTTP-only cookies instead of localStorage (more secure)
- Frontend (3000)
- Generates code_verifier
- Hashes it → code_challenge
- Sends user to Auth0 /authorize
- User logs in at Auth0
- Auth0 redirects back with ?code=XYZ
- Frontend retrieves code + code_verifier
- Exchanges code for tokens at /oauth/token
- Calls API with Authorization: Bearer <access_token>
Auth0
- Verifies login credentials
- Verifies PKCE proof (challenge vs verifier)
- Issues tokens (ID & Access)
- Backend API (3001)
- Middleware validates JWT signature using JWKS
- Confirms issuer, audience, expiry, signature
- Attaches decoded user → req.user
- Returns protected response