Skip to main content

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.

Not the same as connecting a wallet

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 versionWho the server acceptsChain check (checkIssuerRole in src/lib/server/role-check.ts)
v0.3An active issuer of the active contract setisActiveIssuer(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.2A universityhasRole(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):

  1. Needs the key-value store; without one (production without Redis) it answers 500 CONFIG_MISSING.
  2. Counts the request against the auth budget of the client IP address (60 per hour, shared with /api/auth/verify).
  3. issueNonce (src/lib/server/sign-in.ts) makes 16 random bytes, writes them as 32 hex characters, and stores the key nonce:<value> for 300 seconds (NONCE_TTL_SEC).
  4. Answers { data: { nonce, expiresAt } } with Cache-Control: no-store.

2. Message​

buildSignInMessage (src/services/upload.ts) builds the message with the siwe library:

FieldValue
domainwindow.location.host
addressThe connected wallet
statement"Sign in to Certichain to upload records"
uriwindow.location.origin
version1
chainIdThe wallet's current chain id
nonceFrom step 1
issuedAtNow
expirationTimeNow 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):

  1. Needs readAuthConfig() and the store, else 500 CONFIG_MISSING.
  2. Counts the request against the same auth budget.
  3. 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".
  4. verifySignIn checks, in this order, and answers 401 "Sign-in could not be verified" on the first failure:
    • The message parses as SIWE.
    • Its chainId equals NEXT_PUBLIC_NETWORK_ID.
    • Its domain is one of the allowed domains: SIWE_ALLOWED_DOMAINS (comma-separated) when set, otherwise the host of the request. Production refuses to run without SIWE_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.verify accepts the signature at the current time (this also enforces the expiration).
  5. 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." with Retry-After: 30. If the wallet is not an issuer: 403 FORBIDDEN "Wallet is not a registered university".
  6. Creates the session token and answers { data: { address, expiresAt } } with a Set-Cookie header.

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:

ClaimValue
alg (header)HS256
subThe wallet address, lowercase
isscertichain
audcertichain-upload
iatIssue time
expIssue time plus 1,800 seconds (30 minutes)
rcWhen the issuer role was last confirmed (seconds)

The cookie:

Set-Cookie: certichain_session=<token>; Path=/api; HttpOnly; Secure; SameSite=Strict; Max-Age=1800
AttributeWhy
Path=/apiThe cookie is only sent to API routes, not with page requests
HttpOnlyPage scripts cannot read it
SecureOnly sent over HTTPS (browsers treat localhost as secure, though Safari may refuse it on http://localhost)
SameSite=StrictNot sent on requests started from other sites, which blocks cross-site request forgery
Max-Age=1800The 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​

RouteStatusCodeMessage
both500CONFIG_MISSING"Upload service not configured"
both429RATE_LIMITED"Too many requests" (with Retry-After)
verify400BAD_REQUEST"Expected message and signature"
verify401UNAUTHORIZED"Sign-in could not be verified"
verify403FORBIDDEN"Wallet is not a registered university"
verify503UPSTREAM_ERROR"Could not reach the network to check the university role. Try again shortly."
issuer routes401UNAUTHORIZED"Sign in with a university wallet first"
issuer routes403FORBIDDEN"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​

NameRequiredMeaning
SESSION_SECRETyesHMAC key for session tokens, at least 32 characters. Server only.
NEXT_PUBLIC_NETWORK_IDyesThe chain id sign-in messages must name
RPC_URLS, RPC_URL (or the public names)yesEndpoints for the role check
NEXT_PUBLIC_CONTRACT_VERSIONnov03 selects the IssuerRegistry check
NEXT_PUBLIC_CONTRACT_ADDRESSon v0.2The AcademicRecords address
SIWE_ALLOWED_DOMAINSin productionComma-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 productionThe shared store for nonces and limits

Source files​

  • src/app/api/auth/nonce/route.ts, src/app/api/auth/verify/route.ts
  • src/lib/server/sign-in.ts, session.ts, issuer-session.ts
  • src/lib/server/role-check.ts, roles.ts, issuer-registry.ts
  • src/lib/server/config.ts, limits.ts, rate-limit.ts, http.ts
  • src/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.ts
  • src/lib/server/rate-limit.test.ts, src/lib/server/limits.test.ts, src/lib/server/http.test.ts
  • src/services/upload.test.ts
  • e2e/journeys/encrypted-documents.journey.ts and the other issuing journeys sign in through these routes against a local chain