I don’t have to introduce burp… right?


In web application penetration testing, automating authentication processes can save valuable time and reduce manual effort, especially when dealing with complex multi-step logins or dynamic token handling. This article will go through automating both simple and multi-step login sequences using Burp Suite, so that you will never have to manually refresh and copy tokens from repeater again.

Automating Simple Login

Burp has a few built in tools that can automatically request a token. Using the session handling rules, together with macros, we can quite easily automate simple logins. Consider the following login sequence:

  1. A POST request is sent to /login with the user’s credentials, which sets a JWT token in the cookie.
simple-login-step-1.webp
  1. The token is presented to /protected, the server checks the validity of the JWT and allows access.
simple-login-step-2.webp

To automate this, first set up a session handling rule, go to Settings > Sessions > Add session handling rules > Add “Check session is valid” in rule actions.

We can use the following config in the action editor, make sure to adjust the expression based on the response of your webapp if needed.

simple-login-automation.webp

For macro, we can simply select the /login POST request earlier, since the JWT is returned in the Set-Cookie header, Burp will automatically capture and inject the token. When Burp detects a 401 Unauthorized response, the login request will be sent to request a new token.

Remember to set the scope for this rule, and it will be applied across burp. Now your repeaters will always have a valid token.

Automating Multi-Step Login

The example above is fine and dandy for simple, regular login as shown. But when dealing with complex webapps and APIs, we can run into multi-step logins that presents data to multiple URLs or endpoints with dynamic tokens in-between, or websites with aggressive CSRF token that is refreshed frequently. This is where the built in tools fall short, Burp cannot capture or modify data in the request/response body, for example, when data is returned and presented as json objects in the body. Consider the following login sequence (a little extreme, I know):

Code for this example webapp is at the bottom, follow along if you want to.

  1. A POST request is sent to /login with the user’s credentials, the server returns with a loginId or onTimeToken.
multi-step-login-1.webp

In real world, the loginId can represent a user ID, while the onTimeToken can represent an OTP or an authentication token. In UAT environments, email/SMS OTPs are usually not configured, and these are used as place holders.

  1. Both token are presented to /verify-token, which returns with a JWT.
multi-step-login-2.webp

(1 minute expiry)

jwt-expiry.webp r-u-kidding.webp

Client side JavaScript stores the JWT in local storage for future use.

  1. The JWT is presented to /get-csrf-token using Bearer Authentication set by client side JavaScript.
mult-step-login-3.webp

The server returns a CSRF shared secret _csrf as a cookie, and a csrfToken in the body.

  1. The JWT and CSRF tokens are posted to /secrets, if all the tokens are valid, the secret is returned.
multi-step-login-4.webp

To successfully repeat these steps in brup, we will need 4 different repeater tabs, and manually copy 5 values across them. How can this be automated?

For that, we will need the Stepper extension.

With the extension installed, add a new sequence and name it appropriately (double click the tab). Take note of the name as we will need it later.

Go to the captured requests, in repeater, for example, right click and send to Stepper.

send-request-to-stepper.webp

Do the same for the other requests in the sequence.

For the first request, click the Execute Step button at the top left, add the corresponding variables in the Post-Execution Variables. Name them appropriately and take note of the names, as we will need it later.

Under Condition, add the regex string to capture the token. If you’re asking how to write the regex string, I say, learn regex. But burp can generate that for you:

Right click the response and click Send to Sequencer. In sequencer tab, select the request and click Custom location > Configure. Highlight the string you want to extract, and enable Extract from regex group, copy the string and paste it into Stepper.

burp-auto-regex-string.webp

Once configured, the first step should look something like this:

stepper-step-1.webp

Make sure the Value is captured correctly, they should automatically appear if the regex string is correct.

For the second step, we will need to replace the dynamic tokens. Inside the extension, the variable name uses the following syntax: $VAR:varName. Or you can right click to copy the value:

copy-stepper-variable.webp

Use the Execute Sequence button, make sure the request is sent correctly, you can use the Stepper Replacements tab to view the actual request. Configure the Post-Execution Variables similar to step 1. The second step should look something like this:

stepper-step-2.webp

Do the same for step 3:

stepper-step-3.webp

Use the Execute Sequence button to run through the whole thing, you should see Stepper stepping through all the requests.

Now, we will have all the token needed to access the secrets.

Let’s go back to repeater, specifically, the POST request to /secrets. First thing we will want to add is a header called X-Stepper-Execute-Before, this tells Stepper to execute a sequence before this request, the header will not be sent to the server. The value should be the name of the sequence above, for me, it will be X-Stepper-Execute-Before: refresh-tokens. You can use the context menu to copy the value:

