Authentication
When an app is behind AppHaven's login gate (any private deployment, and the previews of a public app), every request that reaches it has already been authenticated, apart from the app's own public paths. Your app receives the signed-in user's identity in one request header. This page is the contract for reading it.
AppHaven handles authentication, proving who the user is. Your app handles authorization, deciding what that user may do, using the identity it receives.
The request flow
For a visitor with no session, the first page load passes through the gate:
| Step | What happens |
|---|---|
| 1 | A visitor requests a page. The gate sees no active session. |
| 2 | The gate sends them to sign in. |
| 3 | AppHaven authenticates the visitor. |
| 4 | The gate establishes a session and returns the visitor to the page they asked for. |
| 5 | The request reaches your app, carrying the identity header below. |
On every later request the gate validates the session, keeps it fresh, and forwards the request with a freshly issued identity header. Your app does nothing for any of this except read one header. The session is the gate's concern: it is encrypted, held in a secure cookie, and never seen by your app.
Requests without a session
What a signed-out client receives depends on what it can do with a login page:
| Client | Response |
|---|---|
Accepts HTML (the Accept header names text/html), as every browser navigation does | 302 redirect to the sign-in flow, returning to the requested page afterwards |
Everything else: fetch and XHR calls, API clients, curl | 401 Unauthorized, empty body |
The 401 carries Cache-Control: no-store and an X-Apphaven-Login header
naming the sign-in path, so a frontend that receives it (for example when a
session expires mid-use) can send the browser there:
if (response.status === 401) {
const login = response.headers.get("X-Apphaven-Login") ?? "/.apphaven/login";
window.location.assign(`${login}?return_to=${encodeURIComponent(location.pathname)}`);
}
An uptime probe without text/html in its Accept header receives 401 from
a login-protected app. Probe a public path instead, or
treat 401 as "up and protected".
The identity header
On every authenticated request, your app receives a single header:
X-Apphaven-Auth: <token>
That is the only identity input your app needs: no second header, no cookie, no OAuth token to exchange. The value is a JSON Web Token (JWT) carrying the user's identity, reissued on each request and short-lived by design.
HTTP header names are case-insensitive, and the casing your app sees depends on
how the request travelled: X-Apphaven-Auth over HTTP/1.1, x-apphaven-auth
over HTTP/2. Every standard HTTP library's header getter handles this for you;
only code that compares raw header names byte for byte needs to take care.
Claims
| Claim | Meaning |
|---|---|
sub | Stable, unique identifier for the user. Use this as the primary key for a user in your app. |
email | The user's email address. |
name | The user's display name. |
groups | Optional list of group names, when present. |
iss | The issuer of the token: always apphaven-proxy. |
aud | The audience the token was issued for. |
iat / exp | Issued-at and expiry, in seconds. |
Example payload:
{
"iss": "apphaven-proxy",
"sub": "5f8b1c2a-9d40-4e7b-8b1a-2c3d4e5f6a7b",
"email": "marie@example.com",
"name": "Marie Dubois",
"iat": 1710000000,
"exp": 1710000300
}
Use sub as the stable user identifier in your database. Email addresses can
change; the subject does not.
Why the header can be trusted
AppHaven removes any inbound X-Apphaven-Auth a visitor sends before setting
its own, and your app is reachable only through AppHaven (see
Security). A client cannot smuggle in an identity: the header your
app sees always came from the gate.
Because of that, reading the claims without checking the signature is safe and is what most apps do. If you prefer defence in depth, the next section is the complete recipe for verifying the token cryptographically.
Verifying the header cryptographically (optional)
The token is a standard JWT, signed with ES256. Every container of a login-protected app receives two environment variables for verifying it:
| Variable | Contents |
|---|---|
APPHAVEN_AUTH_JWKS | A JWK Set (JSON) holding your app's public key or keys. Normally one EC P-256 key; two during a key rotation. This value is not secret. |
APPHAVEN_AUTH_AUDIENCE | The exact value the token's aud claim must equal. |
The variables are set at deploy time, so an app deployed before it was switched to require login picks them up on its next deployment. Public apps do not receive them.
To verify a token:
- Verify the ES256 signature against the key set from
APPHAVEN_AUTH_JWKS. Feed the JSON to your JWT library's key-set type; it selects the right key by the token'skidheader for you. - Check
issequals the literal stringapphaven-proxy. It is not a URL, and it is not the sign-in server's address. - Check
audequalsAPPHAVEN_AUTH_AUDIENCE. - Check
exp. Tokens are reissued on every request and live for about five minutes; allow a small clock skew.
In Python with PyJWT:
import jwt, os
JWKS = jwt.PyJWKSet.from_json(os.environ["APPHAVEN_AUTH_JWKS"])
AUDIENCE = os.environ["APPHAVEN_AUTH_AUDIENCE"]
def current_user(request):
token = request.headers.get("X-Apphaven-Auth")
if token is None:
return None # public path: nobody is signed in
kid = jwt.get_unverified_header(token)["kid"]
key = next(k for k in JWKS.keys if k.key_id == kid)
claims = jwt.decode(token, key=key.key, algorithms=["ES256"],
audience=AUDIENCE, issuer="apphaven-proxy")
return claims["sub"], claims["email"]
In Node.js with jose:
import { createLocalJWKSet, jwtVerify } from "jose";
const jwks = createLocalJWKSet(JSON.parse(process.env.APPHAVEN_AUTH_JWKS));
async function currentUser(req) {
const token = req.headers["x-apphaven-auth"];
if (!token) return null; // public path: nobody is signed in
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["ES256"],
issuer: "apphaven-proxy",
audience: process.env.APPHAVEN_AUTH_AUDIENCE,
});
return { sub: payload.sub, email: payload.email };
}
In Java with Nimbus JOSE+JWT (the library inside Spring Security):
JWKSet jwks = JWKSet.parse(System.getenv("APPHAVEN_AUTH_JWKS"));
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(
new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, new ImmutableJWKSet<>(jwks)));
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
System.getenv("APPHAVEN_AUTH_AUDIENCE"),
new JWTClaimsSet.Builder().issuer("apphaven-proxy").build(),
Set.of("sub", "exp")));
// per request: signature + kid + exp + iss + aud in one call
JWTClaimsSet claims = processor.process(token, null);
Spring Security users can wrap that processor in new NimbusJwtDecoder(processor)
and use it as a JwtDecoder.
Any standard JWT library works the same way: load the key set, verify signature, issuer, audience and expiry, then read the claims.
During a rotation the key set briefly holds two keys, and your library picks
the correct one by the token's kid. Your app's only job is to read
APPHAVEN_AUTH_JWKS at startup; there is no endpoint to poll.
A request with no X-Apphaven-Auth header is a normal anonymous request on
a public path, not a verification failure.
Verify fail-closed when the header is present; treat its absence as "nobody is
signed in", never as an error.
APPHAVEN_AUTH_JWKS contains only public keys: it cannot be used to forge
identities, and it is safe to log or commit to configuration. The matching
private key never leaves the platform. The raw token is still personal data;
do not forward it to services you do not trust with your users' identity.
Public paths carry no header
An app can have a short list of public paths: URLs that
are reachable without a login. A request on one of those paths reaches your app
with no X-Apphaven-Auth header at all.
There is no anonymous token, no placeholder value, and no empty string. The header is absent, and that is the signal:
| Header | What it means |
|---|---|
| Present | The request came from a signed-in user. The claims are theirs. |
| Absent | The request came in on a public path. Nobody is signed in. |
The gate strips any inbound X-Apphaven-Auth on public paths too, exactly as it
does on protected ones, so a client cannot supply one to make an anonymous
request look authenticated.
Treat the missing header as normal and expected, not as a fault. It is the correct behaviour on a public path, and never something to work around by falling back to a default user, trusting another header, or reading identity from the request body. On a public path, your app authenticates the caller itself (a webhook signature, an API key, a shared secret) or serves the request anonymously.
The reserved /.apphaven/ paths
On every app behind the gate, AppHaven reserves a small set of paths and handles them itself:
| Path | Purpose |
|---|---|
/.apphaven/login | Starts the sign-in flow. |
/.apphaven/logout | Signs the user out. |
/.apphaven/session | Returns the current session status as JSON (never raw tokens). |
Link users to /.apphaven/logout for a sign-out action. A request to
/.apphaven/session returns a small JSON object you can use for a
client-side check of who is signed in:
{
"authenticated": true,
"subject": "5f8b1c2a-9d40-4e7b-8b1a-2c3d4e5f6a7b",
"email": "marie@example.com",
"expires_at": "2026-01-01T12:00:00Z"
}
That prefix belongs to the platform. Anything your app serves there is shadowed and unreachable. Keep your own routes outside it.
Authorization is your app's job
The identity header tells your app who a user is. Deciding what they may do
(roles, ownership, per-record permissions) is your app's logic, keyed off sub
(and optionally groups).
Related
- Access: choosing who can reach an app, and opening a public path.
- Security: how the platform protects the path in front of your app.
- Getting started: deploy your first app.