AuthV2 is the OAuth 2.0 Authorization Code flow with PKCEused by SchoolSoft's mobile applications. It enables long-lived, token-based authentication without ever storing raw credentials — exactly how the official SchoolSoft app avoids asking you to log in every hour.
AuthV2 was discovered through weeks of reverse engineeringSchoolSoft's mobile app network traffic. This is not an officially documented API. Endpoints and parameters may change without notice.
Introduction
SchoolSoft exposes two distinct authentication surfaces:
Web authentication — Classic session-based login using JSESSIONID cookies. Valid for roughly one hour.
Mobile authentication (AuthV2) — A full OAuth 2.0 + PKCE flow that returns a long-lived Bearer token valid for up to 30 days. This is what the official SchoolSoft app uses.
SchoolSoft+ uses AuthV2 to give apps and integrations the same long-lived access the official mobile app enjoys — without storing your password anywhere.
Why it matters
Session-based authentication is fine for a browser tab you have open, but it falls apart for mobile apps and background services:
Sessions expire after ~1 hour, forcing the user to log in again.
Background operations (schedule syncing, push notifications) break when the session expires silently.
Storing the username and password to re-authenticate automatically is a serious security risk and defeats the purpose of session auth.
AuthV2 solves this by replacing the short-lived cookie with a token that lives for 30 days. The token can be cached, refreshed, and revoked server-side — without the user ever re-entering their password.
Web vs. Mobile authentication
Understanding the difference between the two auth systems helps clarify why AuthV2 exists.
Web (Session-based)
POST username + password
Server returns JSESSIONID cookie
Cookie expires in ~1 hour
Requires re-login after expiry
No token concept
Mobile (AuthV2 / PKCE)
POST credentials + PKCE challenge
Server returns short-lived auth code
Code is exchanged for Bearer token
Token valid for 30 days
Token can be refreshed silently
AuthV2 can also translate a Bearer token into a session cookie, giving you access to the full regular web API as well.
What is PKCE?
PKCE stands for Proof Key for Code Exchange. It is an extension to OAuth 2.0 designed specifically to protect public clients — apps that cannot safely store a client secret (mobile apps, single-page apps, desktop apps).
Without PKCE, an attacker who intercepts the authorization code (for example via a misconfigured redirect URI) could immediately exchange it for a token. PKCE prevents this by requiring the original requester to prove they initiated the flow.
How PKCE works
PKCE adds a temporary secret to the standard OAuth code flow:
1
Generate code_verifier
The app generates a cryptographically random string called the code_verifier. This is the raw secret — it never leaves the app at this stage.
2
Derive code_challenge
The app computes SHA-256(code_verifier) and Base64url-encodes the result. This is the code_challenge — a one-way hash that can be sent safely.
3
Send challenge to server
The authorization request includes the code_challenge and code_challenge_method=S256. The server stores the challenge alongside the pending auth request.
4
Receive authorization code
If credentials are valid, the server returns a short-lived, one-time-use authorization code.
5
Exchange code + verifier for token
The app sends the code and the original code_verifier. The server recomputes SHA-256(code_verifier) and checks it matches the stored challenge.
6
Token issued
If the hashes match, the server issues the access token. The code alone is useless without the verifier — even if intercepted.
typescript
// Simplified PKCE pair generationimport crypto from"crypto";
function makePkcePair() {
// 1. Generate a random 32-byte verifier, base64url-encodedconst verifier = crypto
.randomBytes(32)
.toString("base64url"); // ~43 chars, no padding// 2. SHA-256 hash of the verifier, base64url-encodedconst challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Usageconst { verifier, challenge } = makePkcePair();
// challenge → sent to SchoolSoft (code_challenge)// verifier → kept secret, sent later during token exchange
Why PKCE for mobile?
Native mobile apps are public clients — any secret baked into the app binary can be extracted by a determined attacker. Standard OAuth 2.0 confidential clients rely on a client_secret that only the server and a trusted backend know. That model breaks down entirely for mobile apps.
PKCE replaces the static client secret with a per-requestephemeral secret. There is nothing permanent to steal from the binary. This is why the OAuth 2.0 Security Best Current Practice (RFC 9700) mandates PKCE for all public clients, and why SchoolSoft's mobile app uses it.
AuthV2 flow overview
The full AuthV2 flow from cold start to usable Bearer token:
Before any network call, the client generates a fresh PKCE pair. The code_verifier must be kept in memory only — never logged, never persisted. The code_challenge is safe to send over the network because it is a one-way hash.
Send the user's credentials to SchoolSoft's password login endpoint, along with the PKCE challenge and OAuth parameters. This is the only point at which the raw password is used.
The password field is used once and then discarded. Never store it in memory longer than this single call.
Step 3 — Receive the authorization code
On a successful authorization request, SchoolSoft returns a JSON body containing a short-lived, one-time-use code. This code is valid for a very short window (seconds to minutes) and can only be used once.
Verify that the state in the response matches the value you sent in Step 2 before proceeding. A mismatch indicates a possible CSRF attempt.
Step 4 — Token exchange
Exchange the authorization code and the original code_verifier for a long-lived Bearer token. This is the core PKCE proof: SchoolSoft recomputes SHA-256(code_verifier) and checks it against the code_challenge stored in Step 2.
typescript
const tokenResponse = await fetch(
`https://sms.schoolsoft.se/${school}/rest-api/login/token`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: "eApp",
grantType: "code",
code: data.code, // from Step 3
code_verifier: verifier, // the original random secret from Step 1
}),
}
);
const token = await tokenResponse.json();
// {// access_token: "eyJhbGc...",// token_type: "Bearer",// expires_in: 2592000, // 30 days in seconds// }
Step 5 — Use the token
Attach the Bearer token as an Authorization header on every subsequent API request. The token acts as your identity proof and replaces the need for a session cookie on the mobile API.
The Bearer token can be translated into a traditional session cookie. This lets you access the full regular web API (including JSP-based HTML scraping endpoints) using the same AuthV2 token without a separate web login.
typescript
// POST to SchoolSoft's session endpoint with the Bearer tokenconst sessionRes = await fetch(
`https://sms.schoolsoft.se/${school}/rest-api/login/session`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
);
// Extract the Set-Cookie headers → JSESSIONID, hash, usertypeconst cookies = sessionRes.headers.getSetCookie();
// These cookies can now be used against the regular web API
This translation is what allows SchoolSoft+ to serve both the mobile API (Bearer token) and the web API (session cookie) from a single AuthV2 login.
POST /api/v2/initiate
SchoolSoft+ server-side wrapper: generates a fresh PKCE pair, stores the verifier server-side, and initiates the authorization request against SchoolSoft. Returns the auth code to the caller.
POST /api/v2/callback
Completes the PKCE exchange. Accepts the authorization code returned from the initiation step and the stored state, then calls SchoolSoft's token endpoint with the internally stored code_verifier.
POST /api/v2/token-refresh
Silently refreshes an existing token before it expires. The caller provides the current token; the server handles re-initiating the PKCE flow if a refresh token is available.
POST /api/login
Legacy combined endpoint: runs the full AuthV2 PKCE flow internally (Steps 1–4) and returns both the Bearer token and the translated session cookies in a single call. Designed for apps that want a simple one-shot login with no multi-step handling.
POST /api/token
Returns the raw token response after a prior /api/login call. Useful for clients that need the token value separately from the session cookies.
POST /api/refresh
Legacy token refresh endpoint. Accepts an existing session token and attempts to obtain a new one, extending the session without requiring the user to re-enter their credentials.
GET /api/session
Returns metadata about the currently authenticated session — user ID, name, school, token expiry, and role. Used to verify that a token is still valid without making a full data request.
PKCE utilities
A minimal self-contained PKCE helper using only Node.js built-ins:
typescript
import crypto from"crypto";
/** Generate a PKCE verifier/challenge pair (RFC 7636, S256 method). */exportfunction makePkcePair(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/** Verify that a received state matches the one we sent. */exportfunction verifyState(sent: string, received: string): boolean {
// Use timingSafeEqual to prevent timing attacksconst a = Buffer.from(sent);
const b = Buffer.from(received);
if (a.length !== b.length) returnfalse;
return crypto.timingSafeEqual(a, b);
}
Full implementation example
The following example performs the complete AuthV2 flow — from cold start to a usable Bearer token — using only Node.js built-ins:
Because the token is valid for 30 days it needs to be stored somewhere. The rules are simple:
Never store in LocalStorage — accessible to any JavaScript on the page (XSS risk).
Prefer HttpOnly cookies — the browser stores the token but scripts cannot read it.
For native mobile apps — use the platform keychain (iOS Keychain, Android Keystore). Never write raw tokens to shared storage or logs.
Server-side sessions — store the token server-side and give the client an opaque session ID. This is what SchoolSoft+ does.
Security model
AuthV2 is designed around the principle that credentials are ephemeral. The password is the identity proof; the token is the capability proof. Once the token is issued, the password is no longer needed.
Credential exposure window
Single HTTP request
Token lifetime
30 days (2 592 000 s)
PKCE verifier scope
Per-request, in-memory only
CSRF protection
Random state parameter
Code replay protection
One-time use + PKCE verifier
Intercept protection
PKCE (no secret in binary)
What is NOT stored
SchoolSoft+ does not store usernames, passwords, or raw credentials anywhere — not in a database, not in LocalStorage, not in logs. The password is forwarded directly to SchoolSoft over HTTPS on the single authorization request and then discarded.
Only the following are persisted (server-side, in an encrypted session):
The Bearer access token
The token expiry timestamp
The school slug and user ID
The code_verifier is held in memory only for the duration of the token exchange (Steps 1–4) and then discarded.
PKCE parameters reference
Parameters
Name
Type
Required
Description
code_verifier
string
Yes
Random 32-byte value, base64url-encoded (~43 chars). Generated fresh per login. NEVER sent to the server during authorization — only during token exchange.
code_challenge
string
Yes
SHA-256(code_verifier), base64url-encoded. Sent to the authorization endpoint.
code_challenge_method
string
Yes
Must be "S256" (SHA-256). Plain is not accepted by SchoolSoft.
client_id
string
Yes
Always "eApp" — the identifier for SchoolSoft's mobile app client.
redirect_uri
string
Yes
"eapp://login" — the deep-link URI the mobile app registers. Must match exactly.
response_type
string
Yes
Always "code" — standard OAuth 2.0 Authorization Code flow.
state
string
Yes
Random value generated per request. Returned unchanged in the response. Verify this matches before token exchange to prevent CSRF.
orgid
string
Yes
The school slug (e.g. "engelska"). Scopes the authorization to the correct school.
grantType
string
Yes
"code" — used during token exchange to identify this as an Authorization Code grant.
clientId
string
Yes
"eApp" — same as client_id but used in the token exchange request body.
Token response fields
Parameters
Name
Type
Required
Description
access_token
string
Yes
The Bearer token. Include as Authorization: Bearer <token> on all API requests.
token_type
string
Yes
Always "Bearer".
expires_in
number
Yes
Token lifetime in seconds. SchoolSoft returns 2592000 (30 days).
scope
string
No
OAuth scopes granted. May be absent if SchoolSoft does not return explicit scopes.
TypeScript
Full AuthV2 login flow using Node.js built-ins — no dependencies required.
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObjectimport java.security.MessageDigestimport java.security.SecureRandomimport java.util.Base64
val JSON_TYPE = "application/json".toMediaType()
val client = OkHttpClient()
fun b64url(b: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(b)
fun makePkce(): Pair<String, String> {
val verifier = b64url(SecureRandom().generateSeed(32))
val challenge = b64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
return verifier to challenge
}
fun post(url: String, body: JSONObject): JSONObject {
val req = Request.Builder().url(url)
.post(body.toString().toRequestBody(JSON_TYPE)).build()
returnJSONObject(client.newCall(req).execute().body!!.string())
}
val school = "engelska"
val state = b64url(SecureRandom().generateSeed(16))
val (verifier, challenge) = makePkce()
val auth = post(
"https://sms.schoolsoft.se/$school/rest-api/login/student/password",
JSONObject(mapOf("username" to "anna.lindqvist", "password" to "s3cr3t",
"code_challenge" to challenge, "code_challenge_method" to "S256",
"client_id" to "eApp", "redirect_uri" to "eapp://login",
"response_type" to "code", "state" to state, "orgid" to school)))
val token = post(
"https://sms.schoolsoft.se/$school/rest-api/login/token",
JSONObject(mapOf("clientId" to "eApp", "grantType" to "code",
"code" to auth.getString("code"), "code_verifier" to verifier)))
println(token.getString("access_token")) // Bearer token
Swift
iOS/macOS example using URLSession and CryptoKit (Swift 5.9+).