stepper-copy-before-header.webp

For the tokens, replace their value with $VAR:sequenceName:valueName$. Again, you can use the context menu, choose Copy Variable To Clipboard this time. Once all the changes have been made, send the request:

stepper-in-repeater.webp

We have just completed the entire sequence of 4 different requests with a click of a single button, and for this repeater request, we don’t have to manually refresh tokens anymore.

Fresh Tokens, Automatically, Everywhere

The single repeater request is great and all, but do we have to configure them for every different requests we want to test? And each request will repeat the login sequence, even if the current tokens are good, a lot of time will be wasted waiting for the sequence to complete, and it’s spamming the server with unnecessary logins.

To fully automate the process across burp, let’s go back to the session handler and add a new rule. We will add actions to replace the tokens automatically, set them as the Stepper variables using the syntax $VAR:sequenceName:valueName$ similar to the repeater request. For headers, use Set a specific header value, for cookies, use Set a specific cookies or parameter value. There’s no need to check Add if not already present as we just want to replace the tokens. The rules would look something like this:

replace-tokens-rules.webp

Disable these rules for now as it will interfere with the steps later!

Let’s quickly go through how Stepper variables work: the values captured in the Stepper sequence we have created earlier is accessible outside of the extension, using the $VAR:sequenceName:valueName$ syntax, when the sequence is executed, a new set of variables are captured based on the output of the requests.

The idea is to execute the sequence if/when the current tokens stored in Stepper expire, we will use a dummy request to trigger the sequence. The specific request doesn’t matter, as long as the X-Stepper-Execute-Before header is added. For method 1 and 2, we will save this request as a macro, and for method 3, we will use repeater.

Now we will need to gracefully detect and trigger the token refresh sequence, there are 3 ways to do it, each with their pros and cons.

Detecting Stale Tokens by Checking the Current Request

The first method is to check if the current request is valid, if it’s not, run the stepper macro.

This method is great if the webapp you’re dealing with has a consistent error message to determine the validity of the session, it does not issue extra requests unless necessary. However, if the errors shown are inconsistent, or errors are expected, it might cause the sequence to be executed even if the session is still valid.

First, we will need to create a macro. This can be a little buggy if the request is non-standard, so choose a lightweight request, for me, it’s just a GET /login:

stepper-macro.webp

Open the Stepper extension in the background, and click Test macro, as long as you see Stepper going through the sequence, that means it’s working and you can ignore the response.

Next, add a new rule action Check session is valid and move it to the top, above the token replacement actions. and Select Issue current request . For this webapp, since there’s a number of invalid responses (400, 401, 403 etc), I will tick HTTP headers to determine session validity, and use 200 OK to indicate a valid session (you might want to change this according to your requirements):

example-session-validation-rule-1.webp

Run the Stepper macro configured earlier if session is invalid, and uncheck the remaining checkboxes, as we’ll be modifying them later:

example-session-validation-rule-2.webp

The final sequence should be in this order, make sure that the Check session is valid action should be the first:

session-validation-rule-action-sequence.webp

Detecting Stale Tokens by Issuing a Pre-defined Request

The second method will send a recorded request with the tokens stored in Stepper, and will only trigger the sequence if the tokens are invalid.

This method is great to ensure that the current tokens will always be checked and refreshed if invalid, but it will increase the number of requests sent to the server.

First, we will need to create a macro (this part is the same as the first method). This can be a little buggy if the request is non-standard, so choose a lightweight request, for me, it’s just a GET /login:

stepper-macro.webp

Open the Stepper extension in the background, and click Test macro, as long as you see Stepper going through the sequence, that means it’s working and you can ignore the response.

Next, create another macro. This macro should be a request that returns a reliable response on whether the session is valid. replace the tokens with the stepper variables. In this case, we can use this:

test-session-macro.webp

The rules we have configured earlier will replace the token automatically to those stored in Stepper, so don’t worry about changing the macro itself.

Finally, add a new rule action Check session is valid and move it to the top, above the token replacement actions. Use the Run macro option and select the Test session macro, we will use the string 401 Unauthorized in the header to determine if the session is invalid (you might want to change this according to your requirements):

session-handling-test-session-macro.webp

If we’re using intruder or scanner, we can set Validate session only every X requests to reduce the number of extra requests sent to the server. but this might cause stale tokens to be used before they can be refreshed.

