HTTP Headers and Redirects
What Are HTTP Headers? In simple terms: Every HTTP response consists of two parts: headers (information about the response) and the body (the page, image, or JSON itself). Headers tell the browser thi
What Are HTTP Headers?
In simple terms: Every HTTP response consists of two parts: headers (information about the response) and the body (the page, image, or JSON itself). Headers tell the browser things like "this is JSON", "download this as a file", "go to another URL", "cache this for one hour", or "don't allow this page inside a frame".
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: no-store
<!DOCTYPE html>
...
Technically: PHP sends a status line and headers before the first byte of the body. The header() function adds or replaces response headers, and http_response_code() sets the status code. Because headers come first, they must be set before any output — or while output is still buffered.
Sending Headers
Syntax
header(string $header, bool $replace = true, int $response_code = 0): void
<?php
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
http_response_code(201);
echo json_encode(['status' => 'created']);
- Each call adds one header line.
- By default, a header with the same name replaces an earlier one; pass
falseas the second argument to add another header with the same name (for example, multipleSet-Cookieheaders). http_response_code()sets the status code; without an argument, it returns the current code.
Inspecting and Removing Headers
| Function | Purpose |
|---|---|
headers_list() | Headers prepared for sending |
header_remove('X-Powered-By') | Remove a header (here, the one revealing the PHP version) |
headers_sent($file, $line) | Whether headers have already been sent, and where output started |
"Headers Already Sent"
<?php
echo "Hello";
header('Location: /login.php');
Output
Hello
PHP Warning: Cannot modify header information - headers already sent by (output started at /path/page.php:2) in /path/page.php on line 3
The echo sent the response headers (and the start of the body), so the redirect is impossible. Common hidden causes are whitespace or a blank line before <?php, a closing ?> followed by a newline in an included file, or a UTF-8 byte order mark (BOM) at the start of a file. The warning tells you the exact file and line where output started.
Fix: Send all headers before output — typically by processing the request first and rendering the template last. Omitting the closing ?> in pure PHP files prevents accidental whitespace.
Common Response Headers
| Header | Example | Purpose |
|---|---|---|
Content-Type | text/html; charset=UTF-8, application/json | Type and encoding of the body |
Content-Disposition | attachment; filename="report.csv" | Download instead of display |
Content-Length | 2048 | Body size in bytes |
Cache-Control | no-store, public, max-age=3600 | Caching rules |
Location | /dashboard | Redirect target (with a 3xx status) |
Retry-After | 120 | When to retry (with 429 or 503) |
Security Headers
| Header | Recommended Value | Protects Against |
|---|---|---|
Content-Security-Policy | default-src 'self' (adjusted to your site) | XSS, loading untrusted scripts |
X-Content-Type-Options | nosniff | Browsers guessing content types |
Content-Security-Policy: frame-ancestors 'self' (or X-Frame-Options: SAMEORIGIN) | — | Clickjacking (your page inside a hostile frame) |
Referrer-Policy | strict-origin-when-cross-origin | Leaking full URLs to other sites |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Downgrade to insecure HTTP (send only over HTTPS) |
<?php
function sendSecurityHeaders(): void
{
header_remove('X-Powered-By');
header("Content-Security-Policy: default-src 'self'; frame-ancestors 'self'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
if (($_SERVER['HTTPS'] ?? '') !== '' && $_SERVER['HTTPS'] !== 'off') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
Security headers are often configured in the web server (Nginx, Apache) instead, so they apply to every response, including static files. A Content Security Policy must be tailored to the scripts, styles, and images your site actually uses; start in report-only mode (Content-Security-Policy-Report-Only) to avoid breaking pages.
Redirects
A redirect is a response with a 3xx status code and a Location header. The browser then requests the new URL.
<?php
header('Location: /dashboard.php', true, 303);
exit;
Always exit After a Redirect
header() only prepares the header; PHP keeps executing the script. Without exit, code after the redirect still runs — for example, saving data again or rendering a protected page whose content is sent to the client before the browser follows the redirect.
Choosing the Status Code
| Status | Name | Meaning | Typical Use |
|---|---|---|---|
301 | Moved Permanently | The URL has changed for good; browsers and search engines remember it | Old URL → new URL, HTTP → HTTPS |
302 | Found | Temporary redirect | Generic temporary redirect |
303 | See Other | "See the result with a GET request" | After a form POST (Post/Redirect/Get) |
307 | Temporary Redirect | Temporary; keeps the method and body | Temporarily moved API endpoint |
308 | Permanent Redirect | Permanent; keeps the method and body | Permanently moved API endpoint |
header('Location: ...') without a code sends 302 (unless a 3xx code was already set). Specify the code explicitly so the intention is clear.
Warning: Permanent redirects (
301,308) are cached by browsers, sometimes indefinitely. Use them only when the move is truly permanent, and test with temporary codes first.
Preventing Open Redirects
Redirecting to a URL taken from the request — such as login.php?return=/orders — is convenient after login. Without validation, an attacker can create a link like login.php?return=https://evil.example, and your trusted site sends users to a phishing page. This is an open redirect vulnerability. Allow only local paths:
<?php
function safeRedirectTarget(?string $target, string $fallback = '/'): string
{
if ($target === null || $target === '' || preg_match('/[\x00-\x1F\x7F]/', $target)) {
return $fallback;
}
// Must be a path on this site: starts with a single "/" (not "//" or "/\")
if ($target[0] !== '/' || str_starts_with($target, '//') || str_starts_with($target, '/\\')) {
return $fallback;
}
return $target;
}
$tests = ['/dashboard', '/orders?id=5', 'https://evil.example', '//evil.example', '/\\evil.example', null];
foreach ($tests as $target) {
echo str_pad(var_export($target, true), 24) . '→ ' . safeRedirectTarget($target) . "\n";
}
Output
'/dashboard' → /dashboard
'/orders?id=5' → /orders?id=5
'https://evil.example' → /
'//evil.example' → /
'/\\evil.example' → /
NULL → /
Why Each Rule Matters
- Absolute URLs (
https://...) point to other sites. //evil.exampleis a protocol-relative URL: browsers treat it ashttps://evil.example.- Some browsers treat a backslash like a slash, so
/\evil.exampleis also dangerous. - Control characters (such as newlines) are rejected; PHP's
header()itself refuses headers containing newlines, which prevents header injection, but rejecting them early keeps the logic clear.
Use it as header('Location: ' . safeRedirectTarget($_GET['return'] ?? null), true, 303);.
Caching With ETags: 304 Not Modified
For responses that change rarely (such as a product catalog API), the server can send an ETag — a fingerprint of the content. On the next request, the browser sends it back in If-None-Match; if the content has not changed, the server replies 304 Not Modified without a body:
<?php
function respondWithEtag(string $body, ?string $ifNoneMatch): array
{
$etag = '"' . hash('sha256', $body) . '"';
if ($ifNoneMatch === $etag) {
return [304, $etag, ''];
}
return [200, $etag, $body];
}
$body = json_encode(['products' => ['Pen', 'Notebook']]);
[$status, $etag] = respondWithEtag($body, null);
echo "First request: $status\n";
[$status] = respondWithEtag($body, $etag);
echo "Repeat request: $status\n";
// In a real endpoint:
// [$status, $etag, $content] = respondWithEtag($body, $_SERVER['HTTP_IF_NONE_MATCH'] ?? null);
// http_response_code($status);
// header("ETag: $etag");
// header('Cache-Control: no-cache'); // revalidate every time
// echo $content;
Output
First request: 200
Repeat request: 304
The browser reuses its cached copy when it receives 304, which saves bandwidth. Cache-Control: no-cache means "you may store it, but check with the server before using it", which is exactly what ETags support.
Common Mistakes
Output Before Headers
Effect: "Headers already sent" warnings; redirects and cookies fail.
Fix: Send headers first; remove stray whitespace and BOMs; omit closing ?>.
Missing exit After Location
Effect: Code continues running and may send protected content.
Fix: Always exit (or return from the controller) after redirecting.
Using 301 for Temporary Moves
Effect: Browsers keep redirecting even after the change is undone.
Fix: Use 302/303/307 for temporary redirects.
Redirecting to Unvalidated URLs
Effect: Open redirect vulnerabilities used in phishing.
Fix: Allow only local paths or an allow-list of domains.
Wrong or Missing Content-Type
Effect: Browsers misinterpret responses; JSON is shown as HTML or vice versa.
Fix: Set Content-Type (with charset) for every non-HTML response.
Best Practices
- Set headers before any output, and keep pure PHP files free of closing tags.
- Use precise status codes: 303 after form posts, 301/308 only for permanent moves.
- Always stop execution after a redirect.
- Validate redirect targets from user input.
- Send security headers (or configure them in the web server), and use ETags or
Cache-Controlfor cacheable responses.
Official reference: https://www.php.net/manual/en/function.header.php