Skip to main content

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:

StepWhat happens
1A visitor requests a page. The gate sees no active session.
2The gate sends them to sign in.
3AppHaven authenticates the visitor.
4The gate establishes a session and returns the visitor to the page they asked for.
5The 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:

ClientResponse
Accepts HTML (the Accept header names text/html), as every browser navigation does302 redirect to the sign-in flow, returning to the requested page afterwards
Everything else: fetch and XHR calls, API clients, curl401 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)}`);
}
Point health checks at a public path

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.

Read the header name case-insensitively

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

ClaimMeaning
subStable, unique identifier for the user. Use this as the primary key for a user in your app.
emailThe user's email address.
nameThe user's display name.
groupsOptional list of group names, when present.
issThe issuer of the token: always apphaven-proxy.
audThe audience the token was issued for.
iat / expIssued-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
}
Key on the subject, not the email

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:

VariableContents
APPHAVEN_AUTH_JWKSA 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_AUDIENCEThe 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:

  1. 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's kid header for you.
  2. Check iss equals the literal string apphaven-proxy. It is not a URL, and it is not the sign-in server's address.
  3. Check aud equals APPHAVEN_AUTH_AUDIENCE.
  4. 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.

Key rotation is automatic for your app

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.

Verify only when the header is present

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.

The key is public

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:

HeaderWhat it means
PresentThe request came from a signed-in user. The claims are theirs.
AbsentThe 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:

PathPurpose
/.apphaven/loginStarts the sign-in flow.
/.apphaven/logoutSigns the user out.
/.apphaven/sessionReturns 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"
}
Do not define your own routes under /.apphaven/

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).

  • 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.