The remaining steps are the same as the first method. Run the Stepper macro configured earlier if session is invalid, and uncheck the remaining checkboxes, as we’ll be modifying them later:

example-session-validation-rule-2.webp

The final sequence should be in this order, make sure that the Check session is valid action should be the first:

session-validation-rule-action-sequence.webp

Sending Timed Intruder Requests

The third method will simply execute the sequence before the token expires.

This is the simplest and most lightweight method, but it will only work if we know when the tokens expire exactly (JWT), and if you have performed some action to invalidate the token, it won’t be renewed automatically until time’s up.

We will use intruder for this and it’s a bit of a hack (heh), I couldn’t find a better way to schedule requests in burp.

Use a simple request for intruder and add the X-Stepper-Execute-Before header, again, the actual request itself doesn’t matter as long as the header is there. For payload position, use something insignificant like a character in User-Agent:

intruder-stepper-request.webp

For the payload, simply use numbers:

intruder-stepper-payload.webp

For resource pool, set Maximum concurrent requests to 1, and set the delay to be smaller than the token validity period:

intruder-stepper-resource-pool.webp

Start intruder and it will execute the sequence before the token expires.

Wrapping Up

Remember to enable the rules once everything is configured!

For this example, we will use the first method. Testing with a repeater request from proxy history and without modifying anything, you should see the tokens being automatically replaced.

stepper-full-sequence.webp

Here’s how it went, from bottom to top: the first repeater request is the request with an expired JWT which returned 401, causing the invalid session rule to kick in and run the configured macro. Step 2-4 is the Stepper sequence, activated before the macro because of the X-Stepper-Execute-Before header. Step 5 is the actual macro, a GET request to /login. And step 6 is the successful secrets access.

If the token is good, we’d only see step 6.

And that’s how you automate refreshing tokens when the login is multi-stepped and the tokens are only good for 2 minutes (true story).

The same principle can be applied to other dynamic values, for example, a token refresh endpoint that invalidate all previous JWTs and requires a dynamic refresh token.

Example Webapp

Install the necessary packages:

sudo apt install npm
npm install express jsonwebtoken body-parser ejs cookie-parser csurf uuid

mkdir test-app
cd test-app/
npm init -y

Create the following files:

app.js
Copied to clipboard
// app.js

const express = require('express');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const csrf = require('csurf');
const { v4: uuidv4 } = require('uuid');
const path = require('path');

const app = express();
const PORT = 3000;
const HOST = '0.0.0.0';
const JWT_SECRET = 'secret';

// Middleware
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(express.static('public'));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// CSRF Protection Middleware
const csrfProtection = csrf({ cookie: true });

let currentLoginId = null;
let currentOneTimeToken = null;
let invalidatedTokens = [];

const USERS = {
  admin: 'admin',
};

// Step 1: Login Route (Username and Password Submission)
app.get('/login', (req, res) => {
  res.render('login', { error: null });
});

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  if (USERS[username] && USERS[username] === password) {
    // Step 2: Generate loginId and one-time token
    currentLoginId = uuidv4();
    currentOneTimeToken = uuidv4();

    // Send loginId and oneTimeToken in JSON response
    res.json({
      loginId: currentLoginId,
      oneTimeToken: currentOneTimeToken,
    });
  } else {
    res.status(401).json({ message: 'Invalid credentials' });
  }
});

// Step 3: One-Time Token Verification
app.post('/verify-token', (req, res) => {
  const { loginId, oneTimeToken } = req.body;
  if (loginId === currentLoginId && oneTimeToken === currentOneTimeToken) {
    // Invalidate the one-time token
    currentOneTimeToken = null;

    // Step 4: Issue JWT
    const token = jwt.sign({ username: 'admin' }, JWT_SECRET, {
      expiresIn: '1m',
    });

    // Send JWT in response body
    res.json({ token });
  } else {
    res.status(400).json({ message: 'Invalid loginId or one-time token' });
  }
});

// Middleware to Verify JWT
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token =
    authHeader && authHeader.startsWith('Bearer ')
      ? authHeader.slice(7)
      : null;

  if (!token) {
    return res.status(401).json({ message: 'JWT missing' });
  }

  if (invalidatedTokens.includes(token)) {
    return res.status(401).json({ message: 'JWT has been invalidated' });
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ message: 'Invalid or expired JWT' });
  }
}

// Step 5: Obtain CSRF Token
app.get('/get-csrf-token', authenticateToken, csrfProtection, (req, res) => {
  const csrfToken = req.csrfToken();
  // Send CSRF token in response body
  res.json({ csrfToken });
});

// Protected Endpoint Requiring JWT and CSRF Token
app.post('/secrets', authenticateToken, csrfProtection, (req, res) => {
  res.json({ message: 'Secret data accessed successfully' });
});

// Logout Route - Invalidate All Tokens
app.post('/logout', authenticateToken, (req, res) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader.slice(7);
  invalidatedTokens.push(token);

  // Clear all in-memory tokens
  currentLoginId = null;
  currentOneTimeToken = null;

  res.json({ message: 'Logged out successfully' });
});

// Error-handling middleware for CSRF token errors
app.use(function (err, req, res, next) {
  if (err.code === 'EBADCSRFTOKEN') {
    // CSRF token validation failed
    return res.status(403).json({ message: 'Invalid CSRF token' });
  }

  // Handle other errors
  res.status(500).json({ message: 'An unexpected error occurred' });
});

// Start the server
app.listen(PORT, HOST, () => {
  console.log(`Server is running on http://${HOST}:${PORT}`);
});
views/login.ejs
Copied to clipboard
<!-- views/login.ejs -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Login</title>
</head>
<body>
  <h1>Login</h1>
  <% if (error) { %>
    <p style="color: red;"><%= error %></p>
  <% } %>
  <form id="loginForm">
    <label for="username">Username:</label>
    <input type="text" name="username" id="username" required /><br />
    <label for="password">Password:</label>
    <input type="password" name="password" id="password" required /><br />
    <button type="submit">Login</button>
  </form>
  <script>
    document.getElementById('loginForm').addEventListener('submit', async function (e) {
      e.preventDefault();
      const username = document.getElementById('username').value;
      const password = document.getElementById('password').value;

      try {
        // Step 1: Submit credentials to /login
        const loginResponse = await fetch('/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, password }),
          credentials: 'include',
        });

        if (!loginResponse.ok) {
          const errorData = await loginResponse.json();
          throw new Error(errorData.message || 'Invalid credentials');
        }

        const { loginId, oneTimeToken } = await loginResponse.json();

        // Step 2: Submit loginId and oneTimeToken to /verify-token
        const tokenResponse = await fetch('/verify-token', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ loginId, oneTimeToken }),
          credentials: 'include',
        });

        if (!tokenResponse.ok) {
          const errorData = await tokenResponse.json();
          throw new Error(errorData.message || 'Token verification failed');
        }

        const { token } = await tokenResponse.json();
        localStorage.setItem('jwt', token);

        // Step 3: Obtain CSRF token
        const csrfResponse = await fetch('/get-csrf-token', {
          method: 'GET',
          headers: { Authorization: 'Bearer ' + token },
          credentials: 'include',
        });

        if (!csrfResponse.ok) {
          const errorData = await csrfResponse.json();
          throw new Error(errorData.message || 'Failed to obtain CSRF token');
        }

        const { csrfToken } = await csrfResponse.json();
        localStorage.setItem('csrfToken', csrfToken);

        // Redirect to secrets page
        window.location.href = '/secrets.html';
      } catch (err) {
        alert(err.message);
      }
    });
  </script>
</body>
</html>
public/secrets.html
Copied to clipboard
<!-- public/secrets.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Secrets</title>
</head>
<body>
  <h1>Secrets Page</h1>
  <button id="accessSecretData">Access Secret Data</button>
  <button id="logoutButton">Logout</button>

  <script>
    document.getElementById('accessSecretData').addEventListener('click', async function () {
      const token = localStorage.getItem('jwt');
      const csrfToken = localStorage.getItem('csrfToken');

      try {
        const response = await fetch('/secrets', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + token,
            'X-CSRF-Token': csrfToken,
          },
          credentials: 'include',
        });

        if (!response.ok) {
          const errorData = await response.json();
          throw new Error(errorData.message || 'Access denied');
        }

        const data = await response.json();
        alert(data.message);
      } catch (err) {
        alert(err.message);
      }
    });

    document.getElementById('logoutButton').addEventListener('click', async function () {
      const token = localStorage.getItem('jwt');

      try {
        const response = await fetch('/logout', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + token,
          },
          credentials: 'include',
        });

        if (!response.ok) {
          const errorData = await response.json();
          throw new Error(errorData.message || 'Logout failed');
        }

        localStorage.removeItem('jwt');
        localStorage.removeItem('csrfToken');
        window.location.href = '/login';
      } catch (err) {
        alert(err.message);
      }
    });
  </script>
</body>
</html>

Finally, run

node app.js