Implement CSRF protection
Prevent cross-site request forgery in your auth flow using the OAuth state parameter and secure cookies
You will add robust CSRF protection to your login flow by using the OAuth state parameter and secure cookies. This prevents cross-site request forgery and open-redirect attacks during authentication.
What goes wrong without CSRF protection
- You are signed in to your app and visit an attacker’s site.
- The site auto-submits your browser to your OAuth callback using the attacker’s authorization code.
- Your app exchanges the code and sets a session — in your browser — for the attacker’s account.
Result: login CSRF (account mix-up). You appear signed in, but as the attacker.
<form action="https://your-app.com/auth/callback" method="GET" id="f"> <input type="hidden" name="code" value="C_attacker" /> <!-- No valid state included --></form><script>document.getElementById('f').submit()</script>With proper state validation, your callback rejects this request (401) because the state is missing or mismatched. The steps below show how to implement that protection.
-
Generate a cryptographically strong state
Section titled “Generate a cryptographically strong state”Create a random, unguessable
statevalue and persist it server-side (session) or in a signed, HTTP-only cookie. Use at least 256 bits of entropy.Express.js import crypto from 'crypto'// Generate and persist stateconst state = crypto.randomBytes(32).toString('hex')// Recommended: store in a server session. If you use a cookie, make it signed & HTTP-only.res.cookie('sk_auth_state', state, {httpOnly: true,secure: true,sameSite: 'lax', // Lax allows the cookie to be sent on the OAuth GET redirectpath: '/',})Flask import secretsfrom flask import sessionstate = secrets.token_urlsafe(32)session['sk_auth_state'] = stateGin import ("crypto/rand""encoding/base64")func newState() string {b := make([]byte, 32)rand.Read(b)return base64.RawURLEncoding.EncodeToString(b)}Spring import java.security.SecureRandom;import java.util.Base64;SecureRandom sr = new SecureRandom();byte[] bytes = new byte[32];sr.nextBytes(bytes);String state = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);request.getSession().setAttribute("sk_auth_state", state); -
Include state in the authorization URL
Section titled “Include state in the authorization URL”Pass the
statewhen you build the authorization URL.Express.js const redirectUri = 'https://your-app.com/auth/callback'const options = {scopes: ['openid', 'profile', 'email', 'offline_access'],state,}const authorizationUrl = scalekit.getAuthorizationUrl(redirectUri, options)res.redirect(authorizationUrl)Flask from scalekit import AuthorizationUrlOptionsredirect_uri = 'https://your-app.com/auth/callback'options = AuthorizationUrlOptions(scopes=['openid','profile','email','offline_access'],state=state)authorization_url = scalekit_client.get_authorization_url(redirect_uri, options)return redirect(authorization_url)Gin redirectUri := "https://your-app.com/auth/callback"options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"},State: state,}authorizationURL, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)c.Redirect(http.StatusFound, authorizationURL.String())Spring import com.scalekit.internal.http.AuthorizationUrlOptions;String redirectUri = "https://your-app.com/auth/callback";AuthorizationUrlOptions options = new AuthorizationUrlOptions();options.setScopes(Arrays.asList("openid","profile","email","offline_access"));options.setState(state);URL authorizationUrl = scalekitClient.authentication().getAuthorizationUrl(redirectUri, options);return new RedirectView(authorizationUrl.toString()); -
Validate state on the callback
Section titled “Validate state on the callback”Compare the
statein the callback with what you stored. Reject on mismatch.Express.js app.get('/auth/callback', (req, res) => {const returned = req.query.stateconst expected = req.cookies.sk_auth_state // or session storeif (!returned || returned !== expected) {return res.status(401).send('Invalid state')}// Clear one-time state after successful validationres.clearCookie('sk_auth_state', { path: '/' })// Proceed to exchange the code})Flask @app.route('/auth/callback')def callback():returned = request.args.get('state')expected = session.get('sk_auth_state')if not returned or returned != expected:return jsonify({'error': 'Invalid state'}), 401# Clear one-time state after successful validationsession.pop('sk_auth_state', None)# Proceed to exchange the codeGin func callback(c *gin.Context) {returned := c.Query("state")expected := sessionGet(c, "sk_auth_state")if returned == "" || returned != expected {c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid state"})return}// Clear one-time state after successful validationsessionDelete(c, "sk_auth_state")// Proceed to exchange the code}Spring @GetMapping("/auth/callback")public Object callback(HttpServletRequest request) {String returned = request.getParameter("state");String expected = (String) request.getSession().getAttribute("sk_auth_state");if (returned == null || !returned.equals(expected)) {return ResponseEntity.status(401).body("Invalid state");}// Clear one-time state after successful validationrequest.getSession().removeAttribute("sk_auth_state");// Proceed to exchange the code} -
Harden cookies and non-OAuth forms
Section titled “Harden cookies and non-OAuth forms”- Use
HttpOnly,Secure, andSameSite=Laxon session cookies used in the OAuth redirect. - Set a short TTL for
sk_auth_stateand delete it after use. - For HTML forms in your app, use framework-native anti-CSRF tokens (separate from OAuth state).
- Use