Sign-in and sessions
Only registered issuers may store documents through Certichain's server, because a pinned file costs the operator storage and could otherwise be used to host anything. The server therefore needs to know two things about a caller: which wallet they control, and whether that wallet is a registered issuer right now. It learns the first from a signed Sign-In with Ethereum (SIWE, EIP-4361) message and the second from the chain. It then keeps a short session in a signed cookie so the issuer is not asked to sign for every request.
There are no passwords, no accounts and no server-side session table.
Connecting a wallet on /login happens in the browser only and tells the server nothing. The server sign-in happens later, on demand, the first time an issuer uploads, looks up or removes a document in a session. See Wallet and network.
Who signs in
| Contract version | Who the server accepts | Chain check (checkIssuerRole in src/lib/server/role-check.ts) |
|---|---|---|
| v0.3 | An active issuer of the active contract set | isActiveIssuer(address) on that set's IssuerRegistry (a registered key that has not been retired). A registry of an older set has no key lineage and reverts that call; on a CALL_EXCEPTION the server falls back to isIssuer(address). |
| v0.2 | A university | hasRole(UNIVERSITY_ROLE, address) on AcademicRecords (NEXT_PUBLIC_CONTRACT_ADDRESS) |
The IssuerRegistry address comes from the bundled manifest for NEXT_PUBLIC_NETWORK_ID. Reads go through the server's failover RPC provider (RPC_URLS, RPC_URL, or the public names).
The sign-in sequence
The browser starts a sign-in only after a protected request came back 401 or 403. withSignIn in src/services/upload.ts then signs in once and repeats the original request.
1. Nonce
GET /api/auth/nonce (src/app/api/auth/nonce/route.ts):
- Needs the key-value store; without one (production without Redis) it answers 500
CONFIG_MISSING. - Counts the request against the
authbudget of the client IP address (60 per hour, shared with/api/auth/verify). issueNonce(src/lib/server/sign-in.ts) makes 16 random bytes, writes them as 32 hex characters, and stores the keynonce:<value>for 300 seconds (NONCE_TTL_SEC).- Answers
{ data: { nonce, expiresAt } }withCache-Control: no-store.
2. Message
buildSignInMessage (src/services/upload.ts) builds the message with the siwe library:
| Field | Value |
|---|---|
domain | window.location.host |
address | The connected wallet |
statement | "Sign in to Certichain to upload records" |
uri | window.location.origin |
version | 1 |
chainId | The wallet's current chain id |
nonce | From step 1 |
issuedAt | Now |
expirationTime | Now plus 5 minutes |
The wallet shows this text to the user and signs it with personal_sign (WalletService.signMessage). This is a signature, not a transaction: it costs nothing and writes nothing on chain.
An example of the text the wallet shows (fake address and nonce):
certichain.example wants you to sign in with your Ethereum account:
0x1111111111111111111111111111111111111111
Sign in to Certichain to upload records
URI: https://certichain.example
Version: 1
Chain ID: 11155111
Nonce: 3f1c0e8a9b7d4c2e1f0a9b8c7d6e5f40
Issued At: 2026-10-24T09:00:00.000Z
Expiration Time: 2026-10-24T09:05:00.000Z
3. Verification
POST /api/auth/verify (src/app/api/auth/verify/route.ts):
- Needs
readAuthConfig()and the store, else 500CONFIG_MISSING. - Counts the request against the same
authbudget. - Reads a JSON body
{ message, signature }. Both must be strings, the message at most 2,048 characters and the signature at most 200. Anything else is 400 "Expected message and signature". verifySignInchecks, in this order, and answers 401 "Sign-in could not be verified" on the first failure:- The message parses as SIWE.
- Its
chainIdequalsNEXT_PUBLIC_NETWORK_ID. - Its
domainis one of the allowed domains:SIWE_ALLOWED_DOMAINS(comma-separated) when set, otherwise the host of the request. Production refuses to run withoutSIWE_ALLOWED_DOMAINS. - Its statement is exactly "Sign in to Certichain to upload records".
- It has an
expirationTime. - Its nonce exists in the store, and is deleted in the same step (
takeOnce), so a nonce works once. SiweMessage.verifyaccepts the signature at the current time (this also enforces the expiration).
- Checks the issuer role on chain. If the chain cannot be read: 503
UPSTREAM_ERROR"Could not reach the network to check the university role. Try again shortly." withRetry-After: 30. If the wallet is not an issuer: 403FORBIDDEN"Wallet is not a registered university". - Creates the session token and answers
{ data: { address, expiresAt } }with aSet-Cookieheader.
Taking the nonce before checking the signature means a failed attempt also burns the nonce. The browser simply asks for a new one.
The session
src/lib/server/session.ts makes the session a JSON Web Token signed with HMAC-SHA256 (jose), keyed with SESSION_SECRET:
| Claim | Value |
|---|---|
alg (header) | HS256 |
sub | The wallet address, lowercase |
iss | certichain |
aud | certichain-upload |
iat | Issue time |
exp | Issue time plus 1,800 seconds (30 minutes) |
rc | When the issuer role was last confirmed (seconds) |
The cookie:
Set-Cookie: certichain_session=<token>; Path=/api; HttpOnly; Secure; SameSite=Strict; Max-Age=1800
| Attribute | Why |
|---|---|
Path=/api | The cookie is only sent to API routes, not with page requests |
HttpOnly | Page scripts cannot read it |
Secure | Only sent over HTTPS (browsers treat localhost as secure, though Safari may refuse it on http://localhost) |
SameSite=Strict | Not sent on requests started from other sites, which blocks cross-site request forgery |
Max-Age=1800 | The browser drops it with the token |
verifySessionToken accepts a token only when the signature, issuer, audience, algorithm and expiry are right and it carries sub, rc and exp. Any other token, tampered or expired, is treated as no session.
There is no logout route. A session ends when it expires after 30 minutes, when the browser drops the cookie, or when SESSION_SECRET changes (every existing token then fails verification).
Using the session: requireIssuerSession
Every issuer-only route (/api/upload, /api/documents) starts with requireIssuerSession(request, config, store, scope) (src/lib/server/issuer-session.ts). It runs three checks in order:
The role re-check (ROLE_RECHECK_SEC, 300 seconds) bounds how long a removed or retired issuer can keep uploading with an old session. The cookie is not reissued after a re-check, so once a session is older than five minutes every request on it re-reads the role until the session expires.
Account switches
The session belongs to the wallet that signed in. If the user switches accounts in the wallet, document lookups and removals name the new wallet in their issuer query parameter, and the server answers 403 "The signed-in wallet is not the one asking. Sign in again with the current wallet." For uploads, a v0.3 container names its issuer inside its header, and a container encrypted by another wallet is refused with 403. In both cases withSignIn treats the 403 as a reason to sign in again with the current wallet and repeat the request once.
Errors
| Route | Status | Code | Message |
|---|---|---|---|
| both | 500 | CONFIG_MISSING | "Upload service not configured" |
| both | 429 | RATE_LIMITED | "Too many requests" (with Retry-After) |
| verify | 400 | BAD_REQUEST | "Expected message and signature" |
| verify | 401 | UNAUTHORIZED | "Sign-in could not be verified" |
| verify | 403 | FORBIDDEN | "Wallet is not a registered university" |
| verify | 503 | UPSTREAM_ERROR | "Could not reach the network to check the university role. Try again shortly." |
| issuer routes | 401 | UNAUTHORIZED | "Sign in with a university wallet first" |
| issuer routes | 403 | FORBIDDEN | "Wallet is no longer a registered university" |
The word "university" in these messages is a leftover of v0.2 wording; on v0.3 it means a registered issuer.
Configuration
| Name | Required | Meaning |
|---|---|---|
SESSION_SECRET | yes | HMAC key for session tokens, at least 32 characters. Server only. |
NEXT_PUBLIC_NETWORK_ID | yes | The chain id sign-in messages must name |
RPC_URLS, RPC_URL (or the public names) | yes | Endpoints for the role check |
NEXT_PUBLIC_CONTRACT_VERSION | no | v03 selects the IssuerRegistry check |
NEXT_PUBLIC_CONTRACT_ADDRESS | on v0.2 | The AcademicRecords address |
SIWE_ALLOWED_DOMAINS | in production | Comma-separated hosts accepted in the SIWE domain field |
UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (or KV_REST_API_URL and KV_REST_API_TOKEN) | in production | The shared store for nonces and limits |
Source files
src/app/api/auth/nonce/route.ts,src/app/api/auth/verify/route.tssrc/lib/server/sign-in.ts,session.ts,issuer-session.tssrc/lib/server/role-check.ts,roles.ts,issuer-registry.tssrc/lib/server/config.ts,limits.ts,rate-limit.ts,http.tssrc/services/upload.ts(browser side:buildSignInMessage,signInForUpload,withSignIn)
Tests
src/lib/server/config.test.ts,src/lib/server/role-check.test.ts,src/lib/server/roles.test.tssrc/lib/server/rate-limit.test.ts,src/lib/server/limits.test.ts,src/lib/server/http.test.tssrc/services/upload.test.tse2e/journeys/encrypted-documents.journey.tsand the other issuing journeys sign in through these routes against a local chain