Authentication Lab
Practice real, backend-verified authentication — login, session reuse, role-based access, token expiry, and 401 vs 403. Every step below hits the real API. Nothing here is simulated.
✓ Authentication Complete
You successfully:
Authenticate with a real account and receive a JWT + session cookie.
A real request builder — edit the JSON body below and click "Execute" to fire an actual HTTP request to this server.
Body (JSON) — edit email/password, then Execute sends exactly this
Can't send this request
Calling /api/auth/login…
Logged in ✓
Email:
Token (first 32 chars): …
Login failed
Current Execution
No request executed yet — log in above to see it here.
Status
Not run yet
Success Message
Not run yet
Response Body
Not run yet
🔍 JWT Decoder
This JWT belongs to the authenticated user selected above — decoded entirely in your browser, nothing is sent anywhere.
✓ What you learned
✓ Same API endpoint is used for every user.
✓ Login returns both a JWT and an HttpOnly cookie.
✓ Playwright can reuse this authenticated session.
✓ Future requests no longer require logging in again.
🚀 Suggested Learning Flow
Review the Available Test Accounts
All three users authenticate through the same endpoint — only the credentials differ.
Edit the JSON request body
Change only email and password to switch which account you authenticate as.
Click Execute
Fires the real request — observe exactly what you typed gets sent.
Inspect the Current Execution workspace
Study the Response, JWT Decoder, and History tabs below.
Open Chrome DevTools
F12 → Network → POST /api/auth/login → inspect Request Headers, Request Payload, Response, Timing, and Status. Then Application → Cookies → localhost — the browser stores the HttpOnly session cookie automatically. JavaScript can't read it, but Playwright can still reuse it because the browser sends it on every request for you.
🔑 Available Test Accounts
All three users authenticate using the same endpoint. Only the request payload changes.
🎯 What you'll learn
A real login call returns a JWT. The server sets it as an httpOnly cookie AND returns it in the response body — the body copy is what Playwright actually uses to inject the session elsewhere.
🌍 Real-world example
Filling a login form in every single test is slow. Hitting the login API directly takes milliseconds and is exactly what the form does behind the scenes anyway.
✅ Key Takeaways
✓ Same API endpoint is used for every user.
✓ Only the email and password change.
✓ Login returns both a JWT and an HttpOnly cookie.
✓ Playwright can reuse this authenticated session.
✓ Future authenticated requests no longer require logging in again.
🔄 Authentication Flow — one connected process
Execute Request
Click Execute → Playwright's request.post() sends POST /api/auth/login directly, no browser UI involved.
Validate Credentials
Email and password are checked against the database.
JWT + HttpOnly Cookie
Both are created together, in the same response.
Response Returned
The JWT comes back in the body; the cookie arrives via a Set-Cookie header.
Session Stored
Playwright keeps the token and cookie ready for reuse.
Authenticated
No need to log in again for the rest of the test.
📬 Try it in Postman
http://localhost:3001/api/auth/login
Headers: Content-Type: application/json
The endpoint never changes — only the body does
✓ Same POST /api/auth/login endpoint for every role
✓ Different email/password in the body
✓ Different JWT returned — its payload carries the matching role
✓ Different authorization — that role then gates what the account can do
This panel is educational only — it doesn't send anything from here.
💡 Playwright method — request.post()
Calls the login API directly, no browser UI involved. The returned token can be injected into a context with addCookies().
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('login via API returns a token with the right role', async ({ request }) => {
const res = await request.post('/api/auth/login', {
data: { email: 'manager@playwrightautomation.com', password: 'ManagerPass123' }
});
expect(res.ok()).toBeTruthy();
const { token, user } = await res.json();
expect(user.role).toBe('manager');
});
🔍 Before You Test
• Open Chrome DevTools: Press F12
• Network tab → POST /api/auth/login → inspect Headers, Request Payload, Response, Timing
• Application → Cookies → localhost: find the session cookie
The cookie appears right after a successful login. It's HttpOnly, so JavaScript can't read it — but the browser sends it automatically on every future request.
Capture the authenticated browser session the way Playwright's storageState() does.
Capture your authenticated browser context exactly the way Playwright's context.storageState() works. The generated state.json file can later be reused to start new browser contexts without logging in again.
📌 Prerequisite
Complete Section 1 first. You must authenticate successfully before Playwright can export the authenticated browser session into a state.json file.
✅ Prerequisite Complete
Authentication completed successfully. The browser now contains an authenticated session. You can generate state.json to capture the current browser cookies and local storage.
Complete Section 1 first to enable state.json generation.
Session captured ✓
Log in via Section 1 first — there's no session to save yet.
state.json Preview
No session captured yet — click Save Session to see it here.
Status
Not run yet
Message
Not run yet
Generated state.json preview
Not run yet
✓ What you learned
✓ Log in only once.
✓ The authenticated session gets captured.
✓ Cookies (and local storage, if any) are stored.
✓ Future tests can reuse this same session.
🎯 What you'll learn
storageState() writes your browser's cookies and local storage into a JSON file, so future tests can start already authenticated — no repeated logins.
🌍 Real-world example
A regression suite with 300 UI tests shouldn't log in 300 times. Log in once, save the session, and every test reuses the same file.
✅ Key Takeaways
✓ Log in only once.
✓ Save the authenticated session.
✓ Cookies are stored in the file.
✓ Local storage is stored too.
✓ Future tests reuse this same session.
🔄 Save Flow — one connected process
Login Successful
Section 1 already authenticated this browser.
Authenticated Browser
Cookies and storage already prove who you are.
storageState()
Captures a snapshot of the current session.
state.json created
The snapshot is written to a JSON file.
Cookies stored
Every cookie, including HttpOnly ones, is written in.
Local Storage stored
Any localStorage values are captured too.
Ready for reuse
Any future context can load this file pre-authenticated.
No new network request
This step reuses the session already established by Step 1. Playwright does not create authentication data here — it simply reads the authenticated browser context and exports it into a state.json file. That export is a local file write, not an HTTP call.
💡 Playwright method — context.storageState()
Writes every cookie and local storage value to a JSON file on disk.
What each piece does
page.context()
Gets the active browser context.
storageState()
Captures cookies and local storage.
path
Writes everything into a reusable JSON file.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test } from '@playwright/test';
test('capture an authenticated session once', async ({ browser }) => {
const ctx = await browser.newContext();
const pg = await ctx.newPage();
await pg.request.post('/api/auth/login', {
data: { email: 'manager@playwrightautomation.com', password: 'ManagerPass123' }
});
await ctx.storageState({ path: 'manager-auth.json' });
});
🔍 Where does state.json come from?
Playwright does not create authentication data. It simply reads your authenticated browser context and exports it into a state.json file — the information comes from your browser after a successful login.
📍 Cookies
📍 Local Storage
Nothing is downloaded from the server again. Playwright simply exports your current authenticated browser session.
💡 Playwright Method
context.storageState()
Writes cookies and localStorage into a JSON file for later reuse.
Prove the saved session works without logging in again.
Simulates how Playwright restores an authenticated session — not an API request/response, a brand-new browser context starting pre-authenticated.
📌 Prerequisite
Complete Section 2 first. Generate the state.json file before creating a new authenticated browser context.
Saved Session
✔ state.json Available
✔ Cookies
✔ Local Storage
✔ User Information
✔ Session Metadata
browser.newContext({ storageState: "state.json" })
Technical detail
Browser Context Preview
No context created yet — click Create New Browser Context to see it here.
This will feel like opening an already-authenticated browser, not reading an HTTP response.
✓ What you learned
✓ Log in only once.
✓ Reuse the saved state.
✓ Cookies are restored automatically.
✓ Execution is faster.
🎯 What you'll learn
browser.newContext({ storageState }) creates a brand-new browser context that already contains the cookies and local storage saved in state.json. No login page, no username, no password — already authenticated.
🌍 Real-world example
Regression suites with hundreds or thousands of tests never log in before every test. They simply load state.json, and every browser context starts authenticated immediately.
✅ Key Takeaways
✓ Log in only once.
✓ Reuse state.json.
✓ Cookies are restored automatically.
✓ Local storage is restored too.
✓ Faster execution.
✓ Best practice for regression suites.
🔄 Reuse Flow — one connected process
state.json
The file saved back in Section 2.
newContext()
A brand-new browser context is created.
Cookies Loaded
Restored straight from the file, no network call.
Local Storage Loaded
Any saved entries are restored too.
Navigate to Protected Page
The very first navigation, no login page visited.
Already Logged In
No login form was ever shown.
Continue Testing
The actual test logic starts immediately.
Purpose: Checks whether the current session is still valid.
Request: No body — the browser sends the session cookie automatically.
Expected: 200 if the saved session is still valid; 401 if it has expired.
💡 Playwright method — newContext({ storageState })
Loads a saved file and the new context starts pre-authenticated — Playwright restores cookies and local storage before the first page even opens.
🔄 One connected process
newContext()
storageState
Browser Context
page.goto()
Authenticated Browser
Continue Testing
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('reuse a saved session — no login step', async ({ browser }) => {
const ctx = await browser.newContext({ storageState: 'manager-auth.json' });
const pg = await ctx.newPage();
const res = await pg.request.get('/api/auth/verify');
expect(res.ok()).toBeTruthy(); // authenticated without ever calling /login
});
⚡ Execution Workflow
📄
state.json
🌐
newContext()
🍪
Cookies Injected
💽
Storage Injected
✅
Browser Ready
🧭
Navigate
🔓
Authenticated
❌ Without storageState()
8 Steps
Slow
Repeated before every test
✅ With storageState()
4 Steps
Fast
Reusable · Recommended
🔍 Before You Test
• Open Chrome DevTools: Press F12
• Application → Cookies: observe the authenticated cookies
Now imagine a brand-new browser context. Those same cookies already exist immediately — Playwright restored them from state.json before the first page even opened.
Call an admin-only endpoint as different roles and compare the results.
📌 Prerequisite
Complete Section 1 first. Authenticate successfully before testing role-based authorization.
API Endpoint
Protected administrator resource
Returns protected administrator statistics. Only accessible to accounts with the admin role.
Expected Responses
The endpoint never changes. Only the authenticated user's role determines whether access is granted.
Calling /api/admin/dashboard-stats…
Log in via Section 1 first to try this with any role.
Execution Result
No request executed yet.
Response Data
Log in via Section 1, then click Call Admin Endpoint to see the result here.
✓ What you learned
✅ Authentication proves your identity.
✅ Authorization verifies your permissions.
✅ A valid JWT does not automatically grant access.
✅ The same endpoint can return different responses depending on the logged-in user's role.
✅ Playwright automates both Authentication and Authorization testing.
🎯 What you'll learn
Being authenticated only proves your identity. Authorization checks whether your role is allowed to access a protected resource. A successful login does not guarantee access.
🌍 Real-world example
Imagine logging into Amazon as a customer. Your login succeeds — but you still cannot open the Admin Dashboard. Your identity is valid. Your role is not. This is Authorization.
✅ Key Takeaways
✓ Authentication verifies identity.
✓ Authorization verifies permissions.
✓ Same endpoint. Different roles. Different responses.
✓ Admin receives 200.
✓ Customer receives 403.
✓ Manager receives 403.
✓ A valid JWT alone is not enough.
🔄 Authorization Flow — one connected process
Login Successful
Any account. Authentication passes.
JWT Created
Token contains the user's role in its payload.
Role Extracted
Server reads the role claim from the JWT.
Protected Endpoint
GET /api/admin/dashboard-stats
Authorization Middleware
requireRole('admin') checks the role.
Role Validation
Is the role exactly admin?
Response
200 OK or 403 Forbidden
Purpose: Admin-only dashboard data — requires both authentication and the admin role.
Requires: role = admin
Expected: 200 (admin) · 403 (authenticated, wrong role) · 401 (not authenticated)
💡 Playwright Pattern — request.get() + role assertion
There's no single "role" API in Playwright — test role-gating by logging in as different accounts and asserting different status codes from the same endpoint.
🔄 Test Execution Flow
🔑
Login
📡
Call Endpoint
🧪
Assert Status
📋
Validate Response
✅
Test Passed
🔐 Authorization Decision Flow
JWT
Role
Protected Request
Auth Middleware
Permission Check
200 / 403
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('customer is rejected from the admin endpoint', async ({ request }) => {
await request.post('/api/auth/login', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
const res = await request.get('/api/admin/dashboard-stats');
expect(res.status()).toBe(403);
});
// Same endpoint, admin account → 200
test('admin can read dashboard stats', async ({ request }) => {
await request.post('/api/auth/login', {
data: { email: 'admin@playwrightautomation.com', password: 'AdminPass123' }
});
const res = await request.get('/api/admin/dashboard-stats');
expect(res.status()).toBe(200);
});
🔍 Learn Through Chrome DevTools
Understand how authentication and authorization work inside the browser.
📍 Inspect Browser Session
📍 Inspect Local Storage
What are you learning?
When you log in successfully, the browser stores authentication information. Cookies and Local Storage help maintain your session. The server later validates your identity and role before allowing access to protected endpoints. Playwright automates these exact browser behaviors.
🧪 Learn It Yourself
Observe how Authentication and Authorization work inside a real browser before automating them with Playwright.
💡 Pro Tip
Authentication proves WHO you are. Authorization verifies WHAT you are allowed to access. Playwright simply automates these same browser behaviors.
🔐 Authorization Decision Flow
Authenticated
JWT Valid
Role Extracted
Protected Endpoint
Role Compared
Access Granted
/ Denied
❌ Customer
✅ Admin
💡 Playwright Best Practice
Always validate both outcomes from the same endpoint:
✓ Positive authorization — Admin → expect 200
✓ Negative authorization — Customer → expect 403
✓ Negative authorization — Manager → expect 403
One endpoint. Three different outcomes.
Watch a short-lived token expire, then recover it with a refresh token — no password.
A real request builder — every "Execute" below fires an actual HTTP request to this server, exactly like Postman or Swagger would.
Purpose
Issue a short-lived access token.
Request
JSON body required (email + password).
Expected
200 OK — access token + refresh token issued, countdown starts.
Body (JSON) — edited here is what Execute sends, even if you collapse this card afterward
Purpose
Check whether the current session is still valid.
Request
No body — your browser sends the current cookies automatically.
Expected
200 right after login; 401 once the access token has expired.
Purpose
Issue a new access token using the refresh token — no password.
Request
No body — your browser sends the refresh-token cookie automatically.
Expected
200 OK — new access + refresh token; the old refresh token stops working.
Current Execution
No request executed yet — run any endpoint on the left to see it here.
Status
Not run yet
Response Body
Not run yet
🔍 JWT Decoder
Your latest token is pre-filled below — decoded entirely in your browser, nothing is sent anywhere.
Execute Refresh after Login to compare the previous and new token.
✓
Congratulations
You successfully completed the Session Expiry & Refresh lab — one of the most misunderstood topics in authentication testing, including by experienced engineers.
You now understand:
✅ Why access tokens are deliberately short-lived
✅ Why refresh tokens exist, and how they avoid repeated logins
✅ Refresh token rotation, and why each one is single-use
✅ Why rotation is what actually prevents replay attacks
✅ The difference between "401 — token expired" and "logged out"
✅ Manual verification using the browser and DevTools
✅ API verification using the Playground, like Postman or Swagger
✅ Automating the full lifecycle with Playwright
☑️ Things you actually experienced in this lesson
☑ Got a 10-second access token and watched the countdown
☑ Watched it expire and received a real 401
☑ Refreshed without entering a password again
☑ Compared the old and new tokens and confirmed they were different
☑ Understood why clicking "Refresh" twice is NOT a replay attack
☑ Saw how a copied old token gets rejected — a real replay attempt
🚀 Suggested Learning Flow
Login
Execute POST /login-demo-short-lived — you'll receive an access token and a refresh token.
Verify immediately
Execute GET /verify right away. Expected: 200 OK.
Wait ~10 seconds, then Verify again
Same endpoint, same click — but now the access token has expired. Expected: 401 Unauthorized.
Refresh Token
Execute POST /refresh — recovers a working session with no password.
Verify once more
Confirms the new token actually works. Expected: 200 OK again.
💡 Before You Test
• Open DevTools: Press F12
• Network tab: Filter Fetch/XHR
• Click the request: Inspect Headers, Payload, Response, Cookies
• Application → Cookies: View the HttpOnly session cookies stored by the browser
• Timing: Check request duration and status (200 / 401)
Tip: Compare what you see in DevTools with the API Playground response. Both represent the same HTTP request from different perspectives.
📘 Introduction — in plain English
When you log in, the server gives your browser a small piece of proof called a token. Every request after that, your browser shows the token instead of your password. But tokens are deliberately made to stop working after a while — and that's where this section's whole story begins.
❓ Why does the access token expire?
If a token never expired and someone stole it, they'd have permanent access. A short lifetime (here: ~10 seconds for this demo; ~15 minutes to 1 hour in real apps) limits the damage window if it leaks.
❓ Why does a refresh token exist at all?
Without one, the user would have to re-type their password every time the access token expires — every 15 minutes, forever. A refresh token lets the app silently get a new access token, no password, no interruption.
🌍 Real-world story — your banking app
Imagine logging into your bank's app. The access token lasts only 10 minutes. The refresh token lasts 30 days. When the access token expires mid-session, you are not asked for your password again — the app silently uses the refresh token to get a new access token in the background. You just keep scrolling your statement, unaware anything happened. This is exactly what you're about to test below, just compressed into 10 seconds instead of 10 minutes.
The same pattern runs Google, GitHub, and Office 365 — you rarely re-enter your password during a normal day, even though the underlying access token is constantly expiring and quietly being replaced.
Execution sequence
Login
(10s token)
Verify → 200
Wait ~10s
Verify → 401
Refresh
(no password)
Verify → 200
📮 Try it in Postman
Same three requests as the cards on the left — build them manually in Postman or any HTTP client.
http://localhost:3001/api/auth/login-demo-short-lived
Headers: Content-Type: application/json
Body: {"email": "student@playwrightautomation.com", "password": "Password123"}
http://localhost:3001/api/auth/verify
Headers: none required
http://localhost:3001/api/auth/refresh
Headers: none required
Enable "Send cookies" in Postman so each request reuses the session from the one before it.
Everything below is simply automating the exact same steps you just performed by hand in Tab 2 — get a token, wait for it to expire, confirm the 401, then refresh and confirm recovery. Nothing new is being tested here, only the clicking is being replaced with code.
💡 Playwright pattern — wait, fail, recover
page.waitForTimeout() to outlast the token, assert the 401, then call refresh and assert recovery.
📖 Full lifecycle test
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('expired token recovers via refresh', async ({ page }) => {
await page.request.post('/api/auth/login-demo-short-lived', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
await page.waitForTimeout(11000); // outlast the ~10s token
const expired = await page.request.get('/api/auth/verify');
expect(expired.status()).toBe(401);
// Recover — no password needed
const refreshed = await page.request.post('/api/auth/refresh');
expect(refreshed.ok()).toBeTruthy();
});
📖 Replay-attack rotation test
// Proves rotation: the SAME refresh token cannot be used twice
test('a used refresh token cannot be replayed', async ({ page }) => {
await page.request.post('/api/auth/login-demo-short-lived', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
// Capture the refresh cookie BEFORE rotating it
const cookiesBefore = await page.context().cookies();
const oldRefresh = cookiesBefore.find(c => c.name === 'pw_refresh_token');
// First refresh succeeds and rotates the token
const first = await page.request.post('/api/auth/refresh');
expect(first.ok()).toBeTruthy();
// Manually replay the OLD cookie — simulating a stolen token
await page.context().addCookies([{ ...oldRefresh, url: 'http://localhost:3001' }]);
const replay = await page.request.post('/api/auth/refresh');
expect(replay.status()).toBe(401); // rotation blocked the reused token
});
⚠️ Common mistakes
"Why does refreshing twice in the UI not trigger the replay error?"
This trips up even experienced engineers. Clicking "Refresh Token" twice in this page does NOT simulate a replay attack — the browser automatically replaces the cookie after the first successful refresh, so the second click sends the new token, not the old one. A real replay attack means getting hold of a token that's no longer current — for example, copying the cookie value, refreshing once, then manually pasting that old value back in. That's something one person can absolutely do by hand; it doesn't require any automation at all.
There's a second way to see the same protection — by accident
Try clicking "Refresh Token" in the playground as fast as you possibly can, several times in a row. You'll likely see a real 401 appear in the Network tab — not because you replayed an old token on purpose, but because those rapid clicks went out before the browser had finished applying the first one's new cookie. Every click after the first one is, without you intending it, trying to reuse a token that's already been retired. This is a genuinely manual way to trigger the exact same protection — it's just unreliable by hand, which is why the automated example exists: a script can guarantee that race every time, instead of leaving it to how fast you happen to click.
Confusing 401 with "logged out"
A 401 here means the access token specifically expired — the refresh token (and the user's actual session) is often still perfectly valid. Treating every 401 as "force re-login" throws away the refresh token for no reason.
Forgetting credentials: 'include'
Without it, the browser won't send the httpOnly cookies at all, and every call looks like a fresh, unauthenticated request — easy to misdiagnose as "the refresh endpoint is broken" when it's actually a fetch configuration issue.
Assuming rotation is optional
Without rotation, a single stolen refresh token works forever. Rotation means each one is single-use — stealing it only buys an attacker one refresh, not infinite access.
🛠️ Troubleshooting — where to look, in order
💬 Interview questions
Why rotate refresh tokens instead of reusing one forever?
A non-rotating token is permanently valid if stolen. Rotation makes each refresh token single-use, so a stolen one only grants one extra access token before it stops working.
What is a replay attack?
Reusing a previously valid, intercepted token/request to gain access again — as if "replaying" a recording. Rotation defeats this for refresh tokens specifically.
Difference between an access token and a session?
An access token is stateless and self-contained (the server can verify it without a DB lookup, until revocation checks are added). A traditional session is a server-side record the cookie merely points to.
See the real difference between “who are you?” (401) and “you can’t do that” (403).
Called with no authentication — no token, no session. Server cannot identify the caller.
Auth
None
Expected
401
401 Unauthorized
Auto-logs in as a customer account, then calls the admin-only endpoint. Authentication succeeds, authorization fails.
Auth
Customer JWT
Required
admin
Expected
403
403 Forbidden
Scenario Comparison
🚫 401 Unauthorized
⛔ 403 Forbidden
✅ 200 OK
✓ What you learned
✓ Authentication proves identity.
✓ Authorization verifies permissions.
✓ 401 and 403 are different problems.
✓ They need different Playwright assertions.
🎯 What you'll learn
401 Unauthorized
• Server doesn't know who you are.
• Missing, expired, or invalid token.
• Authentication failed.
403 Forbidden
• Server knows exactly who you are.
• Authentication succeeded.
• Role or permission not allowed.
• Authorization failed.
🌍 Real-world example
401 — Security asks: "Who are you?"
You have no ID card. The server cannot identify you at all.
403 — Security recognizes you. Then says:
"You are not allowed into the Server Room." Identity is confirmed — permission is denied.
✅ Key Takeaways
✓ Authentication proves identity.
✓ Authorization verifies permissions.
✓ 401 and 403 are completely different problems.
✓ Different causes. Different fixes.
✓ Different Playwright assertions.
🔄 Request Decision Flow
📡
Request
🔑
Authentication
🛡️
Authorization
📬
Response
200 OK
401 Unauth
403 Forbidden
Called with no authentication — no token, no session. Server cannot identify the caller.
Auto-logs in as a customer account, then calls the admin-only endpoint. Authentication succeeds, authorization fails.
💡 Playwright Pattern — assert the exact status code
Never just assert "not 200" — assert the specific status code, since 401 and 403 require completely different test setup and mean different things.
🔄 Automation Sequence
📡
Request
🔑
Auth Header
🪙
JWT
📬
Response
✅
Assertion
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('no token at all → 401', async ({ request }) => {
const res = await request.get('/api/auth/verify');
expect(res.status()).toBe(401);
});
// Valid token, wrong role → 403
test('valid customer token, admin-only route → 403', async ({ request }) => {
await request.post('/api/auth/login', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
const res = await request.get('/api/admin/dashboard-stats');
expect(res.status()).toBe(403); // NOT 401 — they ARE logged in
});
🎯 What You Learned
✅ 401 means authentication failed.
✅ 403 means authorization failed.
✅ Authentication and Authorization are different.
✅ Browser DevTools helps understand API behavior.
✅ Playwright automates the same browser workflow.
✅ Always assert the exact HTTP status code.
📖 Manual Validation Guide
Before automating API authorization with Playwright, every QA engineer should first understand how these responses occur manually. The three scenarios below reproduce the exact responses your Playwright tests will later automate.
🔴 Scenario 1 — Observe 401 Unauthorized
Understand what happens when the server cannot identify the user.
Preparation
✔ Open a new Incognito window.
✔ Do NOT log in.
Steps
1. Open the application.
2. Navigate to GET /api/auth/verify or click Trigger 401.
3. Observe the response.
Expected Result: 401 Unauthorized
No session. No cookies. No JWT. The server cannot identify who you are. Authentication failed.
ℹ️ Note — Existing session detected?
If you are already logged in, you may receive 200 instead of 401. To reproduce a genuine 401, open this page in a fresh Incognito window or clear authentication data first.
🟠 Scenario 2 — Observe 403 Forbidden
Understand authentication succeeds but authorization fails.
Preparation
✔ Use a normal browser.
✔ Login as Customer.
Steps
1. Login using the Customer account.
2. Navigate to GET /api/admin/dashboard-stats or click Trigger 403.
3. Observe the response.
Expected Result: 403 Forbidden
Authentication succeeded. The server knows exactly who you are. However, your role does not have permission to access this endpoint. Authorization failed.
🟢 Scenario 3 — Observe 200 OK
Understand successful authentication and authorization.
Preparation
✔ Login as Admin.
Steps
1. Login using the Admin account (Section 1).
2. Navigate to GET /api/admin/dashboard-stats.
3. Observe the response.
Expected Result: 200 OK
Authentication succeeded. Authorization succeeded. The endpoint returned successfully.
🎯 Remember
401
Server does NOT know who you are. Authentication failed.
403
Server knows who you are, but you do NOT have permission. Authorization failed.
200
Server knows who you are AND you have permission. Everything succeeds.
These three manual scenarios are exactly what Playwright automation will verify in the Automation tab. Always understand the manual behavior before automating it.
💡 Playwright Best Practice
Never write:
expect(response.ok()).toBeTruthy()
Always assert the exact status code:
expect(response.status()).toBe(200)
expect(response.status()).toBe(401)
expect(response.status()).toBe(403)
401 and 403 require completely different test setup. Exact codes matter.