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
- Node 22 or later. Save the file below as
auth-demo.js. One account, one protected route, two logout routes, no dependencies. - curl 8 or later. See curl's HTTP cookie documentation for the jar file format.
- A directory you can write
jar.txtinto. curl writes the jar when the transfer ends, not while it runs.
// 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
- 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 - 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 e14444abcc45612ab628eb72c1317000The 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 theHttpOnlyone. The expiry0marks a session cookie with noExpiresand noMax-Age. The secure flag isFALSEbecause this target runs over plain HTTP. - 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/accountSigned in as alice status 200 - Step 4.
Run the whole login as one command with
--locationand 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 401The credentials were correct and the redirect was followed, and the result is still
401. curl received theSet-Cookieheader and threw it away, because nothing had switched the cookie engine on. - Step 5.
Add
-cand 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 OKTwo things changed at once. The cookie is kept, and the second hop is a
GET. curl turns aPOSTinto aGETwhen it follows a302, which is what a browser does with a form as well. - Step 6.
Add
-X POSTto 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-Xsets the method for every request in the run, redirects included.-dalready impliesPOSTon the first hop, so-X POSTadds nothing there and breaks the second. - Step 7.
Log in through
localhostand try the jar against127.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/accountNot signed in status 401Stop 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
What to check next
- How to test login functionality: the wrong-credential and no-cookie cases this page assumes already pass.
- How to check if session expires after logout: replay the jar from step 1 after signing out.
- How to check cookie flags with curl: read
Secure,HttpOnlyandSameSitefrom the header rather than from the jar. - How to check if a redirect is 301 or 302: why the status code decides whether the method survives the hop.
- How to test API authentication: the same run when the credential is a header instead of a cookie.
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.
Related on this site
basic7 minpublished updated Maks Verny