How to test login with curl

Save the session cookie on the way in and send it back on the way out. curl -c jar.txt -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' answers 302, and curl -b jar.txt http://127.0.0.1:8946/account then returns 200 Signed in as alice. Without -c or -b, curl discards every Set-Cookie header it receives.

Why check this

Reach for curl when a login works in one client and not in another. curl prints the request it sent and the headers that came back, so the failure lands on the server, on the cookie attributes or on your own command. Run it on staging after a change to the cookie domain or the redirect target.

The part worth learning is the cookie engine. curl keeps no cookies until -c or -b switches it on, and that holds inside a single command as well as between commands. A login one-liner that follows its own redirect still fails without a jar, which is what step 4 prints.

Prerequisites

// auth-demo.js - local target for the login and logout checks. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');

const USERS = { alice: 'correct-horse' };
const sessions = new Map();                       // sid -> username

const FORM = `<!doctype html><title>Demo login</title><form method="post" action="/login">
<input name="username"><input name="password" type="password"><button>Sign in</button></form>`;

const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const body = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(s)); });
const text = { 'content-type': 'text/plain' };

http.createServer(async (req, res) => {
  const p = new URL(req.url, 'http://127.0.0.1').pathname;

  if (p === '/') return res.writeHead(200, { 'content-type': 'text/html' }).end(FORM);

  if (p === '/login' && req.method === 'POST') {
    const f = new URLSearchParams(await body(req));
    const u = f.get('username') || '', pw = f.get('password') || '';
    if (!Object.hasOwn(USERS, u)) return res.writeHead(401, text).end(`No account for ${u}\n`);
    if (USERS[u] !== pw) return res.writeHead(401, text).end('Wrong password\n');
    const id = crypto.randomBytes(16).toString('hex');
    sessions.set(id, u);
    return res.writeHead(302, { 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax`, location: '/account' }).end();
  }

  if (p === '/account') {
    if (req.method !== 'GET') return res.writeHead(405, { ...text, allow: 'GET' }).end('Method not allowed\n');
    const id = sid(req);
    if (!id || !sessions.has(id)) return res.writeHead(401, text).end('Not signed in\n');
    return res.writeHead(200, text).end(`Signed in as ${sessions.get(id)}\n`);
  }

  // The defect under test: logout clears the browser cookie and keeps the server record.
  if (p === '/logout' && req.method === 'POST')
    return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();

  // The fix: drop the server-side session as well.
  if (p === '/logout-server' && req.method === 'POST') {
    sessions.delete(sid(req));
    return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();
  }

  return res.writeHead(404, text).end('Not found\n');
}).listen(8946, () => console.log('auth demo on http://127.0.0.1:8946'));

Start it and hold the process id. Stop that id when you finish, never every process named node.

node auth-demo.js & SRV=$!

Steps

  1. Step 1.

    Post the credentials and write the cookie into a jar. Do not follow the redirect yet.

    curl -s -c jar.txt -o /dev/null -w 'status %{http_code}  location %{redirect_url}\n' -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse'
    
    status 302  location http://127.0.0.1:8946/account
  2. Step 2.

    Read the jar. It is a text file, and reading it is the fastest way to see what curl believes it stored.

    cat jar.txt
    
    # Netscape HTTP Cookie File
    # https://curl.se/docs/http-cookies.html
    # This file was generated by libcurl! Edit at your own risk.
    
    #HttpOnly_127.0.0.1	FALSE	/	FALSE	0	sid	e14444abcc45612ab628eb72c1317000

    The fields are host, subdomain flag, path, secure flag, expiry, name, value. #HttpOnly_ is a prefix on the host field, not a comment, so a cookie line that looks commented out is the HttpOnly one. The expiry 0 marks a session cookie with no Expires and no Max-Age. The secure flag is FALSE because this target runs over plain HTTP.

  3. Step 3.

    Send the jar to the protected route.

    curl -s -b jar.txt -w '\nstatus %{http_code}\n' http://127.0.0.1:8946/account
    
    Signed in as alice
    
    status 200
  4. Step 4.

    Run the whole login as one command with --location and no jar at all.

    curl -s -L http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' -w '\nurl %{url_effective}  redirects %{num_redirects}  status %{http_code}\n'
    
    Not signed in
    
    url http://127.0.0.1:8946/account  redirects 1  status 401

    The credentials were correct and the redirect was followed, and the result is still 401. curl received the Set-Cookie header and threw it away, because nothing had switched the cookie engine on.

  5. Step 5.

    Add -c and repeat with -v, keeping only the request lines and the statuses.

    curl -s -v -L -c jarv.txt -o /dev/null http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' 2>&1 | grep -E '^(> [A-Z]+ |< HTTP|< set-cookie)'
    
    > POST /login HTTP/1.1
    < HTTP/1.1 302 Found
    < set-cookie: sid=a5770f88c386dcf3033f106f71f70ef4; Path=/; HttpOnly; SameSite=Lax
    > GET /account HTTP/1.1
    < HTTP/1.1 200 OK

    Two things changed at once. The cookie is kept, and the second hop is a GET. curl turns a POST into a GET when it follows a 302, which is what a browser does with a form as well.

  6. Step 6.

    Add -X POST to the same command and watch the second hop change method.

    curl -s -v -L -c jarw.txt -o /dev/null -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' 2>&1 | grep -E '^(> [A-Z]+ |< HTTP|< allow)'
    
    > POST /login HTTP/1.1
    < HTTP/1.1 302 Found
    > POST /account HTTP/1.1
    < HTTP/1.1 405 Method Not Allowed
    < allow: GET

    -X sets the method for every request in the run, redirects included. -d already implies POST on the first hop, so -X POST adds nothing there and breaks the second.

  7. Step 7.

    Log in through localhost and try the jar against 127.0.0.1, the same server under its other name.

    curl -s -c local.txt -o /dev/null http://localhost:8946/login -d 'username=alice&password=correct-horse' && curl -s -b local.txt -w '\nstatus %{http_code}\n' http://127.0.0.1:8946/account
    
    Not signed in
    
    status 401

    Stop the server when you are done: kill $SRV.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 302 and a jar file holding sid | The login worked and curl stored the cookie | Nothing. Spend it with -b. | | 200 from the protected route with -b | The stored cookie is accepted | Nothing. The session round trip is complete. | | 401 after a -L run with no jar | curl dropped the cookie, the server is fine | Add -c jar.txt. The cookie engine is off until you ask for it. | | 405 with allow: GET on the second hop | -X POST forced the method onto the redirect target | Drop -X. -d already makes the first request a POST. | | 401 with a jar that clearly holds sid | The jar host does not match the request host | Use one spelling of the host everywhere, or edit the host field in the jar. | | An empty jar file after a successful login | The response carried no Set-Cookie | The session is not cookie based. Look for a token in the body or in a header. |

Common mistakes

Sign: A one-line login with --location returns 401 and the server logs show correct credentials.Cause: curl keeps no cookies until -c or -b appears on the command line, and that applies within a single run too. The Set-Cookie from the 302 is discarded before the redirect is followed. Step 4 and step 5 differ only by -c.
Sign: Adding -X POST to a working login breaks it with a 405 or a 404.Cause: -X applies the method to every hop, so curl posts to the page it was redirected to. Without -X, curl follows a 302 with a GET. Use --post302 only when you actually want the body re-sent.
Sign: The jar file holds the cookie but the next command gets 401.Cause: Cookies are stored per host string. A jar written from localhost is not sent to 127.0.0.1, even though both reach the same server. curl compares the host it was given, not the address it resolved to.
Sign: A logout command leaves the old cookie sitting in the jar.Cause: -b reads the jar and never writes it. After a logout run with -b alone, the jar still held the old sid; with -b jar.txt -c jar.txt the line was gone. Pass both when a command is meant to change the session.

What to check next

FAQ

How to save cookies with curl after login?

Add -c jar.txt to the login request. curl writes the file when the transfer ends, so an interrupted command leaves no jar. Use -b jar.txt to send it back, and pass both -b and -c on any request that changes the session.

How to test a login API that returns JSON?

Swap -d 'user=...' for -H 'Content-Type: application/json' -d '{"username":"alice"}' and read the body instead of the cookie. Token APIs answer 200 with the token in the body, so there is no jar and no redirect to follow.

Why does curl send a GET after my POST?

Because the server answered 302. Step 5 shows the second hop as GET /account, which matches what a browser does after a form post. Use --post302 to keep the method, and 307 on the server side when the method must survive.

Can I log in to a website with curl?

Yes, when the form posts fields curl can send and the session is a cookie. A CSRF token or a JavaScript-built body has to be reproduced first, which is usually where a browser tool becomes the cheaper option.

What does the 0 in the jar file mean?

It is the expiry, as a Unix timestamp. 0 marks a session cookie, one with no Expires and no Max-Age, which a browser drops when it closes. curl keeps it in the file, so a jar survives a reboot in a way the browser would not.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny