How to stop Stripe from over-writing client's session cookie after a successful purchase

Viewed 879

Environment: Express, express-session, Stripe

In the following simplified example when a user requests the home page express-session assigns the user a session cookie. Refreshing the page retains the same session id as does visiting the success or fail routes. Clicking the upgrade button takes the client to a Stripe shopping cart screen that also keeps the same session id. However once the user is at the Stripe shopping cart if the user makes a successful purchase he is forwarded to the success route and the session id is over-written by Stripe. In the full version this is a problem because the user would be logged in and this causes the user to be automatically logged out after the successful purchase. I'm not sure why this is happening or how to stop it.

app.js

const bodyParser = require('body-parser');
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

app.set('view engine', 'ejs');

app.use(express.static('views'));

app.use(
    session({
        maxAge: 24 * 60 * 60 * 1000,
        name: 'randomName',
        resave: false,
        saveUninitialized: true,
        secret: 'randomSecret',
        cookie: {
            sameSite: true,
            secure: false
        } 
    })
);

app.get('/', function(req, res) {

    req.session.userValues = true;
    console.log(req.session);

    res.render('index', { stripePublicKey: process.env.STRIPE_PUBLIC_KEY });
});

app.get('/success', function(req, res) {

    console.log(req.session);

    res.render('success');
});    

app.get('/fail', function(req, res) {

    console.log(req.session);

    res.render('fail');
});

app.post('/create-checkout-session', bodyParser.raw({ type: 'application/json' }), async function(req, res) {

    console.log(req.session);

    const session = await stripe.checkout.sessions.create({
        submit_type: 'auto',
        payment_method_types: ['card'],
        line_items: [
            {
                price_data: {
                    currency: 'usd',
                    product_data: {
                        name: 'name of product',
                        description: 'description of product'
                    },
                    unit_amount: 100
                },
                quantity: 1,
            }
        ],
        locale: 'en',
        mode: 'payment',
        success_url: 'http://localhost:8080/success',
        cancel_url: 'http://localhost:8080/fail'
    });

    res.json({ id: session.id });
});    

app.listen(8080, function() {
    console.log('listening on port 8080');
});

index.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upgrade</title>
    <script>var stripePublicKey = '<%- stripePublicKey %>';</script>
    <script defer src="https://js.stripe.com/v3/"></script>
    <script defer src="checkout.js"></script>
</head>
<body>
    
    <button id="checkout-button">upgrade premium</button>

</body>
</html>

checkout.js

var stripe = Stripe(stripePublicKey);

var checkoutButton = document.getElementById('checkout-button');

checkoutButton.addEventListener('click', checkoutSequence);

function checkoutSequence() {

    fetch('/create-checkout-session', {
        method: 'POST',
    })
    .then(function(response) {
        return response.json();
    })
    .then(function(session) {
        console.log(session);
        return stripe.redirectToCheckout({ sessionId: session.id });
    })
    .then(function(result) {
        if (result.error) {
            alert(result.error.message);
        }
    })
    .catch(function(error) {
        console.error('Error:', error);
    });
}
2 Answers

After 6 hours of testing I found the problem. cookie.sameSite must be set to lax instead of true. Evidently when Stripe hits the success route express-session determines that this is coming from an outside site and resets the cookie.

app.use(
    session({
        maxAge: 24 * 60 * 60 * 1000,
        name: 'randomName',
        resave: false,
        saveUninitialized: true,
        secret: 'randomSecret',
        cookie: {
            sameSite: 'lax',
            secure: false
        } 
    })
);

I agree that turning off the SameSite strict solves the problem easily. But I was intrigued to see if there was a way to keep the security setting.

I found you can work around the fact that SameSite cookies are not sent when loading the /success page directly from Stripe, if you bounce it off an "un-sessioned tween" page on your site, i.e. a page that has no session handling and therefore doesn't detect the missing session cookie and so doesn't replace it. However I found you have to use a client-side redirect for it to work.

This seems to make sense, as the meta refresh is performing a client-side navigation from your site to your site, so it's like a user clicking a link. However in the server-redirect the browser seems to "know" the chain of events hasn't been broken from the Stripe site to yours. If you set a breakpoint in the server-side redirect, you can see the browser is still sitting in Stripe, so I guess it still thinks it's in that context when responding to the 301 redirect response, and doesn't send the cookie. Interesting boundary case.

The flow:

  • Your site page: configure return url as /success-redirect
  • Stripe payment...
  • /success-redirect (from Stripe with no session cookie)
  • < meta refresh > in browser
  • /success (has cookie!)

App.js / router

    const url = require('url');

    // Add this route before adding your session to the app/router
    // so this route will not set a new session cookie, and won't overwrite the session. 
    // The previous session cookie stays in the browser...
    app.get('/success-redirect', (req, res, next)=>{
        // Server side redirect doesn't work, this still wont send the cookie, 
        // even though it's the browser responding to the 302/301
        // res.redirect(url.format({
        //  pathname:'/success',
        //  query:req.query,
        // }));

        // If you load an actual page on the site domain, then it does a meta-redirect,
       // the original session cookie is sent and this success page is logged in normally
        res.render('success-redirect.hbs', {
            url: url.format({pathname:'/success',query:req.query,}),
        });

    });

    // then add your session middleware as usual
    app.use(session(...));

success-redirect.hbs

<meta http-equiv="refresh" content="1;URL='{{url}}'" />

This kind of thing probably explains why some complex/aggregate sites have so many transient redirect pages.

Related